Files
netbird/shared/relay/client/dialer/wt/wt_js.go
Claude 078c323ef3 relay: add WebTransport listener + WASM client, share UDP/443 via ALPN mux
The relay now accepts WebTransport sessions on the same UDP socket that
serves raw QUIC. The ALPN-multiplexing QUIC listener owns the socket and
dispatches incoming connections: "nb-quic" continues to the existing
relay handler, "h3" is handed to webtransport-go via http3.Server.
Browsers reach the relay over 443/udp without a second port.

Client side:
- Native builds keep using raw QUIC (no WT dialer registered).
- WASM/browser builds gain a WebTransport dialer that bridges syscall/js
  to the browser's WebTransport API and uses datagrams (matching the
  native QUIC dialer's semantics — no head-of-line blocking).
- The race dialer learned a transport hint so clients skip dialers a
  given relay has not advertised.

Management protocol carries the hint as a new RelayEndpoint{url,
transports[]} list on RelayConfig, mirroring how peers and proxies
announce capabilities. Older management servers that only send urls keep
working unchanged.

devcert build: relay generates an ECDSA P-256 cert with 13-day validity
(within the WebTransport serverCertificateHashes 14-day cap) and exposes
its SHA-256 so the WASM dialer can pin it.

Bumps quic-go v0.55.0 -> v0.59.0 (no API breaks for relay's importers)
and adds github.com/quic-go/webtransport-go v0.10.0.
2026-05-17 11:08:30 +00:00

143 lines
3.9 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"
"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. It respects ctx
// cancellation and releases its js.Func callbacks on the resolve/reject path.
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
release := 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:
}
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:
}
return nil
})
p.Call("then", thenFn).Call("catch", catchFn)
select {
case r := <-ch:
release()
return r.val, r.err
case <-ctx.Done():
release()
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
}