WIP: rebase fork onto upstream/main (v1.103.0) #15

Closed
codinget wants to merge 670 commits from webnet into save/webnet-2026-07-29
17 changed files with 905 additions and 21 deletions
Showing only changes of commit 04ae61fe4b - Show all commits
+7
View File
@@ -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
+2 -2
View File
@@ -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+
+1 -1
View File
@@ -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) {
+20
View File
@@ -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
View File
@@ -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)
+153
View File
@@ -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
}
+7 -1
View File
@@ -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"))
+1 -1
View File
@@ -164,4 +164,4 @@
});
};
}
# nix-direnv cache busting line: sha256-Xwm+ZLNqd2k7c2GFQJ2Pf/xuFLMcXhYl5I/YVgS9V4U=
# nix-direnv cache busting line: sha256-HCYBBM2rp4wuwS6x4fvbpJ2R9WHoT5tC1t7d6jtj/n8=
+2 -2
View File
@@ -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="
}
}
+7 -1
View File
@@ -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
+18 -2
View File
@@ -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=
+1 -1
View File
@@ -16,4 +16,4 @@
) {
src = ./.;
}).shellNix
# nix-direnv cache busting line: sha256-Xwm+ZLNqd2k7c2GFQJ2Pf/xuFLMcXhYl5I/YVgS9V4U=
# nix-direnv cache busting line: sha256-HCYBBM2rp4wuwS6x4fvbpJ2R9WHoT5tC1t7d6jtj/n8=
+4 -1
View File
@@ -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)
+167
View File
@@ -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>
+466
View File
@@ -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
}