fix(handler/masque): add recorder stats, idle timeout, reorder auth/DNS/200

Wire pStats into sub-handlers to capture real byte counts from QUIC
streams instead of the dummy listener conn. Resolve target address and
validate capsule-protocol after authentication instead of before 200
response to prevent dead tunnels and unauthenticated probing. Merge
pending observer events rather than replacing on consecutive failures.
Add idle read timeout to TCP tunnel relay. Add doc comments on exported
symbols and NewHandler.
This commit is contained in:
ginuerzh
2026-05-31 21:00:00 +08:00
parent cf89192211
commit a9d3ffa496
2 changed files with 59 additions and 46 deletions
+55 -45
View File
@@ -44,9 +44,13 @@ func init() {
} }
var ( var (
// ErrBadRequest indicates the request was malformed or invalid.
ErrBadRequest = errors.New("masque: bad request") ErrBadRequest = errors.New("masque: bad request")
// ErrMethodNotAllowed indicates the request method is not CONNECT.
ErrMethodNotAllowed = errors.New("masque: method not allowed") 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") 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") ErrDatagramNotSupport = errors.New("masque: datagrams not supported")
) )
@@ -59,6 +63,8 @@ type masqueHandler struct {
recorder recorder.RecorderObject recorder recorder.RecorderObject
} }
// NewHandler creates a MASQUE protocol handler that supports CONNECT-UDP
// (RFC 9298) and CONNECT for TCP tunneling over HTTP/3.
func NewHandler(opts ...handler.Option) handler.Handler { func NewHandler(opts ...handler.Option) handler.Handler {
options := handler.Options{} options := handler.Options{}
for _, opt := range opts { for _, opt := range opts {
@@ -129,8 +135,7 @@ func (h *masqueHandler) Handle(ctx context.Context, conn net.Conn, opts ...handl
}) })
log.Infof("%s <> %s", conn.RemoteAddr(), conn.LocalAddr()) log.Infof("%s <> %s", conn.RemoteAddr(), conn.LocalAddr())
pStats := xstats.Stats{} pStats := &xstats.Stats{}
conn = stats_wrapper.WrapConn(conn, &pStats)
defer func() { defer func() {
if err != nil { if err != nil {
@@ -167,7 +172,11 @@ func (h *masqueHandler) Handle(ctx context.Context, conn net.Conn, opts ...handl
w, _ := ctxMd.Get("w").(http.ResponseWriter) w, _ := ctxMd.Get("w").(http.ResponseWriter)
r, _ := ctxMd.Get("r").(*http.Request) r, _ := ctxMd.Get("r").(*http.Request)
if w == nil || r == nil { if w == nil {
return ErrBadRequest
}
if r == nil {
w.WriteHeader(http.StatusBadRequest)
return ErrBadRequest return ErrBadRequest
} }
@@ -184,12 +193,12 @@ func (h *masqueHandler) Handle(ctx context.Context, conn net.Conn, opts ...handl
case "connect-udp": case "connect-udp":
// Extended CONNECT for UDP (RFC 9298) // Extended CONNECT for UDP (RFC 9298)
ro.Network = "udp" ro.Network = "udp"
return h.handleConnectUDP(ctx, w, r, conn.LocalAddr(), ro, log) return h.handleConnectUDP(ctx, w, r, conn.LocalAddr(), ro, log, pStats)
case "HTTP/3.0", "": case "HTTP/3.0", "":
// Standard CONNECT for TCP (RFC 9114) // Standard CONNECT for TCP (RFC 9114)
// r.Proto is "HTTP/3.0" for standard HTTP/3 CONNECT (no :protocol header) // r.Proto is "HTTP/3.0" for standard HTTP/3 CONNECT (no :protocol header)
ro.Network = "tcp" ro.Network = "tcp"
return h.handleConnectTCP(ctx, w, r, conn.LocalAddr(), ro, log) return h.handleConnectTCP(ctx, w, r, conn.LocalAddr(), ro, log, pStats)
default: default:
w.WriteHeader(http.StatusBadRequest) w.WriteHeader(http.StatusBadRequest)
log.Errorf("masque: unsupported protocol: %s", r.Proto) log.Errorf("masque: unsupported protocol: %s", r.Proto)
@@ -204,14 +213,7 @@ func (h *masqueHandler) Close() error {
return nil return nil
} }
func (h *masqueHandler) handleConnectUDP(ctx context.Context, w http.ResponseWriter, r *http.Request, laddr net.Addr, ro *xrecorder.HandlerRecorderObject, log logger.Logger) error { 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 {
// 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
}
// Extract user for logging // Extract user for logging
if u, _, _ := h.basicProxyAuth(r.Header.Get("Proxy-Authorization")); u != "" { if u, _, _ := h.basicProxyAuth(r.Header.Get("Proxy-Authorization")); u != "" {
log = log.WithFields(map[string]any{"user": u}) log = log.WithFields(map[string]any{"user": u})
@@ -253,6 +255,20 @@ func (h *masqueHandler) handleConnectUDP(ctx context.Context, w http.ResponseWri
return xbypass.ErrBypass 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 {
log.Error("masque: failed to resolve target address: ", err)
return err
}
// Get HTTP/3 stream for datagrams // Get HTTP/3 stream for datagrams
streamer, ok := w.(http3.HTTPStreamer) streamer, ok := w.(http3.HTTPStreamer)
if !ok { if !ok {
@@ -261,6 +277,9 @@ func (h *masqueHandler) handleConnectUDP(ctx context.Context, w http.ResponseWri
return ErrDatagramNotSupport return ErrDatagramNotSupport
} }
// Get the underlying HTTP/3 stream
stream := streamer.HTTPStream()
// Send success response with capsule-protocol header // Send success response with capsule-protocol header
w.Header().Set("Capsule-Protocol", "?1") w.Header().Set("Capsule-Protocol", "?1")
w.WriteHeader(http.StatusOK) w.WriteHeader(http.StatusOK)
@@ -270,22 +289,13 @@ func (h *masqueHandler) handleConnectUDP(ctx context.Context, w http.ResponseWri
flusher.Flush() flusher.Flush()
} }
// Get the underlying HTTP/3 stream
stream := streamer.HTTPStream()
// Resolve target address
raddr, err := net.ResolveUDPAddr("udp", targetAddr)
if err != nil {
log.Error("masque: failed to resolve target address: ", err)
return err
}
// Create datagram connection wrapping the HTTP/3 stream (client side) // Create datagram connection wrapping the HTTP/3 stream (client side)
datagramConn := masque_util.NewDatagramConn(stream, laddr, raddr) datagramConn := masque_util.NewDatagramConn(stream, laddr, raddr)
defer datagramConn.Close() defer datagramConn.Close()
// Wrap with traffic limiter // Wrap with recorder stats and traffic limiter
var clientPC net.PacketConn = datagramConn var clientPC net.PacketConn = datagramConn
clientPC = stats_wrapper.WrapPacketConn(clientPC, pStats)
clientPC = traffic_wrapper.WrapPacketConn( clientPC = traffic_wrapper.WrapPacketConn(
clientPC, clientPC,
h.limiter, h.limiter,
@@ -369,7 +379,7 @@ func (h *masqueHandler) handleConnectUDP(ctx context.Context, w http.ResponseWri
return relay.Run(ctx) return relay.Run(ctx)
} }
func (h *masqueHandler) handleConnectTCP(ctx context.Context, w http.ResponseWriter, r *http.Request, laddr net.Addr, ro *xrecorder.HandlerRecorderObject, log logger.Logger) error { 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 // Extract user for logging
if u, _, _ := h.basicProxyAuth(r.Header.Get("Proxy-Authorization")); u != "" { if u, _, _ := h.basicProxyAuth(r.Header.Get("Proxy-Authorization")); u != "" {
log = log.WithFields(map[string]any{"user": u}) log = log.WithFields(map[string]any{"user": u})
@@ -415,12 +425,11 @@ func (h *masqueHandler) handleConnectTCP(ctx context.Context, w http.ResponseWri
return xbypass.ErrBypass return xbypass.ErrBypass
} }
// Get HTTP/3 stream // Resolve target address for StreamConn
streamer, ok := w.(http3.HTTPStreamer) raddr, err := net.ResolveTCPAddr("tcp", targetAddr)
if !ok { if err != nil {
w.WriteHeader(http.StatusInternalServerError) log.Error("masque: failed to resolve target address: ", err)
log.Error("masque: failed to get HTTP/3 streamer") return err
return errors.New("masque: HTTP/3 streamer not available")
} }
// Dial target connection // Dial target connection
@@ -430,7 +439,6 @@ func (h *masqueHandler) handleConnectTCP(ctx context.Context, w http.ResponseWri
} }
var cc net.Conn var cc net.Conn
var err error
var buf bytes.Buffer var buf bytes.Buffer
if h.options.Router != nil { if h.options.Router != nil {
@@ -450,6 +458,17 @@ func (h *masqueHandler) handleConnectTCP(ctx context.Context, w http.ResponseWri
ro.SrcAddr = cc.LocalAddr().String() ro.SrcAddr = cc.LocalAddr().String()
ro.DstAddr = cc.RemoteAddr().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 // Send 200 OK response
w.WriteHeader(http.StatusOK) w.WriteHeader(http.StatusOK)
@@ -458,22 +477,13 @@ func (h *masqueHandler) handleConnectTCP(ctx context.Context, w http.ResponseWri
flusher.Flush() flusher.Flush()
} }
// Get the underlying HTTP/3 stream
stream := streamer.HTTPStream()
// 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
}
// Create stream connection wrapping the HTTP/3 stream // Create stream connection wrapping the HTTP/3 stream
streamConn := masque_util.NewStreamConn(stream, laddr, raddr) streamConn := masque_util.NewStreamConn(stream, laddr, raddr)
defer streamConn.Close() defer streamConn.Close()
// Wrap with traffic limiter // Wrap with recorder stats and traffic limiter
var clientConn net.Conn = streamConn var clientConn net.Conn = streamConn
clientConn = stats_wrapper.WrapConn(clientConn, pStats)
clientConn = traffic_wrapper.WrapConn( clientConn = traffic_wrapper.WrapConn(
clientConn, clientConn,
h.limiter, h.limiter,
@@ -501,7 +511,7 @@ func (h *masqueHandler) handleConnectTCP(ctx context.Context, w http.ResponseWri
log.Infof("%s <-> %s", ro.RemoteAddr, targetAddr) log.Infof("%s <-> %s", ro.RemoteAddr, targetAddr)
// Bidirectional relay // Bidirectional relay
xnet.Pipe(ctx, clientConn, cc) xnet.Pipe(ctx, clientConn, cc, xnet.WithReadTimeout(h.md.idleTimeout))
log.WithFields(map[string]any{ log.WithFields(map[string]any{
"duration": time.Since(ro.Time), "duration": time.Since(ro.Time),
@@ -575,7 +585,7 @@ func (h *masqueHandler) observeStats(ctx context.Context) {
evs := h.stats.Events() evs := h.stats.Events()
if err := h.options.Observer.Observe(ctx, evs); err != nil { if err := h.options.Observer.Observe(ctx, evs); err != nil {
events = evs events = append(events, evs...)
} }
case <-ctx.Done(): case <-ctx.Done():
+4 -1
View File
@@ -15,7 +15,8 @@ const (
type metadata struct { type metadata struct {
hash string hash string
bufferSize int bufferSize int
authBasicRealm string authBasicRealm string
idleTimeout time.Duration
observerPeriod time.Duration observerPeriod time.Duration
observerResetTraffic bool observerResetTraffic bool
@@ -42,6 +43,8 @@ func (h *masqueHandler) parseMetadata(md mdata.Metadata) (err error) {
} }
h.md.observerResetTraffic = mdutil.GetBool(md, "observer.resetTraffic") h.md.observerResetTraffic = mdutil.GetBool(md, "observer.resetTraffic")
h.md.idleTimeout = mdutil.GetDuration(md, "readTimeout", "read.timeout", "idleTimeout")
h.md.limiterRefreshInterval = mdutil.GetDuration(md, "limiter.refreshInterval") h.md.limiterRefreshInterval = mdutil.GetDuration(md, "limiter.refreshInterval")
h.md.limiterCleanupInterval = mdutil.GetDuration(md, "limiter.cleanupInterval") h.md.limiterCleanupInterval = mdutil.GetDuration(md, "limiter.cleanupInterval")