Files
netbird/shared/relay/client/dialer/race_dialer.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

149 lines
4.1 KiB
Go

package dialer
import (
"context"
"errors"
"net"
"time"
log "github.com/sirupsen/logrus"
)
const (
DefaultConnectionTimeout = 30 * time.Second
)
type DialeFn interface {
// Dial connects to address. serverName, when non-empty, overrides the TLS
// ServerName used for SNI/cert validation. Empty means derive from address.
Dial(ctx context.Context, address, serverName string) (net.Conn, error)
Protocol() string
}
type dialResult struct {
Conn net.Conn
Protocol string
Err error
}
type RaceDial struct {
log *log.Entry
serverURL string
serverName string
dialerFns []DialeFn
connectionTimeout time.Duration
transportHint []string
}
func NewRaceDial(log *log.Entry, connectionTimeout time.Duration, serverURL string, dialerFns ...DialeFn) *RaceDial {
return &RaceDial{
log: log,
serverURL: serverURL,
dialerFns: dialerFns,
connectionTimeout: connectionTimeout,
}
}
// WithServerName sets a TLS SNI/cert validation override. Used when serverURL
// contains an IP literal but the cert is issued for a different hostname.
//
// Mutates the receiver and is not safe for concurrent reconfiguration; a
// RaceDial is intended to be constructed per dial and discarded.
func (r *RaceDial) WithServerName(serverName string) *RaceDial {
r.serverName = serverName
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) {
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 dialers {
go r.dial(dfn, abortCtx, connChan)
}
go r.processResults(connChan, winnerConn, abort, len(dialers))
conn, ok := <-winnerConn
if !ok {
return nil, errors.New("failed to dial to Relay server on any protocol")
}
return conn, nil
}
func (r *RaceDial) dial(dfn DialeFn, abortCtx context.Context, connChan chan dialResult) {
ctx, cancel := context.WithTimeout(abortCtx, r.connectionTimeout)
defer cancel()
r.log.Infof("dialing Relay server via %s", dfn.Protocol())
conn, err := dfn.Dial(ctx, r.serverURL, r.serverName)
connChan <- dialResult{Conn: conn, Protocol: dfn.Protocol(), Err: err}
}
func (r *RaceDial) processResults(connChan chan dialResult, winnerConn chan net.Conn, abort context.CancelFunc, total int) {
var hasWinner bool
for i := 0; i < total; i++ {
dr := <-connChan
if dr.Err != nil {
if errors.Is(dr.Err, context.Canceled) {
r.log.Infof("connection attempt aborted via: %s", dr.Protocol)
} else {
r.log.Errorf("failed to dial via %s: %s", dr.Protocol, dr.Err)
}
continue
}
if hasWinner {
if cerr := dr.Conn.Close(); cerr != nil {
r.log.Warnf("failed to close connection via %s: %s", dr.Protocol, cerr)
}
continue
}
r.log.Infof("successfully dialed via: %s", dr.Protocol)
abort()
hasWinner = true
winnerConn <- dr.Conn
}
close(winnerConn)
}