From 82337299b96f22dae29a0db564d9c38b131aba00 Mon Sep 17 00:00:00 2001 From: Sergei Maklagin Date: Sun, 22 Feb 2026 14:48:52 +0300 Subject: [PATCH] Update xhttp --- common/xray/pipe/impl.go | 45 +++--- common/xray/utils/browser.go | 28 ++++ common/xray/utils/padding.go | 24 +++ common/xray/uuid/uuid.go | 6 +- option/v2ray_transport.go | 235 +++++++++++++++++++++++++-- transport/v2rayxhttp/client.go | 62 ++++--- transport/v2rayxhttp/dialer.go | 152 ++++++++++++++++-- transport/v2rayxhttp/server.go | 147 ++++++++++++++--- transport/v2rayxhttp/xpadding.go | 268 +++++++++++++++++++++++++++++++ 9 files changed, 863 insertions(+), 104 deletions(-) create mode 100644 common/xray/utils/browser.go create mode 100644 common/xray/utils/padding.go create mode 100644 transport/v2rayxhttp/xpadding.go diff --git a/common/xray/pipe/impl.go b/common/xray/pipe/impl.go index b24305e4..7b2a97a8 100644 --- a/common/xray/pipe/impl.go +++ b/common/xray/pipe/impl.go @@ -3,7 +3,6 @@ package pipe import ( "errors" "io" - "runtime" "sync" "time" @@ -136,11 +135,10 @@ func (p *pipe) writeMultiBufferInternal(mb buf.MultiBuffer) error { if p.data == nil { p.data = mb - return nil + } else { + p.data, _ = buf.MergeMulti(p.data, mb) } - - p.data, _ = buf.MergeMulti(p.data, mb) - return errSlowDown + return nil } func (p *pipe) WriteMultiBuffer(mb buf.MultiBuffer) error { @@ -155,30 +153,23 @@ func (p *pipe) WriteMultiBuffer(mb buf.MultiBuffer) error { return nil } - if err == errSlowDown { - p.readSignal.Signal() - - // Yield current goroutine. Hopefully the reading counterpart can pick up the payload. - runtime.Gosched() - return nil + if err == errBufferFull { + if p.option.discardOverflow { + buf.ReleaseMulti(mb) + return nil + } + select { + case <-p.writeSignal.Wait(): + continue + case <-p.done.Wait(): + buf.ReleaseMulti(mb) + return io.ErrClosedPipe + } } - if err == errBufferFull && p.option.discardOverflow { - buf.ReleaseMulti(mb) - return nil - } - - if err != errBufferFull { - buf.ReleaseMulti(mb) - p.readSignal.Signal() - return err - } - - select { - case <-p.writeSignal.Wait(): - case <-p.done.Wait(): - return io.ErrClosedPipe - } + buf.ReleaseMulti(mb) + p.readSignal.Signal() + return err } } diff --git a/common/xray/utils/browser.go b/common/xray/utils/browser.go new file mode 100644 index 00000000..91209f4b --- /dev/null +++ b/common/xray/utils/browser.go @@ -0,0 +1,28 @@ +package utils + +import ( + "math/rand" + "strconv" + "time" + + "github.com/klauspost/cpuid/v2" +) + +func ChromeVersion() int { + // Use only CPU info as seed for PRNG + seed := int64(cpuid.CPU.Family + cpuid.CPU.Model + cpuid.CPU.PhysicalCores + cpuid.CPU.LogicalCores + cpuid.CPU.CacheLine) + rng := rand.New(rand.NewSource(seed)) + // Start from Chrome 144 released on 2026.1.13 + releaseDate := time.Date(2026, 1, 13, 0, 0, 0, 0, time.UTC) + version := 144 + now := time.Now() + // Each version has random 25-45 day interval + for releaseDate.Before(now) { + releaseDate = releaseDate.AddDate(0, 0, rng.Intn(21)+25) + version++ + } + return version - 1 +} + +// ChromeUA provides default browser User-Agent based on CPU-seeded PRNG. +var ChromeUA = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/" + strconv.Itoa(ChromeVersion()) + ".0.0.0 Safari/537.36" diff --git a/common/xray/utils/padding.go b/common/xray/utils/padding.go new file mode 100644 index 00000000..fe95ba9a --- /dev/null +++ b/common/xray/utils/padding.go @@ -0,0 +1,24 @@ +package utils + +import ( + "math/rand/v2" +) + +var ( + // 8 รท (397/62) + h2packCorrectionFactor = 1.2493702770780857 + base62TotalCharsNum = 62 + base62Chars = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz" +) + +// H2Base62Pad generates a base62 padding string for HTTP/2 header +// The total len will be slightly longer than the input to match the length after h2(h3 also) header huffman encoding +func H2Base62Pad[T int32 | int64 | int](expectedLen T) string { + actualLenFloat := float64(expectedLen) * h2packCorrectionFactor + actualLen := int(actualLenFloat) + result := make([]byte, actualLen) + for i := range actualLen { + result[i] = base62Chars[rand.N(base62TotalCharsNum)] + } + return string(result) +} diff --git a/common/xray/uuid/uuid.go b/common/xray/uuid/uuid.go index a0219036..31c7b531 100644 --- a/common/xray/uuid/uuid.go +++ b/common/xray/uuid/uuid.go @@ -85,10 +85,14 @@ func ParseString(str string) (UUID, error) { b := uuid.Bytes() for _, byteGroup := range byteGroups { - if text[0] == '-' { + if len(text) > 0 && text[0] == '-' { text = text[1:] } + if len(text) < byteGroup { + return uuid, E.New("invalid UUID: ", str) + } + if _, err := hex.Decode(b[:byteGroup/2], text[:byteGroup]); err != nil { return uuid, err } diff --git a/option/v2ray_transport.go b/option/v2ray_transport.go index e3698f66..d4ed9341 100644 --- a/option/v2ray_transport.go +++ b/option/v2ray_transport.go @@ -2,10 +2,10 @@ package option import ( "net/http" - "net/url" "strings" Xbadoption "github.com/sagernet/sing-box/common/xray/json/badoption" + "github.com/sagernet/sing-box/common/xray/utils" C "github.com/sagernet/sing-box/constant" E "github.com/sagernet/sing/common/exceptions" "github.com/sagernet/sing/common/json" @@ -122,14 +122,29 @@ type V2RayXHTTPBaseOptions struct { ScMaxBufferedPosts int64 `json:"sc_max_buffered_posts,omitempty"` ScStreamUpServerSecs Xbadoption.Range `json:"sc_stream_up_server_secs"` Xmux *V2RayXHTTPXmuxOptions `json:"xmux"` + XPaddingObfsMode bool `json:"x_padding_obfs_mode,omitempty"` + XPaddingKey string `json:"x_padding_key,omitempty"` + XPaddingHeader string `json:"x_padding_header,omitempty"` + XPaddingPlacement string `json:"x_padding_placement,omitempty"` + XPaddingMethod string `json:"x_padding_method,omitempty"` + UplinkHTTPMethod string `json:"uplink_http_method,omitempty"` + SessionPlacement string `json:"session_placement,omitempty"` + SessionKey string `json:"session_key,omitempty"` + SeqPlacement string `json:"seq_placement,omitempty"` + SeqKey string `json:"seq_key,omitempty"` + UplinkDataPlacement string `json:"uplink_data_placement,omitempty"` + UplinkDataKey string `json:"uplink_data_key,omitempty"` + UplinkChunkSize uint32 `json:"uplink_chunk_size,omitempty"` } -type V2RayXHTTPOptions struct { +type _V2RayXHTTPOptions struct { Mode string `json:"mode"` V2RayXHTTPBaseOptions Download *V2RayXHTTPDownloadOptions `json:"download"` } +type V2RayXHTTPOptions _V2RayXHTTPOptions + type V2RayXHTTPDownloadOptions struct { V2RayXHTTPBaseOptions ServerOptions @@ -137,6 +152,153 @@ type V2RayXHTTPDownloadOptions struct { Detour string `json:"detour,omitempty"` } +const ( + PlacementQueryInHeader = "queryInHeader" + PlacementCookie = "cookie" + PlacementHeader = "header" + PlacementQuery = "query" + PlacementPath = "path" + PlacementBody = "body" +) + +func (c *V2RayXHTTPOptions) UnmarshalJSON(bytes []byte) error { + err := json.Unmarshal(bytes, (*_V2RayXHTTPOptions)(c)) + if err != nil { + return err + } + switch c.Mode { + case "": + c.Mode = "auto" + case "auto", "packet-up", "stream-up", "stream-one": + default: + return E.New("unsupported mode: " + c.Mode) + } + err = checkV2RayXHTTPBaseOptions(c.Mode, &c.V2RayXHTTPBaseOptions) + if err != nil { + return err + } + if c.Download != nil { + err = checkV2RayXHTTPBaseOptions(c.Mode, &c.Download.V2RayXHTTPBaseOptions) + if err != nil { + return err + } + } + return nil +} + +func checkV2RayXHTTPBaseOptions(mode string, options *V2RayXHTTPBaseOptions) error { + // Priority (client): host > serverName > address + for k := range options.Headers { + if strings.ToLower(k) == "host" { + return E.New(`"headers" can't contain "host"`) + } + } + if options.XPaddingBytes.From <= 0 || options.XPaddingBytes.To <= 0 { + return E.New("xPaddingBytes cannot be disabled") + } + if options.XPaddingKey == "" { + options.XPaddingKey = "x_padding" + } + if options.XPaddingHeader == "" { + options.XPaddingHeader = "X-Padding" + } + switch options.XPaddingPlacement { + case "": + options.XPaddingPlacement = "queryInHeader" + case "cookie", "header", "query", "queryInHeader": + default: + return E.New("unsupported padding placement: " + options.XPaddingPlacement) + } + switch options.XPaddingMethod { + case "": + options.XPaddingMethod = "repeat-x" + case "repeat-x", "tokenish": + default: + return E.New("unsupported padding method: " + options.XPaddingMethod) + } + switch options.UplinkDataPlacement { + case "": + options.UplinkDataPlacement = "body" + case "body": + case "cookie", "header": + if mode != "packet-up" { + return E.New("UplinkDataPlacement can be " + options.UplinkDataPlacement + " only in packet-up mode") + } + default: + return E.New("unsupported uplink data placement: " + options.UplinkDataPlacement) + } + if options.UplinkHTTPMethod == "" { + options.UplinkHTTPMethod = "POST" + } + options.UplinkHTTPMethod = strings.ToUpper(options.UplinkHTTPMethod) + if options.UplinkHTTPMethod == "GET" && mode != "packet-up" { + return E.New("uplinkHTTPMethod can be GET only in packet-up mode") + } + switch options.SessionPlacement { + case "": + options.SessionPlacement = "path" + case "path", "cookie", "header", "query": + default: + return E.New("unsupported session placement: " + options.SessionPlacement) + } + switch options.SeqPlacement { + case "": + options.SeqPlacement = "path" + case "path", "cookie", "header", "query": + if options.SessionPlacement == "path" { + return E.New("SeqPlacement must be path when SessionPlacement is path") + } + default: + return E.New("unsupported seq placement: " + options.SeqPlacement) + } + if options.SessionPlacement != "path" && options.SessionKey == "" { + switch options.SessionPlacement { + case "cookie", "query": + options.SessionKey = "x_session" + case "header": + options.SessionKey = "X-Session" + } + } + if options.SeqPlacement != "path" && options.SeqKey == "" { + switch options.SeqPlacement { + case "cookie", "query": + options.SeqKey = "x_seq" + case "header": + options.SeqKey = "X-Seq" + } + } + if options.UplinkDataPlacement != "body" && options.UplinkDataKey == "" { + switch options.UplinkDataPlacement { + case "cookie": + options.UplinkDataKey = "x_data" + case "header": + options.UplinkDataKey = "X-Data" + } + } + if options.UplinkChunkSize == 0 { + switch options.UplinkDataPlacement { + case "cookie": + options.UplinkChunkSize = 3 * 1024 // 3KB + case "header": + options.UplinkChunkSize = 4 * 1024 // 4KB + } + } else if options.UplinkChunkSize < 64 { + options.UplinkChunkSize = 64 + } + if options.Xmux == nil { + options.Xmux = &V2RayXHTTPXmuxOptions{} + options.Xmux.MaxConcurrency.From = 1 + options.Xmux.MaxConcurrency.To = 1 + options.Xmux.HMaxRequestTimes.From = 600 + options.Xmux.HMaxRequestTimes.To = 900 + options.Xmux.HMaxReusableSecs.From = 1800 + options.Xmux.HMaxReusableSecs.To = 3000 + } else if options.Xmux.MaxConnections.To > 0 && options.Xmux.MaxConcurrency.To > 0 { + return E.New("maxConnections cannot be specified together with maxConcurrency") + } + return nil +} + func (c *V2RayXHTTPBaseOptions) GetNormalizedPath() string { pathAndQuery := strings.SplitN(c.Path, "?", 2) path := pathAndQuery[0] @@ -158,19 +320,14 @@ func (c *V2RayXHTTPBaseOptions) GetNormalizedQuery() string { return query } -func (c *V2RayXHTTPBaseOptions) GetRequestHeader(rawURL string) http.Header { +func (c *V2RayXHTTPBaseOptions) GetRequestHeader() http.Header { header := http.Header{} for k, v := range c.Headers { header.Add(k, v) } - u, _ := url.Parse(rawURL) - // https://www.rfc-editor.org/rfc/rfc7541.html#appendix-B - // h2's HPACK Header Compression feature employs a huffman encoding using a static table. - // 'X' is assigned an 8 bit code, so HPACK compression won't change actual padding length on the wire. - // https://www.rfc-editor.org/rfc/rfc9204.html#section-4.1.2-2 - // h3's similar QPACK feature uses the same huffman table. - u.RawQuery = "x_padding=" + strings.Repeat("X", int(c.GetNormalizedXPaddingBytes().Rand())) - header.Set("Referer", u.String()) + if header.Get("User-Agent") == "" { + header.Set("User-Agent", utils.ChromeUA) + } return header } @@ -184,6 +341,13 @@ func (c *V2RayXHTTPBaseOptions) GetNormalizedXPaddingBytes() Xbadoption.Range { return c.XPaddingBytes } +func (c *V2RayXHTTPBaseOptions) GetNormalizedUplinkHTTPMethod() string { + if c.UplinkHTTPMethod == "" { + return "POST" + } + return c.UplinkHTTPMethod +} + func (c *V2RayXHTTPBaseOptions) GetNormalizedScMaxEachPostBytes() Xbadoption.Range { if c.ScMaxEachPostBytes.To == 0 { return Xbadoption.Range{ @@ -222,6 +386,55 @@ func (c *V2RayXHTTPBaseOptions) GetNormalizedScStreamUpServerSecs() Xbadoption.R return c.ScStreamUpServerSecs } +func (c *V2RayXHTTPBaseOptions) GetNormalizedSessionPlacement() string { + if c.SessionPlacement == "" { + return PlacementPath + } + return c.SessionPlacement +} + +func (c *V2RayXHTTPBaseOptions) GetNormalizedSeqPlacement() string { + if c.SeqPlacement == "" { + return PlacementPath + } + return c.SeqPlacement +} + +func (c *V2RayXHTTPBaseOptions) GetNormalizedUplinkDataPlacement() string { + if c.UplinkDataPlacement == "" { + return PlacementBody + } + return c.UplinkDataPlacement +} + +func (c *V2RayXHTTPBaseOptions) GetNormalizedSessionKey() string { + if c.SessionKey != "" { + return c.SessionKey + } + switch c.GetNormalizedSessionPlacement() { + case PlacementHeader: + return "X-Session" + case PlacementCookie, PlacementQuery: + return "x_session" + default: + return "" + } +} + +func (c *V2RayXHTTPBaseOptions) GetNormalizedSeqKey() string { + if c.SeqKey != "" { + return c.SeqKey + } + switch c.GetNormalizedSeqPlacement() { + case PlacementHeader: + return "X-Seq" + case PlacementCookie, PlacementQuery: + return "x_seq" + default: + return "" + } +} + type V2RayXHTTPXmuxOptions struct { MaxConcurrency Xbadoption.Range `json:"max_concurrency"` MaxConnections Xbadoption.Range `json:"max_connections"` diff --git a/transport/v2rayxhttp/client.go b/transport/v2rayxhttp/client.go index 7df6a91d..99587703 100644 --- a/transport/v2rayxhttp/client.go +++ b/transport/v2rayxhttp/client.go @@ -35,12 +35,12 @@ import ( ) type Client struct { - ctx context.Context - options *option.V2RayXHTTPOptions - getRequestURL func(sessionId string) url.URL - getRequestURL2 func(sessionId string) url.URL - getHTTPClient func() (DialerClient, *XmuxClient) - getHTTPClient2 func() (DialerClient, *XmuxClient) + ctx context.Context + options *option.V2RayXHTTPOptions + baseRequestURL url.URL + baseRequestURL2 url.URL + getHTTPClient func() (DialerClient, *XmuxClient) + getHTTPClient2 func() (DialerClient, *XmuxClient) } func NewClient(ctx context.Context, dialer N.Dialer, serverAddr M.Socksaddr, options option.V2RayXHTTPOptions, tlsConfig tls.Config) (adapter.V2RayClientTransport, error) { @@ -48,17 +48,10 @@ func NewClient(ctx context.Context, dialer N.Dialer, serverAddr M.Socksaddr, opt return nil, E.New("mode is not set") } dest := serverAddr - baseRequestURL, err := getBaseRequestURL( - &options.V2RayXHTTPBaseOptions, dest, tlsConfig, - ) + baseRequestURL, err := getBaseRequestURL(&options.V2RayXHTTPBaseOptions, dest, tlsConfig) if err != nil { return nil, err } - getRequestURL := func(sessionId string) url.URL { - requestURL := baseRequestURL - requestURL.Path += sessionId - return requestURL - } var xmuxOptions option.V2RayXHTTPXmuxOptions if options.Xmux != nil { xmuxOptions = *options.Xmux @@ -70,7 +63,7 @@ func NewClient(ctx context.Context, dialer N.Dialer, serverAddr M.Socksaddr, opt xmuxClient := xmuxManager.GetXmuxClient(ctx) return xmuxClient.XmuxConn.(DialerClient), xmuxClient } - getRequestURL2 := getRequestURL + baseRequestURL2 := baseRequestURL getHTTPClient2 := getHTTPClient if options.Download != nil { options2 := options.Download @@ -90,15 +83,10 @@ func NewClient(ctx context.Context, dialer N.Dialer, serverAddr M.Socksaddr, opt return nil, err } } - baseRequestURL2, err := getBaseRequestURL(&options2.V2RayXHTTPBaseOptions, dest2, tlsConfig2) + baseRequestURL2, err = getBaseRequestURL(&options2.V2RayXHTTPBaseOptions, dest2, tlsConfig2) if err != nil { return nil, err } - getRequestURL2 = func(sessionId string) url.URL { - requestURL2 := baseRequestURL2 - requestURL2.Path += sessionId - return requestURL2 - } var xmuxOptions2 option.V2RayXHTTPXmuxOptions if options2.Xmux != nil { xmuxOptions2 = *options2.Xmux @@ -112,21 +100,25 @@ func NewClient(ctx context.Context, dialer N.Dialer, serverAddr M.Socksaddr, opt } } return &Client{ - ctx: ctx, - options: &options, - getHTTPClient: getHTTPClient, - getHTTPClient2: getHTTPClient2, - getRequestURL: getRequestURL, - getRequestURL2: getRequestURL2, + ctx: ctx, + options: &options, + getHTTPClient: getHTTPClient, + getHTTPClient2: getHTTPClient2, + baseRequestURL: baseRequestURL, + baseRequestURL2: baseRequestURL2, }, nil } func (c *Client) DialContext(ctx context.Context) (net.Conn, error) { options := c.options mode := c.options.Mode - sessionIdUuid := uuid.New() - requestURL := c.getRequestURL(sessionIdUuid.String()) - requestURL2 := c.getRequestURL2(sessionIdUuid.String()) + sessionId := "" + if c.options.Mode != "stream-one" { + sessionIdUuid := uuid.New() + sessionId = sessionIdUuid.String() + } + requestURL := c.baseRequestURL + requestURL2 := c.baseRequestURL2 httpClient, xmuxClient := c.getHTTPClient() httpClient2, xmuxClient2 := c.getHTTPClient2() if xmuxClient != nil { @@ -157,7 +149,7 @@ func (c *Client) DialContext(ctx context.Context) (net.Conn, error) { if xmuxClient != nil { xmuxClient.LeftRequests.Add(-1) } - conn.reader, conn.remoteAddr, conn.localAddr, err = httpClient.OpenStream(ctx, requestURL.String(), reader, false) + conn.reader, conn.remoteAddr, conn.localAddr, err = httpClient.OpenStream(ctx, requestURL.String(), sessionId, reader, false) if err != nil { // browser dialer only return nil, err } @@ -166,7 +158,7 @@ func (c *Client) DialContext(ctx context.Context) (net.Conn, error) { if xmuxClient2 != nil { xmuxClient2.LeftRequests.Add(-1) } - conn.reader, conn.remoteAddr, conn.localAddr, err = httpClient2.OpenStream(ctx, requestURL2.String(), nil, false) + conn.reader, conn.remoteAddr, conn.localAddr, err = httpClient2.OpenStream(ctx, requestURL2.String(), sessionId, nil, false) if err != nil { // browser dialer only return nil, err } @@ -175,7 +167,7 @@ func (c *Client) DialContext(ctx context.Context) (net.Conn, error) { if xmuxClient != nil { xmuxClient.LeftRequests.Add(-1) } - _, _, _, err = httpClient.OpenStream(ctx, requestURL.String(), reader, true) + _, _, _, err = httpClient.OpenStream(ctx, requestURL.String(), sessionId, reader, true) if err != nil { // browser dialer only return nil, err } @@ -209,7 +201,7 @@ func (c *Client) DialContext(ctx context.Context) (net.Conn, error) { // this intentionally makes a shallow-copy of the struct so we // can reassign Path (potentially concurrently) url := requestURL - url.Path += "/" + strconv.FormatInt(seq, 10) + seqStr := strconv.FormatInt(seq, 10) seq += 1 if scMinPostsIntervalMs.From > 0 { time.Sleep(time.Duration(scMinPostsIntervalMs.Rand())*time.Millisecond - time.Since(lastWrite)) @@ -230,6 +222,8 @@ func (c *Client) DialContext(ctx context.Context) (net.Conn, error) { err := httpClient.PostPacket( ctx, url.String(), + sessionId, + seqStr, &buf.MultiBufferContainer{MultiBuffer: chunk}, int64(chunk.Len()), ) diff --git a/transport/v2rayxhttp/dialer.go b/transport/v2rayxhttp/dialer.go index 13dcdd9a..fcdb9466 100644 --- a/transport/v2rayxhttp/dialer.go +++ b/transport/v2rayxhttp/dialer.go @@ -3,14 +3,16 @@ package xhttp import ( "bytes" "context" + "encoding/base64" "fmt" "io" "net" "net/http" "net/http/httptrace" + "strings" "sync" - "github.com/sagernet/sing-box/common/xray" + common "github.com/sagernet/sing-box/common/xray" "github.com/sagernet/sing-box/common/xray/signal/done" "github.com/sagernet/sing-box/option" ) @@ -19,11 +21,11 @@ import ( type DialerClient interface { IsClosed() bool - // ctx, url, body, uploadOnly - OpenStream(context.Context, string, io.Reader, bool) (io.ReadCloser, net.Addr, net.Addr, error) + // ctx, url, sessionId, body, uploadOnly + OpenStream(context.Context, string, string, io.Reader, bool) (io.ReadCloser, net.Addr, net.Addr, error) - // ctx, url, body, contentLength - PostPacket(context.Context, string, io.Reader, int64) error + // ctx, url, sessionId, seqStr, body, contentLength + PostPacket(context.Context, string, string, string, io.Reader, int64) error } // implements xhttp.DialerClient in terms of direct network connections @@ -41,7 +43,7 @@ func (c *DefaultDialerClient) IsClosed() bool { return c.closed } -func (c *DefaultDialerClient) OpenStream(ctx context.Context, url string, body io.Reader, uploadOnly bool) (wrc io.ReadCloser, remoteAddr, localAddr net.Addr, err error) { +func (c *DefaultDialerClient) OpenStream(ctx context.Context, url string, sessionId string, body io.Reader, uploadOnly bool) (wrc io.ReadCloser, remoteAddr, localAddr net.Addr, err error) { // this is done when the TCP/UDP connection to the server was established, // and we can unblock the Dial function and print correct net addresses in // logs @@ -55,11 +57,31 @@ func (c *DefaultDialerClient) OpenStream(ctx context.Context, url string, body i }) method := "GET" // stream-down if body != nil { - method = "POST" // stream-up/one + method = c.options.GetNormalizedUplinkHTTPMethod() // stream-up/one } req, _ := http.NewRequestWithContext(context.WithoutCancel(ctx), method, url, body) - req.Header = c.options.GetRequestHeader(url) - if method == "POST" && !c.options.NoGRPCHeader { + req.Header = c.options.GetRequestHeader() + length := int(c.options.GetNormalizedXPaddingBytes().Rand()) + config := XPaddingConfig{Length: length} + if c.options.XPaddingObfsMode { + config.Placement = XPaddingPlacement{ + Placement: c.options.XPaddingPlacement, + Key: c.options.XPaddingKey, + Header: c.options.XPaddingHeader, + RawURL: url, + } + config.Method = PaddingMethod(c.options.XPaddingMethod) + } else { + config.Placement = XPaddingPlacement{ + Placement: option.PlacementQueryInHeader, + Key: "x_padding", + Header: "Referer", + RawURL: url, + } + } + ApplyXPaddingToRequest(req, config) + ApplyMetaToRequest(c.options, req, sessionId, "") + if method == c.options.GetNormalizedUplinkHTTPMethod() && !c.options.NoGRPCHeader { req.Header.Set("Content-Type", "application/grpc") } wrc = &WaitReadCloser{Wait: make(chan struct{})} @@ -85,13 +107,76 @@ func (c *DefaultDialerClient) OpenStream(ctx context.Context, url string, body i return } -func (c *DefaultDialerClient) PostPacket(ctx context.Context, url string, body io.Reader, contentLength int64) error { - req, err := http.NewRequestWithContext(context.WithoutCancel(ctx), "POST", url, body) +func (c *DefaultDialerClient) PostPacket(ctx context.Context, url string, sessionId string, seqStr string, body io.Reader, contentLength int64) error { + var encodedData string + dataPlacement := c.options.GetNormalizedUplinkDataPlacement() + if dataPlacement != option.PlacementBody { + data, err := io.ReadAll(body) + if err != nil { + return err + } + encodedData = base64.RawURLEncoding.EncodeToString(data) + body = nil + contentLength = 0 + } + method := c.options.GetNormalizedUplinkHTTPMethod() + req, err := http.NewRequestWithContext(context.WithoutCancel(ctx), method, url, body) if err != nil { return err } req.ContentLength = contentLength - req.Header = c.options.GetRequestHeader(url) + req.Header = c.options.GetRequestHeader() + if dataPlacement != option.PlacementBody { + key := c.options.UplinkDataKey + chunkSize := int(c.options.UplinkChunkSize) + switch dataPlacement { + case option.PlacementHeader: + for i := 0; i < len(encodedData); i += chunkSize { + end := i + chunkSize + if end > len(encodedData) { + end = len(encodedData) + } + chunk := encodedData[i:end] + headerKey := fmt.Sprintf("%s-%d", key, i/chunkSize) + req.Header.Set(headerKey, chunk) + } + + req.Header.Set(key+"-Length", fmt.Sprintf("%d", len(encodedData))) + req.Header.Set(key+"-Upstream", "1") + case option.PlacementCookie: + for i := 0; i < len(encodedData); i += chunkSize { + end := i + chunkSize + if end > len(encodedData) { + end = len(encodedData) + } + chunk := encodedData[i:end] + cookieName := fmt.Sprintf("%s_%d", key, i/chunkSize) + req.AddCookie(&http.Cookie{Name: cookieName, Value: chunk}) + } + + req.AddCookie(&http.Cookie{Name: key + "_upstream", Value: "1"}) + } + } + length := int(c.options.GetNormalizedXPaddingBytes().Rand()) + config := XPaddingConfig{Length: length} + if c.options.XPaddingObfsMode { + config.Placement = XPaddingPlacement{ + Placement: c.options.XPaddingPlacement, + Key: c.options.XPaddingKey, + Header: c.options.XPaddingHeader, + RawURL: url, + } + config.Method = PaddingMethod(c.options.XPaddingMethod) + } else { + config.Placement = XPaddingPlacement{ + Placement: option.PlacementQueryInHeader, + Key: "x_padding", + Header: "Referer", + RawURL: url, + } + } + ApplyXPaddingToRequest(req, config) + ApplyMetaToRequest(c.options, req, sessionId, seqStr) if c.httpVersion != "1.1" { resp, err := c.client.Do(req) if err != nil { @@ -150,7 +235,6 @@ func (c *DefaultDialerClient) PostPacket(ctx context.Context, url string, body i } c.uploadRawPool.Put(uploadConn) } - return nil } @@ -190,3 +274,45 @@ func (w *WaitReadCloser) Close() error { close(w.Wait) return nil } + +func ApplyMetaToRequest(options *option.V2RayXHTTPBaseOptions, req *http.Request, sessionId string, seqStr string) { + sessionPlacement := options.GetNormalizedSessionPlacement() + seqPlacement := options.GetNormalizedSeqPlacement() + sessionKey := options.GetNormalizedSessionKey() + seqKey := options.GetNormalizedSeqKey() + if sessionId != "" { + switch sessionPlacement { + case option.PlacementPath: + req.URL.Path = appendToPath(req.URL.Path, sessionId) + case option.PlacementQuery: + q := req.URL.Query() + q.Set(sessionKey, sessionId) + req.URL.RawQuery = q.Encode() + case option.PlacementHeader: + req.Header.Set(sessionKey, sessionId) + case option.PlacementCookie: + req.AddCookie(&http.Cookie{Name: sessionKey, Value: sessionId}) + } + } + if seqStr != "" { + switch seqPlacement { + case option.PlacementPath: + req.URL.Path = appendToPath(req.URL.Path, seqStr) + case option.PlacementQuery: + q := req.URL.Query() + q.Set(seqKey, seqStr) + req.URL.RawQuery = q.Encode() + case option.PlacementHeader: + req.Header.Set(seqKey, seqStr) + case option.PlacementCookie: + req.AddCookie(&http.Cookie{Name: seqKey, Value: seqStr}) + } + } +} + +func appendToPath(path, value string) string { + if strings.HasSuffix(path, "/") { + return path + value + } + return path + "/" + value +} diff --git a/transport/v2rayxhttp/server.go b/transport/v2rayxhttp/server.go index 1454539c..1f0b5034 100644 --- a/transport/v2rayxhttp/server.go +++ b/transport/v2rayxhttp/server.go @@ -3,6 +3,8 @@ package xhttp import ( "bytes" "context" + "encoding/base64" + "fmt" "io" "net" "net/http" @@ -17,13 +19,11 @@ import ( "github.com/sagernet/quic-go/http3" "github.com/sagernet/sing-box/adapter" "github.com/sagernet/sing-box/common/tls" + "github.com/sagernet/sing-box/common/xray/signal/done" C "github.com/sagernet/sing-box/constant" "github.com/sagernet/sing-box/log" "github.com/sagernet/sing-box/option" qtls "github.com/sagernet/sing-quic" - - // qtls "github.com/sagernet/sing-quic" - "github.com/sagernet/sing-box/common/xray/signal/done" "github.com/sagernet/sing/common" "github.com/sagernet/sing/common/logger" M "github.com/sagernet/sing/common/metadata" @@ -99,8 +99,23 @@ func (s *Server) ServeHTTP(writer http.ResponseWriter, request *http.Request) { return } writer.Header().Set("Access-Control-Allow-Origin", "*") - writer.Header().Set("Access-Control-Allow-Methods", "GET, POST") - writer.Header().Set("X-Padding", strings.Repeat("X", int(s.options.GetNormalizedXPaddingBytes().Rand()))) + writer.Header().Set("Access-Control-Allow-Methods", "*") + length := int(s.options.GetNormalizedXPaddingBytes().Rand()) + config := XPaddingConfig{Length: length} + if s.options.XPaddingObfsMode { + config.Placement = XPaddingPlacement{ + Placement: s.options.XPaddingPlacement, + Key: s.options.XPaddingKey, + Header: s.options.XPaddingHeader, + } + config.Method = PaddingMethod(s.options.XPaddingMethod) + } else { + config.Placement = XPaddingPlacement{ + Placement: option.PlacementHeader, + Header: "X-Padding", + } + } + ApplyXPaddingToHeader(writer.Header(), config) validRange := s.options.GetNormalizedXPaddingBytes() paddingLength := 0 referrer := request.Header.Get("Referer") @@ -117,11 +132,7 @@ func (s *Server) ServeHTTP(writer http.ResponseWriter, request *http.Request) { writer.WriteHeader(http.StatusBadRequest) return } - sessionId := "" - subpath := strings.Split(request.URL.Path[len(s.path):], "/") - if len(subpath) > 0 { - sessionId = subpath[0] - } + sessionId, seqStr := ExtractMetaFromRequest(s.options, request, s.path) if sessionId == "" && s.options.Mode != "" && s.options.Mode != "auto" && s.options.Mode != "stream-one" && s.options.Mode != "stream-up" { s.logger.ErrorContext(request.Context(), "stream-one mode is not allowed") writer.WriteHeader(http.StatusBadRequest) @@ -154,12 +165,25 @@ func (s *Server) ServeHTTP(writer http.ResponseWriter, request *http.Request) { currentSession = s.upsertSession(sessionId) } scMaxEachPostBytes := int(s.options.GetNormalizedScMaxEachPostBytes().To) - if request.Method == "POST" && sessionId != "" { // stream-up, packet-up - seq := "" - if len(subpath) > 1 { - seq = subpath[1] + uplinkHTTPMethod := s.options.GetNormalizedUplinkHTTPMethod() + isUplinkRequest := false + if uplinkHTTPMethod != "GET" && request.Method == uplinkHTTPMethod { + isUplinkRequest = true + } + uplinkDataPlacement := s.options.GetNormalizedUplinkDataPlacement() + uplinkDataKey := s.options.UplinkDataKey + switch uplinkDataPlacement { + case option.PlacementHeader: + if request.Header.Get(uplinkDataKey+"-Upstream") == "1" { + isUplinkRequest = true } - if seq == "" { + case option.PlacementCookie: + if c, _ := request.Cookie(uplinkDataKey + "_upstream"); c != nil && c.Value == "1" { + isUplinkRequest = true + } + } + if isUplinkRequest && sessionId != "" { // stream-up, packet-up + if seqStr == "" { if s.options.Mode != "" && s.options.Mode != "auto" && s.options.Mode != "stream-up" { s.logger.ErrorContext(request.Context(), "stream-up mode is not allowed") writer.WriteHeader(http.StatusBadRequest) @@ -181,6 +205,7 @@ func (s *Server) ServeHTTP(writer http.ResponseWriter, request *http.Request) { writer.Header().Set("Cache-Control", "no-store") writer.WriteHeader(http.StatusOK) scStreamUpServerSecs := s.options.GetNormalizedScStreamUpServerSecs() + referrer := request.Header.Get("Referer") if referrer != "" && scStreamUpServerSecs.To > 0 { go func() { for { @@ -205,7 +230,55 @@ func (s *Server) ServeHTTP(writer http.ResponseWriter, request *http.Request) { writer.WriteHeader(http.StatusBadRequest) return } - payload, err := io.ReadAll(io.LimitReader(request.Body, int64(scMaxEachPostBytes)+1)) + var payload []byte + if uplinkDataPlacement != option.PlacementBody { + var encodedStr string + switch uplinkDataPlacement { + case option.PlacementHeader: + dataLenStr := request.Header.Get(uplinkDataKey + "-Length") + if dataLenStr != "" { + dataLen, _ := strconv.Atoi(dataLenStr) + var chunks []string + i := 0 + for { + chunk := request.Header.Get(fmt.Sprintf("%s-%d", uplinkDataKey, i)) + if chunk == "" { + break + } + chunks = append(chunks, chunk) + i++ + } + encodedStr = strings.Join(chunks, "") + if len(encodedStr) != dataLen { + encodedStr = "" + } + } + case option.PlacementCookie: + var chunks []string + i := 0 + for { + cookieName := fmt.Sprintf("%s_%d", uplinkDataKey, i) + if c, _ := request.Cookie(cookieName); c != nil { + chunks = append(chunks, c.Value) + i++ + } else { + break + } + } + if len(chunks) > 0 { + encodedStr = strings.Join(chunks, "") + } + } + if encodedStr != "" { + payload, err = base64.RawURLEncoding.DecodeString(encodedStr) + } else { + s.logger.ErrorContext(request.Context(), err, "failed to extract data from key "+uplinkDataKey+" placed in "+uplinkDataPlacement) + writer.WriteHeader(http.StatusInternalServerError) + return + } + } else { + payload, err = io.ReadAll(io.LimitReader(request.Body, int64(scMaxEachPostBytes)+1)) + } if len(payload) > scMaxEachPostBytes { s.logger.ErrorContext(request.Context(), "Too large upload. scMaxEachPostBytes is set to ", scMaxEachPostBytes, "but request size exceed it. Adjust scMaxEachPostBytes on the server to be at least as large as client.") writer.WriteHeader(http.StatusRequestEntityTooLarge) @@ -216,7 +289,7 @@ func (s *Server) ServeHTTP(writer http.ResponseWriter, request *http.Request) { writer.WriteHeader(http.StatusInternalServerError) return } - seqInt, err := strconv.ParseUint(seq, 10, 64) + seq, err := strconv.ParseUint(seqStr, 10, 64) if err != nil { s.logger.InfoContext(request.Context(), err, "failed to upload (ParseUint)") writer.WriteHeader(http.StatusInternalServerError) @@ -224,7 +297,7 @@ func (s *Server) ServeHTTP(writer http.ResponseWriter, request *http.Request) { } err = currentSession.uploadQueue.Push(Packet{ Payload: payload, - Seq: seqInt, + Seq: seq, }) if err != nil { s.logger.InfoContext(request.Context(), err, "failed to upload (PushPayload)") @@ -352,3 +425,41 @@ func (s *Server) upsertSession(sessionId string) *httpSession { }() return session } + +func ExtractMetaFromRequest(options *option.V2RayXHTTPOptions, req *http.Request, path string) (sessionId string, seqStr string) { + sessionPlacement := options.GetNormalizedSessionPlacement() + seqPlacement := options.GetNormalizedSeqPlacement() + sessionKey := options.GetNormalizedSessionKey() + seqKey := options.GetNormalizedSeqKey() + if sessionPlacement == option.PlacementPath && seqPlacement == option.PlacementPath { + subpath := strings.Split(req.URL.Path[len(path):], "/") + if len(subpath) > 0 { + sessionId = subpath[0] + } + if len(subpath) > 1 { + seqStr = subpath[1] + } + return sessionId, seqStr + } + switch sessionPlacement { + case option.PlacementQuery: + sessionId = req.URL.Query().Get(sessionKey) + case option.PlacementHeader: + sessionId = req.Header.Get(sessionKey) + case option.PlacementCookie: + if cookie, e := req.Cookie(sessionKey); e == nil { + sessionId = cookie.Value + } + } + switch seqPlacement { + case option.PlacementQuery: + seqStr = req.URL.Query().Get(seqKey) + case option.PlacementHeader: + seqStr = req.Header.Get(seqKey) + case option.PlacementCookie: + if cookie, e := req.Cookie(seqKey); e == nil { + seqStr = cookie.Value + } + } + return sessionId, seqStr +} diff --git a/transport/v2rayxhttp/xpadding.go b/transport/v2rayxhttp/xpadding.go new file mode 100644 index 00000000..f9dbf868 --- /dev/null +++ b/transport/v2rayxhttp/xpadding.go @@ -0,0 +1,268 @@ +package xhttp + +import ( + "crypto/rand" + "math" + "net/http" + "net/url" + "strings" + + "github.com/sagernet/sing-box/option" + "golang.org/x/net/http2/hpack" +) + +type PaddingMethod string + +const ( + PaddingMethodRepeatX PaddingMethod = "repeat-x" + PaddingMethodTokenish PaddingMethod = "tokenish" +) + +const charsetBase62 = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz" + +// Huffman encoding gives ~20% size reduction for base62 sequences +const avgHuffmanBytesPerCharBase62 = 0.8 + +const validationTolerance = 2 + +type XPaddingPlacement struct { + Placement string + Key string + Header string + RawURL string +} + +type XPaddingConfig struct { + Length int + Placement XPaddingPlacement + Method PaddingMethod +} + +func randStringFromCharset(n int, charset string) (string, bool) { + if n <= 0 || len(charset) == 0 { + return "", false + } + m := len(charset) + limit := byte(256 - (256 % m)) + result := make([]byte, n) + i := 0 + buf := make([]byte, 256) + for i < n { + if _, err := rand.Read(buf); err != nil { + return "", false + } + for _, rb := range buf { + if rb >= limit { + continue + } + result[i] = charset[int(rb)%m] + i++ + if i == n { + break + } + } + } + return string(result), true +} + +func absInt(x int) int { + if x < 0 { + return -x + } + return x +} + +func GenerateTokenishPaddingBase62(targetHuffmanBytes int) string { + n := int(math.Ceil(float64(targetHuffmanBytes) / avgHuffmanBytesPerCharBase62)) + if n < 1 { + n = 1 + } + randBase62Str, ok := randStringFromCharset(n, charsetBase62) + if !ok { + return "" + } + const maxIter = 150 + adjustChar := byte('X') + // Adjust until close enough + for iter := 0; iter < maxIter; iter++ { + currentLength := int(hpack.HuffmanEncodeLength(randBase62Str)) + diff := currentLength - targetHuffmanBytes + + if absInt(diff) <= validationTolerance { + return randBase62Str + } + if diff < 0 { + // Too small -> append padding char(s) + randBase62Str += string(adjustChar) + // Avoid a long run of identical chars + if adjustChar == 'X' { + adjustChar = 'Z' + } else { + adjustChar = 'X' + } + } else { + // Too big -> remove from the end + if len(randBase62Str) <= 1 { + return randBase62Str + } + randBase62Str = randBase62Str[:len(randBase62Str)-1] + } + } + return randBase62Str +} + +func GeneratePadding(method PaddingMethod, length int) string { + if length <= 0 { + return "" + } + // https://www.rfc-editor.org/rfc/rfc7541.html#appendix-B + // h2's HPACK Header Compression feature employs a huffman encoding using a static table. + // 'X' and 'Z' are assigned an 8 bit code, so HPACK compression won't change actual padding length on the wire. + // https://www.rfc-editor.org/rfc/rfc9204.html#section-4.1.2-2 + // h3's similar QPACK feature uses the same huffman table. + switch method { + case PaddingMethodRepeatX: + return strings.Repeat("X", length) + case PaddingMethodTokenish: + paddingValue := GenerateTokenishPaddingBase62(length) + if paddingValue == "" { + return strings.Repeat("X", length) + } + return paddingValue + default: + return strings.Repeat("X", length) + } +} + +func ApplyPaddingToCookie(req *http.Request, name, value string) { + if req == nil || name == "" || value == "" { + return + } + req.AddCookie(&http.Cookie{ + Name: name, + Value: value, + Path: "/", + }) +} + +func ApplyPaddingToQuery(u *url.URL, key, value string) { + if u == nil || key == "" || value == "" { + return + } + q := u.Query() + q.Set(key, value) + u.RawQuery = q.Encode() +} + +func ApplyXPaddingToHeader(h http.Header, config XPaddingConfig) { + if h == nil { + return + } + paddingValue := GeneratePadding(config.Method, config.Length) + switch p := config.Placement; p.Placement { + case option.PlacementHeader: + h.Set(p.Header, paddingValue) + case option.PlacementQueryInHeader: + u, err := url.Parse(p.RawURL) + if err != nil || u == nil { + return + } + u.RawQuery = p.Key + "=" + paddingValue + h.Set(p.Header, u.String()) + } +} + +func ApplyXPaddingToRequest(req *http.Request, config XPaddingConfig) { + if req == nil { + return + } + if req.Header == nil { + req.Header = make(http.Header) + } + placement := config.Placement.Placement + if placement == option.PlacementHeader || placement == option.PlacementQueryInHeader { + ApplyXPaddingToHeader(req.Header, config) + return + } + paddingValue := GeneratePadding(config.Method, config.Length) + switch placement { + case option.PlacementCookie: + ApplyPaddingToCookie(req, config.Placement.Key, paddingValue) + case option.PlacementQuery: + ApplyPaddingToQuery(req.URL, config.Placement.Key, paddingValue) + } +} + +func ExtractXPaddingFromRequest(options *option.V2RayXHTTPBaseOptions, req *http.Request, obfsMode bool) (string, string) { + if req == nil { + return "", "" + } + if !obfsMode { + referrer := req.Header.Get("Referer") + if referrer != "" { + if referrerURL, err := url.Parse(referrer); err == nil { + paddingValue := referrerURL.Query().Get("x_padding") + paddingPlacement := option.PlacementQueryInHeader + "=Referer, key=x_padding" + return paddingValue, paddingPlacement + } + } else { + paddingValue := req.URL.Query().Get("x_padding") + return paddingValue, option.PlacementQuery + ", key=x_padding" + } + } + key := options.XPaddingKey + header := options.XPaddingHeader + if cookie, err := req.Cookie(key); err == nil { + if cookie != nil && cookie.Value != "" { + paddingValue := cookie.Value + paddingPlacement := option.PlacementCookie + ", key=" + key + return paddingValue, paddingPlacement + } + } + headerValue := req.Header.Get(header) + if headerValue != "" { + if options.XPaddingPlacement == option.PlacementHeader { + paddingPlacement := option.PlacementHeader + "=" + header + return headerValue, paddingPlacement + } + + if parsedURL, err := url.Parse(headerValue); err == nil { + paddingPlacement := option.PlacementQueryInHeader + "=" + header + ", key=" + key + + return parsedURL.Query().Get(key), paddingPlacement + } + } + queryValue := req.URL.Query().Get(key) + if queryValue != "" { + paddingPlacement := option.PlacementQuery + ", key=" + key + return queryValue, paddingPlacement + } + return "", "" +} + +func IsPaddingValid(options *option.V2RayXHTTPBaseOptions, paddingValue string, from, to int32, method PaddingMethod) bool { + if paddingValue == "" { + return false + } + if to <= 0 { + r := options.GetNormalizedXPaddingBytes() + from, to = r.From, r.To + } + switch method { + case PaddingMethodRepeatX: + n := int32(len(paddingValue)) + return n >= from && n <= to + case PaddingMethodTokenish: + const tolerance = int32(validationTolerance) + n := int32(hpack.HuffmanEncodeLength(paddingValue)) + f := from - tolerance + t := to + tolerance + if f < 0 { + f = 0 + } + return n >= f && n <= t + default: + n := int32(len(paddingValue)) + return n >= from && n <= to + } +}