mirror of
https://github.com/XTLS/Xray-core.git
synced 2026-10-26 13:06:58 +00:00
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:
@@ -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{
|
||||
|
||||
@@ -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() }
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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"])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user