mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-10-21 19:31:28 +00:00
feat(mtproto): ServerHello splitting (client-mss) for MTProto inbounds
Some DPI systems recognise mtg's FakeTLS ServerHello when it leaves in full-size TCP segments and then ban the proxy's IP and port. mtg-multi can send only the ServerHello in small segments ([network] client-mss) and run the rest of the session at a normal MSS ([network] client-mss-bulk). Expose both as network.clientMss and network.clientMssBulk. They are validated against mtg's own limits (client-mss 0 or 48..1460, bulk 0 or 536..65495 and greater than client-mss, client-mss >= 88 when bulk is 0) and written to [network] only when client-mss is set. A nil bulk leaves mtg's default (1400); an explicit 0 is kept, since it means "whole session at client-mss". Together with routing through Xray this is the relay setup: small ServerHello towards clients, Telegram reached through an outbound.
This commit is contained in:
parent
8049e30245
commit
133bf884d8
2 changed files with 92 additions and 8 deletions
|
|
@ -43,11 +43,15 @@ type Options struct {
|
|||
}
|
||||
|
||||
type NetworkOptions struct {
|
||||
DNS string `json:"dns,omitempty"`
|
||||
Proxies []string `json:"proxies,omitempty"`
|
||||
TCPNotSentLowat string `json:"tcpNotSentLowat,omitempty"`
|
||||
Timeout TimeoutOptions `json:"timeout"`
|
||||
KeepAlive KeepAliveOptions `json:"keepAlive"`
|
||||
DNS string `json:"dns,omitempty"`
|
||||
Proxies []string `json:"proxies,omitempty"`
|
||||
TCPNotSentLowat string `json:"tcpNotSentLowat,omitempty"`
|
||||
// ClientMSS splits the FakeTLS ServerHello into segments of this MSS. A nil
|
||||
// ClientMSSBulk leaves the rest of the session to mtg's default; 0 keeps it at ClientMSS.
|
||||
ClientMSS int `json:"clientMss,omitempty"`
|
||||
ClientMSSBulk *int `json:"clientMssBulk,omitempty"`
|
||||
Timeout TimeoutOptions `json:"timeout"`
|
||||
KeepAlive KeepAliveOptions `json:"keepAlive"`
|
||||
}
|
||||
|
||||
type TimeoutOptions struct {
|
||||
|
|
@ -143,6 +147,14 @@ const (
|
|||
maxListEntries = 32
|
||||
// MaxExtraTOMLBytes caps the free-form config block.
|
||||
MaxExtraTOMLBytes = 64 << 10
|
||||
|
||||
// client-mss bounds of mtg-multi's [network] section. With a bulk of 0 the
|
||||
// client MSS goes on the socket, and the kernel refuses TCP_MAXSEG below 88.
|
||||
ClientMSSMin = 48
|
||||
ClientMSSMax = 1460
|
||||
ClientMSSKernelMin = 88
|
||||
ClientMSSBulkMin = 536
|
||||
ClientMSSBulkMax = 65495
|
||||
)
|
||||
|
||||
// forbiddenExtraKeys are top-level keys the extra TOML may not set: the panel owns the
|
||||
|
|
@ -177,6 +189,10 @@ type optionCheck struct {
|
|||
// switch: mtg's proxies list is then owned by the Xray bridge.
|
||||
func (o *Options) checks(routeThroughXray bool) []optionCheck {
|
||||
n, d, s, w := &o.Network, &o.Defense, &o.Stats, &o.Web
|
||||
bulkSet, bulk := n.ClientMSSBulk != nil, 0
|
||||
if bulkSet {
|
||||
bulk = *n.ClientMSSBulk
|
||||
}
|
||||
c := []optionCheck{
|
||||
{"concurrency", fmt.Sprintf("must be between 1 and %d", maxUint16), !validCount(o.Concurrency), func(o *Options) { o.Concurrency = 0 }},
|
||||
{"tolerateTimeSkewness", "must be a positive duration like 5s", !validDuration(o.TolerateTimeSkewness), func(o *Options) { o.TolerateTimeSkewness = "" }},
|
||||
|
|
@ -186,6 +202,10 @@ func (o *Options) checks(routeThroughXray bool) []optionCheck {
|
|||
{"network.proxies", fmt.Sprintf("must be at most %d socks5:// URLs", maxListEntries), !validList(n.Proxies, validProxyURL), func(o *Options) { o.Network.Proxies = nil }},
|
||||
{"network.proxies", "cannot be combined with routing through Xray", routeThroughXray && len(n.Proxies) > 0, func(o *Options) { o.Network.Proxies = nil }},
|
||||
{"network.tcpNotSentLowat", "must be a size like 128kib", !validBytes(n.TCPNotSentLowat), func(o *Options) { o.Network.TCPNotSentLowat = "" }},
|
||||
{"network.clientMss", fmt.Sprintf("must be 0 or between %d and %d", ClientMSSMin, ClientMSSMax), !validMSS(n.ClientMSS, ClientMSSMin, ClientMSSMax), func(o *Options) { o.Network.ClientMSS = 0 }},
|
||||
{"network.clientMssBulk", fmt.Sprintf("must be 0 or between %d and %d", ClientMSSBulkMin, ClientMSSBulkMax), bulkSet && !validMSS(bulk, ClientMSSBulkMin, ClientMSSBulkMax), func(o *Options) { o.Network.ClientMSSBulk = nil }},
|
||||
{"network.clientMssBulk", "must be greater than network.clientMss", bulkSet && n.ClientMSS > 0 && bulk > 0 && bulk <= n.ClientMSS, func(o *Options) { o.Network.ClientMSSBulk = nil }},
|
||||
{"network.clientMss", fmt.Sprintf("must be at least %d when network.clientMssBulk is 0", ClientMSSKernelMin), bulkSet && bulk == 0 && n.ClientMSS > 0 && n.ClientMSS < ClientMSSKernelMin, func(o *Options) { o.Network.ClientMSSBulk = nil }},
|
||||
{"network.timeout.tcp", "must be a positive duration", !validDuration(n.Timeout.TCP), func(o *Options) { o.Network.Timeout.TCP = "" }},
|
||||
{"network.timeout.http", "must be a positive duration", !validDuration(n.Timeout.HTTP), func(o *Options) { o.Network.Timeout.HTTP = "" }},
|
||||
{"network.timeout.idle", "must be a positive duration", !validDuration(n.Timeout.Idle), func(o *Options) { o.Network.Timeout.Idle = "" }},
|
||||
|
|
@ -355,6 +375,9 @@ func SecuredEnabled(settings string) bool {
|
|||
|
||||
func validCount(v int) bool { return v >= 0 && v <= maxUint16 }
|
||||
|
||||
// validMSS accepts 0 (unset) or a segment size within [lo, hi].
|
||||
func validMSS(v, lo, hi int) bool { return v == 0 || (v >= lo && v <= hi) }
|
||||
|
||||
func validDuration(v string) bool {
|
||||
if v == "" {
|
||||
return true
|
||||
|
|
@ -591,6 +614,12 @@ func (o Options) writeNetwork(b *strings.Builder, xrayProxy string) {
|
|||
}
|
||||
s.str("dns", n.DNS)
|
||||
s.str("tcp-not-sent-lowat", n.TCPNotSentLowat)
|
||||
if n.ClientMSS > 0 {
|
||||
s.num("client-mss", n.ClientMSS)
|
||||
if n.ClientMSSBulk != nil {
|
||||
s.line("client-mss-bulk = %d", *n.ClientMSSBulk)
|
||||
}
|
||||
}
|
||||
|
||||
t := §ionWriter{b: b, header: "network.timeout"}
|
||||
t.str("tcp", n.Timeout.TCP)
|
||||
|
|
|
|||
|
|
@ -32,6 +32,8 @@ func fullOptions() Options {
|
|||
DNS: "https://1.1.1.1/dns-query",
|
||||
Proxies: []string{"socks5://user:pass@10.0.0.1:1080"},
|
||||
TCPNotSentLowat: "1mib",
|
||||
ClientMSS: 92,
|
||||
ClientMSSBulk: new(1200),
|
||||
Timeout: TimeoutOptions{TCP: "5s", HTTP: "10s", Idle: "5m", Handshake: "10s"},
|
||||
KeepAlive: KeepAliveOptions{Disabled: true, Idle: "15s", Interval: "15s", Count: 9},
|
||||
},
|
||||
|
|
@ -65,7 +67,7 @@ func TestRenderOptionGroups(t *testing.T) {
|
|||
|
||||
for _, want := range []string{
|
||||
"concurrency = 2048\ntolerate-time-skewness = \"5s\"\nallow-fallback-on-unknown-dc = true\nauto-update = true\n",
|
||||
"\n[network]\nproxies = [\"socks5://user:pass@10.0.0.1:1080\"]\ndns = \"https://1.1.1.1/dns-query\"\ntcp-not-sent-lowat = \"1mib\"\n",
|
||||
"\n[network]\nproxies = [\"socks5://user:pass@10.0.0.1:1080\"]\ndns = \"https://1.1.1.1/dns-query\"\ntcp-not-sent-lowat = \"1mib\"\nclient-mss = 92\nclient-mss-bulk = 1200\n",
|
||||
"\n[network.timeout]\ntcp = \"5s\"\nhttp = \"10s\"\nidle = \"5m\"\nhandshake = \"10s\"\n",
|
||||
"\n[network.keep-alive]\ndisabled = true\nidle = \"15s\"\ninterval = \"15s\"\ncount = 9\n",
|
||||
"\n[throttle]\nmax-connections = 100\ncheck-interval = \"10s\"\n",
|
||||
|
|
@ -151,6 +153,44 @@ func TestRenderNetworkWithXrayRoute(t *testing.T) {
|
|||
if strings.Count(cfg, "[network]") != 1 {
|
||||
t.Fatalf("[network] must be emitted once:\n%s", cfg)
|
||||
}
|
||||
|
||||
// A relay splits the ServerHello towards its clients and still dials
|
||||
// Telegram through the bridge: both land in the one [network] table.
|
||||
inst.Options.Network = NetworkOptions{ClientMSS: 92}
|
||||
if cfg := renderConfig(inst, 5000, ""); !strings.Contains(cfg, "\n[network]\nproxies = [\"socks5://127.0.0.1:50000\"]\nclient-mss = 92\n\n") {
|
||||
t.Fatalf("client-mss must join the bridged [network]:\n%s", cfg)
|
||||
}
|
||||
}
|
||||
|
||||
// client-mss-bulk is written only next to client-mss (mtg ignores it alone),
|
||||
// and an explicit 0 must survive: it keeps the whole session at client-mss.
|
||||
func TestRenderClientMSS(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
network NetworkOptions
|
||||
want string
|
||||
wantBulk any
|
||||
}{
|
||||
{"bulk left to mtg", NetworkOptions{ClientMSS: 92}, "client-mss = 92\n", nil},
|
||||
{"own bulk", NetworkOptions{ClientMSS: 92, ClientMSSBulk: new(1400)}, "client-mss = 92\nclient-mss-bulk = 1400\n", int64(1400)},
|
||||
{"whole session small", NetworkOptions{ClientMSS: 120, ClientMSSBulk: new(0)}, "client-mss = 120\nclient-mss-bulk = 0\n", int64(0)},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
cfg := renderConfig(optInstance(Options{Network: tc.network}), 5000, "")
|
||||
if !strings.Contains(cfg, "\n[network]\n"+tc.want+"\n") {
|
||||
t.Fatalf("config is missing %q:\n%s", tc.want, cfg)
|
||||
}
|
||||
network := assertValidMtgConfig(t, cfg)["network"].(map[string]any)
|
||||
if network["client-mss-bulk"] != tc.wantBulk {
|
||||
t.Fatalf("client-mss-bulk = %v, want %v", network["client-mss-bulk"], tc.wantBulk)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
cfg := renderConfig(optInstance(Options{Network: NetworkOptions{ClientMSSBulk: new(1400)}}), 5000, "")
|
||||
if strings.Contains(cfg, "client-mss") || strings.Contains(cfg, "[network]") {
|
||||
t.Fatalf("client-mss-bulk without client-mss must render nothing:\n%s", cfg)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateSettings(t *testing.T) {
|
||||
|
|
@ -167,6 +207,12 @@ func TestValidateSettings(t *testing.T) {
|
|||
`{"network":{"dns":"tls://dns.example.com"},` + clients + `}`,
|
||||
`{"network":{"dns":"udp://8.8.8.8"},` + clients + `}`,
|
||||
`{"network":{"tcpNotSentLowat":"4 MiB"},` + clients + `}`,
|
||||
`{"network":{"clientMss":48},` + clients + `}`,
|
||||
`{"network":{"clientMss":1460,"clientMssBulk":65495},` + clients + `}`,
|
||||
`{"network":{"clientMss":92,"clientMssBulk":536},` + clients + `}`,
|
||||
`{"network":{"clientMss":88,"clientMssBulk":0},` + clients + `}`,
|
||||
`{"network":{"clientMss":0,"clientMssBulk":0},` + clients + `}`,
|
||||
`{"routeThroughXray":true,"network":{"clientMss":92},` + clients + `}`,
|
||||
`{"defense":{"pendingHandshakes":{"maxPerIp":0}},` + clients + `}`,
|
||||
`{"mtgSecuredWebBuild":true,"web":{"bindTo":"[::1]:18080","host":"proxy.example.com"},` + clients + `}`,
|
||||
`{"mtgSecuredWebBuild":true,"secured":true,` + clients + `}`,
|
||||
|
|
@ -191,6 +237,13 @@ func TestValidateSettings(t *testing.T) {
|
|||
{`{"network":{"proxies":["http://10.0.0.1:3128"]}}`, "network.proxies"},
|
||||
{`{"routeThroughXray":true,"network":{"proxies":["socks5://10.0.0.1:1080"]}}`, "network.proxies"},
|
||||
{`{"network":{"tcpNotSentLowat":"lots"}}`, "network.tcpNotSentLowat"},
|
||||
{`{"network":{"clientMss":47}}`, "network.clientMss must be 0 or between 48 and 1460"},
|
||||
{`{"network":{"clientMss":1461}}`, "network.clientMss must be 0 or between 48 and 1460"},
|
||||
{`{"network":{"clientMss":-1}}`, "network.clientMss must be 0 or between 48 and 1460"},
|
||||
{`{"network":{"clientMss":92,"clientMssBulk":535}}`, "network.clientMssBulk must be 0 or between 536 and 65495"},
|
||||
{`{"network":{"clientMss":92,"clientMssBulk":65496}}`, "network.clientMssBulk must be 0 or between 536 and 65495"},
|
||||
{`{"network":{"clientMss":1400,"clientMssBulk":1400}}`, "network.clientMssBulk must be greater than network.clientMss"},
|
||||
{`{"network":{"clientMss":87,"clientMssBulk":0}}`, "network.clientMss must be at least 88 when network.clientMssBulk is 0"},
|
||||
{`{"network":{"timeout":{"tcp":"0s"}}}`, "network.timeout.tcp"},
|
||||
{`{"network":{"timeout":{"handshake":"10"}}}`, "network.timeout.handshake"},
|
||||
{`{"network":{"keepAlive":{"count":70000}}}`, "network.keepAlive.count"},
|
||||
|
|
@ -240,7 +293,7 @@ func TestValidateSettings(t *testing.T) {
|
|||
// mtg never sees them.
|
||||
func TestInstanceFromInboundSanitizesOptions(t *testing.T) {
|
||||
settings := `{"concurrency":70000,"tolerateTimeSkewness":"5s",` +
|
||||
`"network":{"dns":"ftp://x","timeout":{"idle":"1m"}},` +
|
||||
`"network":{"dns":"ftp://x","clientMss":2000,"clientMssBulk":100,"timeout":{"idle":"1m"}},` +
|
||||
`"defense":{"blocklist":{"enabled":true,"urls":["file:///etc/passwd"]}},` +
|
||||
`"stats":{"prometheus":{"enabled":true,"bindTo":"0.0.0.0:3129"}},` +
|
||||
`"web":{"bindTo":"0.0.0.0:80","host":"proxy.example.com"},` +
|
||||
|
|
@ -251,7 +304,7 @@ func TestInstanceFromInboundSanitizesOptions(t *testing.T) {
|
|||
t.Fatal("expected a usable instance")
|
||||
}
|
||||
o := inst.Options
|
||||
if o.Concurrency != 0 || o.Network.DNS != "" || o.Defense.Blocklist.Enabled || o.Stats.Prometheus.Enabled ||
|
||||
if o.Concurrency != 0 || o.Network.DNS != "" || o.Network.ClientMSS != 0 || o.Network.ClientMSSBulk != nil || o.Defense.Blocklist.Enabled || o.Stats.Prometheus.Enabled ||
|
||||
o.Web.BindTo != "" || o.ExtraTOML != "" {
|
||||
t.Fatalf("invalid options must be dropped: %+v", o)
|
||||
}
|
||||
|
|
@ -295,6 +348,8 @@ func TestOptionsFingerprint(t *testing.T) {
|
|||
"dns": func(o *Options) { o.Network.DNS = "1.1.1.1" },
|
||||
"proxies": func(o *Options) { o.Network.Proxies = []string{"socks5://10.0.0.1:1080"} },
|
||||
"lowat": func(o *Options) { o.Network.TCPNotSentLowat = "1mib" },
|
||||
"clientMss": func(o *Options) { o.Network.ClientMSS = 92 },
|
||||
"clientMssBulk": func(o *Options) { o.Network.ClientMSSBulk = new(0) },
|
||||
"timeoutTCP": func(o *Options) { o.Network.Timeout.TCP = "1s" },
|
||||
"timeoutHTTP": func(o *Options) { o.Network.Timeout.HTTP = "1s" },
|
||||
"timeoutIdle": func(o *Options) { o.Network.Timeout.Idle = "1s" },
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue