tstest/integration/jswasmtest: add headless-Chromium tests for @tailscale/connect
Add Go tests that drive a real headless Chromium (via chromedp) against
the built cmd/tsconnect/pkg/ artifact and verify the @tailscale/connect
public API surface end-to-end. The package has not been republished in
three years, in part because no test exercises the produced artifact at
runtime — only tsc --noEmit and a Go build run in CI.
TestCreateIPN loads pkg.js into the browser, calls createIPN with a junk
auth key, and asserts that pkg.createIPN / pkg.runSSHSession are
functions and that createIPN() returns an IPN with the documented
run/login/logout/ssh/fetch methods. No control-plane traffic.
TestFetchTailnetPeer stands up a full local tailnet (testcontrol +
DERP + a tsnet.Server peer) and verifies that the browser-side WASM
client can join over WebSocket-noise to the same control, connect to
DERP over WSS, and then ipn.fetch() an HTTP service hosted on the tsnet
peer through the tailnet. The test asserts the response body matches a
known string. Browser state transitions are logged: NoState -> NeedsLogin
-> Starting -> Running.
Tests are opt-in via --run-headless-browser-tests (matching the existing
--run-vm-tests pattern in tstest/natlab/vmtest) so they never fire in
casual `go test ./...` runs. When the flag is set, a test is skipped if
cmd/tsconnect/pkg/ has not been built, and fails with t.Error if no
chromium binary is found on $PATH (honoring $CHROME_BIN as an override).
findChromium also falls back to /Applications/Google Chrome.app and
/Applications/Chromium.app on darwin, since macOS Chrome's executable
lives inside an .app bundle and is not on $PATH by default. The
.github/workflows/test.yml wasm job is extended to install
google-chrome-stable and run the tests with the flag after build-pkg.
To prevent silently testing a stale pkg/main.wasm (built from an older
checkout than the rest of the test invocation), build-pkg now writes
pkg/build-info.json recording the sha256 of the raw (pre-wasm-opt)
go-build output. The test does its own `go build` of
cmd/tsconnect/wasm with the same -tags/-trimpath/-ldflags (factored
into a new cmd/tsconnect/wasmbuild package shared by both call sites)
and t.Fatalfs with a "rebuild" instruction on mismatch. Cost is
near-zero because the Go build cache from the prior build-pkg makes
the rebuild a cache hit.
The new wasmbuild package also replaces cmd/tsconnect's hardcoded -tags
string with a minimal-feature-set computation. wasmbuild.Keep names the
small set of feature/featuretags entries the browser client actually
needs (netstack, logtail, dns, health, c2n, ipnbus); wasmbuild.Tags()
emits a ts_omit_<f> for every other
omittable feature in feature/featuretags.Features, with transitive deps
expanded via featuretags.Requires. An init() panics if Keep references
a feature unknown to feature/featuretags so a rename there fails
loudly. Net effect on size: 32M raw / 9.4M brotli before this change,
25M raw / 4.4M brotli after — vs the last-published 1.39.98 at 21M /
3.8M. The transitive package-import graph is unchanged (176
tailscale.com/* packages either way): featuretags omits eliminate
dead code via `const HasX = false`, not imports. Trimming the import
graph would require a separate, larger refactor splitting interface
packages by build tag.
Writing TestFetchTailnetPeer surfaced several real issues, all fixed
here:
* cmd/tsconnect built the wasm with the nethttpomithttp2 tag, but
control/ts2021 (since commit 1d93bdce2, "control/controlclient:
remove x/net/http2, use net/http", Oct 2025) requires HTTP/2 from
net/http's bundled implementation. With nethttpomithttp2 set, the
bundle is excluded and the wasm client cannot speak HTTP/2 to any
control plane, including production. Drop the tag. Wasm size grows
~1 MB raw / ~300 KB brotli (more than offset by the feature
pruning above). The last published @tailscale/connect (1.39.98,
early 2023) pre-dates the regression, which is why no consumer has
reported the breakage.
* tstest/integration/testcontrol.Server's /ts2021 noise upgrade
endpoint rejected anything but POST. WebSocket clients (the only
transport available to browser-WASM) come in as GET. Allow both;
the controlhttp AcceptHTTP path dispatches on the Upgrade header,
so the websocket library still enforces GET for WS upgrades.
This matches production, where the same controlhttpserver.AcceptHTTP
routes purely on the Upgrade header without checking method.
* derp/derphttp's urlString built the DERP URL from node.HostName
only, dropping node.DERPPort. Non-WS clients use a separate code
path (connectToHost) that honors DERPPort, but WebSocket-only
clients (browser-WASM) went through urlString and so could not
reach a DERP running on any port other than 443. Include the port
when it differs from the scheme default.
Also move addWebSocketSupport from cmd/derper (where it was main-only)
to derp/derpserver.AddWebSocketSupport so tstest/integration.RunDERPAndSTUN
can wrap its DERP handler with WebSocket support — without that, the
test DERP would not accept the browser's wss connection.
Fixes #9394
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
Change-Id: Iff9cdee303e3b239924249b5bffb2fd04e02f391
This commit is contained in:
committed by
Brad Fitzpatrick
parent
95d874e9b4
commit
04ae61fe4b
@@ -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
|
||||
|
||||
@@ -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+
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
+33
-3
@@ -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)
|
||||
|
||||
@@ -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_<feature> 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
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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"))
|
||||
|
||||
@@ -164,4 +164,4 @@
|
||||
});
|
||||
};
|
||||
}
|
||||
# nix-direnv cache busting line: sha256-Xwm+ZLNqd2k7c2GFQJ2Pf/xuFLMcXhYl5I/YVgS9V4U=
|
||||
# nix-direnv cache busting line: sha256-HCYBBM2rp4wuwS6x4fvbpJ2R9WHoT5tC1t7d6jtj/n8=
|
||||
|
||||
+2
-2
@@ -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="
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -16,4 +16,4 @@
|
||||
) {
|
||||
src = ./.;
|
||||
}).shellNix
|
||||
# nix-direnv cache busting line: sha256-Xwm+ZLNqd2k7c2GFQJ2Pf/xuFLMcXhYl5I/YVgS9V4U=
|
||||
# nix-direnv cache busting line: sha256-HCYBBM2rp4wuwS6x4fvbpJ2R9WHoT5tC1t7d6jtj/n8=
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
<!doctype html>
|
||||
<!--
|
||||
Copyright (c) Tailscale Inc & contributors
|
||||
SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
Fixture page for tailscale.com/tstest/integration/jswasmtest.
|
||||
|
||||
It imports the built pkg.js (served by the Go harness at /_pkg/) and
|
||||
dispatches into one of two flows based on URL query parameters, then
|
||||
reports back via window.tsTest so the Go-side chromedp driver only
|
||||
has to poll one flag (window.tsTest.done) and then read static fields.
|
||||
|
||||
createIPN flow (no query params): just instantiate via createIPN with
|
||||
a junk auth key and report the IPN object's method types. Used by
|
||||
TestCreateIPN.
|
||||
|
||||
fetch flow (?mode=fetch&controlURL=...&authKey=...&peerURL=...&hostname=...):
|
||||
call createIPN with the given controlURL+authKey, wait for the IPN to
|
||||
reach BackendState=Running, then ipn.fetch(peerURL) and report the
|
||||
response status + body. Used by TestFetchTailnetPeer.
|
||||
-->
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>@tailscale/connect jswasmtest</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="status">loading...</div>
|
||||
<script type="module">
|
||||
const tsTest = {
|
||||
mode: null,
|
||||
imported: false,
|
||||
importErrors: [],
|
||||
runtimeErrors: [],
|
||||
panics: [],
|
||||
exports: null,
|
||||
|
||||
// createIPN flow:
|
||||
ipnMethods: null,
|
||||
instantiateError: null,
|
||||
|
||||
// Fetch flow:
|
||||
states: [],
|
||||
browseURLs: [],
|
||||
runError: null,
|
||||
fetchResult: null,
|
||||
fetchError: null,
|
||||
|
||||
done: false,
|
||||
};
|
||||
window.tsTest = tsTest;
|
||||
|
||||
window.addEventListener("error", (e) => {
|
||||
tsTest.runtimeErrors.push(String(e.error || e.message));
|
||||
});
|
||||
window.addEventListener("unhandledrejection", (e) => {
|
||||
tsTest.runtimeErrors.push(String(e.reason));
|
||||
});
|
||||
|
||||
const setStatus = (s) => { document.getElementById("status").textContent = s; };
|
||||
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
tsTest.mode = params.get("mode") || "createIPN";
|
||||
|
||||
try {
|
||||
const mod = await import("/_pkg/pkg.js");
|
||||
tsTest.imported = true;
|
||||
tsTest.exports = {
|
||||
createIPN: typeof mod.createIPN,
|
||||
runSSHSession: typeof mod.runSSHSession,
|
||||
};
|
||||
setStatus("imported");
|
||||
|
||||
if (tsTest.mode === "createIPN") {
|
||||
// createIPN with a junk auth key just to verify the WASM loads and
|
||||
// newIPN returns an object with the documented methods. We never
|
||||
// call ipn.run(), so no control-plane traffic is attempted.
|
||||
try {
|
||||
const ipn = await mod.createIPN({
|
||||
authKey: "tskey-pkgtest-not-real",
|
||||
wasmURL: "/_pkg/main.wasm",
|
||||
panicHandler: (err) => { tsTest.panics.push(String(err)); },
|
||||
});
|
||||
tsTest.ipnMethods = {
|
||||
run: typeof ipn.run,
|
||||
login: typeof ipn.login,
|
||||
logout: typeof ipn.logout,
|
||||
ssh: typeof ipn.ssh,
|
||||
fetch: typeof ipn.fetch,
|
||||
};
|
||||
setStatus("instantiated");
|
||||
} catch (err) {
|
||||
tsTest.instantiateError = String(err);
|
||||
setStatus("instantiate failed");
|
||||
}
|
||||
} else if (tsTest.mode === "fetch") {
|
||||
const controlURL = params.get("controlURL");
|
||||
const authKey = params.get("authKey");
|
||||
const peerURL = params.get("peerURL");
|
||||
const hostname = params.get("hostname") || "browser-pkgtest";
|
||||
|
||||
const ipn = await mod.createIPN({
|
||||
authKey,
|
||||
controlURL,
|
||||
hostname,
|
||||
wasmURL: "/_pkg/main.wasm",
|
||||
panicHandler: (err) => { tsTest.panics.push(String(err)); },
|
||||
});
|
||||
setStatus("instantiated");
|
||||
|
||||
// Wait for the IPN to reach "Running" via the notifyState callback,
|
||||
// then fetch the peer URL through ipn.fetch (which dials via the
|
||||
// tailnet, not the browser).
|
||||
await new Promise((resolve, reject) => {
|
||||
let settled = false;
|
||||
const settle = (err) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
if (err) reject(err); else resolve();
|
||||
};
|
||||
ipn.run({
|
||||
notifyState: (state) => {
|
||||
tsTest.states.push(state);
|
||||
setStatus("state: " + state);
|
||||
// LocalBackend.Start does not auto-call Login on a fresh
|
||||
// profile (no node key, no config file). Match what
|
||||
// tsnet.Server.Up does: when state is NeedsLogin and we
|
||||
// were given an auth key, kick off StartLoginInteractive
|
||||
// so the auth key gets used.
|
||||
if (state === "NeedsLogin") ipn.login();
|
||||
if (state === "Running") settle();
|
||||
},
|
||||
notifyNetMap: () => {},
|
||||
notifyBrowseToURL: (url) => { tsTest.browseURLs.push(url); },
|
||||
notifyPanicRecover: (err) => { settle(new Error("panic: " + err)); },
|
||||
});
|
||||
});
|
||||
|
||||
try {
|
||||
const resp = await ipn.fetch(peerURL);
|
||||
const body = await resp.text();
|
||||
tsTest.fetchResult = { status: resp.status, body };
|
||||
setStatus("fetched");
|
||||
} catch (err) {
|
||||
tsTest.fetchError = String(err);
|
||||
setStatus("fetch failed");
|
||||
}
|
||||
} else {
|
||||
tsTest.instantiateError = "unknown mode: " + tsTest.mode;
|
||||
}
|
||||
} catch (err) {
|
||||
if (!tsTest.imported) {
|
||||
tsTest.importErrors.push(String(err));
|
||||
setStatus("import failed");
|
||||
} else if (tsTest.mode === "fetch") {
|
||||
tsTest.runError = String(err);
|
||||
setStatus("run failed");
|
||||
} else {
|
||||
tsTest.instantiateError = String(err);
|
||||
setStatus("instantiate failed");
|
||||
}
|
||||
} finally {
|
||||
tsTest.done = true;
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -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 "<terminal> 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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user