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
+103 -34
View File
@@ -7,7 +7,6 @@ import (
"net/netip"
"slices"
"sync"
"sync/atomic"
"time"
"golang.zx2c4.com/wireguard/tun"
@@ -26,6 +25,7 @@ import (
"github.com/xtls/xray-core/transport"
"github.com/xtls/xray-core/transport/internet"
"github.com/xtls/xray-core/transport/internet/masque"
"github.com/xtls/xray-core/transport/internet/splithttp"
"github.com/xtls/xray-core/transport/internet/stat"
"github.com/xtls/xray-core/transport/internet/tls"
)
@@ -43,11 +43,15 @@ type Client struct {
ctx context.Context
cancel context.CancelFunc
tunnel atomic.Pointer[tunnel]
mu sync.Mutex
xmux *splithttp.XmuxManager
dialCtx context.Context
dialer internet.Dialer
lastErr error
lastErrAt time.Time
tunnelsMu sync.Mutex
tunnels map[*tunnel]struct{}
}
func NewClient(ctx context.Context, config *ClientConfig) (*Client, error) {
@@ -55,7 +59,8 @@ func NewClient(ctx context.Context, config *ClientConfig) (*Client, error) {
p := v.GetFeature(policy.ManagerType()).(policy.Manager)
streamSettings := session.StreamSettingsFromContext(ctx).(*internet.MemoryStreamConfig)
if _, ok := streamSettings.ProtocolSettings.(*masque.Config); !ok {
transportConfig, ok := streamSettings.ProtocolSettings.(*masque.Config)
if !ok {
return nil, errors.New("not masque transport")
}
if tls.ConfigFromStreamSettings(streamSettings) == nil {
@@ -86,7 +91,13 @@ func NewClient(ctx context.Context, config *ClientConfig) (*Client, error) {
server: server,
policyManager: p,
remoteDNS: remoteDNS,
tunnels: make(map[*tunnel]struct{}),
}
xmuxConfig := transportConfig.Xmux
if xmuxConfig == nil {
xmuxConfig = &splithttp.XmuxConfig{}
}
c.xmux = splithttp.NewXmuxManager(*xmuxConfig, c.newXmuxConn)
c.ctx, c.cancel = context.WithCancel(context.Background())
return c, nil
}
@@ -100,10 +111,11 @@ func (c *Client) Process(ctx context.Context, link *transport.Link, dialer inter
ob.Name = "masque"
ob.CanSpliceCopy = 3
t, err := c.getTunnel(ctx, dialer)
xmuxClient, t, err := c.getTunnel(ctx, dialer)
if err != nil {
return errors.New("failed to establish CONNECT-IP tunnel").Base(err)
}
defer xmuxClient.DoneRunning()
var newCtx context.Context
var newCancel context.CancelFunc
@@ -180,40 +192,63 @@ func (c *Client) Process(ctx context.Context, link *transport.Link, dialer inter
return nil
}
func (c *Client) getTunnel(ctx context.Context, dialer internet.Dialer) (*tunnel, error) {
func (c *Client) getTunnel(ctx context.Context, dialer internet.Dialer) (*splithttp.XmuxClient, *tunnel, error) {
c.mu.Lock()
defer c.mu.Unlock()
if c.ctx.Err() != nil {
return nil, errors.New("closed")
}
if t := c.tunnel.Load(); t != nil {
select {
case <-t.done:
default:
return t, nil
}
return nil, nil, errors.New("closed")
}
if err := ctx.Err(); err != nil {
return nil, err
return nil, nil, err
}
var xmuxClient *splithttp.XmuxClient
if c.lastErr != nil && time.Since(c.lastErrAt) < retryInterval {
return nil, c.lastErr
if xmuxClient = c.xmux.GetExistingXmuxClient(ctx); xmuxClient == nil {
return nil, nil, c.lastErr
}
} else {
c.dialCtx, c.dialer = ctx, dialer
xmuxClient = c.xmux.GetXmuxClient(ctx)
c.dialCtx, c.dialer = nil, nil
if err := xmuxClient.XmuxConn.(*tunnel).err; err != nil {
if xmuxClient = c.xmux.GetExistingXmuxClient(ctx); xmuxClient == nil {
return nil, nil, err
}
}
}
t := xmuxClient.XmuxConn.(*tunnel)
if c.ctx.Err() != nil {
return nil, nil, errors.New("closed")
}
xmuxClient.AddRunning()
return xmuxClient, t, nil
}
t, err := c.establish(ctx, dialer)
func (c *Client) newXmuxConn() splithttp.XmuxConn {
t, err := c.establish(c.dialCtx, c.dialer)
if err != nil {
c.lastErr, c.lastErrAt = err, time.Now()
return nil, err
return failedTunnel(err)
}
c.lastErr = nil
c.tunnel.Store(t)
if c.ctx.Err() != nil {
if c.tunnel.CompareAndSwap(t, nil) {
t.close()
}
return nil, errors.New("closed")
c.tunnelsMu.Lock()
if c.tunnels == nil {
c.tunnelsMu.Unlock()
t.close()
return failedTunnel(errors.New("closed"))
}
return t, nil
c.tunnels[t] = struct{}{}
c.tunnelsMu.Unlock()
if t.IsClosed() {
c.removeTunnel(t)
}
return t
}
func (c *Client) removeTunnel(t *tunnel) {
c.tunnelsMu.Lock()
delete(c.tunnels, t)
c.tunnelsMu.Unlock()
}
func (c *Client) establish(ctx context.Context, dialer internet.Dialer) (*tunnel, error) {
@@ -229,7 +264,7 @@ func (c *Client) establish(ctx context.Context, dialer internet.Dialer) (*tunnel
conn.Close()
return nil, errors.New("not a CONNECT-IP connection")
}
t, err := newTunnel(conn, mconn.LocalAddrs(), c.remoteDNS)
t, err := newTunnel(conn, mconn.LocalAddrs(), c.remoteDNS, c.removeTunnel)
if err != nil {
conn.Close()
return nil, err
@@ -240,7 +275,11 @@ func (c *Client) establish(ctx context.Context, dialer internet.Dialer) (*tunnel
func (c *Client) Close() error {
c.cancel()
if t := c.tunnel.Swap(nil); t != nil {
c.tunnelsMu.Lock()
tunnels := c.tunnels
c.tunnels = nil
c.tunnelsMu.Unlock()
for t := range tunnels {
t.close()
}
return nil
@@ -252,9 +291,31 @@ type tunnel struct {
tnet *wireguard.Net
done chan struct{}
closeOnce sync.Once
onClose func(*tunnel)
err error
}
func newTunnel(conn stat.Connection, local []netip.Addr, remoteDNS []netip.Addr) (*tunnel, error) {
func failedTunnel(err error) *tunnel {
t := &tunnel{err: err, done: make(chan struct{})}
t.close()
return t
}
func (t *tunnel) IsClosed() bool {
select {
case <-t.done:
return true
default:
return false
}
}
func (t *tunnel) Close() error {
t.close()
return nil
}
func newTunnel(conn stat.Connection, local []netip.Addr, remoteDNS []netip.Addr, onClose func(*tunnel)) (*tunnel, error) {
var dns []netip.Addr
for _, addr := range remoteDNS {
if slices.ContainsFunc(local, func(l netip.Addr) bool { return l.Is4() == addr.Is4() }) {
@@ -271,10 +332,11 @@ func newTunnel(conn stat.Connection, local []netip.Addr, remoteDNS []netip.Addr)
return nil, err
}
t := &tunnel{
conn: conn,
dev: dev,
tnet: tnet,
done: make(chan struct{}),
conn: conn,
dev: dev,
tnet: tnet,
done: make(chan struct{}),
onClose: onClose,
}
go t.readFromTunnel()
go t.writeToTunnel()
@@ -316,8 +378,15 @@ func (t *tunnel) writeToTunnel() {
func (t *tunnel) close() {
t.closeOnce.Do(func() {
close(t.done)
t.conn.Close()
t.dev.Close()
if t.conn != nil {
t.conn.Close()
}
if t.dev != nil {
t.dev.Close()
}
if t.onClose != nil {
t.onClose(t)
}
})
}
+54
View File
@@ -0,0 +1,54 @@
package masque
import (
"context"
"errors"
"testing"
"time"
"github.com/xtls/xray-core/transport/internet/splithttp"
)
func newTestClient(newConn func(c *Client) splithttp.XmuxConn) *Client {
c := &Client{ctx: context.Background()}
c.xmux = splithttp.NewXmuxManager(splithttp.XmuxConfig{
MaxConnections: &splithttp.RangeConfig{From: 3, To: 3},
}, func() splithttp.XmuxConn {
return newConn(c)
})
return c
}
func TestGetTunnelFallsBack(t *testing.T) {
refused := errors.New("refused")
healthy := &tunnel{done: make(chan struct{})}
dials := 0
c := newTestClient(func(c *Client) splithttp.XmuxConn {
if dials++; dials == 1 {
return healthy
}
c.lastErr, c.lastErrAt = refused, time.Now()
return failedTunnel(refused)
})
for i := 0; i < 5; i++ {
if _, got, err := c.getTunnel(context.Background(), nil); err != nil || got != healthy {
t.Fatalf("flow %d: got %p, %v instead of the healthy tunnel", i, got, err)
}
}
if dials != 2 {
t.Errorf("dialed %d times, a failed tunnel should not be retried within %v", dials, retryInterval)
}
}
func TestGetTunnelFailsWithoutTunnels(t *testing.T) {
refused := errors.New("refused")
c := newTestClient(func(c *Client) splithttp.XmuxConn {
c.lastErr, c.lastErrAt = refused, time.Now()
return failedTunnel(refused)
})
for i := 0; i < 2; i++ {
if _, _, err := c.getTunnel(context.Background(), nil); err != refused {
t.Fatalf("flow %d: got %v instead of %v", i, err, refused)
}
}
}