Add MASQUE/CONNECT-UDP support (RFC 9298)

Implement UDP tunneling over HTTP/3 using HTTP Datagrams (RFC 9297):
- Add masque handler for server-side CONNECT-UDP
- Add masque connector and h3-masque dialer for client-side
- Add enableDatagrams option to HTTP/3 listener
- Add shared utilities for datagram connections and path parsing

Resource management and connection caching:
- Add deferred stream cleanup in connector on error paths
- Add IsClosed() and Close() methods to Client for proper session management
- Clean up stale cached clients in dialer before reuse
- Close underlying stream when DatagramConn is closed
- Move RequestStream opening from connector to dialer to enable dead
  connection detection and cache invalidation (follows QUIC dialer pattern)
This commit is contained in:
David Manouchehri
2025-12-28 21:13:30 +00:00
committed by ginuerzh
parent b3b5986b63
commit 7625973ca1
11 changed files with 1283 additions and 1 deletions
+176
View File
@@ -0,0 +1,176 @@
package masque
import (
"context"
"io"
"net"
"sync"
"time"
"github.com/quic-go/quic-go/http3"
)
// DatagramStreamer is an interface for sending and receiving HTTP/3 datagrams.
// Both http3.Stream and http3.RequestStream implement this interface.
type DatagramStreamer interface {
SendDatagram(b []byte) error
ReceiveDatagram(ctx context.Context) ([]byte, error)
}
// DatagramConn wraps an HTTP/3 stream's datagram methods as net.PacketConn.
// This allows UDP packets to be tunneled over HTTP/3 datagrams per RFC 9297/9298.
type DatagramConn struct {
stream DatagramStreamer
closer io.Closer // Optional closer for the underlying stream
localAddr net.Addr
remoteAddr net.Addr
closed chan struct{}
closeOnce sync.Once
mu sync.RWMutex
readDeadline time.Time
}
// NewDatagramConn creates a new DatagramConn wrapping an HTTP/3 stream.
func NewDatagramConn(stream *http3.Stream, laddr, raddr net.Addr) *DatagramConn {
return &DatagramConn{
stream: stream,
localAddr: laddr,
remoteAddr: raddr,
closed: make(chan struct{}),
}
}
// NewDatagramConnFromRequestStream creates a new DatagramConn wrapping an HTTP/3 request stream.
// This is used by the client-side connector. The stream will be closed when Close() is called.
func NewDatagramConnFromRequestStream(stream *http3.RequestStream, laddr, raddr net.Addr) *DatagramConn {
return &DatagramConn{
stream: stream,
closer: stream, // RequestStream implements io.Closer
localAddr: laddr,
remoteAddr: raddr,
closed: make(chan struct{}),
}
}
// ReadFrom reads a UDP datagram from the HTTP/3 stream.
// Per RFC 9297, HTTP datagrams have a context ID prefix.
// For CONNECT-UDP (RFC 9298), the context ID is 0.
func (c *DatagramConn) ReadFrom(b []byte) (n int, addr net.Addr, err error) {
select {
case <-c.closed:
return 0, nil, net.ErrClosed
default:
}
ctx := context.Background()
c.mu.RLock()
deadline := c.readDeadline
c.mu.RUnlock()
if !deadline.IsZero() {
var cancel context.CancelFunc
ctx, cancel = context.WithDeadline(ctx, deadline)
defer cancel()
}
data, err := c.stream.ReceiveDatagram(ctx)
if err != nil {
return 0, nil, err
}
if len(data) == 0 {
return 0, c.remoteAddr, nil
}
// Per RFC 9297: datagram format is context-id (varint) + payload
// For CONNECT-UDP with context ID 0, the first byte is 0x00
// We strip the context ID prefix
if data[0] == 0x00 {
data = data[1:]
}
n = copy(b, data)
return n, c.remoteAddr, nil
}
// WriteTo sends a UDP datagram via the HTTP/3 stream.
// Per RFC 9297, we prepend the context ID (0x00 for CONNECT-UDP).
func (c *DatagramConn) WriteTo(b []byte, addr net.Addr) (n int, err error) {
select {
case <-c.closed:
return 0, net.ErrClosed
default:
}
// Prepend context ID (0x00 for context ID 0)
datagram := make([]byte, 1+len(b))
datagram[0] = 0x00 // Context ID = 0
copy(datagram[1:], b)
if err := c.stream.SendDatagram(datagram); err != nil {
return 0, err
}
return len(b), nil
}
// Read reads data from the connection (net.Conn interface).
func (c *DatagramConn) Read(b []byte) (n int, err error) {
n, _, err = c.ReadFrom(b)
return
}
// Write writes data to the connection (net.Conn interface).
func (c *DatagramConn) Write(b []byte) (n int, err error) {
return c.WriteTo(b, c.remoteAddr)
}
// Close closes the datagram connection and the underlying stream.
func (c *DatagramConn) Close() error {
var err error
c.closeOnce.Do(func() {
close(c.closed)
if c.closer != nil {
err = c.closer.Close()
}
})
return err
}
// LocalAddr returns the local network address.
func (c *DatagramConn) LocalAddr() net.Addr {
return c.localAddr
}
// RemoteAddr returns the remote network address.
func (c *DatagramConn) RemoteAddr() net.Addr {
return c.remoteAddr
}
// SetDeadline sets the read and write deadlines.
func (c *DatagramConn) SetDeadline(t time.Time) error {
c.SetReadDeadline(t)
c.SetWriteDeadline(t)
return nil
}
// SetReadDeadline sets the read deadline.
func (c *DatagramConn) SetReadDeadline(t time.Time) error {
c.mu.Lock()
c.readDeadline = t
c.mu.Unlock()
return nil
}
// SetWriteDeadline sets the write deadline.
// Note: Write deadlines are not used for HTTP/3 datagrams as SendDatagram is non-blocking.
func (c *DatagramConn) SetWriteDeadline(t time.Time) error {
return nil
}
// Ensure DatagramConn implements both net.Conn and net.PacketConn
var (
_ net.Conn = (*DatagramConn)(nil)
_ net.PacketConn = (*DatagramConn)(nil)
)
+60
View File
@@ -0,0 +1,60 @@
package masque
import (
"errors"
"net/url"
"strconv"
"strings"
)
const (
// WellKnownPath is the well-known path prefix for MASQUE UDP proxying
WellKnownPath = "/.well-known/masque/udp/"
)
var (
ErrInvalidPath = errors.New("masque: invalid path template")
ErrInvalidPort = errors.New("masque: invalid port number")
)
// ParseMasquePath parses a MASQUE UDP proxy path template.
// The expected format is: /.well-known/masque/udp/{host}/{port}/
// Returns the host and port extracted from the path.
func ParseMasquePath(path string) (host string, port int, err error) {
if !strings.HasPrefix(path, WellKnownPath) {
return "", 0, ErrInvalidPath
}
remainder := strings.TrimPrefix(path, WellKnownPath)
remainder = strings.TrimSuffix(remainder, "/")
parts := strings.Split(remainder, "/")
if len(parts) != 2 {
return "", 0, ErrInvalidPath
}
host = parts[0]
if host == "" {
return "", 0, ErrInvalidPath
}
// URL decode the host in case it contains percent-encoded characters
host, err = url.PathUnescape(host)
if err != nil {
return "", 0, ErrInvalidPath
}
port, err = strconv.Atoi(parts[1])
if err != nil || port <= 0 || port > 65535 {
return "", 0, ErrInvalidPort
}
return host, port, nil
}
// BuildMasquePath constructs a MASQUE UDP proxy path from host and port.
func BuildMasquePath(host string, port int) string {
// URL encode the host in case it contains special characters (like IPv6 addresses)
encodedHost := url.PathEscape(host)
return WellKnownPath + encodedHost + "/" + strconv.Itoa(port) + "/"
}