The UDP relay treated the destination address as packet-scoped while
applying ACL/outbound policy only once when a new session was created.
After an authenticated client opened a UDP session using a permitted
first destination, later packets carrying a different Addr in the same
SessionID were written via the established outbound socket without
re-checking policy, allowing the client to reach destinations that ACL
should reject — including localhost and RFC1918 from the server's
network perspective. See GHSA-vgrc-hq28-p3xp.
Add a no-I/O CheckUDP method to the Outbound / PluggableOutbound
chain. The UDP session entry now consults CheckUDP for every packet
whose destination differs from the session's first one, dropping
rejected packets before WriteTo. Decisions are cached per destination
within the session (bounded at 256 entries with simple eviction) so
steady-state cost is one map lookup per packet and no extra sockets
or dials. CheckUDP propagates through the existing chain:
- aclEngine routes through the matched outbound's CheckUDP, with
aclRejectOutbound returning the rejection error.
- directOutbound / socks5Outbound / speedtestHandler return nil.
- httpOutbound returns errHTTPUDPNotSupported.
- Resolvers (system / dot / doh) run resolve() then forward to
Next.CheckUDP so IP-based ACL rules keep matching.
Regression tests in core/internal/integration_tests/udp_acl_test.go
use an in-package stub Outbound to assert that a rejected destination
is not relayed after the session is opened on a permitted one, and
that multi-destination sessions over permitted addresses still work.
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
421 lines
12 KiB
Go
421 lines
12 KiB
Go
package server
|
|
|
|
import (
|
|
"context"
|
|
"crypto/tls"
|
|
"errors"
|
|
"math/rand"
|
|
"net/http"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/apernet/quic-go"
|
|
"github.com/apernet/quic-go/http3"
|
|
"github.com/apernet/quic-go/quicvarint"
|
|
|
|
"github.com/apernet/hysteria/core/v2/internal/congestion"
|
|
"github.com/apernet/hysteria/core/v2/internal/protocol"
|
|
"github.com/apernet/hysteria/core/v2/internal/utils"
|
|
)
|
|
|
|
const (
|
|
closeErrCodeOK = 0x100 // HTTP3 ErrCodeNoError
|
|
closeErrCodeTrafficLimitReached = 0x107 // HTTP3 ErrCodeExcessiveLoad
|
|
)
|
|
|
|
type Server interface {
|
|
Serve() error
|
|
Close() error
|
|
}
|
|
|
|
func convertToStdTLSConfig(config *Config) *tls.Config {
|
|
var clientAuth tls.ClientAuthType
|
|
if config.TLSConfig.ClientCAs != nil {
|
|
clientAuth = tls.RequireAndVerifyClientCert
|
|
} else {
|
|
clientAuth = tls.NoClientCert
|
|
}
|
|
return http3.ConfigureTLSConfig(&tls.Config{
|
|
Certificates: config.TLSConfig.Certificates,
|
|
GetCertificate: config.TLSConfig.GetCertificate,
|
|
ClientCAs: config.TLSConfig.ClientCAs,
|
|
ClientAuth: clientAuth,
|
|
})
|
|
}
|
|
|
|
func NewServer(config *Config) (Server, error) {
|
|
if err := config.fill(); err != nil {
|
|
return nil, err
|
|
}
|
|
tlsConfig := convertToStdTLSConfig(config)
|
|
quicConfig := &quic.Config{
|
|
InitialStreamReceiveWindow: config.QUICConfig.InitialStreamReceiveWindow,
|
|
MaxStreamReceiveWindow: config.QUICConfig.MaxStreamReceiveWindow,
|
|
InitialConnectionReceiveWindow: config.QUICConfig.InitialConnectionReceiveWindow,
|
|
MaxConnectionReceiveWindow: config.QUICConfig.MaxConnectionReceiveWindow,
|
|
MaxIdleTimeout: config.QUICConfig.MaxIdleTimeout,
|
|
MaxIncomingStreams: config.QUICConfig.MaxIncomingStreams,
|
|
DisablePathMTUDiscovery: config.QUICConfig.DisablePathMTUDiscovery,
|
|
EnableDatagrams: true,
|
|
MaxDatagramFrameSize: protocol.MaxDatagramFrameSize,
|
|
AssumePeerMaxDatagramFrameSize: protocol.MaxDatagramFrameSize,
|
|
DisablePathManager: true,
|
|
}
|
|
tr := &quic.Transport{Conn: config.Conn}
|
|
listener, err := tr.Listen(tlsConfig, quicConfig)
|
|
if err != nil {
|
|
err = errors.Join(err, tr.Close(), config.Conn.Close())
|
|
if config.Cleanup != nil {
|
|
err = errors.Join(err, config.Cleanup.Close())
|
|
}
|
|
return nil, err
|
|
}
|
|
return &serverImpl{
|
|
config: config,
|
|
tr: tr,
|
|
listener: listener,
|
|
}, nil
|
|
}
|
|
|
|
type serverImpl struct {
|
|
config *Config
|
|
tr *quic.Transport
|
|
listener *quic.Listener
|
|
}
|
|
|
|
func (s *serverImpl) Serve() error {
|
|
for {
|
|
conn, err := s.listener.Accept(context.Background())
|
|
if err != nil {
|
|
return err
|
|
}
|
|
go s.handleClient(conn)
|
|
}
|
|
}
|
|
|
|
func (s *serverImpl) Close() error {
|
|
err := errors.Join(s.listener.Close(), s.tr.Close(), s.config.Conn.Close())
|
|
if s.config.Cleanup != nil {
|
|
err = errors.Join(err, s.config.Cleanup.Close())
|
|
}
|
|
return err
|
|
}
|
|
|
|
func (s *serverImpl) handleClient(conn *quic.Conn) {
|
|
handler := newH3sHandler(s.config, conn)
|
|
h3s := http3.Server{
|
|
Handler: handler,
|
|
StreamDispatcher: handler.ProxyStreamHijacker,
|
|
}
|
|
err := h3s.ServeQUICConn(conn)
|
|
// If the client is authenticated, we need to log the disconnect event
|
|
if handler.authenticated {
|
|
if tl := s.config.TrafficLogger; tl != nil {
|
|
tl.LogOnlineState(handler.authID, false)
|
|
}
|
|
if el := s.config.EventLogger; el != nil {
|
|
el.Disconnect(conn.RemoteAddr(), handler.authID, err)
|
|
}
|
|
}
|
|
_ = conn.CloseWithError(closeErrCodeOK, "")
|
|
}
|
|
|
|
type h3sHandler struct {
|
|
config *Config
|
|
conn *quic.Conn
|
|
|
|
authenticated bool
|
|
authMutex sync.Mutex
|
|
authID string
|
|
connID uint32 // a random id for dump streams
|
|
|
|
udpSM *udpSessionManager // Only set after authentication
|
|
}
|
|
|
|
func newH3sHandler(config *Config, conn *quic.Conn) *h3sHandler {
|
|
return &h3sHandler{
|
|
config: config,
|
|
conn: conn,
|
|
connID: rand.Uint32(),
|
|
}
|
|
}
|
|
|
|
func (h *h3sHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method == http.MethodPost && r.Host == protocol.URLHost && r.URL.Path == protocol.URLPath {
|
|
h.authMutex.Lock()
|
|
defer h.authMutex.Unlock()
|
|
if h.authenticated {
|
|
// Already authenticated
|
|
protocol.AuthResponseToHeader(w.Header(), protocol.AuthResponse{
|
|
UDPEnabled: !h.config.DisableUDP,
|
|
Rx: h.config.BandwidthConfig.MaxRx,
|
|
RxAuto: h.config.IgnoreClientBandwidth,
|
|
})
|
|
w.WriteHeader(protocol.StatusAuthOK)
|
|
return
|
|
}
|
|
authReq := protocol.AuthRequestFromHeader(r.Header)
|
|
actualTx := authReq.Rx
|
|
ok, id := h.config.Authenticator.Authenticate(h.conn.RemoteAddr(), authReq.Auth, actualTx)
|
|
if ok {
|
|
// Set authenticated flag
|
|
h.authenticated = true
|
|
h.authID = id
|
|
if h.config.IgnoreClientBandwidth {
|
|
// Ignore client bandwidth and use the configured congestion controller.
|
|
congestion.UseConfigured(h.conn, h.config.CongestionConfig.Type, h.config.CongestionConfig.BBRProfile)
|
|
actualTx = 0
|
|
} else {
|
|
// actualTx = min(serverTx, clientRx)
|
|
if h.config.BandwidthConfig.MaxTx > 0 && actualTx > h.config.BandwidthConfig.MaxTx {
|
|
// We have a maxTx limit and the client is asking for more than that,
|
|
// return and use the limit instead
|
|
actualTx = h.config.BandwidthConfig.MaxTx
|
|
}
|
|
if actualTx > 0 {
|
|
congestion.UseBrutal(h.conn, actualTx)
|
|
} else {
|
|
// Client doesn't know its own bandwidth, use the configured congestion controller.
|
|
congestion.UseConfigured(h.conn, h.config.CongestionConfig.Type, h.config.CongestionConfig.BBRProfile)
|
|
}
|
|
}
|
|
// Auth OK, send response
|
|
protocol.AuthResponseToHeader(w.Header(), protocol.AuthResponse{
|
|
UDPEnabled: !h.config.DisableUDP,
|
|
Rx: h.config.BandwidthConfig.MaxRx,
|
|
RxAuto: h.config.IgnoreClientBandwidth,
|
|
})
|
|
w.WriteHeader(protocol.StatusAuthOK)
|
|
// Call event logger
|
|
if tl := h.config.TrafficLogger; tl != nil {
|
|
tl.LogOnlineState(id, true)
|
|
}
|
|
if el := h.config.EventLogger; el != nil {
|
|
el.Connect(h.conn.RemoteAddr(), id, actualTx)
|
|
}
|
|
// Initialize UDP session manager (if UDP is enabled)
|
|
// We use sync.Once to make sure that only one goroutine is started,
|
|
// as ServeHTTP may be called by multiple goroutines simultaneously
|
|
if !h.config.DisableUDP {
|
|
go func() {
|
|
sm := newUDPSessionManager(
|
|
&udpIOImpl{h.conn, id, h.config.TrafficLogger, h.config.RequestHook, h.config.Outbound},
|
|
&udpEventLoggerImpl{h.conn, id, h.config.EventLogger},
|
|
h.config.UDPIdleTimeout,
|
|
)
|
|
h.udpSM = sm
|
|
go sm.Run()
|
|
}()
|
|
}
|
|
} else {
|
|
// Auth failed, pretend to be a normal HTTP server
|
|
h.masqHandler(w, r)
|
|
}
|
|
} else {
|
|
// Not an auth request, pretend to be a normal HTTP server
|
|
h.masqHandler(w, r)
|
|
}
|
|
}
|
|
|
|
func (h *h3sHandler) ProxyStreamHijacker(ft http3.FrameType, stream *quic.Stream, err error) (bool, error) {
|
|
if err != nil || !h.authenticated {
|
|
return false, nil
|
|
}
|
|
|
|
switch ft {
|
|
case protocol.FrameTypeTCPRequest:
|
|
// StreamDispatcher only peeks the frame type. Consume it so ReadTCPRequest
|
|
// starts at address length, matching pre-upgrade StreamHijacker behavior.
|
|
if _, err := quicvarint.Read(quicvarint.NewReader(stream)); err != nil {
|
|
return false, err
|
|
}
|
|
// Wraps the stream with QStream, which handles Close() properly
|
|
qStream := &utils.QStream{Stream: stream}
|
|
go h.handleTCPRequest(qStream)
|
|
return true, nil
|
|
default:
|
|
return false, nil
|
|
}
|
|
}
|
|
|
|
func (h *h3sHandler) handleTCPRequest(stream *utils.QStream) {
|
|
trafficLogger := h.config.TrafficLogger
|
|
streamStats := &StreamStats{
|
|
AuthID: h.authID,
|
|
ConnID: h.connID,
|
|
InitialTime: time.Now(),
|
|
}
|
|
streamStats.State.Store(StreamStateInitial)
|
|
streamStats.LastActiveTime.Store(time.Now())
|
|
defer func() {
|
|
streamStats.State.Store(StreamStateClosed)
|
|
}()
|
|
if trafficLogger != nil {
|
|
trafficLogger.TraceStream(stream, streamStats)
|
|
defer trafficLogger.UntraceStream(stream)
|
|
}
|
|
|
|
// Read request
|
|
reqAddr, err := protocol.ReadTCPRequest(stream)
|
|
if err != nil {
|
|
_ = stream.Close()
|
|
return
|
|
}
|
|
streamStats.ReqAddr.Store(reqAddr)
|
|
// Call the hook if set
|
|
var putback []byte
|
|
var hooked bool
|
|
if h.config.RequestHook != nil {
|
|
hooked = h.config.RequestHook.Check(false, reqAddr)
|
|
// When the hook is enabled, the server should always accept a connection
|
|
// so that the client will send whatever request the hook wants to see.
|
|
// This is essentially a server-side fast-open.
|
|
if hooked {
|
|
streamStats.State.Store(StreamStateHooking)
|
|
_ = protocol.WriteTCPResponse(stream, true, "RequestHook enabled")
|
|
putback, err = h.config.RequestHook.TCP(stream, &reqAddr)
|
|
if err != nil {
|
|
_ = stream.Close()
|
|
return
|
|
}
|
|
streamStats.setHookedReqAddr(reqAddr)
|
|
}
|
|
}
|
|
// Log the event
|
|
if h.config.EventLogger != nil {
|
|
h.config.EventLogger.TCPRequest(h.conn.RemoteAddr(), h.authID, reqAddr)
|
|
}
|
|
// Dial target
|
|
streamStats.State.Store(StreamStateConnecting)
|
|
tConn, err := h.config.Outbound.TCP(reqAddr)
|
|
if err != nil {
|
|
if !hooked {
|
|
_ = protocol.WriteTCPResponse(stream, false, err.Error())
|
|
}
|
|
_ = stream.Close()
|
|
// Log the error
|
|
if h.config.EventLogger != nil {
|
|
h.config.EventLogger.TCPError(h.conn.RemoteAddr(), h.authID, reqAddr, err)
|
|
}
|
|
return
|
|
}
|
|
if !hooked {
|
|
_ = protocol.WriteTCPResponse(stream, true, "Connected")
|
|
}
|
|
streamStats.State.Store(StreamStateEstablished)
|
|
// Put back the data if the hook requested
|
|
if len(putback) > 0 {
|
|
n, _ := tConn.Write(putback)
|
|
streamStats.Tx.Add(uint64(n))
|
|
}
|
|
// Start proxying
|
|
if trafficLogger != nil {
|
|
err = copyTwoWayEx(h.authID, stream, tConn, trafficLogger, streamStats)
|
|
} else {
|
|
// Use the fast path if no traffic logger is set
|
|
err = copyTwoWay(stream, tConn)
|
|
}
|
|
if h.config.EventLogger != nil {
|
|
h.config.EventLogger.TCPError(h.conn.RemoteAddr(), h.authID, reqAddr, err)
|
|
}
|
|
// Cleanup
|
|
_ = tConn.Close()
|
|
_ = stream.Close()
|
|
// Disconnect the client if TrafficLogger requested
|
|
if err == errDisconnect {
|
|
_ = h.conn.CloseWithError(closeErrCodeTrafficLimitReached, "")
|
|
}
|
|
}
|
|
|
|
func (h *h3sHandler) masqHandler(w http.ResponseWriter, r *http.Request) {
|
|
if h.config.MasqHandler != nil {
|
|
h.config.MasqHandler.ServeHTTP(w, r)
|
|
} else {
|
|
// Return 404 for everything
|
|
http.NotFound(w, r)
|
|
}
|
|
}
|
|
|
|
// udpIOImpl is the IO implementation for udpSessionManager with TrafficLogger support
|
|
type udpIOImpl struct {
|
|
Conn *quic.Conn
|
|
AuthID string
|
|
TrafficLogger TrafficLogger
|
|
RequestHook RequestHook
|
|
Outbound Outbound
|
|
}
|
|
|
|
func (io *udpIOImpl) ReceiveMessage() (*protocol.UDPMessage, error) {
|
|
for {
|
|
msg, err := io.Conn.ReceiveDatagram(context.Background())
|
|
if err != nil {
|
|
// Connection error, this will stop the session manager
|
|
return nil, err
|
|
}
|
|
udpMsg, err := protocol.ParseUDPMessage(msg)
|
|
if err != nil {
|
|
// Invalid message, this is fine - just wait for the next
|
|
continue
|
|
}
|
|
if io.TrafficLogger != nil {
|
|
ok := io.TrafficLogger.LogTraffic(io.AuthID, uint64(len(udpMsg.Data)), 0)
|
|
if !ok {
|
|
// TrafficLogger requested to disconnect the client
|
|
_ = io.Conn.CloseWithError(closeErrCodeTrafficLimitReached, "")
|
|
return nil, errDisconnect
|
|
}
|
|
}
|
|
return udpMsg, nil
|
|
}
|
|
}
|
|
|
|
func (io *udpIOImpl) SendMessage(buf []byte, msg *protocol.UDPMessage) error {
|
|
if io.TrafficLogger != nil {
|
|
ok := io.TrafficLogger.LogTraffic(io.AuthID, 0, uint64(len(msg.Data)))
|
|
if !ok {
|
|
// TrafficLogger requested to disconnect the client
|
|
_ = io.Conn.CloseWithError(closeErrCodeTrafficLimitReached, "")
|
|
return errDisconnect
|
|
}
|
|
}
|
|
msgN := msg.Serialize(buf)
|
|
if msgN < 0 {
|
|
// Message larger than buffer, silent drop
|
|
return nil
|
|
}
|
|
return io.Conn.SendDatagram(buf[:msgN])
|
|
}
|
|
|
|
func (io *udpIOImpl) Hook(data []byte, reqAddr *string) error {
|
|
if io.RequestHook != nil && io.RequestHook.Check(true, *reqAddr) {
|
|
return io.RequestHook.UDP(data, reqAddr)
|
|
} else {
|
|
return nil
|
|
}
|
|
}
|
|
|
|
func (io *udpIOImpl) UDP(reqAddr string) (UDPConn, error) {
|
|
return io.Outbound.UDP(reqAddr)
|
|
}
|
|
|
|
func (io *udpIOImpl) CheckUDP(reqAddr string) error {
|
|
return io.Outbound.CheckUDP(reqAddr)
|
|
}
|
|
|
|
type udpEventLoggerImpl struct {
|
|
Conn *quic.Conn
|
|
AuthID string
|
|
EventLogger EventLogger
|
|
}
|
|
|
|
func (l *udpEventLoggerImpl) New(sessionID uint32, reqAddr string) {
|
|
if l.EventLogger != nil {
|
|
l.EventLogger.UDPRequest(l.Conn.RemoteAddr(), l.AuthID, sessionID, reqAddr)
|
|
}
|
|
}
|
|
|
|
func (l *udpEventLoggerImpl) Close(sessionID uint32, err error) {
|
|
if l.EventLogger != nil {
|
|
l.EventLogger.UDPError(l.Conn.RemoteAddr(), l.AuthID, sessionID, err)
|
|
}
|
|
}
|