mirror of
https://github.com/netbirdio/netbird.git
synced 2026-05-20 15:49:55 +00:00
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.
143 lines
3.9 KiB
Go
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
|
|
}
|