From 061c2df291753ce10f54855d4088c12f834dfe77 Mon Sep 17 00:00:00 2001 From: Shilmoon <62980108+Shilmoon@users.noreply.github.com> Date: Wed, 21 Oct 2026 12:33:27 +0200 Subject: [PATCH] HTTP outbound: Fix hanging on server-first traffic (#7193) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit https://github.com/XTLS/Xray-core/pull/7193#issuecomment-6312885274 Fixes https://github.com/XTLS/Xray-core/issues/3459 --------- Co-authored-by: Claude Opus 5.5 Co-authored-by: 风扇滑翔翼 --- proxy/http/client.go | 32 ++++++++++++- testing/scenarios/http_test.go | 87 ++++++++++++++++++++++++++++++++++ 2 files changed, 117 insertions(+), 2 deletions(-) diff --git a/proxy/http/client.go b/proxy/http/client.go index e82a747fe..f33fcfff9 100644 --- a/proxy/http/client.go +++ b/proxy/http/client.go @@ -10,6 +10,7 @@ import ( "net/url" "sync" "text/template" + "time" "github.com/xtls/xray-core/common" "github.com/xtls/xray-core/common/buf" @@ -86,7 +87,13 @@ func (c *Client) Process(ctx context.Context, link *transport.Link, dialer inter user := server.User var conn stat.Connection - mbuf, _ := link.Reader.ReadMultiBuffer() + var mbuf buf.MultiBuffer + if reader, ok := link.Reader.(buf.TimeoutReader); ok { + // Don't wait forever: server-first protocols (SMTP, FTP, MySQL...) send nothing until the server greets. + mbuf, _ = reader.ReadMultiBufferTimeout(time.Millisecond * 500) + } else { + mbuf, _ = link.Reader.ReadMultiBuffer() + } len := mbuf.Len() firstPayload := bytespool.Alloc(len) mbuf, _ = buf.SplitBytes(mbuf, firstPayload) @@ -234,7 +241,8 @@ func setUpHTTPTunnel(ctx context.Context, dest net.Destination, target string, u return nil, err } - resp, err := http.ReadResponse(bufio.NewReader(rawConn), req) + reader := bufio.NewReader(rawConn) + resp, err := http.ReadResponse(reader, req) if err != nil { rawConn.Close() return nil, err @@ -245,6 +253,11 @@ func setUpHTTPTunnel(ctx context.Context, dest net.Destination, target string, u rawConn.Close() return nil, errors.New("Proxy responded with non 200 code: " + resp.Status) } + // Tunneled bytes (e.g. a server-first greeting) may arrive in the same read as the 200 response. + if n := reader.Buffered(); n > 0 { + early, _ := reader.Peek(n) + return &earlyDataConn{Conn: rawConn, early: early}, nil + } return rawConn, nil } @@ -375,6 +388,21 @@ func (h *http2Conn) Close() error { return h.out.Close() } +// earlyDataConn returns the bytes read ahead together with the CONNECT response before reading from Conn. +type earlyDataConn struct { + net.Conn + early []byte +} + +func (c *earlyDataConn) Read(p []byte) (int, error) { + if len(c.early) > 0 { + n := copy(p, c.early) + c.early = c.early[n:] + return n, nil + } + return c.Conn.Read(p) +} + func init() { common.Must(common.RegisterConfig((*ClientConfig)(nil), func(ctx context.Context, config interface{}) (interface{}, error) { return NewClient(ctx, config.(*ClientConfig)) diff --git a/testing/scenarios/http_test.go b/testing/scenarios/http_test.go index 26fef0ab7..28d0ea4e8 100644 --- a/testing/scenarios/http_test.go +++ b/testing/scenarios/http_test.go @@ -16,8 +16,10 @@ import ( "github.com/xtls/xray-core/common" "github.com/xtls/xray-core/common/buf" "github.com/xtls/xray-core/common/net" + "github.com/xtls/xray-core/common/protocol" "github.com/xtls/xray-core/common/serial" "github.com/xtls/xray-core/core" + "github.com/xtls/xray-core/proxy/dokodemo" "github.com/xtls/xray-core/proxy/freedom" v2http "github.com/xtls/xray-core/proxy/http" v2httptest "github.com/xtls/xray-core/testing/servers/http" @@ -377,3 +379,88 @@ func TestHttpBasicAuth(t *testing.T) { } } } + +// Server-first protocols (SMTP, FTP, MySQL) send nothing from the client until the server's +// greeting arrives, so the HTTP outbound must not wait for a first payload before dialing. +func TestHTTPOutboundServerFirst(t *testing.T) { + tcpServer := tcp.Server{ + MsgProcessor: xor, + SendFirst: []byte("send first"), + } + dest, err := tcpServer.Start() + common.Must(err) + defer tcpServer.Close() + + serverPort := tcp.PickPort() + serverConfig := &core.Config{ + Inbound: []*core.InboundHandlerConfig{ + { + ReceiverSettings: serial.ToTypedMessage(&proxyman.ReceiverConfig{ + PortList: &net.PortList{Range: []*net.PortRange{net.SinglePortRange(serverPort)}}, + Listen: net.NewIPOrDomain(net.LocalHostIP), + }), + ProxySettings: serial.ToTypedMessage(&v2http.ServerConfig{}), + }, + }, + Outbound: []*core.OutboundHandlerConfig{ + { + ProxySettings: serial.ToTypedMessage(&freedom.Config{ + FinalRules: []*freedom.FinalRuleConfig{{Action: freedom.RuleAction_Allow}}, + }), + }, + }, + } + + clientPort := tcp.PickPort() + clientConfig := &core.Config{ + Inbound: []*core.InboundHandlerConfig{ + { + ReceiverSettings: serial.ToTypedMessage(&proxyman.ReceiverConfig{ + PortList: &net.PortList{Range: []*net.PortRange{net.SinglePortRange(clientPort)}}, + Listen: net.NewIPOrDomain(net.LocalHostIP), + }), + ProxySettings: serial.ToTypedMessage(&dokodemo.Config{ + RewriteAddress: net.NewIPOrDomain(dest.Address), + RewritePort: uint32(dest.Port), + AllowedNetworks: []net.Network{net.Network_TCP}, + }), + }, + }, + Outbound: []*core.OutboundHandlerConfig{ + { + ProxySettings: serial.ToTypedMessage(&v2http.ClientConfig{ + Server: &protocol.ServerEndpoint{ + Address: net.NewIPOrDomain(net.LocalHostIP), + Port: uint32(serverPort), + }, + }), + }, + }, + } + + servers, err := InitializeServerConfigs(serverConfig, clientConfig) + common.Must(err) + defer CloseAllServers(servers) + + conn, err := net.DialTCP("tcp", nil, &net.TCPAddr{ + IP: []byte{127, 0, 0, 1}, + Port: int(clientPort), + }) + common.Must(err) + defer conn.Close() + + common.Must(conn.SetReadDeadline(time.Now().Add(5 * time.Second))) + response := make([]byte, 1024) + nBytes, err := conn.Read(response) + if err != nil { + t.Fatal("no server greeting through HTTP outbound: ", err) + } + if string(response[:nBytes]) != "send first" { + t.Fatal("unexpected first response: ", string(response[:nBytes])) + } + common.Must(conn.SetReadDeadline(time.Time{})) + + if err := testTCPConn2(conn, 1024, time.Second*5)(); err != nil { + t.Error(err) + } +}