diff --git a/internal/mtproto/options.go b/internal/mtproto/options.go index 62312d7a1..c6d1008a0 100644 --- a/internal/mtproto/options.go +++ b/internal/mtproto/options.go @@ -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) diff --git a/internal/mtproto/options_test.go b/internal/mtproto/options_test.go index fa8713744..e9b8fcc3d 100644 --- a/internal/mtproto/options_test.go +++ b/internal/mtproto/options_test.go @@ -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" },