diff --git a/Dockerfile.wasmclient b/Dockerfile.wasmclient new file mode 100644 index 00000000..cc939a09 --- /dev/null +++ b/Dockerfile.wasmclient @@ -0,0 +1,30 @@ +# For integration testing only. +# +# Builds the Tailscale control client (integration/wasmic/wasmclient) for +# GOOS=js/GOARCH=wasm and packages it with Go's wasm_exec Node runner. The +# container idles; the integration test execs +# node /app/wasm_exec_node.js /app/client.wasm +# to drive a real browser-style WebSocket GET against headscale's /ts2021, +# guarding the regression in issue #3357. + +FROM golang:1.26.4-alpine AS build + +WORKDIR /src + +# Only the module metadata and the wasm client package are needed to build the +# js/wasm binary; its imports (tailscale.com/control/controlhttp, ...) resolve +# from the module proxy. +COPY go.mod go.sum ./ +COPY integration/wasmic/wasmclient ./integration/wasmic/wasmclient + +RUN GOOS=js GOARCH=wasm go build -o /out/client.wasm ./integration/wasmic/wasmclient \ + && cp "$(go env GOROOT)/lib/wasm/wasm_exec.js" /out/wasm_exec.js \ + && cp "$(go env GOROOT)/lib/wasm/wasm_exec_node.js" /out/wasm_exec_node.js + +FROM node:24-alpine + +WORKDIR /app +COPY --from=build /out/ /app/ + +# Idle; the test execs the client on demand with the headscale control URL. +ENTRYPOINT ["tail", "-f", "/dev/null"] diff --git a/integration/ts2021_websocket_test.go b/integration/ts2021_websocket_test.go new file mode 100644 index 00000000..fc7f425e --- /dev/null +++ b/integration/ts2021_websocket_test.go @@ -0,0 +1,315 @@ +package integration + +import ( + "context" + "crypto/tls" + "crypto/x509" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "net/http" + "net/netip" + "net/url" + "testing" + "time" + + "github.com/coder/websocket" + "github.com/juanfont/headscale/integration/dockertestutil" + "github.com/juanfont/headscale/integration/hsic" + "github.com/juanfont/headscale/integration/tsic" + "github.com/ory/dockertest/v3" + "github.com/samber/lo" + "github.com/stretchr/testify/require" + "tailscale.com/control/controlbase" + "tailscale.com/control/controlhttp/controlhttpcommon" + "tailscale.com/net/wsconn" + "tailscale.com/tailcfg" + "tailscale.com/types/key" + "tailscale.com/util/rands" +) + +// Tailscale's JS/WASM control client opens /ts2021 as a browser WebSocket — an +// HTTP GET upgrade — rather than the native client's HTTP POST upgrade. A router +// that registers /ts2021 for POST only rejects that GET with 405 before the +// Noise handshake starts, which breaks every WASM client (issue #3357). +// +// These two tests guard that path against real headscale: +// +// - TestTS2021WebSocketGET dials the WebSocket GET directly from the test +// process using the same coder/websocket + controlbase primitives the WASM +// client uses. It is fast and always on. +// - TestTS2021WASMClientUnderNode runs the *actual* tailscale.com js/wasm +// control dial (integration/wasmic/wasmclient, built for GOOS=js) inside a +// Node container, alongside normal Tailscale clients, and asserts it +// completes the Noise handshake with headscale over the WebSocket. +// +// The server cannot tell the two apart: both send GET /ts2021 with +// Sec-WebSocket-Protocol: tailscale-control-protocol. Before the fix both fail +// with 405; after it, both complete the handshake. + +// TestTS2021WebSocketGET connects to /ts2021 over a WebSocket GET from the test +// process and completes the Noise handshake, exactly as a browser/WASM client +// would. +func TestTS2021WebSocketGET(t *testing.T) { + IntegrationSkip(t) + t.Parallel() + + spec := ScenarioSpec{ + NodesPerUser: 1, + Users: []string{"user1"}, + } + + scenario, err := NewScenario(spec) + + require.NoErrorf(t, err, "failed to create scenario: %s", err) + defer scenario.ShutdownAssertNoPanics(t) + + err = scenario.CreateHeadscaleEnv([]tsic.Option{}, hsic.WithTestName("ts2021ws")) + requireNoErrHeadscaleEnv(t, err) + + headscale, err := scenario.Headscale() + requireNoErrGetHeadscale(t, err) + + conn, err := dialTS2021WebSocket(t, headscale.GetEndpoint(), headscale.GetCert()) + require.NoError(t, err, + "WebSocket GET to /ts2021 must reach NoiseUpgradeHandler, not be rejected by the router with 405") + require.NotNil(t, conn) + t.Cleanup(func() { _ = conn.Close() }) + + t.Logf("noise established over websocket, protocol version %d", conn.ProtocolVersion()) +} + +// TestTS2021WASMClientUnderNode runs the real tailscale.com js/wasm control dial +// inside a Node container against real headscale, next to normal Tailscale +// clients, and asserts the WASM client completes the /ts2021 handshake. +func TestTS2021WASMClientUnderNode(t *testing.T) { + IntegrationSkip(t) + t.Parallel() + + spec := ScenarioSpec{ + NodesPerUser: 2, + Users: []string{"user1"}, + Networks: map[string]NetworkSpec{ + "wasmnet": {Users: []string{"user1"}}, + }, + ExtraService: map[string][]extraServiceFunc{ + "wasmnet": {wasmClientService}, + }, + // The wasm client image builds from this module; pair it with the + // head Tailscale clients so the whole environment is current. + Versions: []string{"head"}, + } + + scenario, err := NewScenario(spec) + + require.NoErrorf(t, err, "failed to create scenario: %s", err) + defer scenario.ShutdownAssertNoPanics(t) + + // The Tailscale JS/WASM client dials the control server as a WebSocket. + // client_js.go only honours a custom port for ws:// (plain HTTP); over + // wss:// it always targets :443, so it cannot reach a TLS control server on + // :8080. Run headscale without TLS, matching the http:// setup in the issue. + err = scenario.CreateHeadscaleEnv( + []tsic.Option{}, + hsic.WithTestName("ts2021wasm"), + hsic.WithoutTLS(), + ) + requireNoErrHeadscaleEnv(t, err) + + allClients, err := scenario.ListTailscaleClients() + requireNoErrListClients(t, err) + + allIPs, err := scenario.ListTailscaleClientsIPs() + requireNoErrListClientIPs(t, err) + + // Normal Tailscale clients come up and form a working tailnet alongside the + // WASM control client. + err = scenario.WaitForTailscaleSync() + requireNoErrSync(t, err) + + headscale, err := scenario.Headscale() + requireNoErrGetHeadscale(t, err) + + // Sanity-check the tailnet the WASM client is joining: the normal clients + // must be able to reach each other. + allAddrs := lo.Map(allIPs, func(x netip.Addr, _ int) string { return x.String() }) + assertPingAll(t, allClients, allAddrs) + + services, err := scenario.Services("wasmnet") + require.NoError(t, err) + require.Len(t, services, 1, "expected the wasm client container") + + wasm := services[0] + controlURL := headscale.GetEndpoint() + + // Fetch the server's Noise key here and pass it to the WASM client: Go's + // net/http DNS resolver is unavailable under GOOS=js, so the client can only + // use the JS WebSocket transport, not an HTTP GET to /key. + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + + controlKey, err := fetchServerNoiseKey(ctx, &http.Client{Timeout: 15 * time.Second}, controlURL) + require.NoError(t, err) + controlKeyText, err := controlKey.MarshalText() + require.NoError(t, err) + + // Run the real js/wasm control client under Node: it dials /ts2021 as a + // WebSocket GET; success means the Noise handshake completed. + stdout, stderr, err := dockertestutil.ExecuteCommand( + wasm, + []string{"node", "/app/wasm_exec_node.js", "/app/client.wasm", controlURL, string(controlKeyText)}, + []string{}, + dockertestutil.ExecuteCommandTimeout(60*time.Second), + ) + t.Logf("wasm client stdout:\n%s", stdout) + t.Logf("wasm client stderr:\n%s", stderr) + + require.NoError(t, err, + "wasm control client must connect to /ts2021 over websocket (405 means the router rejected the GET)") + require.Contains(t, stdout, "WASM_TS2021_OK", + "wasm control client should report a completed Noise handshake") +} + +// wasmClientService builds and starts the Node + js/wasm control-client +// container (Dockerfile.wasmclient) on the given network so it can reach +// headscale by hostname. It idles; the test execs the client on demand. +func wasmClientService(s *Scenario, networkName string) (*dockertest.Resource, error) { + hash := rands.HexString(hsicOIDCMockHashLength) + hostname := "hs-wasmclient-" + hash + + network, ok := s.networks[s.prefixedNetworkName(networkName)] + if !ok { + return nil, fmt.Errorf("network does not exist: %s", networkName) //nolint:err113 + } + + runOpts := &dockertest.RunOptions{ + Name: hostname, + Networks: []*dockertest.Network{network}, + Env: []string{}, + } + dockertestutil.DockerAddIntegrationLabels(runOpts, "wasmclient") + + buildOpts := &dockertest.BuildOptions{ + Dockerfile: "Dockerfile.wasmclient", + ContextDir: dockerContextPath, + } + + resource, err := s.pool.BuildAndRunWithBuildOptions( + buildOpts, + runOpts, + dockertestutil.DockerRestartPolicy, + ) + if err != nil { + return nil, fmt.Errorf("building wasm client container: %w", err) + } + + return resource, nil +} + +// dialTS2021WebSocket opens /ts2021 as a WebSocket GET (subprotocol +// tailscale-control-protocol) and completes the Noise handshake, mirroring what +// tailscale.com/control/controlhttp/client_js.go does in a browser. It returns +// the established Noise connection, or an error (a router that only allows POST +// returns 405 here). +func dialTS2021WebSocket(t *testing.T, endpoint string, caCert []byte) (*controlbase.Conn, error) { + t.Helper() + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + t.Cleanup(cancel) + + u, err := url.Parse(endpoint) + require.NoError(t, err) + + httpClient := &http.Client{Timeout: 15 * time.Second} + + if u.Scheme == "https" { + pool := x509.NewCertPool() + pool.AppendCertsFromPEM(caCert) + httpClient.Transport = &http.Transport{ + TLSClientConfig: &tls.Config{RootCAs: pool, MinVersion: tls.VersionTLS12}, + } + } + + controlKey, err := fetchServerNoiseKey(ctx, httpClient, endpoint) + require.NoError(t, err) + + init, cont, err := controlbase.ClientDeferred( + key.NewMachine(), + controlKey, + uint16(tailcfg.CurrentCapabilityVersion), + ) + require.NoError(t, err) + + wsScheme := "ws" + if u.Scheme == "https" { + wsScheme = "wss" + } + + wsURL := &url.URL{ + Scheme: wsScheme, + Host: u.Host, + Path: "/ts2021", + RawQuery: url.Values{ + controlhttpcommon.HandshakeHeaderName: []string{base64.StdEncoding.EncodeToString(init)}, + }.Encode(), + } + + wsConn, resp, err := websocket.Dial(ctx, wsURL.String(), &websocket.DialOptions{ + Subprotocols: []string{controlhttpcommon.UpgradeHeaderValue}, + HTTPClient: httpClient, + }) + if resp != nil && resp.Body != nil { + _ = resp.Body.Close() + } + + if err != nil { + return nil, err + } + + netConn := wsconn.NetConn(ctx, wsConn, websocket.MessageBinary, wsURL.String()) + + cbConn, err := cont(ctx, netConn) + if err != nil { + _ = netConn.Close() + return nil, fmt.Errorf("noise handshake over websocket: %w", err) + } + + return cbConn, nil +} + +// fetchServerNoiseKey retrieves headscale's Noise public key from /key, the same +// endpoint a real client consults before dialing /ts2021. +func fetchServerNoiseKey( + ctx context.Context, + client *http.Client, + endpoint string, +) (key.MachinePublic, error) { + var zero key.MachinePublic + + keyURL := fmt.Sprintf("%s/key?v=%d", endpoint, tailcfg.CurrentCapabilityVersion) + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, keyURL, nil) + if err != nil { + return zero, err + } + + resp, err := client.Do(req) + if err != nil { + return zero, err + } + defer resp.Body.Close() + + var k tailcfg.OverTLSPublicKeyResponse + + err = json.NewDecoder(resp.Body).Decode(&k) + if err != nil { + return zero, fmt.Errorf("decoding /key response: %w", err) + } + + if k.PublicKey.IsZero() { + return zero, errors.New("server returned zero Noise public key") //nolint:err113 + } + + return k.PublicKey, nil +} diff --git a/integration/wasmic/wasmclient/main.go b/integration/wasmic/wasmclient/main.go new file mode 100644 index 00000000..dfc09925 --- /dev/null +++ b/integration/wasmic/wasmclient/main.go @@ -0,0 +1,103 @@ +//go:build js + +// Command wasmclient is a minimal Tailscale control client compiled to +// GOOS=js/GOARCH=wasm and run under Node. It exercises the real +// tailscale.com/control/controlhttp js/wasm dial path +// (control/controlhttp/client_js.go), which opens /ts2021 as a browser-style +// WebSocket GET — the exact transport a Tailscale JS/WASM client uses. +// +// It is the container-side half of the integration test guarding issue #3357: +// headscale must register /ts2021 for GET, not POST only, or the WebSocket +// upgrade is rejected with 405 before the Noise handshake can start. +// +// It is intentionally not the full tsconnect IPN — the regression is entirely +// in the control-connection upgrade, and this drives the real upgrade code with +// the smallest possible harness. On success it prints wasmSuccessMarker and +// exits 0; on any failure it prints wasmFailureMarker and exits non-zero. +package main + +import ( + "context" + "fmt" + "net/url" + "os" + "time" + + "tailscale.com/control/controlhttp" + "tailscale.com/tailcfg" + "tailscale.com/types/key" +) + +// These markers are matched by the integration test on the client's stdout. +const ( + wasmSuccessMarker = "WASM_TS2021_OK" + wasmFailureMarker = "WASM_TS2021_FAIL" +) + +func main() { + if len(os.Args) < 3 { + fmt.Printf("%s: usage: wasmclient \n", wasmFailureMarker) + os.Exit(2) + } + + if err := run(os.Args[1], os.Args[2]); err != nil { + fmt.Printf("%s: %v\n", wasmFailureMarker, err) + os.Exit(1) + } +} + +// run dials /ts2021 exactly as tailscale.com/control/controlhttp/client_js.go +// does in a browser: a WebSocket GET via the JS/undici WebSocket. The server's +// Noise key is passed in (the test fetches /key) rather than fetched here, +// because Go's net/http DNS resolver is unavailable under GOOS=js — only the +// WebSocket transport, which runs through the JS host, works. +func run(controlURL, noiseKeyText string) error { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + u, err := url.Parse(controlURL) + if err != nil { + return fmt.Errorf("parse control url %q: %w", controlURL, err) + } + + var controlKey key.MachinePublic + if err := controlKey.UnmarshalText([]byte(noiseKeyText)); err != nil { + return fmt.Errorf("parse noise key %q: %w", noiseKeyText, err) + } + + port := u.Port() + if port == "" { + if u.Scheme == "https" { + port = "443" + } else { + port = "80" + } + } + + // client_js.go selects ws:// (and appends the port) only when HTTPPort is a + // custom non-80 port and HTTPS is 443 or disabled; otherwise it dials wss:// + // on the default port. Set the fields to match the server's actual scheme. + d := &controlhttp.Dialer{ + Hostname: u.Hostname(), + MachineKey: key.NewMachine(), + ControlKey: controlKey, + ProtocolVersion: uint16(tailcfg.CurrentCapabilityVersion), + } + if u.Scheme == "https" { + d.HTTPSPort = port + } else { + d.HTTPPort = port + d.HTTPSPort = controlhttp.NoPort + } + + conn, err := d.Dial(ctx) + if err != nil { + return fmt.Errorf("ts2021 websocket dial: %w", err) + } + defer conn.Close() + + fmt.Printf("%s: noise established over websocket, protocol version %d\n", + wasmSuccessMarker, conn.ProtocolVersion()) + + return nil +} diff --git a/integration/wasmic/wasmclient/stub.go b/integration/wasmic/wasmclient/stub.go new file mode 100644 index 00000000..10ee01de --- /dev/null +++ b/integration/wasmic/wasmclient/stub.go @@ -0,0 +1,8 @@ +//go:build !js + +// This package only does something when built for GOOS=js (see main.go). The +// stub exists so `go build ./...` and `go vet ./...` on the host don't fail with +// "build constraints exclude all Go files" for this directory. +package main + +func main() {}