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:
Andrey Osipuk 2026-10-07 20:58:14 +03:00
parent 8049e30245
commit 133bf884d8
No known key found for this signature in database
2 changed files with 92 additions and 8 deletions

View file

@ -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 := &sectionWriter{b: b, header: "network.timeout"}
t.str("tcp", n.Timeout.TCP)

View file

@ -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" },