Browse Source

feat(mtproto): expose mtg-multi's per-IP pending-handshake limit

mtg-multi v1.16.0 adds [defense.pending-handshakes] max-per-ip, which
closes new connections from an IP already holding that many
unauthenticated handshakes, so one address cannot pin the workers other
clients need. The MTProto inbound form now carries it as
pendingHandshakesMaxPerIp; the key is written only when set above 0, so
existing inbounds render a byte-identical config and are not restarted.
A change to it moves the structural fingerprint and restarts the sidecar.

dry-run is left out on purpose: it only feeds the pending_handshake_limit
Prometheus metric, which the panel does not surface. The hint warns that
clients behind carrier NAT share the limit.
MHSanaei 9 hours ago
parent
commit
b177b98ce8

+ 7 - 0
frontend/src/pages/inbounds/form/protocols/mtproto.tsx

@@ -74,6 +74,13 @@ export default function MtprotoFields() {
       >
         <InputNumber min={0} placeholder="0" style={{ width: '100%' }} />
       </FormField>
+      <FormField
+        name={['settings', 'pendingHandshakesMaxPerIp']}
+        label={t('pages.inbounds.form.mtgPendingHandshakesMaxPerIp')}
+        tooltip={t('pages.inbounds.form.mtgPendingHandshakesMaxPerIpHint')}
+      >
+        <InputNumber min={0} placeholder="0" style={{ width: '100%' }} />
+      </FormField>
       <FormField
         name={['settings', 'routeThroughXray']}
         label={t('pages.inbounds.form.mtgRouteThroughXray')}

+ 2 - 0
frontend/src/schemas/protocols/inbound/mtproto.ts

@@ -53,6 +53,8 @@ export const MtprotoInboundSettingsSchema = z.object({
   // Caps concurrent connections across all users with a fair-share algorithm;
   // 0 or unset disables throttling.
   throttleMaxConnections: z.number().int().min(0).optional(),
+  // mtg's [defense.pending-handshakes] max-per-ip; 0 or unset leaves it off.
+  pendingHandshakesMaxPerIp: z.number().int().min(0).optional(),
   // When set, the mtg sidecar dials Telegram through a loopback SOCKS bridge in
   // the Xray config so the egress obeys routing rules. `outboundTag` optionally
   // forces that traffic out a specific outbound/balancer. `routeXrayPort` is the

+ 34 - 24
internal/mtproto/manager.go

@@ -58,6 +58,10 @@ type Instance struct {
 	// fair-share algorithm; zero disables throttling.
 	ThrottleMaxConnections int
 
+	// PendingHandshakesMaxPerIP caps unauthenticated handshakes held open by one
+	// client IP (mtg-multi >= 1.16.0); zero leaves the limit off.
+	PendingHandshakesMaxPerIP int
+
 	// PublicIPv4/PublicIPv6 pin the proxy's reachable address the Telegram
 	// middle proxy needs when clients carry advertising tags; they are omitted
 	// when empty so mtg auto-detects, and a change forces a restart.
@@ -93,6 +97,7 @@ func (inst Instance) structuralFingerprint() string {
 		strconv.Itoa(inst.FrontingPort),
 		strconv.FormatBool(inst.FrontingProxyProtocol),
 		strconv.Itoa(inst.ThrottleMaxConnections),
+		strconv.Itoa(inst.PendingHandshakesMaxPerIP),
 		strconv.FormatBool(inst.RouteThroughXray),
 		strconv.Itoa(inst.XrayRoutePort),
 		inst.PublicIPv4,
@@ -179,13 +184,14 @@ func InstanceFromInbound(ib *model.Inbound) (Instance, bool) {
 			Port          int    `json:"port"`
 			ProxyProtocol bool   `json:"proxyProtocol"`
 		} `json:"domainFronting"`
-		PreferIP               string `json:"preferIp"`
-		ThrottleMaxConnections int    `json:"throttleMaxConnections"`
-		RouteThroughXray       bool   `json:"routeThroughXray"`
-		RouteXrayPort          int    `json:"routeXrayPort"`
-		PublicIPv4             string `json:"publicIpv4"`
-		PublicIPv6             string `json:"publicIpv6"`
-		Clients                []struct {
+		PreferIP                  string `json:"preferIp"`
+		ThrottleMaxConnections    int    `json:"throttleMaxConnections"`
+		PendingHandshakesMaxPerIP int    `json:"pendingHandshakesMaxPerIp"`
+		RouteThroughXray          bool   `json:"routeThroughXray"`
+		RouteXrayPort             int    `json:"routeXrayPort"`
+		PublicIPv4                string `json:"publicIpv4"`
+		PublicIPv6                string `json:"publicIpv6"`
+		Clients                   []struct {
 			Email      string `json:"email"`
 			Secret     string `json:"secret"`
 			AdTag      string `json:"adTag"`
@@ -215,22 +221,23 @@ func InstanceFromInbound(ib *model.Inbound) (Instance, bool) {
 		return Instance{}, false
 	}
 	return Instance{
-		Id:                     ib.Id,
-		Tag:                    ib.Tag,
-		Listen:                 ib.Listen,
-		Port:                   ib.Port,
-		Secrets:                secrets,
-		Debug:                  parsed.Debug,
-		ProxyProtocolListener:  parsed.ProxyProtocolListener,
-		PreferIP:               parsed.PreferIP,
-		FrontingIP:             parsed.DomainFronting.IP,
-		FrontingPort:           parsed.DomainFronting.Port,
-		FrontingProxyProtocol:  parsed.DomainFronting.ProxyProtocol,
-		ThrottleMaxConnections: parsed.ThrottleMaxConnections,
-		RouteThroughXray:       parsed.RouteThroughXray,
-		XrayRoutePort:          parsed.RouteXrayPort,
-		PublicIPv4:             strings.TrimSpace(parsed.PublicIPv4),
-		PublicIPv6:             strings.TrimSpace(parsed.PublicIPv6),
+		Id:                        ib.Id,
+		Tag:                       ib.Tag,
+		Listen:                    ib.Listen,
+		Port:                      ib.Port,
+		Secrets:                   secrets,
+		Debug:                     parsed.Debug,
+		ProxyProtocolListener:     parsed.ProxyProtocolListener,
+		PreferIP:                  parsed.PreferIP,
+		FrontingIP:                parsed.DomainFronting.IP,
+		FrontingPort:              parsed.DomainFronting.Port,
+		FrontingProxyProtocol:     parsed.DomainFronting.ProxyProtocol,
+		ThrottleMaxConnections:    parsed.ThrottleMaxConnections,
+		PendingHandshakesMaxPerIP: parsed.PendingHandshakesMaxPerIP,
+		RouteThroughXray:          parsed.RouteThroughXray,
+		XrayRoutePort:             parsed.RouteXrayPort,
+		PublicIPv4:                strings.TrimSpace(parsed.PublicIPv4),
+		PublicIPv6:                strings.TrimSpace(parsed.PublicIPv6),
 	}, true
 }
 
@@ -523,7 +530,7 @@ func FreeLocalPort() (int, error) {
 // precede any [section] header in TOML, and [secrets] must be the final section
 // so trailing keys are not swallowed by another table. The layout is therefore:
 // top-level scalars (incl. api-bind-to and api-token), then [domain-fronting],
-// [network] and [throttle], then [secret-ad-tags] for clients overriding the
+// [network], [throttle] and [defense.pending-handshakes], then [secret-ad-tags] for clients overriding the
 // global advertising tag, and finally [secrets] with one named secret per
 // active client.
 func renderConfig(inst Instance, apiPort int, apiToken string) string {
@@ -569,6 +576,9 @@ func renderConfig(inst Instance, apiPort int, apiToken string) string {
 	if inst.ThrottleMaxConnections > 0 {
 		fmt.Fprintf(&b, "\n[throttle]\nmax-connections = %d\n", inst.ThrottleMaxConnections)
 	}
+	if inst.PendingHandshakesMaxPerIP > 0 {
+		fmt.Fprintf(&b, "\n[defense.pending-handshakes]\nmax-per-ip = %d\n", inst.PendingHandshakesMaxPerIP)
+	}
 	// Only clients present in [secrets] may appear here: mtg rejects a config
 	// whose [secret-ad-tags] names an unknown secret, so a disabled client's
 	// override must vanish together with its secret.

+ 27 - 18
internal/mtproto/manager_test.go

@@ -18,7 +18,7 @@ func TestInstanceFromInbound(t *testing.T) {
 		Settings: `{"fakeTlsDomain":"example.com",` +
 			`"debug":true,"proxyProtocolListener":true,"preferIp":"prefer-ipv4",` +
 			`"domainFronting":{"ip":"127.0.0.1","port":9443,"proxyProtocol":true},` +
-			`"throttleMaxConnections":5000,` +
+			`"throttleMaxConnections":5000,"pendingHandshakesMaxPerIp":64,` +
 			`"routeThroughXray":true,"routeXrayPort":50000,` +
 			`"adTag":" 0123456789abcdef0123456789abcdef ",` +
 			`"clients":[` +
@@ -60,6 +60,9 @@ func TestInstanceFromInbound(t *testing.T) {
 	if inst.ThrottleMaxConnections != 5000 {
 		t.Fatalf("throttle not parsed: %+v", inst)
 	}
+	if inst.PendingHandshakesMaxPerIP != 64 {
+		t.Fatalf("pending-handshake limit not parsed: %+v", inst)
+	}
 	if !inst.RouteThroughXray || inst.XrayRoutePort != 50000 {
 		t.Fatalf("xray routing not parsed: %+v", inst)
 	}
@@ -90,7 +93,7 @@ func TestRenderConfig(t *testing.T) {
 		Secrets: []SecretEntry{{Name: "alice", Secret: "ee00"}},
 		Listen:  "0.0.0.0", Port: 8443,
 	}, 5000, "")
-	for _, unwanted := range []string{"debug", "proxy-protocol-listener", "prefer-ip", "[domain-fronting]", "[stats.prometheus]", "[throttle]", "[secret-ad-tags]", "api-token"} {
+	for _, unwanted := range []string{"debug", "proxy-protocol-listener", "prefer-ip", "[domain-fronting]", "[stats.prometheus]", "[throttle]", "[defense.pending-handshakes]", "[secret-ad-tags]", "api-token"} {
 		if strings.Contains(bare, unwanted) {
 			t.Fatalf("bare config should not contain %q:\n%s", unwanted, bare)
 		}
@@ -116,9 +119,10 @@ func TestRenderConfig(t *testing.T) {
 		Listen: "0.0.0.0", Port: 443,
 		Debug: true, ProxyProtocolListener: true, PreferIP: "only-ipv6",
 		FrontingIP: "127.0.0.1", FrontingPort: 9443, FrontingProxyProtocol: true,
-		ThrottleMaxConnections: 5000,
-		PublicIPv4:             "1.2.3.4",
-		PublicIPv6:             "2001:db8::1",
+		ThrottleMaxConnections:    5000,
+		PendingHandshakesMaxPerIP: 64,
+		PublicIPv4:                "1.2.3.4",
+		PublicIPv6:                "2001:db8::1",
 	}, 6000, "sesame")
 	for _, want := range []string{
 		"debug = true\n",
@@ -133,6 +137,7 @@ func TestRenderConfig(t *testing.T) {
 		"proxy-protocol = true\n",
 		"[throttle]",
 		"max-connections = 5000",
+		"[defense.pending-handshakes]\nmax-per-ip = 64\n",
 		"[secret-ad-tags]",
 		`"bob" = "fedcba9876543210fedcba9876543210"`,
 	} {
@@ -160,6 +165,9 @@ func TestRenderConfig(t *testing.T) {
 	if strings.LastIndex(full, "[secrets]") < strings.Index(full, "[throttle]") {
 		t.Fatalf("[throttle] must precede [secrets]:\n%s", full)
 	}
+	if strings.LastIndex(full, "[secrets]") < strings.Index(full, "[defense.pending-handshakes]") {
+		t.Fatalf("[defense.pending-handshakes] must precede [secrets]:\n%s", full)
+	}
 	if strings.LastIndex(full, "[secrets]") < strings.Index(full, "[secret-ad-tags]") {
 		t.Fatalf("[secret-ad-tags] must precede [secrets]:\n%s", full)
 	}
@@ -244,19 +252,20 @@ func TestFingerprintSplit(t *testing.T) {
 	base := Instance{Secrets: []SecretEntry{{Name: "a", Secret: "ee"}}, Listen: "0.0.0.0", Port: 443}
 
 	for name, mutate := range map[string]func(*Instance){
-		"debug":         func(i *Instance) { i.Debug = true },
-		"listener":      func(i *Instance) { i.ProxyProtocolListener = true },
-		"preferIp":      func(i *Instance) { i.PreferIP = "only-ipv4" },
-		"frontingIP":    func(i *Instance) { i.FrontingIP = "127.0.0.1" },
-		"frontingPort":  func(i *Instance) { i.FrontingPort = 9443 },
-		"frontingProxy": func(i *Instance) { i.FrontingProxyProtocol = true },
-		"throttle":      func(i *Instance) { i.ThrottleMaxConnections = 5000 },
-		"routeXray":     func(i *Instance) { i.RouteThroughXray = true },
-		"routeXrayPort": func(i *Instance) { i.XrayRoutePort = 50000 },
-		"port":          func(i *Instance) { i.Port = 8443 },
-		"listen":        func(i *Instance) { i.Listen = "127.0.0.1" },
-		"publicIpv4":    func(i *Instance) { i.PublicIPv4 = "1.2.3.4" },
-		"publicIpv6":    func(i *Instance) { i.PublicIPv6 = "2001:db8::1" },
+		"debug":             func(i *Instance) { i.Debug = true },
+		"listener":          func(i *Instance) { i.ProxyProtocolListener = true },
+		"preferIp":          func(i *Instance) { i.PreferIP = "only-ipv4" },
+		"frontingIP":        func(i *Instance) { i.FrontingIP = "127.0.0.1" },
+		"frontingPort":      func(i *Instance) { i.FrontingPort = 9443 },
+		"frontingProxy":     func(i *Instance) { i.FrontingProxyProtocol = true },
+		"throttle":          func(i *Instance) { i.ThrottleMaxConnections = 5000 },
+		"pendingHandshakes": func(i *Instance) { i.PendingHandshakesMaxPerIP = 64 },
+		"routeXray":         func(i *Instance) { i.RouteThroughXray = true },
+		"routeXrayPort":     func(i *Instance) { i.XrayRoutePort = 50000 },
+		"port":              func(i *Instance) { i.Port = 8443 },
+		"listen":            func(i *Instance) { i.Listen = "127.0.0.1" },
+		"publicIpv4":        func(i *Instance) { i.PublicIPv4 = "1.2.3.4" },
+		"publicIpv6":        func(i *Instance) { i.PublicIPv6 = "2001:db8::1" },
 	} {
 		t.Run("structural/"+name, func(t *testing.T) {
 			changed := base

+ 2 - 0
internal/web/translation/ar-EG.json

@@ -505,6 +505,8 @@
         "mtprotoFakeTlsDomainHint": "نطاق FakeTLS الافتراضي المستخدم لإنشاء سر عميل جديد. يمكن لكل عميل استخدام نطاقه الخاص.",
         "mtgThrottleMaxConnections": "الحد الأقصى للاتصالات",
         "mtgThrottleMaxConnectionsHint": "تحديد الاتصالات المتزامنة لجميع المستخدمين بتوزيع عادل. القيمة 0 تعطّل الميزة.",
+        "mtgPendingHandshakesMaxPerIp": "الحد الأقصى للمصافحات المعلقة لكل IP",
+        "mtgPendingHandshakesMaxPerIpHint": "تُغلق الاتصالات الجديدة من عنوان IP لديه هذا العدد من المصافحات غير المكتملة. يتشارك المستخدمون خلف عنوان IP مشترك (NAT لدى المشغّل) هذا الحد. القيمة 0 تعطّله.",
         "mtgAdTagInvalid": "يجب أن تتكوّن العلامة الإعلانية من 32 حرفًا ست عشريًا بالضبط.",
         "mtgPublicIpv4": "عنوان IPv4 العام",
         "mtgPublicIpv6": "عنوان IPv6 العام",

+ 2 - 0
internal/web/translation/en-US.json

@@ -507,6 +507,8 @@
         "mtprotoFakeTlsDomainHint": "Default FakeTLS domain used to generate a new client's secret. Each client can front its own domain.",
         "mtgThrottleMaxConnections": "Max connections",
         "mtgThrottleMaxConnectionsHint": "Cap concurrent connections across all users with a fair-share limit. 0 disables throttling.",
+        "mtgPendingHandshakesMaxPerIp": "Max pending handshakes per IP",
+        "mtgPendingHandshakesMaxPerIpHint": "Close new connections from an IP that already has this many unfinished handshakes. Clients behind a shared IP (carrier NAT) share the limit. 0 disables it.",
         "mtgAdTagInvalid": "Ad-tag must be exactly 32 hexadecimal characters.",
         "mtgPublicIpv4": "Public IPv4",
         "mtgPublicIpv6": "Public IPv6",

+ 2 - 0
internal/web/translation/es-ES.json

@@ -505,6 +505,8 @@
         "mtprotoFakeTlsDomainHint": "Dominio FakeTLS predeterminado para generar el secreto de un nuevo cliente. Cada cliente puede usar su propio dominio.",
         "mtgThrottleMaxConnections": "Conexiones máximas",
         "mtgThrottleMaxConnectionsHint": "Limita las conexiones simultáneas de todos los usuarios con reparto equitativo. 0 desactiva el límite.",
+        "mtgPendingHandshakesMaxPerIp": "Máx. handshakes pendientes por IP",
+        "mtgPendingHandshakesMaxPerIpHint": "Cierra las conexiones nuevas de una IP que ya tiene esta cantidad de handshakes sin completar. Los clientes detrás de una IP compartida (NAT del operador) comparten el límite. 0 lo desactiva.",
         "mtgAdTagInvalid": "El ad-tag debe tener exactamente 32 caracteres hexadecimales.",
         "mtgPublicIpv4": "IPv4 pública",
         "mtgPublicIpv6": "IPv6 pública",

+ 2 - 0
internal/web/translation/fa-IR.json

@@ -505,6 +505,8 @@
         "mtprotoFakeTlsDomainHint": "دامنه پیش‌فرض FakeTLS برای ساخت سکرت کلاینت جدید. هر کلاینت می‌تواند دامنه مخصوص خود را داشته باشد.",
         "mtgThrottleMaxConnections": "حداکثر اتصالات",
         "mtgThrottleMaxConnectionsHint": "محدود کردن اتصالات همزمان همه کاربران با تقسیم منصفانه. مقدار ۰ غیرفعال است.",
+        "mtgPendingHandshakesMaxPerIp": "حداکثر handshake معلق برای هر IP",
+        "mtgPendingHandshakesMaxPerIpHint": "اتصال‌های جدید از IPای که این تعداد handshake ناتمام دارد بسته می‌شوند. کاربرانی که پشت یک IP مشترک (NAT اپراتور) هستند این سقف را با هم شریک‌اند. مقدار ۰ غیرفعال است.",
         "mtgAdTagInvalid": "برچسب تبلیغاتی باید دقیقاً ۳۲ کاراکتر هگزادسیمال باشد.",
         "mtgPublicIpv4": "IPv4 عمومی",
         "mtgPublicIpv6": "IPv6 عمومی",

+ 2 - 0
internal/web/translation/id-ID.json

@@ -505,6 +505,8 @@
         "mtprotoFakeTlsDomainHint": "Domain FakeTLS default untuk membuat secret klien baru. Setiap klien bisa memakai domainnya sendiri.",
         "mtgThrottleMaxConnections": "Koneksi maksimum",
         "mtgThrottleMaxConnectionsHint": "Batasi koneksi bersamaan semua pengguna dengan pembagian adil. 0 menonaktifkan.",
+        "mtgPendingHandshakesMaxPerIp": "Maks. handshake tertunda per IP",
+        "mtgPendingHandshakesMaxPerIpHint": "Tutup koneksi baru dari IP yang sudah memiliki handshake belum selesai sebanyak ini. Klien di balik IP bersama (NAT operator) berbagi batas ini. 0 menonaktifkan.",
         "mtgAdTagInvalid": "Ad-tag harus tepat 32 karakter heksadesimal.",
         "mtgPublicIpv4": "IPv4 Publik",
         "mtgPublicIpv6": "IPv6 Publik",

+ 2 - 0
internal/web/translation/ja-JP.json

@@ -526,6 +526,8 @@
         "mtprotoFakeTlsDomainHint": "新しいクライアントのシークレット生成に使う既定の FakeTLS ドメイン。クライアントごとに別のドメインを使用できます。",
         "mtgThrottleMaxConnections": "最大接続数",
         "mtgThrottleMaxConnectionsHint": "全ユーザーの同時接続数を公平配分で制限します。0 で無効。",
+        "mtgPendingHandshakesMaxPerIp": "IP あたりの未完了ハンドシェイク上限",
+        "mtgPendingHandshakesMaxPerIpHint": "未完了のハンドシェイクをこの数だけ保持している IP からの新規接続を閉じます。共有 IP(キャリア NAT)配下のクライアントはこの上限を共有します。0 で無効。",
         "mtgAdTagInvalid": "広告タグは正確に 32 文字の 16 進数である必要があります。",
         "mtgPublicIpv4": "パブリック IPv4",
         "mtgPublicIpv6": "パブリック IPv6",

+ 2 - 0
internal/web/translation/pt-BR.json

@@ -526,6 +526,8 @@
         "mtprotoFakeTlsDomainHint": "Domínio FakeTLS padrão usado para gerar o segredo de um novo cliente. Cada cliente pode usar seu próprio domínio.",
         "mtgThrottleMaxConnections": "Conexões máximas",
         "mtgThrottleMaxConnectionsHint": "Limita conexões simultâneas de todos os usuários com distribuição justa. 0 desativa o limite.",
+        "mtgPendingHandshakesMaxPerIp": "Máx. handshakes pendentes por IP",
+        "mtgPendingHandshakesMaxPerIpHint": "Fecha novas conexões de um IP que já tem esta quantidade de handshakes não concluídos. Clientes atrás de um IP compartilhado (NAT da operadora) dividem o limite. 0 desativa.",
         "mtgAdTagInvalid": "A ad-tag deve ter exatamente 32 caracteres hexadecimais.",
         "mtgPublicIpv4": "IPv4 público",
         "mtgPublicIpv6": "IPv6 público",

+ 2 - 0
internal/web/translation/ru-RU.json

@@ -528,6 +528,8 @@
         "mtprotoFakeTlsDomainHint": "Домен FakeTLS по умолчанию для генерации секрета нового клиента. Каждый клиент может использовать свой домен.",
         "mtgThrottleMaxConnections": "Макс. подключений",
         "mtgThrottleMaxConnectionsHint": "Ограничение одновременных подключений всех пользователей по справедливому распределению. 0 — отключено.",
+        "mtgPendingHandshakesMaxPerIp": "Макс. незавершённых рукопожатий на IP",
+        "mtgPendingHandshakesMaxPerIpHint": "Новые подключения с IP, у которого уже столько незавершённых рукопожатий, закрываются. Клиенты за общим IP (NAT оператора) делят этот лимит. 0 — отключено.",
         "mtgAdTagInvalid": "Рекламный тег должен содержать ровно 32 шестнадцатеричных символа.",
         "mtgPublicIpv4": "Публичный IPv4",
         "mtgPublicIpv6": "Публичный IPv6",

+ 2 - 0
internal/web/translation/tr-TR.json

@@ -505,6 +505,8 @@
         "mtprotoFakeTlsDomainHint": "Yeni bir istemcinin sırrını oluştururken kullanılan varsayılan FakeTLS alan adı. Her istemci kendi alan adını kullanabilir.",
         "mtgThrottleMaxConnections": "Maks. bağlantı",
         "mtgThrottleMaxConnectionsHint": "Tüm kullanıcıların eşzamanlı bağlantılarını adil paylaşımla sınırlar. 0 devre dışı bırakır.",
+        "mtgPendingHandshakesMaxPerIp": "IP başına maks. bekleyen el sıkışma",
+        "mtgPendingHandshakesMaxPerIpHint": "Bu sayıda tamamlanmamış el sıkışması olan bir IP'den gelen yeni bağlantıları kapatır. Paylaşılan IP arkasındaki istemciler (operatör NAT) sınırı paylaşır. 0 devre dışı bırakır.",
         "mtgAdTagInvalid": "Reklam etiketi tam olarak 32 onaltılık karakter olmalıdır.",
         "mtgPublicIpv4": "Genel IPv4",
         "mtgPublicIpv6": "Genel IPv6",

+ 2 - 0
internal/web/translation/uk-UA.json

@@ -505,6 +505,8 @@
         "mtprotoFakeTlsDomainHint": "Домен FakeTLS за замовчуванням для генерації секрету нового клієнта. Кожен клієнт може використовувати власний домен.",
         "mtgThrottleMaxConnections": "Макс. з'єднань",
         "mtgThrottleMaxConnectionsHint": "Обмеження одночасних з'єднань усіх користувачів зі справедливим розподілом. 0 — вимкнено.",
+        "mtgPendingHandshakesMaxPerIp": "Макс. незавершених рукостискань на IP",
+        "mtgPendingHandshakesMaxPerIpHint": "Нові з'єднання з IP, що вже має стільки незавершених рукостискань, закриваються. Клієнти за спільним IP (NAT оператора) ділять цей ліміт. 0 — вимкнено.",
         "mtgAdTagInvalid": "Рекламний тег має містити рівно 32 шістнадцяткові символи.",
         "mtgPublicIpv4": "Публічний IPv4",
         "mtgPublicIpv6": "Публічний IPv6",

+ 2 - 0
internal/web/translation/vi-VN.json

@@ -526,6 +526,8 @@
         "mtprotoFakeTlsDomainHint": "Tên miền FakeTLS mặc định dùng để tạo secret cho client mới. Mỗi client có thể dùng tên miền riêng.",
         "mtgThrottleMaxConnections": "Số kết nối tối đa",
         "mtgThrottleMaxConnectionsHint": "Giới hạn kết nối đồng thời của tất cả người dùng theo phân bổ công bằng. 0 để tắt.",
+        "mtgPendingHandshakesMaxPerIp": "Số bắt tay chờ tối đa mỗi IP",
+        "mtgPendingHandshakesMaxPerIpHint": "Đóng kết nối mới từ IP đang có số bắt tay chưa hoàn tất bằng mức này. Các máy khách dùng chung IP (NAT nhà mạng) chia sẻ giới hạn. 0 để tắt.",
         "mtgAdTagInvalid": "Ad-tag phải có đúng 32 ký tự thập lục phân.",
         "mtgPublicIpv4": "IPv4 công khai",
         "mtgPublicIpv6": "IPv6 công khai",

+ 2 - 0
internal/web/translation/zh-CN.json

@@ -525,6 +525,8 @@
         "mtprotoFakeTlsDomainHint": "生成新客户端密钥时使用的默认 FakeTLS 域名。每个客户端可使用各自的域名。",
         "mtgThrottleMaxConnections": "最大连接数",
         "mtgThrottleMaxConnectionsHint": "按公平分配限制所有用户的并发连接数。0 表示不限制。",
+        "mtgPendingHandshakesMaxPerIp": "每 IP 最大未完成握手数",
+        "mtgPendingHandshakesMaxPerIpHint": "当某个 IP 的未完成握手达到此数量时,关闭其新连接。共享 IP(运营商 NAT)后的客户端共用此上限。0 表示禁用。",
         "mtgAdTagInvalid": "广告标签必须为 32 位十六进制字符。",
         "mtgPublicIpv4": "公网 IPv4",
         "mtgPublicIpv6": "公网 IPv6",

+ 2 - 0
internal/web/translation/zh-TW.json

@@ -505,6 +505,8 @@
         "mtprotoFakeTlsDomainHint": "產生新用戶端金鑰時使用的預設 FakeTLS 網域。每個用戶端可使用各自的網域。",
         "mtgThrottleMaxConnections": "最大連線數",
         "mtgThrottleMaxConnectionsHint": "以公平分配限制所有使用者的並行連線數。0 表示不限制。",
+        "mtgPendingHandshakesMaxPerIp": "每 IP 最大未完成交握數",
+        "mtgPendingHandshakesMaxPerIpHint": "當某個 IP 的未完成交握達到此數量時,關閉其新連線。共用 IP(電信 NAT)後的用戶端共用此上限。0 表示停用。",
         "mtgAdTagInvalid": "廣告標籤必須為 32 位十六進位字元。",
         "mtgPublicIpv4": "公網 IPv4",
         "mtgPublicIpv6": "公網 IPv6",