1d34d4543a
- listener/http3: default enableDatagrams to true when not explicitly set, so CONNECT-UDP (RFC 9298) works out of the box without requiring ?enableDatagrams=true in the config URL - handler/masque: dial target before sending 200 OK in handleConnectUDP, matching the handleConnectTCP order; if target dial fails, the handler can still return an error status instead of having already committed 200 - handler/masque: add missing WriteHeader(BadRequest) when ResolveUDPAddr fails, so the client gets a proper HTTP error instead of an opaque H3_REQUEST_CANCELLED stream reset Fixes #103
779 lines
26 KiB
Go
779 lines
26 KiB
Go
// Package masque implements a MASQUE (Multiplexed Application Substrate over
|
|
// QUIC Encryption) proxy handler for GOST. MASQUE is an IETF standard for
|
|
// tunneling IP traffic over HTTP/3, used by systems like Apple's iCloud
|
|
// Private Relay.
|
|
//
|
|
// This handler implements two proxy modes over HTTP/3 (QUIC):
|
|
//
|
|
// - CONNECT-UDP (RFC 9298): Proxies UDP datagrams via HTTP/3 Datagram frames
|
|
// (RFC 9297). The client sends an Extended CONNECT request with
|
|
// :protocol="connect-udp" to the well-known path
|
|
// "/.well-known/masque/udp/{host}/{port}/". UDP packets are then exchanged
|
|
// as HTTP/3 datagrams with a context ID prefix per RFC 9297.
|
|
//
|
|
// - CONNECT-TCP (RFC 9114): Tunnels TCP connections over HTTP/3 stream bodies.
|
|
// The client sends a standard HTTP/3 CONNECT request with the target in the
|
|
// :authority pseudo-header. Data is then relayed bidirectionally through the
|
|
// HTTP/3 stream.
|
|
//
|
|
// # Request flow
|
|
//
|
|
// The HTTP/3 listener injects the http.ResponseWriter and http.Request into the
|
|
// context metadata. Handle() extracts them, validates the CONNECT method, and
|
|
// dispatches to handleConnectUDP or handleConnectTCP based on the :protocol
|
|
// pseudo-header (stored in r.Proto by quic-go).
|
|
//
|
|
// # Cross-cutting concerns
|
|
//
|
|
// The handler integrates with GOST's cross-cutting infrastructure: HTTP Basic
|
|
// proxy authentication, bypass rules, rate limiting, traffic limiting, Prometheus
|
|
// metrics, observer event reporting, and traffic recording.
|
|
//
|
|
// # Registration
|
|
//
|
|
// The handler is registered as "masque" in the handler registry via init().
|
|
// It must be paired with an HTTP/3 listener (registered as "h3") to function.
|
|
package masque
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/base64"
|
|
"errors"
|
|
"fmt"
|
|
"net"
|
|
"net/http"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/go-gost/core/auth"
|
|
"github.com/go-gost/core/bypass"
|
|
"github.com/go-gost/core/handler"
|
|
"github.com/go-gost/core/limiter"
|
|
"github.com/go-gost/core/limiter/traffic"
|
|
"github.com/go-gost/core/logger"
|
|
md "github.com/go-gost/core/metadata"
|
|
"github.com/go-gost/core/observer"
|
|
"github.com/go-gost/core/observer/stats"
|
|
"github.com/go-gost/core/recorder"
|
|
xbypass "github.com/go-gost/x/bypass"
|
|
xctx "github.com/go-gost/x/ctx"
|
|
ictx "github.com/go-gost/x/internal/ctx"
|
|
xnet "github.com/go-gost/x/internal/net"
|
|
"github.com/go-gost/x/internal/net/udp"
|
|
masque_util "github.com/go-gost/x/internal/util/masque"
|
|
stats_util "github.com/go-gost/x/internal/util/stats"
|
|
rate_limiter "github.com/go-gost/x/limiter/rate"
|
|
cache_limiter "github.com/go-gost/x/limiter/traffic/cache"
|
|
traffic_wrapper "github.com/go-gost/x/limiter/traffic/wrapper"
|
|
metrics "github.com/go-gost/x/metrics/wrapper"
|
|
xstats "github.com/go-gost/x/observer/stats"
|
|
stats_wrapper "github.com/go-gost/x/observer/stats/wrapper"
|
|
xrecorder "github.com/go-gost/x/recorder"
|
|
"github.com/go-gost/x/registry"
|
|
"github.com/quic-go/quic-go/http3"
|
|
)
|
|
|
|
// init registers the MASQUE handler in the global handler registry under the
|
|
// name "masque". This enables the handler to be referenced by name in GOST
|
|
// configuration files and CLI flags (e.g., handler.type: masque).
|
|
func init() {
|
|
registry.HandlerRegistry().Register("masque", NewHandler)
|
|
}
|
|
|
|
var (
|
|
// ErrBadRequest indicates the request was malformed or invalid.
|
|
ErrBadRequest = errors.New("masque: bad request")
|
|
// ErrMethodNotAllowed indicates the request method is not CONNECT.
|
|
ErrMethodNotAllowed = errors.New("masque: method not allowed")
|
|
// ErrCapsuleRequired indicates the Capsule-Protocol header is missing or invalid.
|
|
ErrCapsuleRequired = errors.New("masque: capsule-protocol header required")
|
|
// ErrDatagramNotSupport indicates that the QUIC connection does not support HTTP/3 datagrams.
|
|
ErrDatagramNotSupport = errors.New("masque: datagrams not supported")
|
|
)
|
|
|
|
// masqueHandler implements handler.Handler for the MASQUE proxy protocol.
|
|
// It processes HTTP/3 CONNECT requests to tunnel UDP (RFC 9298) or TCP
|
|
// (RFC 9114) traffic through an HTTP/3 (QUIC) connection.
|
|
//
|
|
// The handler integrates with GOST's cross-cutting infrastructure:
|
|
// - Authentication via HTTP Basic Proxy Authentication
|
|
// - Bypass rules for selective target filtering
|
|
// - Rate limiting and traffic limiting
|
|
// - Observer event reporting for connection statistics
|
|
// - Prometheus metrics collection
|
|
// - Traffic recording for audit/logging
|
|
type masqueHandler struct {
|
|
md metadata // parsed configuration metadata
|
|
options handler.Options // handler options (auth, bypass, router, etc.)
|
|
stats *stats_util.HandlerStats // per-client connection statistics for observer
|
|
limiter traffic.TrafficLimiter // cached traffic limiter wrapping the global limiter
|
|
cancel context.CancelFunc // cancels the background observer goroutine
|
|
recorder recorder.RecorderObject // traffic recorder for handler-level events
|
|
}
|
|
|
|
// NewHandler creates a new MASQUE handler instance. It supports CONNECT-UDP
|
|
// (RFC 9298) for UDP proxying and standard HTTP/3 CONNECT (RFC 9114) for TCP
|
|
// tunneling. The returned handler must be initialized via Init() before use.
|
|
func NewHandler(opts ...handler.Option) handler.Handler {
|
|
options := handler.Options{}
|
|
for _, opt := range opts {
|
|
opt(&options)
|
|
}
|
|
|
|
return &masqueHandler{
|
|
options: options,
|
|
}
|
|
}
|
|
|
|
// Init initializes the MASQUE handler with the provided metadata. It:
|
|
// - Parses configuration from metadata (buffer sizes, timeouts, etc.)
|
|
// - Starts a background observer goroutine if an Observer is configured
|
|
// - Creates a cached traffic limiter wrapping the global limiter
|
|
// - Selects the handler-level recorder from the configured recorders
|
|
func (h *masqueHandler) Init(md md.Metadata) error {
|
|
if err := h.parseMetadata(md); err != nil {
|
|
return err
|
|
}
|
|
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
h.cancel = cancel
|
|
|
|
if h.options.Observer != nil {
|
|
h.stats = stats_util.NewHandlerStats(h.options.Service, h.md.observerResetTraffic)
|
|
go h.observeStats(ctx)
|
|
}
|
|
|
|
if h.options.Limiter != nil {
|
|
h.limiter = cache_limiter.NewCachedTrafficLimiter(h.options.Limiter,
|
|
cache_limiter.RefreshIntervalOption(h.md.limiterRefreshInterval),
|
|
cache_limiter.CleanupIntervalOption(h.md.limiterCleanupInterval),
|
|
cache_limiter.ScopeOption(limiter.ScopeClient),
|
|
)
|
|
}
|
|
|
|
for _, ro := range h.options.Recorders {
|
|
if ro.Record == xrecorder.RecorderServiceHandler {
|
|
h.recorder = ro
|
|
break
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// Handle processes an inbound MASQUE connection. It extracts the HTTP/3 request
|
|
// and response writer from the context metadata (injected by the HTTP/3 listener),
|
|
// validates the CONNECT method, and dispatches to the appropriate handler based on
|
|
// the :protocol pseudo-header:
|
|
// - "connect-udp" → handleConnectUDP (RFC 9298, UDP over HTTP/3 datagrams)
|
|
// - "HTTP/3.0" or "" → handleConnectTCP (RFC 9114, TCP over HTTP/3 stream)
|
|
//
|
|
// The connection's entire lifecycle — rate limiting, stats recording, traffic
|
|
// metrics, and cleanup — is managed within this method and its deferred cleanup.
|
|
func (h *masqueHandler) Handle(ctx context.Context, conn net.Conn, opts ...handler.HandleOption) (err error) {
|
|
defer conn.Close()
|
|
|
|
start := time.Now()
|
|
|
|
ro := &xrecorder.HandlerRecorderObject{
|
|
Network: "",
|
|
Service: h.options.Service,
|
|
RemoteAddr: conn.RemoteAddr().String(),
|
|
LocalAddr: conn.LocalAddr().String(),
|
|
Proto: "masque",
|
|
Time: start,
|
|
SID: xctx.SidFromContext(ctx).String(),
|
|
}
|
|
|
|
if srcAddr := xctx.SrcAddrFromContext(ctx); srcAddr != nil {
|
|
ro.ClientAddr = srcAddr.String()
|
|
}
|
|
|
|
log := h.options.Logger.WithFields(map[string]any{
|
|
"network": ro.Network,
|
|
"remote": conn.RemoteAddr().String(),
|
|
"local": conn.LocalAddr().String(),
|
|
"client": ro.ClientAddr,
|
|
"sid": ro.SID,
|
|
})
|
|
log.Infof("%s <> %s", conn.RemoteAddr(), conn.LocalAddr())
|
|
|
|
// pStats tracks per-connection I/O byte counts for recording and logging.
|
|
pStats := &xstats.Stats{}
|
|
|
|
// Deferred cleanup: record traffic stats, log session end, and report errors.
|
|
defer func() {
|
|
if err != nil {
|
|
ro.Err = err.Error()
|
|
}
|
|
ro.InputBytes = pStats.Get(stats.KindInputBytes)
|
|
ro.OutputBytes = pStats.Get(stats.KindOutputBytes)
|
|
ro.Duration = time.Since(start)
|
|
if h.recorder.Recorder != nil {
|
|
if err := ro.Record(ctx, h.recorder.Recorder); err != nil {
|
|
log.Errorf("record: %v", err)
|
|
}
|
|
}
|
|
|
|
log.WithFields(map[string]any{
|
|
"duration": time.Since(start),
|
|
"inputBytes": ro.InputBytes,
|
|
"outputBytes": ro.OutputBytes,
|
|
}).Infof("%s >< %s", conn.RemoteAddr(), conn.LocalAddr())
|
|
}()
|
|
|
|
if !h.checkRateLimit(conn.RemoteAddr()) {
|
|
return rate_limiter.ErrRateLimit
|
|
}
|
|
|
|
// The HTTP/3 listener injects the http.ResponseWriter and http.Request
|
|
// into the context metadata so the handler can access the HTTP/3
|
|
// request/response layer. Without this metadata, the connection did not
|
|
// originate from an HTTP/3 listener and MASQUE cannot operate.
|
|
ctxMd := ictx.MetadataFromContext(ctx)
|
|
if ctxMd == nil {
|
|
err := errors.New("masque: wrong connection type, requires HTTP/3")
|
|
log.Error(err)
|
|
return err
|
|
}
|
|
|
|
// w is the HTTP/3 response writer; r is the HTTP/3 request.
|
|
// These are set by the HTTP/3 listener when accepting the QUIC stream.
|
|
w, _ := ctxMd.Get("w").(http.ResponseWriter)
|
|
r, _ := ctxMd.Get("r").(*http.Request)
|
|
|
|
if w == nil {
|
|
return ErrBadRequest
|
|
}
|
|
if r == nil {
|
|
w.WriteHeader(http.StatusBadRequest)
|
|
return ErrBadRequest
|
|
}
|
|
|
|
// Validate CONNECT method
|
|
if r.Method != http.MethodConnect {
|
|
w.WriteHeader(http.StatusMethodNotAllowed)
|
|
log.Error(ErrMethodNotAllowed)
|
|
return ErrMethodNotAllowed
|
|
}
|
|
|
|
// Dispatch based on :protocol pseudo-header
|
|
// In quic-go, the :protocol pseudo-header is stored in r.Proto
|
|
switch r.Proto {
|
|
case "connect-udp":
|
|
// Extended CONNECT for UDP (RFC 9298)
|
|
ro.Network = "udp"
|
|
return h.handleConnectUDP(ctx, w, r, conn.LocalAddr(), ro, log, pStats)
|
|
case "HTTP/3.0", "":
|
|
// Standard CONNECT for TCP (RFC 9114)
|
|
// r.Proto is "HTTP/3.0" for standard HTTP/3 CONNECT (no :protocol header)
|
|
ro.Network = "tcp"
|
|
return h.handleConnectTCP(ctx, w, r, conn.LocalAddr(), ro, log, pStats)
|
|
default:
|
|
w.WriteHeader(http.StatusBadRequest)
|
|
log.Errorf("masque: unsupported protocol: %s", r.Proto)
|
|
return ErrBadRequest
|
|
}
|
|
}
|
|
|
|
// Close shuts down the MASQUE handler by cancelling the background observer
|
|
// goroutine. It is called when the owning service is stopped.
|
|
func (h *masqueHandler) Close() error {
|
|
if h.cancel != nil {
|
|
h.cancel()
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// handleConnectUDP implements the CONNECT-UDP method (RFC 9298). It proxies UDP
|
|
// datagrams between the MASQUE client and the target host using HTTP/3 datagrams
|
|
// (RFC 9297) as the transport.
|
|
//
|
|
// Request flow:
|
|
// 1. Authenticate the client via HTTP Basic Proxy Authentication
|
|
// 2. Parse the target host:port from the URI template path
|
|
// "/.well-known/masque/udp/{host}/{port}/"
|
|
// 3. Check bypass rules for the target address
|
|
// 4. Validate the Capsule-Protocol header (required by RFC 9298)
|
|
// 5. Obtain the HTTP/3 stream and respond with 200 OK + Capsule-Protocol header
|
|
// 6. Create a DatagramConn wrapping the HTTP/3 stream's datagram interface
|
|
// 7. Dial the target (via Router chain or direct UDP socket)
|
|
// 8. Run a bidirectional UDP relay between client and target
|
|
//
|
|
// Data path:
|
|
//
|
|
// Client ↔ HTTP/3 Datagram ↔ DatagramConn ↔ [stats/limiter wrappers] ↔ UDP Relay ↔ Target
|
|
func (h *masqueHandler) handleConnectUDP(ctx context.Context, w http.ResponseWriter, r *http.Request, laddr net.Addr, ro *xrecorder.HandlerRecorderObject, log logger.Logger, pStats *xstats.Stats) error {
|
|
if u, _, _ := h.basicProxyAuth(r.Header.Get("Proxy-Authorization")); u != "" {
|
|
log = log.WithFields(map[string]any{"user": u})
|
|
ro.ClientID = u
|
|
}
|
|
|
|
// Authenticate
|
|
clientID, ok := h.authenticate(ctx, w, r, log)
|
|
if !ok {
|
|
return errors.New("authentication failed")
|
|
}
|
|
|
|
if clientID != "" {
|
|
log = log.WithFields(map[string]any{"clientID": clientID})
|
|
ro.ClientID = clientID
|
|
}
|
|
ctx = xctx.ContextWithClientID(ctx, xctx.ClientID(clientID))
|
|
|
|
// Parse target from path
|
|
host, port, err := masque_util.ParseMasquePath(r.URL.Path)
|
|
if err != nil {
|
|
w.WriteHeader(http.StatusBadRequest)
|
|
log.Error("masque: invalid path: ", err)
|
|
return err
|
|
}
|
|
|
|
targetAddr := fmt.Sprintf("%s:%d", host, port)
|
|
ro.Host = targetAddr
|
|
log = log.WithFields(map[string]any{
|
|
"target": targetAddr,
|
|
})
|
|
log.Debug("connect-udp request to ", targetAddr)
|
|
|
|
// Check bypass
|
|
if h.options.Bypass != nil &&
|
|
h.options.Bypass.Contains(ctx, "udp", targetAddr, bypass.WithService(h.options.Service)) {
|
|
w.WriteHeader(http.StatusForbidden)
|
|
log.Debug("bypass: ", targetAddr)
|
|
return xbypass.ErrBypass
|
|
}
|
|
|
|
// Validate capsule-protocol header (required for CONNECT-UDP per RFC 9298)
|
|
if r.Header.Get("Capsule-Protocol") != "?1" {
|
|
w.WriteHeader(http.StatusBadRequest)
|
|
log.Error(ErrCapsuleRequired)
|
|
return ErrCapsuleRequired
|
|
}
|
|
|
|
// Resolve target address
|
|
raddr, err := net.ResolveUDPAddr("udp", targetAddr)
|
|
if err != nil {
|
|
w.WriteHeader(http.StatusBadRequest)
|
|
log.Error("masque: failed to resolve target address: ", err)
|
|
return err
|
|
}
|
|
|
|
// Get HTTP/3 stream for datagrams
|
|
streamer, ok := w.(http3.HTTPStreamer)
|
|
if !ok {
|
|
w.WriteHeader(http.StatusInternalServerError)
|
|
log.Error(ErrDatagramNotSupport)
|
|
return ErrDatagramNotSupport
|
|
}
|
|
|
|
// Get the underlying HTTP/3 stream
|
|
stream := streamer.HTTPStream()
|
|
|
|
// Get target connection - either through router/chain or direct
|
|
var targetPC net.PacketConn
|
|
|
|
switch h.md.hash {
|
|
case "host":
|
|
ctx = xctx.ContextWithHash(ctx, &xctx.Hash{Source: targetAddr})
|
|
}
|
|
|
|
var buf bytes.Buffer
|
|
|
|
if h.options.Router != nil {
|
|
// Use router to dial through chain (for forwarding through upstream proxy)
|
|
c, err := h.options.Router.Dial(ictx.ContextWithBuffer(ctx, &buf), "udp", targetAddr)
|
|
ro.Route = buf.String()
|
|
if err != nil {
|
|
log.Error("masque: failed to dial through router: ", err)
|
|
return err
|
|
}
|
|
defer c.Close()
|
|
|
|
ro.SrcAddr = c.LocalAddr().String()
|
|
|
|
// The connection from router should be a PacketConn (e.g., from masque connector)
|
|
if pc, ok := c.(net.PacketConn); ok {
|
|
targetPC = pc
|
|
log.Debugf("relaying UDP to %s via chain", targetAddr)
|
|
} else {
|
|
// Wrap as PacketConn if it's a regular Conn
|
|
targetPC = &connPacketConn{Conn: c, raddr: raddr}
|
|
log.Debugf("relaying UDP to %s via chain (wrapped)", targetAddr)
|
|
}
|
|
} else {
|
|
// Direct connection - create local UDP socket
|
|
directConn, err := net.ListenPacket("udp", "")
|
|
if err != nil {
|
|
log.Error("masque: failed to create UDP socket: ", err)
|
|
return err
|
|
}
|
|
defer directConn.Close()
|
|
|
|
ro.SrcAddr = directConn.LocalAddr().String()
|
|
|
|
// Wrap with fixed target address
|
|
targetPC = &fixedTargetPacketConn{
|
|
PacketConn: directConn,
|
|
target: raddr,
|
|
}
|
|
log.Debugf("relaying UDP to %s directly via %s", raddr, directConn.LocalAddr())
|
|
}
|
|
|
|
// Wrap target with metrics
|
|
targetPC = metrics.WrapPacketConn(h.options.Service, targetPC)
|
|
|
|
// Send success response with capsule-protocol header
|
|
w.Header().Set("Capsule-Protocol", "?1")
|
|
w.WriteHeader(http.StatusOK)
|
|
|
|
// Flush the response headers
|
|
if flusher, ok := w.(http.Flusher); ok {
|
|
flusher.Flush()
|
|
}
|
|
|
|
// Create datagram connection wrapping the HTTP/3 stream (client side)
|
|
datagramConn := masque_util.NewDatagramConn(stream, laddr, raddr)
|
|
defer datagramConn.Close()
|
|
|
|
// Wrap with recorder stats and traffic limiter
|
|
var clientPC net.PacketConn = datagramConn
|
|
clientPC = stats_wrapper.WrapPacketConn(clientPC, pStats)
|
|
clientPC = traffic_wrapper.WrapPacketConn(
|
|
clientPC,
|
|
h.limiter,
|
|
clientID,
|
|
limiter.ServiceOption(h.options.Service),
|
|
limiter.ScopeOption(limiter.ScopeClient),
|
|
limiter.NetworkOption("udp"),
|
|
limiter.AddrOption(targetAddr),
|
|
limiter.ClientOption(clientID),
|
|
limiter.SrcOption(ro.RemoteAddr),
|
|
)
|
|
|
|
// Track per-client connection stats
|
|
if h.options.Observer != nil {
|
|
pstats := h.stats.Stats(clientID)
|
|
pstats.Add(stats.KindTotalConns, 1)
|
|
pstats.Add(stats.KindCurrentConns, 1)
|
|
defer pstats.Add(stats.KindCurrentConns, -1)
|
|
clientPC = stats_wrapper.WrapPacketConn(clientPC, pstats)
|
|
}
|
|
|
|
// Relay UDP packets between client and target
|
|
relay := udp.NewRelay(clientPC, targetPC).
|
|
WithService(h.options.Service).
|
|
WithLogger(log).
|
|
WithBufferSize(h.md.bufferSize)
|
|
|
|
return relay.Run(ctx)
|
|
}
|
|
|
|
// handleConnectTCP implements standard HTTP/3 CONNECT (RFC 9114) for TCP
|
|
// tunneling. Data is relayed bidirectionally through the HTTP/3 stream body
|
|
// (not via datagrams), providing a reliable, ordered byte stream.
|
|
//
|
|
// Request flow:
|
|
// 1. Authenticate the client via HTTP Basic Proxy Authentication
|
|
// 2. Extract the target address from the :authority pseudo-header (r.Host),
|
|
// defaulting to port 443 if no port is specified
|
|
// 3. Check bypass rules for the target address
|
|
// 4. Dial the target (via Router chain or direct TCP connection)
|
|
// 5. Obtain the HTTP/3 stream and respond with 200 OK
|
|
// 6. Create a StreamConn wrapping the HTTP/3 stream body
|
|
// 7. Run bidirectional data relay between client and target
|
|
//
|
|
// Data path:
|
|
//
|
|
// Client ↔ HTTP/3 Stream Body ↔ StreamConn ↔ [stats/limiter wrappers] ↔ xnet.Pipe ↔ Target
|
|
func (h *masqueHandler) handleConnectTCP(ctx context.Context, w http.ResponseWriter, r *http.Request, laddr net.Addr, ro *xrecorder.HandlerRecorderObject, log logger.Logger, pStats *xstats.Stats) error {
|
|
// Extract user for logging
|
|
if u, _, _ := h.basicProxyAuth(r.Header.Get("Proxy-Authorization")); u != "" {
|
|
log = log.WithFields(map[string]any{"user": u})
|
|
ro.ClientID = u
|
|
}
|
|
|
|
// Authenticate
|
|
clientID, ok := h.authenticate(ctx, w, r, log)
|
|
if !ok {
|
|
return errors.New("authentication failed")
|
|
}
|
|
|
|
if clientID != "" {
|
|
log = log.WithFields(map[string]any{"clientID": clientID})
|
|
ro.ClientID = clientID
|
|
}
|
|
ctx = xctx.ContextWithClientID(ctx, xctx.ClientID(clientID))
|
|
|
|
// Target address comes from :authority (r.Host) for standard CONNECT
|
|
targetAddr := r.Host
|
|
if targetAddr == "" {
|
|
w.WriteHeader(http.StatusBadRequest)
|
|
log.Error("masque: missing target address in :authority")
|
|
return ErrBadRequest
|
|
}
|
|
|
|
// Ensure port is present
|
|
if _, port, _ := net.SplitHostPort(targetAddr); port == "" {
|
|
targetAddr = net.JoinHostPort(strings.Trim(targetAddr, "[]"), "443")
|
|
}
|
|
|
|
ro.Host = targetAddr
|
|
log = log.WithFields(map[string]any{
|
|
"target": targetAddr,
|
|
})
|
|
log.Debug("connect-tcp request to ", targetAddr)
|
|
|
|
// Check bypass
|
|
if h.options.Bypass != nil &&
|
|
h.options.Bypass.Contains(ctx, "tcp", targetAddr, bypass.WithService(h.options.Service)) {
|
|
w.WriteHeader(http.StatusForbidden)
|
|
log.Debug("bypass: ", targetAddr)
|
|
return xbypass.ErrBypass
|
|
}
|
|
|
|
// Resolve target address for StreamConn
|
|
raddr, err := net.ResolveTCPAddr("tcp", targetAddr)
|
|
if err != nil {
|
|
log.Error("masque: failed to resolve target address: ", err)
|
|
return err
|
|
}
|
|
|
|
// Dial target connection
|
|
switch h.md.hash {
|
|
case "host":
|
|
ctx = xctx.ContextWithHash(ctx, &xctx.Hash{Source: targetAddr})
|
|
}
|
|
|
|
var cc net.Conn
|
|
var buf bytes.Buffer
|
|
|
|
if h.options.Router != nil {
|
|
cc, err = h.options.Router.Dial(ictx.ContextWithBuffer(ctx, &buf), "tcp", targetAddr)
|
|
} else {
|
|
cc, err = net.Dial("tcp", targetAddr)
|
|
}
|
|
ro.Route = buf.String()
|
|
|
|
if err != nil {
|
|
w.WriteHeader(http.StatusServiceUnavailable)
|
|
log.Error("masque: failed to dial target: ", err)
|
|
return err
|
|
}
|
|
defer cc.Close()
|
|
|
|
ro.SrcAddr = cc.LocalAddr().String()
|
|
ro.DstAddr = cc.RemoteAddr().String()
|
|
|
|
// Get HTTP/3 stream
|
|
streamer, ok := w.(http3.HTTPStreamer)
|
|
if !ok {
|
|
w.WriteHeader(http.StatusInternalServerError)
|
|
log.Error("masque: failed to get HTTP/3 streamer")
|
|
return errors.New("masque: HTTP/3 streamer not available")
|
|
}
|
|
|
|
// Get the underlying HTTP/3 stream
|
|
stream := streamer.HTTPStream()
|
|
|
|
// Send 200 OK response
|
|
w.WriteHeader(http.StatusOK)
|
|
|
|
// Flush the response headers
|
|
if flusher, ok := w.(http.Flusher); ok {
|
|
flusher.Flush()
|
|
}
|
|
|
|
// Create stream connection wrapping the HTTP/3 stream
|
|
streamConn := masque_util.NewStreamConn(stream, laddr, raddr)
|
|
defer streamConn.Close()
|
|
|
|
// Wrap with recorder stats and traffic limiter
|
|
var clientConn net.Conn = streamConn
|
|
clientConn = stats_wrapper.WrapConn(clientConn, pStats)
|
|
clientConn = traffic_wrapper.WrapConn(
|
|
clientConn,
|
|
h.limiter,
|
|
clientID,
|
|
limiter.ServiceOption(h.options.Service),
|
|
limiter.ScopeOption(limiter.ScopeClient),
|
|
limiter.NetworkOption("tcp"),
|
|
limiter.AddrOption(targetAddr),
|
|
limiter.ClientOption(clientID),
|
|
limiter.SrcOption(ro.RemoteAddr),
|
|
)
|
|
|
|
// Track per-client connection stats
|
|
if h.options.Observer != nil {
|
|
pstats := h.stats.Stats(clientID)
|
|
pstats.Add(stats.KindTotalConns, 1)
|
|
pstats.Add(stats.KindCurrentConns, 1)
|
|
defer pstats.Add(stats.KindCurrentConns, -1)
|
|
clientConn = stats_wrapper.WrapConn(clientConn, pstats)
|
|
}
|
|
|
|
// Wrap target with metrics
|
|
cc = metrics.WrapConn(h.options.Service, cc)
|
|
|
|
log.Infof("%s <-> %s", ro.RemoteAddr, targetAddr)
|
|
|
|
// Bidirectional relay
|
|
xnet.Pipe(ctx, clientConn, cc, xnet.WithReadTimeout(h.md.idleTimeout))
|
|
|
|
log.WithFields(map[string]any{
|
|
"duration": time.Since(ro.Time),
|
|
}).Infof("%s >-< %s", ro.RemoteAddr, targetAddr)
|
|
|
|
return nil
|
|
}
|
|
|
|
// fixedTargetPacketConn wraps a net.PacketConn and pins all reads and writes
|
|
// to a single target address. This is used for direct UDP forwarding where
|
|
// the MASQUE target address is fixed for the lifetime of the connection.
|
|
// ReadFrom returns the fixed target as the source address (instead of the
|
|
// actual source), so the UDP relay always sees a consistent peer.
|
|
type fixedTargetPacketConn struct {
|
|
net.PacketConn
|
|
target net.Addr
|
|
}
|
|
|
|
// ReadFrom reads a packet from the underlying PacketConn but returns the
|
|
// fixed target address as the source, ignoring the actual source address.
|
|
func (c *fixedTargetPacketConn) ReadFrom(b []byte) (n int, addr net.Addr, err error) {
|
|
n, _, err = c.PacketConn.ReadFrom(b)
|
|
return n, c.target, err
|
|
}
|
|
|
|
// WriteTo writes a packet to the fixed target address, ignoring the addr parameter.
|
|
func (c *fixedTargetPacketConn) WriteTo(b []byte, addr net.Addr) (n int, err error) {
|
|
return c.PacketConn.WriteTo(b, c.target)
|
|
}
|
|
|
|
// connPacketConn adapts a stream-based net.Conn to the net.PacketConn interface
|
|
// for use with the UDP relay. This is needed when the Router returns a
|
|
// connection from an upstream MASQUE connector (which uses HTTP/3 stream body
|
|
// framing rather than datagrams). Each Read/Write maps to a single
|
|
// ReadFrom/WriteTo with the fixed remote address.
|
|
type connPacketConn struct {
|
|
net.Conn
|
|
raddr net.Addr
|
|
}
|
|
|
|
// ReadFrom reads data from the stream connection and reports the fixed remote address.
|
|
func (c *connPacketConn) ReadFrom(b []byte) (n int, addr net.Addr, err error) {
|
|
n, err = c.Conn.Read(b)
|
|
return n, c.raddr, err
|
|
}
|
|
|
|
// WriteTo writes data to the stream connection, ignoring the addr parameter.
|
|
func (c *connPacketConn) WriteTo(b []byte, addr net.Addr) (n int, err error) {
|
|
return c.Conn.Write(b)
|
|
}
|
|
|
|
// checkRateLimit checks whether the connection from the given address is
|
|
// allowed by the rate limiter. Returns true if the connection is allowed
|
|
// (including when no rate limiter is configured), false if the limit is exceeded.
|
|
// The rate limit is applied per client IP (host portion of the address).
|
|
func (h *masqueHandler) checkRateLimit(addr net.Addr) bool {
|
|
if h.options.RateLimiter == nil {
|
|
return true
|
|
}
|
|
host, _, _ := net.SplitHostPort(addr.String())
|
|
if limiter := h.options.RateLimiter.Limiter(host); limiter != nil {
|
|
return limiter.Allow(1)
|
|
}
|
|
|
|
return true
|
|
}
|
|
|
|
// observeStats is a background goroutine that periodically collects per-client
|
|
// connection statistics from the HandlerStats and reports them to the Observer.
|
|
// It runs until the context is cancelled (when the handler is closed).
|
|
//
|
|
// If the Observer fails to accept events, they are buffered and retried on the
|
|
// next tick to avoid data loss during transient failures.
|
|
func (h *masqueHandler) observeStats(ctx context.Context) {
|
|
if h.options.Observer == nil {
|
|
return
|
|
}
|
|
|
|
var events []observer.Event
|
|
|
|
ticker := time.NewTicker(h.md.observerPeriod)
|
|
defer ticker.Stop()
|
|
|
|
for {
|
|
select {
|
|
case <-ticker.C:
|
|
// Try to flush any buffered events from a previous failed attempt.
|
|
if len(events) > 0 {
|
|
if err := h.options.Observer.Observe(ctx, events); err != nil {
|
|
continue
|
|
}
|
|
}
|
|
|
|
// Collect and send fresh events.
|
|
if evs := h.stats.Events(); len(evs) > 0 {
|
|
if err := h.options.Observer.Observe(ctx, evs); err != nil {
|
|
events = evs
|
|
continue
|
|
}
|
|
}
|
|
events = nil
|
|
|
|
case <-ctx.Done():
|
|
return
|
|
}
|
|
}
|
|
}
|
|
|
|
// basicProxyAuth parses an HTTP Basic Proxy-Authorization header value.
|
|
// It decodes the Base64-encoded "username:password" credentials.
|
|
// Returns the username, password, and true on success, or empty strings
|
|
// and false if the header is missing, malformed, or not Basic auth.
|
|
func (h *masqueHandler) basicProxyAuth(proxyAuth string) (username, password string, ok bool) {
|
|
if proxyAuth == "" {
|
|
return
|
|
}
|
|
|
|
if !strings.HasPrefix(proxyAuth, "Basic ") {
|
|
return
|
|
}
|
|
c, err := base64.StdEncoding.DecodeString(strings.TrimPrefix(proxyAuth, "Basic "))
|
|
if err != nil {
|
|
return
|
|
}
|
|
cs := string(c)
|
|
s := strings.IndexByte(cs, ':')
|
|
if s < 0 {
|
|
return
|
|
}
|
|
|
|
return cs[:s], cs[s+1:], true
|
|
}
|
|
|
|
// authenticate validates the client's proxy credentials against the configured
|
|
// Authenticator. If no Authenticator is configured, all requests are allowed.
|
|
// On authentication failure, it sends a 407 Proxy Authentication Required response
|
|
// with a Proxy-Authenticate challenge header and returns ("", false).
|
|
// On success, it returns the authenticated client ID and true.
|
|
func (h *masqueHandler) authenticate(ctx context.Context, w http.ResponseWriter, r *http.Request, log logger.Logger) (id string, ok bool) {
|
|
u, p, _ := h.basicProxyAuth(r.Header.Get("Proxy-Authorization"))
|
|
if h.options.Auther == nil {
|
|
return "", true
|
|
}
|
|
if id, ok = h.options.Auther.Authenticate(ctx, u, p, auth.WithService(h.options.Service)); ok {
|
|
return
|
|
}
|
|
|
|
realm := defaultRealm
|
|
if h.md.authBasicRealm != "" {
|
|
realm = h.md.authBasicRealm
|
|
}
|
|
w.Header().Set("Proxy-Authenticate", fmt.Sprintf("Basic realm=\"%s\"", realm))
|
|
w.WriteHeader(http.StatusProxyAuthRequired)
|
|
log.Debug("proxy authentication required")
|
|
return
|
|
}
|