MASQUE client: Support XMUX ("maxConnections": 1 by default); Use Chrome's User-Agent by default (#7121)

https://github.com/XTLS/Xray-core/pull/6810#issuecomment-5842441136
https://github.com/XTLS/Xray-core/pull/6878#issuecomment-6095112915
https://github.com/XTLS/Xray-core/pull/7121#issuecomment-6117636043

---------

Co-authored-by: SVLAVR <skugorov.s@gmail.com>
This commit is contained in:
Cluvex
2026-10-12 19:40:04 +00:00
committed by GitHub
co-authored by SVLAVR
parent 5a64db009b
commit c77a4d0e49
16 changed files with 456 additions and 91 deletions
+9
View File
@@ -1,6 +1,8 @@
package masque
import (
"time"
"github.com/xtls/xray-core/common"
"github.com/xtls/xray-core/transport/internet"
)
@@ -9,6 +11,13 @@ const protocolName = "masque"
const DefaultPath = "/.well-known/masque/ip/*/*/"
func (c *Config) keepAlivePeriod() time.Duration {
if c.Xmux == nil {
return 0
}
return time.Duration(c.Xmux.HKeepAlivePeriod) * time.Second
}
func init() {
common.Must(internet.RegisterProtocolConfigCreator(protocolName, func() interface{} {
return &Config{
+22 -10
View File
@@ -7,6 +7,7 @@
package masque
import (
splithttp "github.com/xtls/xray-core/transport/internet/splithttp"
protoreflect "google.golang.org/protobuf/reflect/protoreflect"
protoimpl "google.golang.org/protobuf/runtime/protoimpl"
reflect "reflect"
@@ -27,6 +28,7 @@ type Config struct {
Path string `protobuf:"bytes,2,opt,name=path,proto3" json:"path,omitempty"`
Headers map[string]string `protobuf:"bytes,3,rep,name=headers,proto3" json:"headers,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"bytes,2,opt,name=value"`
Warp *Warp `protobuf:"bytes,4,opt,name=warp,proto3" json:"warp,omitempty"`
Xmux *splithttp.XmuxConfig `protobuf:"bytes,5,opt,name=xmux,proto3" json:"xmux,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
@@ -89,6 +91,13 @@ func (x *Config) GetWarp() *Warp {
return nil
}
func (x *Config) GetXmux() *splithttp.XmuxConfig {
if x != nil {
return x.Xmux
}
return nil
}
type Warp struct {
state protoimpl.MessageState `protogen:"open.v1"`
PrivateKey []byte `protobuf:"bytes,1,opt,name=private_key,json=privateKey,proto3" json:"private_key,omitempty"`
@@ -153,12 +162,13 @@ var File_transport_internet_masque_config_proto protoreflect.FileDescriptor
const file_transport_internet_masque_config_proto_rawDesc = "" +
"\n" +
"&transport/internet/masque/config.proto\x12\x1exray.transport.internet.masque\"\xf5\x01\n" +
"&transport/internet/masque/config.proto\x12\x1exray.transport.internet.masque\x1a)transport/internet/splithttp/config.proto\"\xb8\x02\n" +
"\x06Config\x12\x12\n" +
"\x04host\x18\x01 \x01(\tR\x04host\x12\x12\n" +
"\x04path\x18\x02 \x01(\tR\x04path\x12M\n" +
"\aheaders\x18\x03 \x03(\v23.xray.transport.internet.masque.Config.HeadersEntryR\aheaders\x128\n" +
"\x04warp\x18\x04 \x01(\v2$.xray.transport.internet.masque.WarpR\x04warp\x1a:\n" +
"\x04warp\x18\x04 \x01(\v2$.xray.transport.internet.masque.WarpR\x04warp\x12A\n" +
"\x04xmux\x18\x05 \x01(\v2-.xray.transport.internet.splithttp.XmuxConfigR\x04xmux\x1a:\n" +
"\fHeadersEntry\x12\x10\n" +
"\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" +
"\x05value\x18\x02 \x01(\tR\x05value:\x028\x01\"`\n" +
@@ -184,18 +194,20 @@ func file_transport_internet_masque_config_proto_rawDescGZIP() []byte {
var file_transport_internet_masque_config_proto_msgTypes = make([]protoimpl.MessageInfo, 3)
var file_transport_internet_masque_config_proto_goTypes = []any{
(*Config)(nil), // 0: xray.transport.internet.masque.Config
(*Warp)(nil), // 1: xray.transport.internet.masque.Warp
nil, // 2: xray.transport.internet.masque.Config.HeadersEntry
(*Config)(nil), // 0: xray.transport.internet.masque.Config
(*Warp)(nil), // 1: xray.transport.internet.masque.Warp
nil, // 2: xray.transport.internet.masque.Config.HeadersEntry
(*splithttp.XmuxConfig)(nil), // 3: xray.transport.internet.splithttp.XmuxConfig
}
var file_transport_internet_masque_config_proto_depIdxs = []int32{
2, // 0: xray.transport.internet.masque.Config.headers:type_name -> xray.transport.internet.masque.Config.HeadersEntry
1, // 1: xray.transport.internet.masque.Config.warp:type_name -> xray.transport.internet.masque.Warp
2, // [2:2] is the sub-list for method output_type
2, // [2:2] is the sub-list for method input_type
2, // [2:2] is the sub-list for extension type_name
2, // [2:2] is the sub-list for extension extendee
0, // [0:2] is the sub-list for field type_name
3, // 2: xray.transport.internet.masque.Config.xmux:type_name -> xray.transport.internet.splithttp.XmuxConfig
3, // [3:3] is the sub-list for method output_type
3, // [3:3] is the sub-list for method input_type
3, // [3:3] is the sub-list for extension type_name
3, // [3:3] is the sub-list for extension extendee
0, // [0:3] is the sub-list for field type_name
}
func init() { file_transport_internet_masque_config_proto_init() }
+3
View File
@@ -6,11 +6,14 @@ option go_package = "github.com/xtls/xray-core/transport/internet/masque";
option java_package = "com.xray.transport.internet.masque";
option java_multiple_files = true;
import "transport/internet/splithttp/config.proto";
message Config {
string host = 1;
string path = 2;
map<string, string> headers = 3;
Warp warp = 4;
xray.transport.internet.splithttp.XmuxConfig xmux = 5;
}
message Warp {
+33 -18
View File
@@ -72,7 +72,11 @@ func Dial(ctx context.Context, dest net.Destination, streamSettings *internet.Me
quicConfig.MaxIdleTimeout = 30 * time.Second
}
if quicParams.KeepAlivePeriod == 0 {
quicConfig.KeepAlivePeriod = net.QuicgoH3KeepAlivePeriod
if keepAlivePeriod := config.keepAlivePeriod(); keepAlivePeriod == 0 {
quicConfig.KeepAlivePeriod = net.QuicgoH3KeepAlivePeriod
} else if keepAlivePeriod > 0 {
quicConfig.KeepAlivePeriod = keepAlivePeriod
}
}
var pktConn net.PacketConn
@@ -179,7 +183,11 @@ func dialHTTP2(ctx context.Context, dest net.Destination, streamSettings *intern
return nil, errors.New("the server negotiated ", strconv.Quote(protocol), " instead of h2")
}
cc, err := newHTTP2ClientConn(conn)
keepAlivePeriod := config.keepAlivePeriod()
if keepAlivePeriod == 0 {
keepAlivePeriod = net.ChromeH2KeepAlivePeriod
}
cc, err := newHTTP2ClientConn(conn, keepAlivePeriod)
if err != nil {
conn.Close()
return nil, err
@@ -212,22 +220,7 @@ func establish(ctx context.Context, client tunnelClient, hconn httpConn, abort f
for k, v := range config.Headers {
header.Set(k, v)
}
switch header.Get("User-Agent") {
case "":
header["User-Agent"] = nil
case "chrome":
header.Set("User-Agent", utils.ChromeUA)
case "firefox":
header.Set("User-Agent", utils.FirefoxUA)
case "safari":
header.Set("User-Agent", utils.SafariUA)
case "edge":
header.Set("User-Agent", utils.MSEdgeUA)
case "curl":
header.Set("User-Agent", utils.CurlUA)
case "golang":
header.Del("User-Agent")
}
setUserAgent(header)
ipConn, _, err := client.Dial(req)
if err != nil {
@@ -327,6 +320,28 @@ func authority(config *Config, serverName string, port net.Port) string {
return net.JoinHostPort(host, port.String())
}
func setUserAgent(header http.Header) {
if _, ok := header["User-Agent"]; !ok {
header.Set("User-Agent", "chrome")
}
switch header.Get("User-Agent") {
case "":
header["User-Agent"] = nil
case "chrome":
header.Set("User-Agent", utils.ChromeUA)
case "firefox":
header.Set("User-Agent", utils.FirefoxUA)
case "safari":
header.Set("User-Agent", utils.SafariUA)
case "edge":
header.Set("User-Agent", utils.MSEdgeUA)
case "curl":
header.Set("User-Agent", utils.CurlUA)
case "golang":
header.Del("User-Agent")
}
}
func init() {
common.Must(internet.RegisterTransportDialer(protocolName, Dial))
}
+25
View File
@@ -1,11 +1,13 @@
package masque
import (
"net/http"
"net/netip"
"slices"
"testing"
"github.com/xtls/xray-core/common/net"
"github.com/xtls/xray-core/common/utils"
"github.com/xtls/xray-core/transport/internet/masque/connectip"
"github.com/xtls/xray-core/transport/internet/tls"
)
@@ -77,3 +79,26 @@ func TestLocalAddrs(t *testing.T) {
}
}
}
func TestSetUserAgent(t *testing.T) {
for _, tc := range []struct {
name string
header http.Header
want []string
}{
{"default", http.Header{}, []string{utils.ChromeUA}},
{"empty", http.Header{"User-Agent": {""}}, nil},
{"preset", http.Header{"User-Agent": {"firefox"}}, []string{utils.FirefoxUA}},
{"custom", http.Header{"User-Agent": {"custom/1.0"}}, []string{"custom/1.0"}},
} {
setUserAgent(tc.header)
if got := tc.header["User-Agent"]; !slices.Equal(got, tc.want) {
t.Errorf("%s: got %q, want %q", tc.name, got, tc.want)
}
}
header := http.Header{"User-Agent": {"golang"}}
setUserAgent(header)
if _, ok := header["User-Agent"]; ok {
t.Errorf("golang: the header should be removed, got %q", header["User-Agent"])
}
}
+20 -9
View File
@@ -32,6 +32,7 @@ const (
http2WindowUpdateSize = 1 << 20
http2KeepAlivePeriod = 10 * time.Second
http2IdleTimeout = 30 * time.Second
http2PingTimeout = 15 * time.Second
http2DefaultUserAgent = "Go-http-client/2.0"
)
@@ -79,7 +80,7 @@ type http2ClientConn struct {
recvUnacked int64
}
func newHTTP2ClientConn(conn net.Conn) (*http2ClientConn, error) {
func newHTTP2ClientConn(conn net.Conn, keepAlivePeriod time.Duration) (*http2ClientConn, error) {
c := &http2ClientConn{
conn: conn,
bw: bufio.NewWriter(conn),
@@ -119,7 +120,9 @@ func newHTTP2ClientConn(conn net.Conn) (*http2ClientConn, error) {
return nil, err
}
go c.readLoop()
go c.keepAlive()
if keepAlivePeriod > 0 {
go c.keepAlive(keepAlivePeriod)
}
return c, nil
}
@@ -363,21 +366,29 @@ func (c *http2ClientConn) abortStream(err error, reset bool) {
}
}
func (c *http2ClientConn) keepAlive() {
ticker := time.NewTicker(http2KeepAlivePeriod)
func (c *http2ClientConn) keepAlive(period time.Duration) {
ticker := time.NewTicker(max(min(period, http2PingTimeout)/3, time.Second))
defer ticker.Stop()
var pingAt time.Time
for {
select {
case <-c.done:
return
case <-ticker.C:
}
idle := time.Since(time.Unix(0, c.lastFrame.Load()))
if idle >= http2IdleTimeout {
c.fail(errHTTP2IdleTimeout)
return
lastFrame := time.Unix(0, c.lastFrame.Load())
if !pingAt.IsZero() {
if lastFrame.Before(pingAt) {
if time.Since(pingAt) >= http2PingTimeout {
c.fail(errHTTP2IdleTimeout)
return
}
continue
}
pingAt = time.Time{}
}
if idle >= http2KeepAlivePeriod {
if time.Since(lastFrame) >= period {
pingAt = time.Now()
go c.write(func(fr *http2.Framer) error {
return fr.WritePing(false, [8]byte{})
})
@@ -127,7 +127,7 @@ func TestHTTP2ServerRoundTrip(t *testing.T) {
_, err := io.Copy(w, r.Body)
assert.NoError(t, err)
})
cc, err := newHTTP2ClientConn(serveHTTP2Pipe(t, handler))
cc, err := newHTTP2ClientConn(serveHTTP2Pipe(t, handler), http2KeepAlivePeriod)
require.NoError(t, err)
defer cc.Close()
+63 -1
View File
@@ -1,13 +1,16 @@
package masque
import (
"bufio"
"bytes"
"context"
"io"
"net"
"net/http"
"strings"
"sync"
"testing"
"testing/synctest"
"time"
"github.com/stretchr/testify/require"
@@ -52,7 +55,7 @@ func newHTTP2Peer(t *testing.T, settings ...http2.Setting) (*http2ClientConn, *h
var cc *http2ClientConn
go func() {
var err error
cc, err = newHTTP2ClientConn(client)
cc, err = newHTTP2ClientConn(client, http2KeepAlivePeriod)
ccErr <- err
}()
preface := make([]byte, len(http2.ClientPreface))
@@ -423,3 +426,62 @@ func TestHTTP2ClientAnswersPings(t *testing.T) {
require.True(t, f.(*http2.PingFrame).IsAck())
require.Equal(t, data, f.(*http2.PingFrame).Data)
}
func TestHTTP2ClientKeepAlive(t *testing.T) {
for _, answer := range []bool{false, true} {
synctest.Test(t, func(t *testing.T) {
client, server := net.Pipe()
defer server.Close()
var mu sync.Mutex
var pings []time.Duration
start := time.Now()
go func() {
br := bufio.NewReader(server)
if _, err := io.ReadFull(br, make([]byte, len(http2.ClientPreface))); err != nil {
return
}
fr := http2.NewFramer(server, br)
time.Sleep(2 * time.Second)
if fr.WriteSettings() != nil {
return
}
for {
f, err := fr.ReadFrame()
if err != nil {
return
}
if p, ok := f.(*http2.PingFrame); ok && !p.IsAck() {
mu.Lock()
pings = append(pings, time.Since(start))
mu.Unlock()
if answer && fr.WritePing(true, p.Data) != nil {
return
}
}
}
}()
cc, err := newHTTP2ClientConn(client, 60*time.Second)
require.NoError(t, err)
if answer {
time.Sleep(200 * time.Second)
select {
case <-cc.done:
t.Fatal("the connection closed although every PING was answered")
default:
}
cc.conn.Close()
<-cc.done
mu.Lock()
defer mu.Unlock()
require.GreaterOrEqual(t, len(pings), 2)
return
}
<-cc.done
closed := time.Since(start)
mu.Lock()
defer mu.Unlock()
require.Len(t, pings, 1, "the connection must send a PING before closing")
require.GreaterOrEqual(t, closed-pings[0], http2PingTimeout)
})
}
}
+15 -1
View File
@@ -79,6 +79,14 @@ func (m *XmuxManager) newXmuxClient() *XmuxClient {
}
func (m *XmuxManager) GetXmuxClient(ctx context.Context) *XmuxClient { // when locking
return m.getXmuxClient(ctx, true)
}
func (m *XmuxManager) GetExistingXmuxClient(ctx context.Context) *XmuxClient {
return m.getXmuxClient(ctx, false)
}
func (m *XmuxManager) getXmuxClient(ctx context.Context, create bool) *XmuxClient {
for i := 0; i < len(m.xmuxClients); {
xmuxClient := m.xmuxClients[i]
if xmuxClient.XmuxConn.IsClosed() ||
@@ -99,11 +107,14 @@ func (m *XmuxManager) GetXmuxClient(ctx context.Context) *XmuxClient { // when l
}
if len(m.xmuxClients) == 0 {
if !create {
return nil
}
errors.LogDebug(ctx, "XMUX: creating xmuxClient because xmuxClients is empty")
return m.newXmuxClient()
}
if m.connections > 0 && len(m.xmuxClients) < int(m.connections) {
if create && m.connections > 0 && len(m.xmuxClients) < int(m.connections) {
errors.LogDebug(ctx, "XMUX: creating xmuxClient because maxConnections was not hit, xmuxClients = ", len(m.xmuxClients))
return m.newXmuxClient()
}
@@ -120,6 +131,9 @@ func (m *XmuxManager) GetXmuxClient(ctx context.Context) *XmuxClient { // when l
}
if len(xmuxClients) == 0 {
if !create {
return nil
}
errors.LogDebug(ctx, "XMUX: creating xmuxClient because maxConcurrency was hit, xmuxClients = ", len(m.xmuxClients))
return m.newXmuxClient()
}
+20
View File
@@ -90,3 +90,23 @@ func TestDefault(t *testing.T) {
t.Error("did not get 1 distinct clients, got ", len(xmuxClients))
}
}
func TestGetExistingXmuxClient(t *testing.T) {
xmuxConfig := XmuxConfig{
MaxConnections: &RangeConfig{From: 2, To: 2},
}
xmuxManager := NewXmuxManager(xmuxConfig, func() XmuxConn {
return &fakeRoundTripper{}
})
if xmuxManager.GetExistingXmuxClient(context.Background()) != nil {
t.Error("got a client without creating one")
}
xmuxClient := xmuxManager.GetXmuxClient(context.Background())
for i := 0; i < 4; i++ {
if xmuxManager.GetExistingXmuxClient(context.Background()) != xmuxClient {
t.Error("did not reuse the existing client")
}
}
}