mirror of
https://github.com/netbirdio/netbird.git
synced 2026-05-20 15:49:55 +00:00
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:
@@ -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
5
go.mod
@@ -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
8
go.sum
@@ -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=
|
||||
|
||||
@@ -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
|
||||
|
||||
74
relay/server/listener/wt/conn.go
Normal file
74
relay/server/listener/wt/conn.go
Normal 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
|
||||
}
|
||||
97
relay/server/listener/wt/listener.go
Normal file
97
relay/server/listener/wt/listener.go
Normal 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()
|
||||
}
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
140
shared/relay/client/dialer/wt/conn_js.go
Normal file
140
shared/relay/client/dialer/wt/conn_js.go
Normal 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") }
|
||||
142
shared/relay/client/dialer/wt/wt_js.go
Normal file
142
shared/relay/client/dialer/wt/wt_js.go
Normal 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
|
||||
}
|
||||
22
shared/relay/client/dialer/wt/wt_other.go
Normal file
22
shared/relay/client/dialer/wt/wt_other.go
Normal 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")
|
||||
}
|
||||
@@ -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{}}
|
||||
}
|
||||
|
||||
37
shared/relay/client/endpoint.go
Normal file
37
shared/relay/client/endpoint.go
Normal 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
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user