WIP: rebase fork onto upstream/main (v1.103.0) #15
@@ -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