Files
netbird/shared/relay/client/dialer/wt/wt_js.go
Claude 90435860b4 wasm relay: harden WebTransport conn lifetime
Two real bugs in the WASM WebTransport conn that would only show up
under conn teardown:

- awaitPromise released its js.Func callbacks on ctx cancellation, so a
  pending datagram read/write promise that later settled (closing the
  WebTransport rejects every in-flight promise) tried to invoke a
  released Go function and crashed the WASM module. The callbacks now
  release themselves exactly once from inside the settlement path; ctx
  cancellation only releases the Go-side waiter.

- Read and Write returned ErrClosedByServer on transport errors but
  didn't mark the conn closed, so the relay client's next Read could
  block on a fresh reader.read() promise instead of short-circuiting.
  Both paths now call markClosed before returning the error.
2026-05-17 11:38:32 +00:00

157 lines
4.4 KiB
Go

//go:build js
// Package wt is the browser/WASM WebTransport dialer for the relay client.
//
// WebTransport is the only browser-exposed primitive over HTTP/3 that gives us
// raw bidi-capable QUIC sessions with datagrams. The relay protocol is
// message-framed and small (<= 8 KB) so we use datagrams here, matching the
// raw-QUIC native dialer's semantics (no head-of-line blocking, unreliable).
//
// In production builds the browser performs normal TLS validation against the
// system trust store. Under the `devcert` build tag the server publishes a
// short-lived ECDSA self-signed cert; the WASM client pins its SHA-256 hash
// through `serverCertificateHashes` so the browser will accept it without a
// trusted CA.
package wt
import (
"context"
"errors"
"fmt"
"net"
"net/url"
"sync"
"syscall/js"
log "github.com/sirupsen/logrus"
"github.com/netbirdio/netbird/shared/relay"
relaytls "github.com/netbirdio/netbird/shared/relay/tls"
)
// Network is the protocol identifier reported via Dialer.Protocol.
const Network = "wt"
type Dialer struct{}
func (Dialer) Protocol() string { return Network }
func (Dialer) Dial(ctx context.Context, address, serverName string) (net.Conn, error) {
wtURL, err := prepareURL(address)
if err != nil {
return nil, err
}
jsWebTransport := js.Global().Get("WebTransport")
if !jsWebTransport.Truthy() {
return nil, errors.New("WebTransport is not supported in this browser")
}
opts := map[string]interface{}{}
if hash := relaytls.DevCertHash(); hash != nil {
u8 := js.Global().Get("Uint8Array").New(len(hash))
js.CopyBytesToJS(u8, hash)
opts["serverCertificateHashes"] = []interface{}{
map[string]interface{}{"algorithm": "sha-256", "value": u8},
}
}
wt := jsWebTransport.New(wtURL, opts)
if _, err := awaitPromise(ctx, wt.Get("ready")); err != nil {
_ = safeCall(wt, "close")
return nil, fmt.Errorf("WebTransport handshake to %s: %w", wtURL, err)
}
log.Debugf("WebTransport session established to %s", wtURL)
return newConn(wt, address), nil
}
// prepareURL rewrites a rels://host[:port] address into the https URL the
// browser dials. Plain rel:// is not supported — WebTransport requires HTTPS.
func prepareURL(address string) (string, error) {
parsed, err := url.Parse(address)
if err != nil {
return "", fmt.Errorf("parse relay address %q: %w", address, err)
}
switch parsed.Scheme {
case "rels":
parsed.Scheme = "https"
case "rel":
return "", errors.New("WebTransport requires TLS; use rels:// not rel://")
default:
return "", fmt.Errorf("unsupported scheme: %s", parsed.Scheme)
}
if parsed.Host == "" {
return "", fmt.Errorf("missing host in relay address %q", address)
}
parsed.Path = relay.WebSocketURLPath
return parsed.String(), nil
}
// awaitPromise bridges a JS Promise to a Go return.
//
// js.Func release timing is the subtle part: if we released the callbacks on
// ctx cancellation, the JS engine would later try to invoke a dead Go
// function when the promise eventually settled (closing the WebTransport
// rejects every pending read/write promise) and crash the WASM module. So
// the callbacks always release themselves from inside the settlement path,
// regardless of whether the Go side is still listening. Ctx cancellation
// only releases the Go-side goroutine; the JS-side callbacks live until JS
// runs them once.
func awaitPromise(ctx context.Context, p js.Value) (js.Value, error) {
type res struct {
val js.Value
err error
}
ch := make(chan res, 1)
var (
thenFn, catchFn js.Func
releaseOnce sync.Once
)
release := func() {
releaseOnce.Do(func() {
thenFn.Release()
catchFn.Release()
})
}
thenFn = js.FuncOf(func(_ js.Value, args []js.Value) interface{} {
var v js.Value
if len(args) > 0 {
v = args[0]
}
select {
case ch <- res{val: v}:
default:
}
release()
return nil
})
catchFn = js.FuncOf(func(_ js.Value, args []js.Value) interface{} {
msg := "promise rejected"
if len(args) > 0 && args[0].Truthy() {
msg = args[0].Call("toString").String()
}
select {
case ch <- res{err: errors.New(msg)}:
default:
}
release()
return nil
})
p.Call("then", thenFn).Call("catch", catchFn)
select {
case r := <-ch:
return r.val, r.err
case <-ctx.Done():
return js.Value{}, ctx.Err()
}
}
// safeCall invokes a js method and swallows panics from a dead JS object.
func safeCall(v js.Value, method string, args ...interface{}) (out js.Value) {
defer func() { _ = recover() }()
out = v.Call(method, args...)
return
}