mirror of
https://github.com/juanfont/headscale.git
synced 2026-07-08 00:50:20 +09:00
f4fba32dc6
A raw coder/websocket dial and the real tailscale.com js/wasm control client under Node, both against headscale alongside normal Tailscale clients. Updates #3357
316 lines
9.9 KiB
Go
316 lines
9.9 KiB
Go
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
|
|
}
|