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.
This commit is contained in:
Claude
2026-05-17 11:08:30 +00:00
parent 3f91f49277
commit 078c323ef3
21 changed files with 1693 additions and 784 deletions

View File

@@ -952,8 +952,20 @@ func (e *Engine) handleRelayUpdate(update *mgmProto.RelayConfig) error {
if override, ok := peer.OverrideRelayURLs(); ok {
log.Infof("overriding relay URLs from %s: %v", peer.EnvKeyNBHomeRelayServers, override)
urls = override
e.relayManager.UpdateServerURLs(urls)
} else if eps := update.GetEndpoints(); len(eps) > 0 {
// Management announced per-relay transport hints; use the rich form.
converted := make([]relayClient.ServerEndpoint, len(eps))
for i, ep := range eps {
converted[i] = relayClient.ServerEndpoint{
URL: ep.GetUrl(),
Transports: ep.GetTransports(),
}
}
e.relayManager.UpdateServerEndpoints(converted)
} else {
e.relayManager.UpdateServerURLs(urls)
}
e.relayManager.UpdateServerURLs(urls)
// Just in case the agent started with an MGM server where the relay was disabled but was later enabled.
// We can ignore all errors because the guard will manage the reconnection retries.

5
go.mod
View File

@@ -91,7 +91,7 @@ require (
github.com/pires/go-proxyproto v0.11.0
github.com/pkg/sftp v1.13.9
github.com/prometheus/client_golang v1.23.2
github.com/quic-go/quic-go v0.55.0
github.com/quic-go/quic-go v0.59.0
github.com/redis/go-redis/v9 v9.7.3
github.com/rs/xid v1.3.0
github.com/shirou/gopsutil/v3 v3.24.4
@@ -182,6 +182,7 @@ require (
github.com/docker/docker v28.0.1+incompatible // indirect
github.com/docker/go-connections v0.6.0 // indirect
github.com/docker/go-units v0.5.0 // indirect
github.com/dunglas/httpsfv v1.1.0 // indirect
github.com/felixge/httpsnoop v1.0.4 // indirect
github.com/fredbi/uri v1.1.1 // indirect
github.com/fxamacker/cbor/v2 v2.9.1 // indirect
@@ -294,6 +295,8 @@ require (
github.com/prometheus/common v0.67.5 // indirect
github.com/prometheus/otlptranslator v1.0.0 // indirect
github.com/prometheus/procfs v0.19.2 // indirect
github.com/quic-go/qpack v0.6.0 // indirect
github.com/quic-go/webtransport-go v0.10.0 // indirect
github.com/russellhaering/goxmldsig v1.6.0 // indirect
github.com/ryanuber/go-glob v1.0.0 // indirect
github.com/rymdport/portal v0.4.2 // indirect

8
go.sum
View File

@@ -148,6 +148,8 @@ github.com/docker/go-connections v0.6.0 h1:LlMG9azAe1TqfR7sO+NJttz1gy6KO7VJBh+pM
github.com/docker/go-connections v0.6.0/go.mod h1:AahvXYshr6JgfUJGdDCs2b5EZG/vmaMAntpSFH5BFKE=
github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4=
github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk=
github.com/dunglas/httpsfv v1.1.0 h1:Jw76nAyKWKZKFrpMMcL76y35tOpYHqQPzHQiwDvpe54=
github.com/dunglas/httpsfv v1.1.0/go.mod h1:zID2mqw9mFsnt7YC3vYQ9/cjq30q41W+1AnDwH8TiMg=
github.com/ebitengine/purego v0.8.4 h1:CF7LEKg5FFOsASUj0+QwaXf8Ht6TlFxg09+S9wz0omw=
github.com/ebitengine/purego v0.8.4/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
github.com/eko/gocache/lib/v4 v4.2.0 h1:MNykyi5Xw+5Wu3+PUrvtOCaKSZM1nUSVftbzmeC7Yuw=
@@ -594,8 +596,14 @@ github.com/prometheus/otlptranslator v1.0.0 h1:s0LJW/iN9dkIH+EnhiD3BlkkP5QVIUVEo
github.com/prometheus/otlptranslator v1.0.0/go.mod h1:vRYWnXvI6aWGpsdY/mOT/cbeVRBlPWtBNDb7kGR3uKM=
github.com/prometheus/procfs v0.19.2 h1:zUMhqEW66Ex7OXIiDkll3tl9a1ZdilUOd/F6ZXw4Vws=
github.com/prometheus/procfs v0.19.2/go.mod h1:M0aotyiemPhBCM0z5w87kL22CxfcH05ZpYlu+b4J7mw=
github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8=
github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII=
github.com/quic-go/quic-go v0.55.0 h1:zccPQIqYCXDt5NmcEabyYvOnomjs8Tlwl7tISjJh9Mk=
github.com/quic-go/quic-go v0.55.0/go.mod h1:DR51ilwU1uE164KuWXhinFcKWGlEjzys2l8zUl5Ss1U=
github.com/quic-go/quic-go v0.59.0 h1:OLJkp1Mlm/aS7dpKgTc6cnpynnD2Xg7C1pwL6vy/SAw=
github.com/quic-go/quic-go v0.59.0/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU=
github.com/quic-go/webtransport-go v0.10.0 h1:LqXXPOXuETY5Xe8ITdGisBzTYmUOy5eSj+9n4hLTjHI=
github.com/quic-go/webtransport-go v0.10.0/go.mod h1:LeGIXr5BQKE3UsynwVBeQrU1TPrbh73MGoC6jd+V7ow=
github.com/redis/go-redis/v9 v9.7.3 h1:YpPyAayJV+XErNsatSElgRZZVCwXX9QzkKYNvO7x0wM=
github.com/redis/go-redis/v9 v9.7.3/go.mod h1:bGUrSggJ9X9GUmZpZNEOQKaANxSGgOEBRltRTZHSvrA=
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=

View File

@@ -1,3 +1,13 @@
// Package quic implements the UDP listener for the relay server. It owns a
// single net.PacketConn and a quic.Transport, and demultiplexes incoming QUIC
// connections by negotiated ALPN:
//
// - "nb-quic" -> raw QUIC relay transport (existing protocol)
// - "h3" -> HTTP/3, used by the WebTransport listener (optional)
//
// One UDP socket (typically 443/udp) carries both transports, so browsers
// using WebTransport and native clients using raw QUIC traverse the same
// firewall-friendly port.
package quic
import (
@@ -5,6 +15,9 @@ import (
"crypto/tls"
"errors"
"fmt"
"net"
"net/http"
"github.com/quic-go/quic-go"
log "github.com/sirupsen/logrus"
@@ -12,46 +25,99 @@ import (
"github.com/netbirdio/netbird/relay/protocol"
relaylistener "github.com/netbirdio/netbird/relay/server/listener"
nbRelay "github.com/netbirdio/netbird/shared/relay"
relaytls "github.com/netbirdio/netbird/shared/relay/tls"
)
const Proto protocol.Protocol = "quic"
// H3Handler serves a single accepted QUIC connection that negotiated the "h3"
// ALPN. The WebTransport listener implements this by delegating to its
// embedded *http3.Server.ServeQUICConn.
type H3Handler interface {
ServeQUICConn(conn *quic.Conn) error
}
// Listener owns the UDP socket and routes accepted QUIC connections by ALPN.
//
// If H3 is nil, only the raw QUIC ALPN is offered and the listener behaves
// exactly like the legacy single-ALPN listener. When H3 is set, both ALPNs
// are offered and h3 connections are handed off to H3.ServeQUICConn.
type Listener struct {
// Address is the address to listen on
Address string
// TLSConfig is the TLS configuration for the server
Address string
TLSConfig *tls.Config
listener *quic.Listener
// H3 is an optional HTTP/3 handler (typically a *http3.Server wrapped by
// a webtransport.Server) that takes over connections negotiating the "h3"
// ALPN. Set this before calling Listen.
H3 H3Handler
udpConn *net.UDPConn
transport *quic.Transport
listener *quic.Listener
}
func (l *Listener) Listen(acceptFn func(conn relaylistener.Conn)) error {
quicCfg := &quic.Config{
udpAddr, err := net.ResolveUDPAddr("udp", l.Address)
if err != nil {
return fmt.Errorf("resolve UDP address %q: %w", l.Address, err)
}
udpConn, err := net.ListenUDP("udp", udpAddr)
if err != nil {
return fmt.Errorf("listen UDP %q: %w", l.Address, err)
}
l.udpConn = udpConn
l.transport = &quic.Transport{Conn: udpConn}
tlsCfg := l.TLSConfig.Clone()
if l.H3 != nil {
tlsCfg.NextProtos = []string{relaytls.NBalpn, relaytls.H3alpn}
log.Infof("QUIC listener on %s with HTTP/3 (WebTransport) ALPN mux", l.Address)
} else {
tlsCfg.NextProtos = []string{relaytls.NBalpn}
log.Infof("QUIC listener on %s (raw QUIC only)", l.Address)
}
listener, err := l.transport.Listen(tlsCfg, &quic.Config{
EnableDatagrams: true,
InitialPacketSize: nbRelay.QUICInitialPacketSize,
}
listener, err := quic.ListenAddr(l.Address, l.TLSConfig, quicCfg)
})
if err != nil {
return fmt.Errorf("failed to create QUIC listener: %v", err)
return fmt.Errorf("create QUIC listener: %w", err)
}
l.listener = listener
log.Infof("QUIC server listening on address: %s", l.Address)
for {
session, err := listener.Accept(context.Background())
conn, err := listener.Accept(context.Background())
if err != nil {
if errors.Is(err, quic.ErrServerClosed) {
return nil
}
log.Errorf("Failed to accept QUIC session: %v", err)
log.Errorf("accept QUIC connection: %v", err)
continue
}
go l.dispatch(conn, acceptFn)
}
}
log.Infof("QUIC client connected from: %s", session.RemoteAddr())
conn := NewConn(session)
acceptFn(conn)
func (l *Listener) dispatch(conn *quic.Conn, acceptFn func(conn relaylistener.Conn)) {
alpn := conn.ConnectionState().TLS.NegotiatedProtocol
switch alpn {
case relaytls.NBalpn:
log.Infof("raw QUIC client connected from %s", conn.RemoteAddr())
acceptFn(NewConn(conn))
case relaytls.H3alpn:
if l.H3 == nil {
log.Warnf("h3 ALPN negotiated but no H3 handler installed; closing %s", conn.RemoteAddr())
_ = conn.CloseWithError(0, "h3 unsupported")
return
}
log.Debugf("h3/WebTransport client connected from %s", conn.RemoteAddr())
if err := l.H3.ServeQUICConn(conn); err != nil && !errors.Is(err, http.ErrServerClosed) {
log.Debugf("h3 connection from %s ended: %v", conn.RemoteAddr(), err)
}
default:
log.Warnf("rejecting QUIC connection from %s: unexpected ALPN %q", conn.RemoteAddr(), alpn)
_ = conn.CloseWithError(0, "unsupported alpn")
}
}
@@ -60,13 +126,25 @@ func (l *Listener) Protocol() protocol.Protocol {
}
func (l *Listener) Shutdown(ctx context.Context) error {
if l.listener == nil {
return nil
}
log.Infof("stopping QUIC listener")
if err := l.listener.Close(); err != nil {
return fmt.Errorf("listener shutdown failed: %v", err)
var firstErr error
if l.listener != nil {
if err := l.listener.Close(); err != nil {
firstErr = fmt.Errorf("listener close: %w", err)
}
}
if l.transport != nil {
if err := l.transport.Close(); err != nil && firstErr == nil {
firstErr = fmt.Errorf("transport close: %w", err)
}
}
if l.udpConn != nil {
if err := l.udpConn.Close(); err != nil && firstErr == nil {
firstErr = fmt.Errorf("udp close: %w", err)
}
}
if firstErr != nil {
return firstErr
}
log.Infof("QUIC listener stopped")
return nil

View File

@@ -0,0 +1,74 @@
// Package wt provides the WebTransport server-side wrapper for the relay.
//
// The relay protocol is message-framed and tops out at ~8 KB, well under a
// single QUIC datagram. To preserve the unreliable/unordered semantics that
// raw QUIC offers today (no head-of-line blocking, drops match WireGuard's
// expectations), the WebTransport transport also uses datagrams rather than
// streams.
package wt
import (
"context"
"errors"
"net"
"sync"
"github.com/quic-go/webtransport-go"
)
type Conn struct {
session *webtransport.Session
closed bool
closedMu sync.Mutex
}
func NewConn(session *webtransport.Session) *Conn {
return &Conn{session: session}
}
func (c *Conn) Read(ctx context.Context, b []byte) (int, error) {
dgram, err := c.session.ReceiveDatagram(ctx)
if err != nil {
return 0, c.remoteCloseErrHandling(err)
}
return copy(b, dgram), nil
}
func (c *Conn) Write(_ context.Context, b []byte) (int, error) {
if err := c.session.SendDatagram(b); err != nil {
return 0, c.remoteCloseErrHandling(err)
}
return len(b), nil
}
func (c *Conn) RemoteAddr() net.Addr {
return c.session.RemoteAddr()
}
func (c *Conn) Close() error {
c.closedMu.Lock()
if c.closed {
c.closedMu.Unlock()
return nil
}
c.closed = true
c.closedMu.Unlock()
return c.session.CloseWithError(0, "normal closure")
}
func (c *Conn) isClosed() bool {
c.closedMu.Lock()
defer c.closedMu.Unlock()
return c.closed
}
func (c *Conn) remoteCloseErrHandling(err error) error {
if c.isClosed() {
return net.ErrClosed
}
var sessErr *webtransport.SessionError
if errors.As(err, &sessErr) && sessErr.ErrorCode == 0 {
return net.ErrClosed
}
return err
}

View File

@@ -0,0 +1,97 @@
package wt
import (
"context"
"crypto/tls"
"fmt"
"net/http"
"sync"
"github.com/quic-go/quic-go"
"github.com/quic-go/quic-go/http3"
"github.com/quic-go/webtransport-go"
log "github.com/sirupsen/logrus"
"github.com/netbirdio/netbird/relay/protocol"
relaylistener "github.com/netbirdio/netbird/relay/server/listener"
nbRelay "github.com/netbirdio/netbird/shared/relay"
)
const (
Proto protocol.Protocol = "wt"
// Path is the HTTP path the browser dials with `new WebTransport("https://host/relay")`.
Path = "/relay"
)
// Handler bridges WebTransport sessions into the relay's accept loop. It does
// not own a UDP socket — instead it exposes ServeQUICConn, which the
// ALPN-mux QUIC listener calls for connections that negotiated the "h3" ALPN.
type Handler struct {
// TLSConfig must include the "h3" ALPN. It is used by http3 for stream
// framing and 0-RTT handling.
TLSConfig *tls.Config
h3 *http3.Server
wt *webtransport.Server
once sync.Once
initErr error
}
func New(tlsCfg *tls.Config) *Handler {
return &Handler{TLSConfig: tlsCfg}
}
func (h *Handler) Protocol() protocol.Protocol { return Proto }
// Install wires the WebTransport HTTP handler. acceptFn receives every new
// session as a relay listener.Conn. Must be called before ServeQUICConn.
func (h *Handler) Install(acceptFn func(conn relaylistener.Conn)) error {
h.once.Do(func() {
mux := http.NewServeMux()
h.h3 = &http3.Server{
TLSConfig: h.TLSConfig,
QUICConfig: &quic.Config{
EnableDatagrams: true,
InitialPacketSize: nbRelay.QUICInitialPacketSize,
},
Handler: mux,
}
h.wt = &webtransport.Server{
H3: h.h3,
CheckOrigin: func(*http.Request) bool { return true },
}
mux.HandleFunc(Path, func(w http.ResponseWriter, r *http.Request) {
sess, err := h.wt.Upgrade(w, r)
if err != nil {
log.Warnf("WebTransport upgrade from %s failed: %v", r.RemoteAddr, err)
w.WriteHeader(http.StatusBadRequest)
return
}
log.Infof("WebTransport client connected from %s", sess.RemoteAddr())
acceptFn(NewConn(sess))
})
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
http.Error(w, "netbird relay: use "+Path+" for WebTransport", http.StatusNotFound)
})
})
return h.initErr
}
// ServeQUICConn satisfies the quic.H3Handler interface used by the
// ALPN-multiplexing QUIC listener.
func (h *Handler) ServeQUICConn(conn *quic.Conn) error {
if h.wt == nil {
return fmt.Errorf("WebTransport handler not installed")
}
return h.wt.ServeQUICConn(conn)
}
func (h *Handler) Shutdown(ctx context.Context) error {
if h.wt == nil {
return nil
}
return h.wt.Close()
}

View File

@@ -14,6 +14,7 @@ import (
"github.com/netbirdio/netbird/relay/server/listener"
"github.com/netbirdio/netbird/relay/server/listener/quic"
"github.com/netbirdio/netbird/relay/server/listener/ws"
"github.com/netbirdio/netbird/relay/server/listener/wt"
quictls "github.com/netbirdio/netbird/shared/relay/tls"
)
@@ -69,14 +70,27 @@ func (r *Server) Listen(cfg ListenerConfig) error {
r.listenerMux.Lock()
r.listeners = append(r.listeners, wSListener)
tlsConfigQUIC, err := quictls.ServerQUICTLSConfig(cfg.TLSConfig)
tlsConfigQUIC, err := quictls.ServerMuxTLSConfig(cfg.TLSConfig)
if err != nil {
log.Warnf("Not starting QUIC listener: %v", err)
} else {
// WebTransport handler shares the QUIC listener's UDP socket via ALPN
// multiplexing. http3 uses its own TLS config with only the "h3" ALPN.
wtTLS := tlsConfigQUIC.Clone()
wtTLS.NextProtos = []string{quictls.H3alpn}
wtHandler := wt.New(wtTLS)
if err := wtHandler.Install(r.relay.Accept); err != nil {
log.Warnf("WebTransport handler not installed: %v", err)
wtHandler = nil
}
quicListener := &quic.Listener{
Address: cfg.Address,
TLSConfig: tlsConfigQUIC,
}
if wtHandler != nil {
quicListener.H3 = wtHandler
}
r.listeners = append(r.listeners, quicListener)
}

File diff suppressed because it is too large Load Diff

View File

@@ -288,9 +288,29 @@ message HostConfig {
}
message RelayConfig {
// urls is the legacy flat list of relay addresses. Clients should prefer
// endpoints when present; urls remains populated for backward compatibility
// with older clients that don't understand RelayEndpoint.
repeated string urls = 1;
string tokenPayload = 2;
string tokenSignature = 3;
// endpoints carries per-relay transport capability hints so clients can
// skip dialers a given relay doesn't speak (e.g. only attempt WebTransport
// against relays that advertise "wt").
repeated RelayEndpoint endpoints = 4;
}
// RelayEndpoint announces a single relay server and the transports it speaks.
// Mirrors the peer/proxy capability-announcement pattern.
message RelayEndpoint {
// url is the relay address, e.g. rels://relay.example.com:443
string url = 1;
// transports lists the protocols the relay accepts. Known values:
// "ws" - WebSocket over TCP/443 (always supported by current relays)
// "quic" - raw QUIC over UDP (native clients only)
// "wt" - WebTransport over HTTP/3 (browser clients)
// Empty means "unknown — try all dialers".
repeated string transports = 2;
}
message FlowConfig {

View File

@@ -172,6 +172,17 @@ type Client struct {
stateSubscription *PeersStateSubscription
mtu uint16
// transportHint optionally restricts the race dialer to the listed
// protocols. Empty means try every locally compiled dialer.
transportHint []string
}
// SetTransportHint configures the relay client to only attempt the given
// transports during the dial race. Pass nil/empty to clear the restriction.
// Must be set before Connect.
func (c *Client) SetTransportHint(transports []string) {
c.transportHint = transports
}
// NewClient creates a new client for the relay server. The client is not connected to the server until the Connect
@@ -374,7 +385,8 @@ func (c *Client) connect(ctx context.Context) (*RelayAddr, error) {
}
if conn == nil {
rd := dialer.NewRaceDial(c.log, dialer.DefaultConnectionTimeout, c.connectionURL, dialers...)
rd := dialer.NewRaceDial(c.log, dialer.DefaultConnectionTimeout, c.connectionURL, dialers...).
WithTransportHint(c.transportHint)
var err error
conn, err = rd.Dial(ctx)
if err != nil {
@@ -405,7 +417,8 @@ func (c *Client) dialRaceDirect(ctx context.Context, dialers []dialer.DialeFn) (
c.log.Debugf("dialing via server IP %s (SNI=%s)", c.serverIP, serverName)
rd := dialer.NewRaceDial(c.log, dialer.DefaultConnectionTimeout, directURL, dialers...).
WithServerName(serverName)
WithServerName(serverName).
WithTransportHint(c.transportHint)
return rd.Dial(ctx)
}

View File

@@ -32,6 +32,7 @@ type RaceDial struct {
serverName string
dialerFns []DialeFn
connectionTimeout time.Duration
transportHint []string
}
func NewRaceDial(log *log.Entry, connectionTimeout time.Duration, serverURL string, dialerFns ...DialeFn) *RaceDial {
@@ -53,17 +54,53 @@ func (r *RaceDial) WithServerName(serverName string) *RaceDial {
return r
}
// WithTransportHint restricts the dial race to dialers whose Protocol() is
// listed in hint. An empty or nil hint means "try every configured dialer"
// (legacy behavior). Used to skip dialers a relay has advertised it doesn't
// support — e.g. don't burn a WebTransport handshake on an old relay.
func (r *RaceDial) WithTransportHint(hint []string) *RaceDial {
r.transportHint = hint
return r
}
// activeDialers returns the subset of dialerFns that match the transport hint.
// With no hint set, all dialers are returned.
func (r *RaceDial) activeDialers() []DialeFn {
if len(r.transportHint) == 0 {
return r.dialerFns
}
allowed := make(map[string]struct{}, len(r.transportHint))
for _, p := range r.transportHint {
allowed[p] = struct{}{}
}
out := make([]DialeFn, 0, len(r.dialerFns))
for _, d := range r.dialerFns {
if _, ok := allowed[d.Protocol()]; ok {
out = append(out, d)
}
}
if len(out) == 0 {
// Hint matched nothing the local build supports — fall back to all
// rather than fail with no dialers. Mirrors race-dialer's "try
// everything" default.
r.log.Debugf("transport hint %v matched no local dialer; falling back to all", r.transportHint)
return r.dialerFns
}
return out
}
func (r *RaceDial) Dial(ctx context.Context) (net.Conn, error) {
connChan := make(chan dialResult, len(r.dialerFns))
dialers := r.activeDialers()
connChan := make(chan dialResult, len(dialers))
winnerConn := make(chan net.Conn, 1)
abortCtx, abort := context.WithCancel(ctx)
defer abort()
for _, dfn := range r.dialerFns {
for _, dfn := range dialers {
go r.dial(dfn, abortCtx, connChan)
}
go r.processResults(connChan, winnerConn, abort)
go r.processResults(connChan, winnerConn, abort, len(dialers))
conn, ok := <-winnerConn
if !ok {
@@ -81,9 +118,9 @@ func (r *RaceDial) dial(dfn DialeFn, abortCtx context.Context, connChan chan dia
connChan <- dialResult{Conn: conn, Protocol: dfn.Protocol(), Err: err}
}
func (r *RaceDial) processResults(connChan chan dialResult, winnerConn chan net.Conn, abort context.CancelFunc) {
func (r *RaceDial) processResults(connChan chan dialResult, winnerConn chan net.Conn, abort context.CancelFunc, total int) {
var hasWinner bool
for i := 0; i < len(r.dialerFns); i++ {
for i := 0; i < total; i++ {
dr := <-connChan
if dr.Err != nil {
if errors.Is(dr.Err, context.Canceled) {

View File

@@ -0,0 +1,140 @@
//go:build js
package wt
import (
"context"
"errors"
"fmt"
"io"
"net"
"sync"
"syscall/js"
"time"
netErr "github.com/netbirdio/netbird/shared/relay/client/dialer/net"
)
// addr satisfies net.Addr for the WebTransport-backed conn. The remote address
// is opaque (the browser doesn't expose the underlying UDP 4-tuple), so we
// surface the dial URL instead.
type addr struct{ s string }
func (a addr) Network() string { return Network }
func (a addr) String() string { return a.s }
// conn wraps a WebTransport session and implements net.Conn over its datagram
// channels. Each Read consumes exactly one inbound datagram (= one relay
// message); each Write transmits exactly one (= one relay message). This
// preserves the message-boundary semantics the relay framing assumes.
type conn struct {
wt js.Value
writer js.Value // datagrams.writable.getWriter()
reader js.Value // datagrams.readable.getReader()
ctx context.Context
cancel context.CancelFunc
closeOnce sync.Once
closed chan struct{}
remote addr
}
func newConn(wt js.Value, dialURL string) *conn {
ctx, cancel := context.WithCancel(context.Background())
c := &conn{
wt: wt,
writer: wt.Get("datagrams").Get("writable").Call("getWriter"),
reader: wt.Get("datagrams").Get("readable").Call("getReader"),
ctx: ctx,
cancel: cancel,
closed: make(chan struct{}),
remote: addr{s: dialURL},
}
// Best-effort close detection: when the session closes, surface it as
// net.ErrClosed on subsequent ops.
go c.watchClosed()
return c
}
func (c *conn) watchClosed() {
closedP := c.wt.Get("closed")
if !closedP.Truthy() {
return
}
_, _ = awaitPromise(c.ctx, closedP)
c.markClosed()
}
func (c *conn) Read(b []byte) (int, error) {
for {
select {
case <-c.closed:
return 0, net.ErrClosed
default:
}
readP := c.reader.Call("read")
v, err := awaitPromise(c.ctx, readP)
if err != nil {
if errors.Is(err, context.Canceled) {
return 0, net.ErrClosed
}
return 0, netErr.ErrClosedByServer
}
if v.Get("done").Bool() {
c.markClosed()
return 0, io.EOF
}
val := v.Get("value")
if !val.Truthy() {
continue
}
n := val.Get("byteLength").Int()
if n > len(b) {
// Datagrams shouldn't exceed the relay's MaxMessageSize (8 KB) so
// this branch is defensive — truncate rather than fail hard.
n = len(b)
}
js.CopyBytesToGo(b[:n], val)
return n, nil
}
}
func (c *conn) Write(b []byte) (int, error) {
select {
case <-c.closed:
return 0, net.ErrClosed
default:
}
u8 := js.Global().Get("Uint8Array").New(len(b))
js.CopyBytesToJS(u8, b)
writeP := c.writer.Call("write", u8)
if _, err := awaitPromise(c.ctx, writeP); err != nil {
if errors.Is(err, context.Canceled) {
return 0, net.ErrClosed
}
return 0, netErr.ErrClosedByServer
}
return len(b), nil
}
func (c *conn) Close() error {
c.markClosed()
_ = safeCall(c.wt, "close")
return nil
}
func (c *conn) markClosed() {
c.closeOnce.Do(func() {
c.cancel()
close(c.closed)
})
}
func (c *conn) LocalAddr() net.Addr { return addr{s: "wasm"} }
func (c *conn) RemoteAddr() net.Addr { return c.remote }
func (c *conn) SetDeadline(time.Time) error { return nil }
func (c *conn) SetReadDeadline(time.Time) error { return fmt.Errorf("SetReadDeadline not implemented") }
func (c *conn) SetWriteDeadline(time.Time) error { return fmt.Errorf("SetWriteDeadline not implemented") }

View File

@@ -0,0 +1,142 @@
//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
}

View File

@@ -0,0 +1,22 @@
//go:build !js
// Package wt's WebTransport dialer is browser-only. This stub keeps the
// package importable from non-WASM builds (for tooling, `go vet`, etc.) without
// pulling in syscall/js. The Dialer here returns an error if used.
package wt
import (
"context"
"errors"
"net"
)
const Network = "wt"
type Dialer struct{}
func (Dialer) Protocol() string { return Network }
func (Dialer) Dial(_ context.Context, _, _ string) (net.Conn, error) {
return nil, errors.New("WebTransport dialer is only available in WASM builds")
}

View File

@@ -5,9 +5,16 @@ package client
import (
"github.com/netbirdio/netbird/shared/relay/client/dialer"
"github.com/netbirdio/netbird/shared/relay/client/dialer/ws"
"github.com/netbirdio/netbird/shared/relay/client/dialer/wt"
)
// getDialers returns the dialers used by the WASM/browser relay client.
//
// WebTransport is tried alongside WebSocket via the race dialer: whichever
// handshake completes first wins. WT loses fast against a relay that doesn't
// speak h3 (TLS no_application_protocol within one RTT), so the fallback to
// WS is cheap. The WASM client never uses raw QUIC — browsers don't expose
// UDP sockets.
func (c *Client) getDialers() []dialer.DialeFn {
// JS/WASM build only uses WebSocket transport
return []dialer.DialeFn{ws.Dialer{}}
return []dialer.DialeFn{wt.Dialer{}, ws.Dialer{}}
}

View File

@@ -0,0 +1,37 @@
package client
// ServerEndpoint announces a relay server along with the transports it speaks.
// Carried via the management RelayConfig.endpoints field; falls back to a
// URL-only entry (Transports == nil, meaning "try all dialers") for back-compat
// with older management servers that only sent flat URLs.
type ServerEndpoint struct {
URL string
Transports []string
}
// EndpointsFromURLs builds a list of hint-less endpoints from a flat URL list.
// Used when the management server sends only RelayConfig.urls (no per-relay
// capability metadata).
func EndpointsFromURLs(urls []string) []ServerEndpoint {
if len(urls) == 0 {
return nil
}
out := make([]ServerEndpoint, len(urls))
for i, u := range urls {
out[i] = ServerEndpoint{URL: u}
}
return out
}
// URLsFromEndpoints projects a list of endpoints back to a flat URL slice,
// preserving order. Used by call sites that don't yet consume transport hints.
func URLsFromEndpoints(endpoints []ServerEndpoint) []string {
if len(endpoints) == 0 {
return nil
}
out := make([]string, len(endpoints))
for i, e := range endpoints {
out[i] = e.URL
}
return out
}

View File

@@ -244,6 +244,16 @@ func (m *Manager) HasRelayAddress() bool {
func (m *Manager) UpdateServerURLs(serverURLs []string) {
log.Infof("update relay server URLs: %v", serverURLs)
m.serverPicker.ServerURLs.Store(serverURLs)
m.serverPicker.Endpoints.Store(EndpointsFromURLs(serverURLs))
}
// UpdateServerEndpoints replaces the picker's relay endpoint list, including
// per-relay transport capability hints announced by the management server.
// Callers that don't have hints should keep using UpdateServerURLs.
func (m *Manager) UpdateServerEndpoints(endpoints []ServerEndpoint) {
log.Infof("update relay endpoints: %d entries", len(endpoints))
m.serverPicker.ServerURLs.Store(URLsFromEndpoints(endpoints))
m.serverPicker.Endpoints.Store(endpoints)
}
// UpdateToken updates the token in the token store.

View File

@@ -24,33 +24,55 @@ type connResult struct {
}
type ServerPicker struct {
TokenStore *auth.TokenStore
TokenStore *auth.TokenStore
// ServerURLs holds the legacy flat list of relay URLs, kept for callers
// that don't yet thread through transport hints. Endpoints, when set, is
// the source of truth — ServerURLs is derived from it.
ServerURLs atomic.Value
Endpoints atomic.Value // []ServerEndpoint
PeerID string
MTU uint16
ConnectionTimeout time.Duration
}
// loadEndpoints returns the per-endpoint list, falling back to a URL-only
// projection of ServerURLs if no endpoints have been set yet (older callers
// that still call UpdateServerURLs without the hint-aware path).
func (sp *ServerPicker) loadEndpoints() []ServerEndpoint {
if v := sp.Endpoints.Load(); v != nil {
if eps, ok := v.([]ServerEndpoint); ok && len(eps) > 0 {
return eps
}
}
if v := sp.ServerURLs.Load(); v != nil {
if urls, ok := v.([]string); ok {
return EndpointsFromURLs(urls)
}
}
return nil
}
func (sp *ServerPicker) PickServer(parentCtx context.Context) (*Client, error) {
ctx, cancel := context.WithTimeout(parentCtx, sp.ConnectionTimeout)
defer cancel()
totalServers := len(sp.ServerURLs.Load().([]string))
endpoints := sp.loadEndpoints()
totalServers := len(endpoints)
connResultChan := make(chan connResult, totalServers)
successChan := make(chan connResult, 1)
concurrentLimiter := make(chan struct{}, maxConcurrentServers)
log.Debugf("pick server from list: %v", sp.ServerURLs.Load().([]string))
for _, url := range sp.ServerURLs.Load().([]string) {
log.Debugf("pick server from list: %d endpoint(s)", totalServers)
for _, ep := range endpoints {
// todo check if we have a successful connection so we do not need to connect to other servers
concurrentLimiter <- struct{}{}
go func(url string) {
go func(ep ServerEndpoint) {
defer func() {
<-concurrentLimiter
}()
sp.startConnection(parentCtx, connResultChan, url)
}(url)
sp.startConnection(parentCtx, connResultChan, ep)
}(ep)
}
go sp.processConnResults(connResultChan, successChan)
@@ -67,13 +89,16 @@ func (sp *ServerPicker) PickServer(parentCtx context.Context) (*Client, error) {
}
}
func (sp *ServerPicker) startConnection(ctx context.Context, resultChan chan connResult, url string) {
log.Infof("try to connecting to relay server: %s", url)
relayClient := NewClient(url, sp.TokenStore, sp.PeerID, sp.MTU)
func (sp *ServerPicker) startConnection(ctx context.Context, resultChan chan connResult, ep ServerEndpoint) {
log.Infof("try to connecting to relay server: %s (transports=%v)", ep.URL, ep.Transports)
relayClient := NewClient(ep.URL, sp.TokenStore, sp.PeerID, sp.MTU)
if len(ep.Transports) > 0 {
relayClient.SetTransportHint(ep.Transports)
}
err := relayClient.Connect(ctx)
resultChan <- connResult{
RelayClient: relayClient,
Url: url,
Url: ep.URL,
Err: err,
}
}

View File

@@ -1,3 +1,10 @@
package tls
const NBalpn = "nb-quic"
const (
// NBalpn is the ALPN identifier for the raw QUIC relay transport.
NBalpn = "nb-quic"
// H3alpn is the ALPN identifier for HTTP/3, which carries WebTransport
// upgrades. Both ALPNs are offered on the same UDP socket so that 443/udp
// can serve raw QUIC clients and WebTransport (browser) clients side by side.
H3alpn = "h3"
)

View File

@@ -3,23 +3,56 @@
package tls
import (
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/rsa"
"crypto/sha256"
"crypto/tls"
"crypto/x509"
"crypto/x509/pkix"
"encoding/pem"
"math/big"
"net"
"sync"
"time"
log "github.com/sirupsen/logrus"
)
// devCertHash holds the SHA-256 hash of the latest generated dev certificate.
// The WASM WebTransport client reads it via DevCertHash() to pin the self-
// signed cert through serverCertificateHashes — browsers require an ECDSA cert
// with validity <= 14 days when this pinning mode is used.
var (
devCertHashMu sync.RWMutex
devCertHash []byte
)
// DevCertHash returns the SHA-256 hash of the dev TLS certificate, or nil if
// no dev cert has been generated yet. WASM clients can pass this through
// serverCertificateHashes on WebTransport handshake.
func DevCertHash() []byte {
devCertHashMu.RLock()
defer devCertHashMu.RUnlock()
if devCertHash == nil {
return nil
}
out := make([]byte, len(devCertHash))
copy(out, devCertHash)
return out
}
func setDevCertHash(certDER []byte) {
sum := sha256.Sum256(certDER)
devCertHashMu.Lock()
devCertHash = sum[:]
devCertHashMu.Unlock()
}
func ServerQUICTLSConfig(originTLSCfg *tls.Config) (*tls.Config, error) {
if originTLSCfg == nil {
log.Warnf("QUIC server will use self signed certificate for testing!")
return generateTestTLSConfig()
return generateTestTLSConfig([]string{NBalpn})
}
cfg := originTLSCfg.Clone()
@@ -27,10 +60,26 @@ func ServerQUICTLSConfig(originTLSCfg *tls.Config) (*tls.Config, error) {
return cfg, nil
}
// GenerateTestTLSConfig creates a self-signed certificate for testing
func generateTestTLSConfig() (*tls.Config, error) {
log.Infof("generating test TLS config")
privateKey, err := rsa.GenerateKey(rand.Reader, 2048)
// ServerMuxTLSConfig returns a TLS config offering both ALPNs so a single UDP
// socket can serve raw QUIC and WebTransport clients.
func ServerMuxTLSConfig(originTLSCfg *tls.Config) (*tls.Config, error) {
if originTLSCfg == nil {
log.Warnf("QUIC/WT server will use self signed certificate for testing!")
return generateTestTLSConfig([]string{NBalpn, H3alpn})
}
cfg := originTLSCfg.Clone()
cfg.NextProtos = []string{NBalpn, H3alpn}
return cfg, nil
}
// generateTestTLSConfig creates a self-signed ECDSA P-256 certificate suitable
// for both raw QUIC and browser WebTransport. Validity is capped at 13 days so
// the cert remains usable with WebTransport serverCertificateHashes pinning
// (browser limit is 14 days).
func generateTestTLSConfig(alpns []string) (*tls.Config, error) {
log.Infof("generating test TLS config (ECDSA P-256, 13 day validity) for ALPNs %v", alpns)
privateKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
return nil, err
}
@@ -40,40 +89,36 @@ func generateTestTLSConfig() (*tls.Config, error) {
Subject: pkix.Name{
Organization: []string{"Test Organization"},
},
NotBefore: time.Now(),
NotAfter: time.Now().Add(time.Hour * 24 * 180), // Valid for 180 days
KeyUsage: x509.KeyUsageKeyEncipherment | x509.KeyUsageDigitalSignature,
ExtKeyUsage: []x509.ExtKeyUsage{
x509.ExtKeyUsageServerAuth,
},
NotBefore: time.Now().Add(-time.Hour),
NotAfter: time.Now().Add(time.Hour * 24 * 13),
KeyUsage: x509.KeyUsageDigitalSignature,
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
BasicConstraintsValid: true,
DNSNames: []string{"localhost"},
IPAddresses: []net.IP{net.ParseIP("127.0.0.1")},
}
// Create certificate
certDER, err := x509.CreateCertificate(rand.Reader, &template, &template, &privateKey.PublicKey, privateKey)
if err != nil {
return nil, err
}
setDevCertHash(certDER)
certPEM := pem.EncodeToMemory(&pem.Block{
Type: "CERTIFICATE",
Bytes: certDER,
})
keyDER, err := x509.MarshalECPrivateKey(privateKey)
if err != nil {
return nil, err
}
privateKeyPEM := pem.EncodeToMemory(&pem.Block{
Type: "RSA PRIVATE KEY",
Bytes: x509.MarshalPKCS1PrivateKey(privateKey),
})
certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: certDER})
keyPEM := pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: keyDER})
tlsCert, err := tls.X509KeyPair(certPEM, privateKeyPEM)
tlsCert, err := tls.X509KeyPair(certPEM, keyPEM)
if err != nil {
return nil, err
}
return &tls.Config{
Certificates: []tls.Certificate{tlsCert},
NextProtos: []string{NBalpn},
NextProtos: alpns,
}, nil
}

View File

@@ -7,6 +7,11 @@ import (
"fmt"
)
// DevCertHash returns nil in production builds. It exists so callers (notably
// the WASM WebTransport dialer) can probe for a self-signed dev cert hash
// without branching on build tags.
func DevCertHash() []byte { return nil }
func ServerQUICTLSConfig(originTLSCfg *tls.Config) (*tls.Config, error) {
if originTLSCfg == nil {
return nil, fmt.Errorf("valid TLS config is required for QUIC listener")
@@ -15,3 +20,15 @@ func ServerQUICTLSConfig(originTLSCfg *tls.Config) (*tls.Config, error) {
cfg.NextProtos = []string{NBalpn}
return cfg, nil
}
// ServerMuxTLSConfig returns a TLS config that advertises both the raw QUIC
// relay ALPN and HTTP/3. The ALPN-multiplexing UDP listener uses it to share a
// single socket between raw QUIC clients and WebTransport (browser) clients.
func ServerMuxTLSConfig(originTLSCfg *tls.Config) (*tls.Config, error) {
if originTLSCfg == nil {
return nil, fmt.Errorf("valid TLS config is required for QUIC/WT listener")
}
cfg := originTLSCfg.Clone()
cfg.NextProtos = []string{NBalpn, H3alpn}
return cfg, nil
}