فهرست منبع

feat(mtproto): add MTProto (FakeTLS) protocol via managed mtg sidecar

Xray-core has no mtproto proxy, so mtproto inbounds run as standalone
mtg (9seconds/mtg) sidecar processes managed by the panel — one per
inbound — and are excluded from the generated Xray config entirely.

- model: MTProto protocol constant, validator, and FakeTLS secret
  helpers (GenerateFakeTLSSecret/HealMtprotoSecret)
- mtproto package: per-inbound mtg process manager with reconcile,
  graceful stop, and best-effort Prometheus traffic scraping
- runtime: delegate mtproto inbounds to the mtg manager instead of the
  Xray gRPC API; skip mtproto when building the Xray config
- web: boot reconcile + StopAll wiring, periodic reconcile/traffic job,
  port-conflict transport, secret healing on inbound add/update
- sub: tg:// proxy share-link generation
- frontend: protocol option, Zod schema, Protocol tab (FakeTLS domain +
  regenerable secret), info-modal link, and i18n
- provisioning: fetch mtg v2.2.8 in install.sh, DockerInit.sh, and the
  Linux + Windows release workflows
MHSanaei 2 روز پیش
والد
کامیت
df6d13d0ee
44فایلهای تغییر یافته به همراه1338 افزوده شده و 7 حذف شده
  1. 19 0
      .github/workflows/release.yml
  2. 12 0
      DockerInit.sh
  3. 66 1
      database/model/model.go
  4. 71 0
      database/model/model_mtproto_test.go
  5. 1 1
      frontend/src/generated/zod.ts
  6. 41 1
      frontend/src/lib/xray/inbound-defaults.ts
  7. 24 0
      frontend/src/lib/xray/inbound-link.ts
  8. 4 0
      frontend/src/pages/inbounds/form/InboundFormModal.tsx
  9. 1 0
      frontend/src/pages/inbounds/form/protocols/index.ts
  10. 40 0
      frontend/src/pages/inbounds/form/protocols/mtproto.tsx
  11. 29 0
      frontend/src/pages/inbounds/info/InboundInfoModal.tsx
  12. 2 0
      frontend/src/schemas/primitives/protocol.ts
  13. 3 0
      frontend/src/schemas/protocols/inbound/index.ts
  14. 10 0
      frontend/src/schemas/protocols/inbound/mtproto.ts
  15. 168 0
      frontend/src/test/__snapshots__/protocol-capabilities.test.ts.snap
  16. 10 0
      frontend/src/test/__snapshots__/protocols.test.ts.snap
  17. 7 0
      frontend/src/test/golden/fixtures/inbound/mtproto-basic.json
  18. 5 0
      install.sh
  19. 316 0
      mtproto/manager.go
  20. 56 0
      mtproto/manager_test.go
  21. 201 0
      mtproto/process.go
  22. 7 0
      mtproto/process_other.go
  23. 66 0
      mtproto/process_windows.go
  24. 32 3
      sub/subService.go
  25. 62 0
      web/job/mtproto_job.go
  26. 18 0
      web/runtime/local.go
  27. 4 1
      web/service/api_scale_postgres_test.go
  28. 13 0
      web/service/inbound.go
  29. 2 0
      web/service/port_conflict.go
  30. 3 0
      web/service/xray.go
  31. 3 0
      web/translation/ar-EG.json
  32. 3 0
      web/translation/en-US.json
  33. 3 0
      web/translation/es-ES.json
  34. 3 0
      web/translation/fa-IR.json
  35. 3 0
      web/translation/id-ID.json
  36. 3 0
      web/translation/ja-JP.json
  37. 3 0
      web/translation/pt-BR.json
  38. 3 0
      web/translation/ru-RU.json
  39. 3 0
      web/translation/tr-TR.json
  40. 3 0
      web/translation/uk-UA.json
  41. 3 0
      web/translation/vi-VN.json
  42. 3 0
      web/translation/zh-CN.json
  43. 3 0
      web/translation/zh-TW.json
  44. 6 0
      web/web.go

+ 19 - 0
.github/workflows/release.yml

@@ -150,6 +150,16 @@ jobs:
           wget -q -O geoip_RU.dat https://github.com/runetfreedom/russia-v2ray-rules-dat/releases/latest/download/geoip.dat
           wget -q -O geosite_RU.dat https://github.com/runetfreedom/russia-v2ray-rules-dat/releases/latest/download/geosite.dat
           mv xray xray-linux-${{ matrix.platform }}
+          # mtg (MTProto sidecar) - only for arches mtg publishes
+          MTG_VER="2.2.8"
+          case "${{ matrix.platform }}" in
+            amd64|arm64|armv7|armv6|386)
+              wget -q "https://github.com/9seconds/mtg/releases/download/v${MTG_VER}/mtg-${MTG_VER}-linux-${{ matrix.platform }}.tar.gz"
+              tar -xzf "mtg-${MTG_VER}-linux-${{ matrix.platform }}.tar.gz"
+              mv "mtg-${MTG_VER}-linux-${{ matrix.platform }}/mtg" "mtg-linux-${{ matrix.platform }}" 2>/dev/null || mv mtg "mtg-linux-${{ matrix.platform }}"
+              rm -rf "mtg-${MTG_VER}-linux-${{ matrix.platform }}" "mtg-${MTG_VER}-linux-${{ matrix.platform }}.tar.gz"
+              ;;
+          esac
           cd ../..
 
       - name: Package
@@ -258,6 +268,15 @@ jobs:
           Invoke-WebRequest -Uri "https://github.com/runetfreedom/russia-v2ray-rules-dat/releases/latest/download/geoip.dat" -OutFile "geoip_RU.dat"
           Invoke-WebRequest -Uri "https://github.com/runetfreedom/russia-v2ray-rules-dat/releases/latest/download/geosite.dat" -OutFile "geosite_RU.dat"
           Rename-Item xray.exe xray-windows-amd64.exe
+
+          # Download mtg (MTProto sidecar) for Windows
+          $MTG_VER = "2.2.8"
+          Invoke-WebRequest -Uri "https://github.com/9seconds/mtg/releases/download/v$MTG_VER/mtg-$MTG_VER-windows-amd64.zip" -OutFile "mtg-windows-amd64.zip"
+          Expand-Archive -Path "mtg-windows-amd64.zip" -DestinationPath "mtg-tmp"
+          $mtgExe = Get-ChildItem -Path "mtg-tmp" -Recurse -Filter "mtg.exe" | Select-Object -First 1
+          Move-Item $mtgExe.FullName "mtg-windows-amd64.exe"
+          Remove-Item "mtg-windows-amd64.zip", "mtg-tmp" -Recurse -Force
+
           cd ..
           Copy-Item -Path ..\windows_files\* -Destination . -Recurse
           cd ..

+ 12 - 0
DockerInit.sh

@@ -3,34 +3,46 @@ case $1 in
     amd64)
         ARCH="64"
         FNAME="amd64"
+        MTG_ARCH="amd64"
         ;;
     i386)
         ARCH="32"
         FNAME="i386"
+        MTG_ARCH="386"
         ;;
     armv8 | arm64 | aarch64)
         ARCH="arm64-v8a"
         FNAME="arm64"
+        MTG_ARCH="arm64"
         ;;
     armv7 | arm | arm32)
         ARCH="arm32-v7a"
         FNAME="arm32"
+        MTG_ARCH="armv7"
         ;;
     armv6)
         ARCH="arm32-v6"
         FNAME="armv6"
+        MTG_ARCH="armv6"
         ;;
     *)
         ARCH="64"
         FNAME="amd64"
+        MTG_ARCH="amd64"
         ;;
 esac
+MTG_VER="2.2.8"
 mkdir -p build/bin
 cd build/bin
 curl -sfLRO "https://github.com/XTLS/Xray-core/releases/download/v26.6.1/Xray-linux-${ARCH}.zip"
 unzip "Xray-linux-${ARCH}.zip"
 rm -f "Xray-linux-${ARCH}.zip" geoip.dat geosite.dat
 mv xray "xray-linux-${FNAME}"
+curl -sfLRO "https://github.com/9seconds/mtg/releases/download/v${MTG_VER}/mtg-${MTG_VER}-linux-${MTG_ARCH}.tar.gz"
+tar -xzf "mtg-${MTG_VER}-linux-${MTG_ARCH}.tar.gz"
+mv "mtg-${MTG_VER}-linux-${MTG_ARCH}/mtg" "mtg-linux-${FNAME}" 2>/dev/null || mv mtg "mtg-linux-${FNAME}"
+rm -rf "mtg-${MTG_VER}-linux-${MTG_ARCH}" "mtg-${MTG_VER}-linux-${MTG_ARCH}.tar.gz"
+chmod +x "mtg-linux-${FNAME}"
 curl -sfLRO https://github.com/Loyalsoldier/v2ray-rules-dat/releases/latest/download/geoip.dat
 curl -sfLRO https://github.com/Loyalsoldier/v2ray-rules-dat/releases/latest/download/geosite.dat
 curl -sfLRo geoip_IR.dat https://github.com/chocolate4u/Iran-v2ray-rules/releases/latest/download/geoip.dat

+ 66 - 1
database/model/model.go

@@ -3,6 +3,8 @@ package model
 
 import (
 	"bytes"
+	"crypto/rand"
+	"encoding/hex"
 	"encoding/json"
 	"fmt"
 	"strings"
@@ -29,6 +31,7 @@ const (
 	Mixed       Protocol = "mixed"
 	WireGuard   Protocol = "wireguard"
 	Hysteria    Protocol = "hysteria"
+	MTProto     Protocol = "mtproto"
 )
 
 // User represents a user account in the 3x-ui panel.
@@ -56,7 +59,7 @@ type Inbound struct {
 	// Xray configuration fields
 	Listen         string   `json:"listen" form:"listen"`
 	Port           int      `json:"port" form:"port" validate:"gte=0,lte=65535"`
-	Protocol       Protocol `json:"protocol" form:"protocol" validate:"required,oneof=vmess vless trojan shadowsocks wireguard hysteria http mixed tunnel tun"`
+	Protocol       Protocol `json:"protocol" form:"protocol" validate:"required,oneof=vmess vless trojan shadowsocks wireguard hysteria http mixed tunnel tun mtproto"`
 	Settings       string   `json:"settings" form:"settings"`
 	StreamSettings string   `json:"streamSettings" form:"streamSettings"`
 	Tag            string   `json:"tag" form:"tag" gorm:"unique"`
@@ -358,6 +361,68 @@ func HealShadowsocksClientMethods(settings string) (string, bool) {
 	return string(out), true
 }
 
+// GenerateFakeTLSSecret builds an MTProto FakeTLS secret for the given domain:
+// the "ee" FakeTLS marker, 16 random bytes, then the domain encoded as hex.
+// This single value is what mtg's config and the client tg:// link both use.
+func GenerateFakeTLSSecret(domain string) string {
+	return "ee" + mtprotoRandomMiddle() + hex.EncodeToString([]byte(domain))
+}
+
+func mtprotoRandomMiddle() string {
+	buf := make([]byte, 16)
+	_, _ = rand.Read(buf)
+	return hex.EncodeToString(buf)
+}
+
+// mtprotoSecretMiddle returns the 16-byte random middle of an existing secret
+// when it is well-formed, otherwise a freshly generated one. Reusing the middle
+// keeps the secret stable when only the FakeTLS domain changes.
+func mtprotoSecretMiddle(secret string) string {
+	s := secret
+	if strings.HasPrefix(s, "ee") || strings.HasPrefix(s, "dd") {
+		s = s[2:]
+	}
+	if len(s) >= 32 {
+		mid := s[:32]
+		if _, err := hex.DecodeString(mid); err == nil {
+			return mid
+		}
+	}
+	return mtprotoRandomMiddle()
+}
+
+// HealMtprotoSecret normalises an mtproto inbound's settings JSON before the
+// value leaves for the mtg sidecar or a share link: it rebuilds `secret` so it
+// is always a valid FakeTLS secret whose trailing domain matches
+// `fakeTlsDomain`, generating the random middle when one is missing and
+// rewriting the domain suffix when the domain changed. Returns the rewritten
+// settings and true when anything changed.
+func HealMtprotoSecret(settings string) (string, bool) {
+	if settings == "" {
+		return settings, false
+	}
+	var parsed map[string]any
+	if err := json.Unmarshal([]byte(settings), &parsed); err != nil {
+		return settings, false
+	}
+	domain, _ := parsed["fakeTlsDomain"].(string)
+	domain = strings.TrimSpace(domain)
+	if domain == "" {
+		return settings, false
+	}
+	secret, _ := parsed["secret"].(string)
+	expected := "ee" + mtprotoSecretMiddle(secret) + hex.EncodeToString([]byte(domain))
+	if secret == expected {
+		return settings, false
+	}
+	parsed["secret"] = expected
+	out, err := json.MarshalIndent(parsed, "", "  ")
+	if err != nil {
+		return settings, false
+	}
+	return string(out), true
+}
+
 // Setting stores key-value configuration settings for the 3x-ui panel.
 type Setting struct {
 	Id    int    `json:"id" form:"id" gorm:"primaryKey;autoIncrement"`

+ 71 - 0
database/model/model_mtproto_test.go

@@ -0,0 +1,71 @@
+package model
+
+import (
+	"encoding/hex"
+	"encoding/json"
+	"strings"
+	"testing"
+)
+
+func TestGenerateFakeTLSSecret(t *testing.T) {
+	domain := "www.cloudflare.com"
+	s := GenerateFakeTLSSecret(domain)
+	if !strings.HasPrefix(s, "ee") {
+		t.Fatalf("secret must start with ee, got %q", s)
+	}
+	wantSuffix := hex.EncodeToString([]byte(domain))
+	if !strings.HasSuffix(s, wantSuffix) {
+		t.Fatalf("secret must end with hex(domain) %q, got %q", wantSuffix, s)
+	}
+	if len(s) != 2+32+len(wantSuffix) {
+		t.Fatalf("unexpected secret length %d", len(s))
+	}
+	if _, err := hex.DecodeString(s[2:34]); err != nil {
+		t.Fatalf("middle is not valid hex: %v", err)
+	}
+}
+
+func TestHealMtprotoSecret(t *testing.T) {
+	domain := "example.com"
+	suffix := hex.EncodeToString([]byte(domain))
+
+	in := `{"fakeTlsDomain":"example.com","secret":""}`
+	out, changed := HealMtprotoSecret(in)
+	if !changed {
+		t.Fatal("expected heal to populate an empty secret")
+	}
+	var parsed map[string]any
+	if err := json.Unmarshal([]byte(out), &parsed); err != nil {
+		t.Fatalf("healed settings not valid json: %v", err)
+	}
+	got, _ := parsed["secret"].(string)
+	if !strings.HasPrefix(got, "ee") || !strings.HasSuffix(got, suffix) {
+		t.Fatalf("healed secret malformed: %q", got)
+	}
+
+	if _, changed2 := HealMtprotoSecret(out); changed2 {
+		t.Fatal("expected no change for an already-valid secret")
+	}
+
+	mid := got[2:34]
+	newDomain := "telegram.org"
+	in3 := `{"fakeTlsDomain":"telegram.org","secret":"` + got + `"}`
+	out3, changed3 := HealMtprotoSecret(in3)
+	if !changed3 {
+		t.Fatal("expected heal to rewrite the domain suffix")
+	}
+	if err := json.Unmarshal([]byte(out3), &parsed); err != nil {
+		t.Fatalf("healed settings not valid json: %v", err)
+	}
+	got3, _ := parsed["secret"].(string)
+	if got3[2:34] != mid {
+		t.Fatalf("random middle should be preserved on domain change: %q vs %q", got3[2:34], mid)
+	}
+	if !strings.HasSuffix(got3, hex.EncodeToString([]byte(newDomain))) {
+		t.Fatalf("suffix not updated for new domain: %q", got3)
+	}
+
+	if _, changed4 := HealMtprotoSecret(`{"secret":"ee"}`); changed4 {
+		t.Fatal("expected no change when fakeTlsDomain is missing")
+	}
+}

+ 1 - 1
frontend/src/generated/zod.ts

@@ -294,7 +294,7 @@ export const InboundSchema = z.object({
   listen: z.string(),
   nodeId: z.number().int().nullable().optional(),
   port: z.number().int().min(0).max(65535),
-  protocol: z.enum(['vmess', 'vless', 'trojan', 'shadowsocks', 'wireguard', 'hysteria', 'http', 'mixed', 'tunnel', 'tun']),
+  protocol: z.enum(['vmess', 'vless', 'trojan', 'shadowsocks', 'wireguard', 'hysteria', 'http', 'mixed', 'tunnel', 'tun', 'mtproto']),
   remark: z.string(),
   settings: z.unknown(),
   sniffing: z.unknown(),

+ 41 - 1
frontend/src/lib/xray/inbound-defaults.ts

@@ -3,6 +3,7 @@ import { RandomUtil, Wireguard } from '@/utils';
 import type { HttpInboundSettings } from '@/schemas/protocols/inbound/http';
 import type { HysteriaClient, HysteriaInboundSettings } from '@/schemas/protocols/inbound/hysteria';
 import type { MixedInboundSettings } from '@/schemas/protocols/inbound/mixed';
+import type { MtprotoInboundSettings } from '@/schemas/protocols/inbound/mtproto';
 import type { ShadowsocksClient, ShadowsocksInboundSettings } from '@/schemas/protocols/inbound/shadowsocks';
 import type { TrojanClient, TrojanInboundSettings } from '@/schemas/protocols/inbound/trojan';
 import type { TunInboundSettings } from '@/schemas/protocols/inbound/tun';
@@ -200,6 +201,43 @@ export function createDefaultMixedInboundSettings(): MixedInboundSettings {
   };
 }
 
+function domainToHex(domain: string): string {
+  return Array.from(new TextEncoder().encode(domain))
+    .map((b) => b.toString(16).padStart(2, '0'))
+    .join('');
+}
+
+// generateMtprotoSecret builds an "ee" FakeTLS secret: the marker, 16 random
+// bytes (32 hex chars), then the domain encoded as hex. Mirrors the Go
+// model.GenerateFakeTLSSecret; the backend re-derives it on save so this is
+// only for immediate display in the form.
+export function generateMtprotoSecret(domain: string): string {
+  return `ee${RandomUtil.randomSeq(32, { type: 'hex' })}${domainToHex(domain)}`;
+}
+
+// mtprotoSecretForDomain rewrites only the domain suffix of an existing secret,
+// preserving its 16-byte random middle when valid (generating one otherwise).
+// Mirrors the Go model.HealMtprotoSecret so editing the FakeTLS domain doesn't
+// needlessly rotate the secret's identity.
+export function mtprotoSecretForDomain(currentSecret: string, domain: string): string {
+  let body = currentSecret;
+  if (body.startsWith('ee') || body.startsWith('dd')) {
+    body = body.slice(2);
+  }
+  const middle = /^[0-9a-f]{32}/i.test(body)
+    ? body.slice(0, 32)
+    : RandomUtil.randomSeq(32, { type: 'hex' });
+  return `ee${middle}${domainToHex(domain)}`;
+}
+
+export function createDefaultMtprotoInboundSettings(): MtprotoInboundSettings {
+  const fakeTlsDomain = 'www.cloudflare.com';
+  return {
+    fakeTlsDomain,
+    secret: generateMtprotoSecret(fakeTlsDomain),
+  };
+}
+
 export function createDefaultTunnelInboundSettings(): TunnelInboundSettings {
   return {
     portMap: {},
@@ -261,7 +299,8 @@ export type AnyInboundSettings =
   | MixedInboundSettings
   | TunInboundSettings
   | TunnelInboundSettings
-  | WireguardInboundSettings;
+  | WireguardInboundSettings
+  | MtprotoInboundSettings;
 
 export function createDefaultInboundSettings(protocol: string): AnyInboundSettings | null {
   switch (protocol) {
@@ -275,6 +314,7 @@ export function createDefaultInboundSettings(protocol: string): AnyInboundSettin
     case 'tunnel':      return createDefaultTunnelInboundSettings();
     case 'tun':         return createDefaultTunInboundSettings();
     case 'wireguard':   return createDefaultWireguardInboundSettings();
+    case 'mtproto':     return createDefaultMtprotoInboundSettings();
     default:            return null;
   }
 }

+ 24 - 0
frontend/src/lib/xray/inbound-link.ts

@@ -680,6 +680,28 @@ export function genHysteriaLink(input: GenHysteriaLinkInput): string {
   return url.toString();
 }
 
+export interface GenMtprotoLinkInput {
+  inbound: Inbound;
+  address: string;
+  port?: number;
+  remark?: string;
+}
+
+// Builds a Telegram proxy deep link for an mtproto inbound:
+// tg://proxy?server=<addr>&port=<port>&secret=<ee FakeTLS secret>.
+export function genMtprotoLink(input: GenMtprotoLinkInput): string {
+  const { inbound, address, port = inbound.port, remark = '' } = input;
+  if (inbound.protocol !== 'mtproto') return '';
+  const secret = inbound.settings.secret ?? '';
+  if (secret.length === 0) return '';
+  const url = new URL('tg://proxy');
+  url.searchParams.set('server', address);
+  url.searchParams.set('port', String(port));
+  url.searchParams.set('secret', secret);
+  url.hash = encodeURIComponent(remark);
+  return url.toString();
+}
+
 export interface GenWireguardLinkInput {
   settings: WireguardInboundSettings;
   address: string;
@@ -867,6 +889,8 @@ export function genLink(input: GenLinkInput): string {
         clientAuth: client.auth ?? '',
         externalProxy,
       });
+    case 'mtproto':
+      return genMtprotoLink({ inbound, address, port, remark });
     default:
       return '';
   }

+ 4 - 0
frontend/src/pages/inbounds/form/InboundFormModal.tsx

@@ -54,6 +54,7 @@ import {
   HttpFields,
   HysteriaFields,
   MixedFields,
+  MtprotoFields,
   ShadowsocksFields,
   TunFields,
   TunnelFields,
@@ -589,6 +590,8 @@ export default function InboundFormModal({
       {protocol === Protocols.HTTP && <HttpFields />}
       {protocol === Protocols.MIXED && <MixedFields mixedUdpOn={mixedUdpOn} />}
 
+      {protocol === Protocols.MTPROTO && <MtprotoFields />}
+
       {protocol === Protocols.SHADOWSOCKS && <ShadowsocksFields form={form} isSSWith2022={isSSWith2022} />}
 
       {protocol === Protocols.VLESS && <VlessFields saving={saving} selectedVlessAuth={selectedVlessAuth} network={network} security={security} getNewVlessEnc={getNewVlessEnc} clearVlessEnc={clearVlessEnc} />}
@@ -893,6 +896,7 @@ export default function InboundFormModal({
               Protocols.TUNNEL,
               Protocols.TUN,
               Protocols.WIREGUARD,
+              Protocols.MTPROTO,
             ] as string[]).includes(protocol) || isFallbackHost
               ? [{ key: 'protocol', label: t('pages.inbounds.protocol'), children: protocolTab, forceRender: true }]
               : []),

+ 1 - 0
frontend/src/pages/inbounds/form/protocols/index.ts

@@ -5,4 +5,5 @@ export { default as WireguardFields } from './wireguard';
 export { default as HysteriaFields } from './hysteria';
 export { default as HttpFields } from './http';
 export { default as MixedFields } from './mixed';
+export { default as MtprotoFields } from './mtproto';
 export { default as VlessFields } from './vless';

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

@@ -0,0 +1,40 @@
+import { useTranslation } from 'react-i18next';
+import { Alert, Button, Form, Input, Space } from 'antd';
+import { ReloadOutlined } from '@ant-design/icons';
+
+import { generateMtprotoSecret, mtprotoSecretForDomain } from '@/lib/xray/inbound-defaults';
+
+export default function MtprotoFields() {
+  const { t } = useTranslation();
+  const form = Form.useFormInstance();
+  return (
+    <>
+      <Form.Item name={['settings', 'fakeTlsDomain']} label={t('pages.inbounds.form.fakeTlsDomain')}>
+        <Input
+          placeholder="www.cloudflare.com"
+          onChange={(e) => {
+            const current = (form.getFieldValue(['settings', 'secret']) as string) ?? '';
+            form.setFieldValue(['settings', 'secret'], mtprotoSecretForDomain(current, e.target.value));
+          }}
+        />
+      </Form.Item>
+      <Form.Item label={t('pages.inbounds.form.mtprotoSecret')}>
+        <Space.Compact block>
+          <Form.Item name={['settings', 'secret']} noStyle>
+            <Input readOnly style={{ width: 'calc(100% - 32px)' }} />
+          </Form.Item>
+          <Button
+            icon={<ReloadOutlined />}
+            onClick={() => {
+              const domain = form.getFieldValue(['settings', 'fakeTlsDomain']);
+              form.setFieldValue(['settings', 'secret'], generateMtprotoSecret(domain as string));
+            }}
+          />
+        </Space.Compact>
+      </Form.Item>
+      <Form.Item wrapperCol={{ span: 24 }}>
+        <Alert type="info" showIcon message={t('pages.inbounds.form.mtprotoHint')} />
+      </Form.Item>
+    </>
+  );
+}

+ 29 - 0
frontend/src/pages/inbounds/info/InboundInfoModal.tsx

@@ -625,6 +625,35 @@ export default function InboundInfoModal({
         </dl>
       )}
 
+      {inbound.protocol === Protocols.MTPROTO && inbound.settings && (
+        <dl className="info-list info-list-block">
+          <div className="info-row">
+            <dt>{t('pages.inbounds.form.fakeTlsDomain')}</dt>
+            <dd><Tag color="green" className="value-tag">{inbound.settings.fakeTlsDomain as string}</Tag></dd>
+          </div>
+          <div className="info-row">
+            <dt>{t('pages.inbounds.form.mtprotoSecret')}</dt>
+            <dd className="value-block">
+              <code className="value-code">{inbound.settings.secret as string}</code>
+              <Tooltip title={t('copy')}>
+                <Button size="small" className="value-copy" icon={<CopyOutlined />} onClick={() => copyText(inbound.settings.secret as string, t)} />
+              </Tooltip>
+            </dd>
+          </div>
+          {links.length > 0 && (
+            <div className="info-row">
+              <dt>{t('pages.inbounds.copyLink')}</dt>
+              <dd className="value-block">
+                <code className="value-code">{links[0].link}</code>
+                <Tooltip title={t('copy')}>
+                  <Button size="small" className="value-copy" icon={<CopyOutlined />} onClick={() => copyText(links[0].link, t)} />
+                </Tooltip>
+              </dd>
+            </div>
+          )}
+        </dl>
+      )}
+
       {dbInbound.isMixed && inbound.settings && (
         <dl className="info-list info-list-block">
           <div className="info-row">

+ 2 - 0
frontend/src/schemas/primitives/protocol.ts

@@ -11,6 +11,7 @@ export const ProtocolSchema = z.enum([
   'mixed',
   'tunnel',
   'tun',
+  'mtproto',
 ]);
 export type Protocol = z.infer<typeof ProtocolSchema>;
 
@@ -31,4 +32,5 @@ export const Protocols = Object.freeze({
   MIXED: 'mixed',
   TUNNEL: 'tunnel',
   TUN: 'tun',
+  MTPROTO: 'mtproto',
 });

+ 3 - 0
frontend/src/schemas/protocols/inbound/index.ts

@@ -3,6 +3,7 @@ import { z } from 'zod';
 import { HttpInboundSettingsSchema } from './http';
 import { HysteriaInboundSettingsSchema } from './hysteria';
 import { MixedInboundSettingsSchema } from './mixed';
+import { MtprotoInboundSettingsSchema } from './mtproto';
 import { ShadowsocksInboundSettingsSchema } from './shadowsocks';
 import { TrojanInboundSettingsSchema } from './trojan';
 import { TunInboundSettingsSchema } from './tun';
@@ -14,6 +15,7 @@ import { WireguardInboundSettingsSchema } from './wireguard';
 export * from './http';
 export * from './hysteria';
 export * from './mixed';
+export * from './mtproto';
 export * from './shadowsocks';
 export * from './trojan';
 export * from './tun';
@@ -38,5 +40,6 @@ export const InboundSettingsSchema = z.discriminatedUnion('protocol', [
   z.object({ protocol: z.literal('mixed'),       settings: MixedInboundSettingsSchema }),
   z.object({ protocol: z.literal('tunnel'),      settings: TunnelInboundSettingsSchema }),
   z.object({ protocol: z.literal('tun'),         settings: TunInboundSettingsSchema }),
+  z.object({ protocol: z.literal('mtproto'),     settings: MtprotoInboundSettingsSchema }),
 ]);
 export type InboundSettings = z.infer<typeof InboundSettingsSchema>;

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

@@ -0,0 +1,10 @@
+import { z } from 'zod';
+
+// MTProto (Telegram) inbound. Served by an mtg sidecar process, not Xray, so
+// it has no clients and no stream settings. `secret` is the FakeTLS secret
+// (ee-prefixed); the backend rebuilds it to match `fakeTlsDomain` on save.
+export const MtprotoInboundSettingsSchema = z.object({
+  fakeTlsDomain: z.string().default('www.cloudflare.com'),
+  secret: z.string().default(''),
+});
+export type MtprotoInboundSettings = z.infer<typeof MtprotoInboundSettingsSchema>;

+ 168 - 0
frontend/src/test/__snapshots__/protocol-capabilities.test.ts.snap

@@ -504,6 +504,174 @@ exports[`protocol capability predicates > mixed-basic :: xhttp/tls 1`] = `
 }
 `;
 
+exports[`protocol capability predicates > mtproto-basic :: grpc/none 1`] = `
+{
+  "canEnableReality": false,
+  "canEnableStream": false,
+  "canEnableTls": false,
+  "canEnableTlsFlow": false,
+  "canEnableVisionSeed": false,
+  "isSS2022": false,
+  "isSSMultiUser": true,
+}
+`;
+
+exports[`protocol capability predicates > mtproto-basic :: grpc/reality 1`] = `
+{
+  "canEnableReality": false,
+  "canEnableStream": false,
+  "canEnableTls": false,
+  "canEnableTlsFlow": false,
+  "canEnableVisionSeed": false,
+  "isSS2022": false,
+  "isSSMultiUser": true,
+}
+`;
+
+exports[`protocol capability predicates > mtproto-basic :: grpc/tls 1`] = `
+{
+  "canEnableReality": false,
+  "canEnableStream": false,
+  "canEnableTls": false,
+  "canEnableTlsFlow": false,
+  "canEnableVisionSeed": false,
+  "isSS2022": false,
+  "isSSMultiUser": true,
+}
+`;
+
+exports[`protocol capability predicates > mtproto-basic :: httpupgrade/none 1`] = `
+{
+  "canEnableReality": false,
+  "canEnableStream": false,
+  "canEnableTls": false,
+  "canEnableTlsFlow": false,
+  "canEnableVisionSeed": false,
+  "isSS2022": false,
+  "isSSMultiUser": true,
+}
+`;
+
+exports[`protocol capability predicates > mtproto-basic :: httpupgrade/tls 1`] = `
+{
+  "canEnableReality": false,
+  "canEnableStream": false,
+  "canEnableTls": false,
+  "canEnableTlsFlow": false,
+  "canEnableVisionSeed": false,
+  "isSS2022": false,
+  "isSSMultiUser": true,
+}
+`;
+
+exports[`protocol capability predicates > mtproto-basic :: kcp/none 1`] = `
+{
+  "canEnableReality": false,
+  "canEnableStream": false,
+  "canEnableTls": false,
+  "canEnableTlsFlow": false,
+  "canEnableVisionSeed": false,
+  "isSS2022": false,
+  "isSSMultiUser": true,
+}
+`;
+
+exports[`protocol capability predicates > mtproto-basic :: tcp/none 1`] = `
+{
+  "canEnableReality": false,
+  "canEnableStream": false,
+  "canEnableTls": false,
+  "canEnableTlsFlow": false,
+  "canEnableVisionSeed": false,
+  "isSS2022": false,
+  "isSSMultiUser": true,
+}
+`;
+
+exports[`protocol capability predicates > mtproto-basic :: tcp/reality 1`] = `
+{
+  "canEnableReality": false,
+  "canEnableStream": false,
+  "canEnableTls": false,
+  "canEnableTlsFlow": false,
+  "canEnableVisionSeed": false,
+  "isSS2022": false,
+  "isSSMultiUser": true,
+}
+`;
+
+exports[`protocol capability predicates > mtproto-basic :: tcp/tls 1`] = `
+{
+  "canEnableReality": false,
+  "canEnableStream": false,
+  "canEnableTls": false,
+  "canEnableTlsFlow": false,
+  "canEnableVisionSeed": false,
+  "isSS2022": false,
+  "isSSMultiUser": true,
+}
+`;
+
+exports[`protocol capability predicates > mtproto-basic :: ws/none 1`] = `
+{
+  "canEnableReality": false,
+  "canEnableStream": false,
+  "canEnableTls": false,
+  "canEnableTlsFlow": false,
+  "canEnableVisionSeed": false,
+  "isSS2022": false,
+  "isSSMultiUser": true,
+}
+`;
+
+exports[`protocol capability predicates > mtproto-basic :: ws/tls 1`] = `
+{
+  "canEnableReality": false,
+  "canEnableStream": false,
+  "canEnableTls": false,
+  "canEnableTlsFlow": false,
+  "canEnableVisionSeed": false,
+  "isSS2022": false,
+  "isSSMultiUser": true,
+}
+`;
+
+exports[`protocol capability predicates > mtproto-basic :: xhttp/none 1`] = `
+{
+  "canEnableReality": false,
+  "canEnableStream": false,
+  "canEnableTls": false,
+  "canEnableTlsFlow": false,
+  "canEnableVisionSeed": false,
+  "isSS2022": false,
+  "isSSMultiUser": true,
+}
+`;
+
+exports[`protocol capability predicates > mtproto-basic :: xhttp/reality 1`] = `
+{
+  "canEnableReality": false,
+  "canEnableStream": false,
+  "canEnableTls": false,
+  "canEnableTlsFlow": false,
+  "canEnableVisionSeed": false,
+  "isSS2022": false,
+  "isSSMultiUser": true,
+}
+`;
+
+exports[`protocol capability predicates > mtproto-basic :: xhttp/tls 1`] = `
+{
+  "canEnableReality": false,
+  "canEnableStream": false,
+  "canEnableTls": false,
+  "canEnableTlsFlow": false,
+  "canEnableVisionSeed": false,
+  "isSS2022": false,
+  "isSSMultiUser": true,
+}
+`;
+
 exports[`protocol capability predicates > shadowsocks-2022 :: grpc/none 1`] = `
 {
   "canEnableReality": false,

+ 10 - 0
frontend/src/test/__snapshots__/protocols.test.ts.snap

@@ -59,6 +59,16 @@ exports[`InboundSettingsSchema fixtures > parses mixed-basic byte-stably 1`] = `
 }
 `;
 
+exports[`InboundSettingsSchema fixtures > parses mtproto-basic byte-stably 1`] = `
+{
+  "protocol": "mtproto",
+  "settings": {
+    "fakeTlsDomain": "www.cloudflare.com",
+    "secret": "ee0123456789abcdef0123456789abcdef7777772e636c6f7564666c6172652e636f6d",
+  },
+}
+`;
+
 exports[`InboundSettingsSchema fixtures > parses shadowsocks-2022 byte-stably 1`] = `
 {
   "protocol": "shadowsocks",

+ 7 - 0
frontend/src/test/golden/fixtures/inbound/mtproto-basic.json

@@ -0,0 +1,7 @@
+{
+  "protocol": "mtproto",
+  "settings": {
+    "fakeTlsDomain": "www.cloudflare.com",
+    "secret": "ee0123456789abcdef0123456789abcdef7777772e636c6f7564666c6172652e636f6d"
+  }
+}

+ 5 - 0
install.sh

@@ -1192,8 +1192,13 @@ install_x-ui() {
     if [[ $(arch) == "armv5" || $(arch) == "armv6" || $(arch) == "armv7" ]]; then
         mv bin/xray-linux-$(arch) bin/xray-linux-arm
         chmod +x bin/xray-linux-arm
+        if [[ -f bin/mtg-linux-$(arch) ]]; then
+            mv bin/mtg-linux-$(arch) bin/mtg-linux-arm
+            chmod +x bin/mtg-linux-arm
+        fi
     fi
     chmod +x x-ui bin/xray-linux-$(arch)
+    [[ -f bin/mtg-linux-$(arch) ]] && chmod +x bin/mtg-linux-$(arch)
 
     # Update x-ui cli and se set permission
     mv -f /usr/bin/x-ui-temp /usr/bin/x-ui

+ 316 - 0
mtproto/manager.go

@@ -0,0 +1,316 @@
+package mtproto
+
+import (
+	"bufio"
+	"encoding/json"
+	"fmt"
+	"net"
+	"net/http"
+	"os"
+	"strconv"
+	"strings"
+	"sync"
+	"time"
+
+	"github.com/mhsanaei/3x-ui/v3/database/model"
+	"github.com/mhsanaei/3x-ui/v3/logger"
+)
+
+// Instance is the desired runtime configuration of one mtproto inbound.
+type Instance struct {
+	Id     int
+	Tag    string
+	Listen string
+	Port   int
+	Secret string
+}
+
+func (inst Instance) bindTo() string {
+	listen := inst.Listen
+	if listen == "" {
+		listen = "0.0.0.0"
+	}
+	return fmt.Sprintf("%s:%d", listen, inst.Port)
+}
+
+func (inst Instance) fingerprint() string {
+	return fmt.Sprintf("%s|%s", inst.bindTo(), inst.Secret)
+}
+
+// Traffic is a per-inbound traffic delta scraped from an mtg metrics endpoint.
+type Traffic struct {
+	Tag  string
+	Up   int64
+	Down int64
+}
+
+type managed struct {
+	proc        *Process
+	tag         string
+	fingerprint string
+	metricsPort int
+	lastUp      int64
+	lastDown    int64
+	haveLast    bool
+}
+
+// Manager owns the set of running mtg processes keyed by inbound id.
+type Manager struct {
+	mu    sync.Mutex
+	procs map[int]*managed
+}
+
+var (
+	managerOnce sync.Once
+	manager     *Manager
+)
+
+// GetManager returns the process-wide mtg manager singleton.
+func GetManager() *Manager {
+	managerOnce.Do(func() {
+		manager = &Manager{procs: map[int]*managed{}}
+	})
+	return manager
+}
+
+// InstanceFromInbound derives a desired Instance from an mtproto inbound,
+// healing the FakeTLS secret so it always matches the configured domain.
+// Returns false when the inbound is not a usable mtproto inbound.
+func InstanceFromInbound(ib *model.Inbound) (Instance, bool) {
+	if ib == nil || ib.Protocol != model.MTProto {
+		return Instance{}, false
+	}
+	settings := ib.Settings
+	if healed, ok := model.HealMtprotoSecret(settings); ok {
+		settings = healed
+	}
+	var parsed struct {
+		Secret string `json:"secret"`
+	}
+	if err := json.Unmarshal([]byte(settings), &parsed); err != nil {
+		return Instance{}, false
+	}
+	if parsed.Secret == "" {
+		return Instance{}, false
+	}
+	return Instance{
+		Id:     ib.Id,
+		Tag:    ib.Tag,
+		Listen: ib.Listen,
+		Port:   ib.Port,
+		Secret: parsed.Secret,
+	}, true
+}
+
+// Ensure starts the mtg process for an instance, or restarts it when its
+// configuration changed. A no-op when the desired process is already running.
+func (m *Manager) Ensure(inst Instance) error {
+	m.mu.Lock()
+	defer m.mu.Unlock()
+	return m.ensureLocked(inst)
+}
+
+func (m *Manager) ensureLocked(inst Instance) error {
+	fp := inst.fingerprint()
+	if cur, ok := m.procs[inst.Id]; ok {
+		if cur.fingerprint == fp && cur.proc.IsRunning() {
+			cur.tag = inst.Tag
+			return nil
+		}
+		cur.proc.Stop()
+		delete(m.procs, inst.Id)
+	}
+	metricsPort, err := freeLocalPort()
+	if err != nil {
+		return err
+	}
+	cfgPath := configPathForID(inst.Id)
+	if err := writeConfig(cfgPath, inst.Secret, inst.bindTo(), metricsPort); err != nil {
+		return err
+	}
+	proc := newProcess(cfgPath)
+	if err := proc.Start(); err != nil {
+		return err
+	}
+	m.procs[inst.Id] = &managed{
+		proc:        proc,
+		tag:         inst.Tag,
+		fingerprint: fp,
+		metricsPort: metricsPort,
+	}
+	logger.Info("mtproto: started mtg for inbound", inst.Id, "on", inst.bindTo())
+	return nil
+}
+
+// Remove stops and forgets the mtg process for an inbound id.
+func (m *Manager) Remove(id int) {
+	m.mu.Lock()
+	defer m.mu.Unlock()
+	if cur, ok := m.procs[id]; ok {
+		cur.proc.Stop()
+		delete(m.procs, id)
+		_ = os.Remove(configPathForID(id))
+		logger.Info("mtproto: stopped mtg for inbound", id)
+	}
+}
+
+// Reconcile drives the running set toward the desired instances: it stops
+// processes that are no longer wanted and (re)starts the rest. Used at boot
+// and periodically to recover from crashes.
+func (m *Manager) Reconcile(desired []Instance) {
+	m.mu.Lock()
+	defer m.mu.Unlock()
+	want := make(map[int]struct{}, len(desired))
+	for _, inst := range desired {
+		want[inst.Id] = struct{}{}
+	}
+	for id, cur := range m.procs {
+		if _, ok := want[id]; !ok {
+			cur.proc.Stop()
+			delete(m.procs, id)
+			_ = os.Remove(configPathForID(id))
+		}
+	}
+	for _, inst := range desired {
+		if err := m.ensureLocked(inst); err != nil {
+			logger.Warning("mtproto: reconcile failed for inbound", inst.Id, ":", err)
+		}
+	}
+}
+
+// StopAll stops every managed mtg process. Called on panel shutdown.
+func (m *Manager) StopAll() {
+	m.mu.Lock()
+	defer m.mu.Unlock()
+	for id, cur := range m.procs {
+		cur.proc.Stop()
+		delete(m.procs, id)
+	}
+}
+
+// CollectTraffic scrapes each running mtg metrics endpoint and returns the
+// per-inbound byte deltas since the previous scrape.
+func (m *Manager) CollectTraffic() []Traffic {
+	m.mu.Lock()
+	defer m.mu.Unlock()
+	out := make([]Traffic, 0, len(m.procs))
+	for _, cur := range m.procs {
+		if cur.proc == nil || !cur.proc.IsRunning() {
+			continue
+		}
+		up, down, ok := scrapeTraffic(cur.metricsPort)
+		if !ok {
+			continue
+		}
+		if cur.haveLast {
+			du := up - cur.lastUp
+			dd := down - cur.lastDown
+			if du < 0 {
+				du = 0
+			}
+			if dd < 0 {
+				dd = 0
+			}
+			if du > 0 || dd > 0 {
+				out = append(out, Traffic{Tag: cur.tag, Up: du, Down: dd})
+			}
+		}
+		cur.lastUp = up
+		cur.lastDown = down
+		cur.haveLast = true
+	}
+	return out
+}
+
+func freeLocalPort() (int, error) {
+	l, err := net.Listen("tcp", "127.0.0.1:0")
+	if err != nil {
+		return 0, err
+	}
+	defer l.Close()
+	return l.Addr().(*net.TCPAddr).Port, nil
+}
+
+func writeConfig(path, secret, bindTo string, metricsPort int) error {
+	if err := os.MkdirAll(configDir(), 0o750); err != nil {
+		return err
+	}
+	content := fmt.Sprintf("secret = %q\nbind-to = %q\n\n[stats.prometheus]\nenabled = true\nbind-to = \"127.0.0.1:%d\"\nhttp-path = \"/metrics\"\nmetric-prefix = \"mtg\"\n",
+		secret, bindTo, metricsPort)
+	return os.WriteFile(path, []byte(content), 0o640)
+}
+
+// scrapeTraffic reads the mtg Prometheus metrics endpoint and sums byte
+// counters by direction. mtg exposes a traffic counter labelled with a
+// direction; "to_telegram" is treated as upload and "to_client" as download.
+// Best-effort: an unreachable endpoint or unrecognised format yields ok=false.
+func scrapeTraffic(port int) (up int64, down int64, ok bool) {
+	client := http.Client{Timeout: 3 * time.Second}
+	resp, err := client.Get(fmt.Sprintf("http://127.0.0.1:%d/metrics", port))
+	if err != nil {
+		return 0, 0, false
+	}
+	defer resp.Body.Close()
+	scanner := bufio.NewScanner(resp.Body)
+	scanner.Buffer(make([]byte, 64*1024), 1024*1024)
+	found := false
+	for scanner.Scan() {
+		line := strings.TrimSpace(scanner.Text())
+		if line == "" || line[0] == '#' || !strings.Contains(line, "traffic") {
+			continue
+		}
+		name, labels, value, perr := parseMetricLine(line)
+		if perr != nil || !strings.HasPrefix(name, "mtg") {
+			continue
+		}
+		switch labels["direction"] {
+		case "to_telegram", "egress", "up":
+			up += int64(value)
+		case "to_client", "ingress", "down":
+			down += int64(value)
+		default:
+			down += int64(value)
+		}
+		found = true
+	}
+	if err := scanner.Err(); err != nil {
+		logger.Debug("mtproto: metrics scan error:", err)
+	}
+	return up, down, found
+}
+
+func parseMetricLine(line string) (name string, labels map[string]string, value float64, err error) {
+	labels = map[string]string{}
+	rest := line
+	if brace := strings.IndexByte(line, '{'); brace >= 0 {
+		name = line[:brace]
+		end := strings.IndexByte(line, '}')
+		if end < brace {
+			return "", nil, 0, fmt.Errorf("malformed metric line")
+		}
+		for _, kv := range strings.Split(line[brace+1:end], ",") {
+			eq := strings.IndexByte(kv, '=')
+			if eq < 0 {
+				continue
+			}
+			labels[strings.TrimSpace(kv[:eq])] = strings.Trim(strings.TrimSpace(kv[eq+1:]), `"`)
+		}
+		rest = strings.TrimSpace(line[end+1:])
+	} else {
+		fields := strings.Fields(line)
+		if len(fields) < 2 {
+			return "", nil, 0, fmt.Errorf("malformed metric line")
+		}
+		name = fields[0]
+		rest = fields[1]
+	}
+	valFields := strings.Fields(rest)
+	if len(valFields) == 0 {
+		return "", nil, 0, fmt.Errorf("missing metric value")
+	}
+	value, err = strconv.ParseFloat(valFields[0], 64)
+	if err != nil {
+		return "", nil, 0, err
+	}
+	return name, labels, value, nil
+}

+ 56 - 0
mtproto/manager_test.go

@@ -0,0 +1,56 @@
+package mtproto
+
+import (
+	"testing"
+
+	"github.com/mhsanaei/3x-ui/v3/database/model"
+)
+
+func TestParseMetricLine(t *testing.T) {
+	name, labels, val, err := parseMetricLine(`mtg_traffic{direction="to_client"} 12345`)
+	if err != nil {
+		t.Fatal(err)
+	}
+	if name != "mtg_traffic" {
+		t.Fatalf("name=%q", name)
+	}
+	if labels["direction"] != "to_client" {
+		t.Fatalf("labels=%v", labels)
+	}
+	if val != 12345 {
+		t.Fatalf("val=%v", val)
+	}
+
+	name2, _, val2, err2 := parseMetricLine(`mtg_concurrency 7`)
+	if err2 != nil {
+		t.Fatal(err2)
+	}
+	if name2 != "mtg_concurrency" || val2 != 7 {
+		t.Fatalf("got %q %v", name2, val2)
+	}
+}
+
+func TestInstanceFromInbound(t *testing.T) {
+	ib := &model.Inbound{
+		Id:       3,
+		Tag:      "inbound-3",
+		Listen:   "0.0.0.0",
+		Port:     8443,
+		Protocol: model.MTProto,
+		Settings: `{"fakeTlsDomain":"example.com","secret":""}`,
+	}
+	inst, ok := InstanceFromInbound(ib)
+	if !ok {
+		t.Fatal("expected a usable instance")
+	}
+	if inst.Secret == "" {
+		t.Fatal("secret should be healed to a non-empty value")
+	}
+	if inst.Port != 8443 || inst.Id != 3 {
+		t.Fatalf("bad instance %+v", inst)
+	}
+
+	if _, ok := InstanceFromInbound(&model.Inbound{Protocol: model.VLESS}); ok {
+		t.Fatal("non-mtproto inbound should not produce an instance")
+	}
+}

+ 201 - 0
mtproto/process.go

@@ -0,0 +1,201 @@
+// Package mtproto manages mtg (github.com/9seconds/mtg) sidecar processes that
+// serve MTProto FakeTLS proxies. Xray-core has no mtproto protocol, so mtproto
+// inbounds are run as standalone mtg processes — one process per inbound —
+// entirely outside the Xray config and lifecycle.
+package mtproto
+
+import (
+	"errors"
+	"fmt"
+	"os"
+	"os/exec"
+	"runtime"
+	"strings"
+	"sync"
+	"sync/atomic"
+	"syscall"
+	"time"
+
+	"github.com/mhsanaei/3x-ui/v3/config"
+	"github.com/mhsanaei/3x-ui/v3/logger"
+)
+
+// GetBinaryName returns the mtg binary filename for the current OS and arch,
+// matching the naming scheme used for the Xray binary. On Windows the ".exe"
+// extension is appended so a natural "mtg-windows-amd64.exe" is found.
+func GetBinaryName() string {
+	name := fmt.Sprintf("mtg-%s-%s", runtime.GOOS, runtime.GOARCH)
+	if runtime.GOOS == "windows" {
+		name += ".exe"
+	}
+	return name
+}
+
+// GetBinaryPath returns the full path to the mtg binary, alongside the Xray binary.
+func GetBinaryPath() string {
+	return config.GetBinFolderPath() + "/" + GetBinaryName()
+}
+
+func configDir() string {
+	return config.GetBinFolderPath() + "/mtproto"
+}
+
+func configPathForID(id int) string {
+	return fmt.Sprintf("%s/mtg-%d.toml", configDir(), id)
+}
+
+var (
+	gracefulStopTimeout = 5 * time.Second
+	forceStopTimeout    = 2 * time.Second
+)
+
+type lastLineWriter struct {
+	mu       sync.Mutex
+	lastLine string
+}
+
+func (w *lastLineWriter) Write(p []byte) (int, error) {
+	line := strings.TrimSpace(string(p))
+	if line != "" {
+		w.mu.Lock()
+		w.lastLine = line
+		w.mu.Unlock()
+	}
+	return len(p), nil
+}
+
+func (w *lastLineWriter) LastLine() string {
+	w.mu.Lock()
+	defer w.mu.Unlock()
+	return w.lastLine
+}
+
+// Process wraps a single mtg process invocation for one mtproto inbound.
+type Process struct {
+	cmd             *exec.Cmd
+	done            chan struct{}
+	configPath      string
+	logWriter       *lastLineWriter
+	exitErr         error
+	intentionalStop atomic.Bool
+}
+
+func newProcess(configPath string) *Process {
+	return &Process{
+		configPath: configPath,
+		logWriter:  &lastLineWriter{},
+	}
+}
+
+// IsRunning reports whether the mtg process is currently running.
+func (p *Process) IsRunning() bool {
+	if p.cmd == nil || p.cmd.Process == nil {
+		return false
+	}
+	if p.done != nil {
+		select {
+		case <-p.done:
+			return false
+		default:
+		}
+	}
+	if p.cmd.ProcessState == nil {
+		return true
+	}
+	return false
+}
+
+// GetResult returns the last log line or the exit error from the mtg process.
+func (p *Process) GetResult() string {
+	if line := p.logWriter.LastLine(); line != "" {
+		return line
+	}
+	if p.exitErr != nil {
+		return p.exitErr.Error()
+	}
+	return ""
+}
+
+// Start launches the mtg process against its generated config file.
+func (p *Process) Start() error {
+	if p.IsRunning() {
+		return errors.New("mtg is already running")
+	}
+	cmd := exec.Command(GetBinaryPath(), "run", p.configPath)
+	cmd.Stdout = p.logWriter
+	cmd.Stderr = p.logWriter
+	p.cmd = cmd
+	p.done = make(chan struct{})
+	p.exitErr = nil
+	p.intentionalStop.Store(false)
+	if err := cmd.Start(); err != nil {
+		close(p.done)
+		p.cmd = nil
+		return err
+	}
+	attachChildLifetime(cmd)
+	go p.wait(cmd)
+	return nil
+}
+
+func (p *Process) wait(cmd *exec.Cmd) {
+	defer close(p.done)
+	err := cmd.Wait()
+	if err == nil || p.intentionalStop.Load() {
+		return
+	}
+	if runtime.GOOS == "windows" {
+		if strings.Contains(strings.ToLower(err.Error()), "exit status 1") {
+			p.exitErr = err
+			return
+		}
+	}
+	logger.Error("mtproto: mtg process exited:", err)
+	p.exitErr = err
+}
+
+// Stop terminates the running mtg process gracefully, falling back to a kill.
+func (p *Process) Stop() error {
+	if !p.IsRunning() {
+		return errors.New("mtg is not running")
+	}
+	p.intentionalStop.Store(true)
+
+	if runtime.GOOS == "windows" {
+		if err := p.cmd.Process.Kill(); err != nil && !errors.Is(err, os.ErrProcessDone) {
+			return err
+		}
+		return p.waitForExit(forceStopTimeout)
+	}
+
+	if err := p.cmd.Process.Signal(syscall.SIGTERM); err != nil {
+		if errors.Is(err, os.ErrProcessDone) {
+			return p.waitForExit(forceStopTimeout)
+		}
+		return err
+	}
+
+	if err := p.waitForExit(gracefulStopTimeout); err == nil {
+		return nil
+	}
+
+	logger.Warning("mtproto: mtg did not stop after SIGTERM, killing process")
+	if err := p.cmd.Process.Kill(); err != nil && !errors.Is(err, os.ErrProcessDone) {
+		return err
+	}
+	return p.waitForExit(forceStopTimeout)
+}
+
+func (p *Process) waitForExit(timeout time.Duration) error {
+	if p.done == nil {
+		return nil
+	}
+	timer := time.NewTimer(timeout)
+	defer timer.Stop()
+	select {
+	case <-p.done:
+		return nil
+	case <-timer.C:
+		return fmt.Errorf("timed out waiting for mtg process to stop after %s", timeout)
+	}
+}

+ 7 - 0
mtproto/process_other.go

@@ -0,0 +1,7 @@
+//go:build !windows
+
+package mtproto
+
+import "os/exec"
+
+func attachChildLifetime(_ *exec.Cmd) {}

+ 66 - 0
mtproto/process_windows.go

@@ -0,0 +1,66 @@
+//go:build windows
+
+package mtproto
+
+import (
+	"os/exec"
+	"sync"
+	"unsafe"
+
+	"github.com/mhsanaei/3x-ui/v3/logger"
+	"golang.org/x/sys/windows"
+)
+
+var (
+	killOnExitJobOnce sync.Once
+	killOnExitJob     windows.Handle
+	killOnExitJobErr  error
+)
+
+func ensureKillOnExitJob() (windows.Handle, error) {
+	killOnExitJobOnce.Do(func() {
+		h, err := windows.CreateJobObject(nil, nil)
+		if err != nil {
+			killOnExitJobErr = err
+			return
+		}
+		info := windows.JOBOBJECT_EXTENDED_LIMIT_INFORMATION{
+			BasicLimitInformation: windows.JOBOBJECT_BASIC_LIMIT_INFORMATION{
+				LimitFlags: windows.JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE,
+			},
+		}
+		_, err = windows.SetInformationJobObject(
+			h,
+			windows.JobObjectExtendedLimitInformation,
+			uintptr(unsafe.Pointer(&info)),
+			uint32(unsafe.Sizeof(info)),
+		)
+		if err != nil {
+			windows.CloseHandle(h)
+			killOnExitJobErr = err
+			return
+		}
+		killOnExitJob = h
+	})
+	return killOnExitJob, killOnExitJobErr
+}
+
+func attachChildLifetime(cmd *exec.Cmd) {
+	if cmd == nil || cmd.Process == nil {
+		return
+	}
+	job, err := ensureKillOnExitJob()
+	if err != nil {
+		logger.Warning("mtproto: kill-on-exit job unavailable:", err)
+		return
+	}
+	h, err := windows.OpenProcess(windows.PROCESS_SET_QUOTA|windows.PROCESS_TERMINATE, false, uint32(cmd.Process.Pid))
+	if err != nil {
+		logger.Warning("mtproto: OpenProcess for job attach failed:", err)
+		return
+	}
+	defer windows.CloseHandle(h)
+	if err := windows.AssignProcessToJobObject(job, h); err != nil {
+		logger.Warning("mtproto: AssignProcessToJobObject failed:", err)
+	}
+}

+ 32 - 3
sub/subService.go

@@ -348,10 +348,38 @@ func (s *SubService) GetLink(inbound *model.Inbound, email string) string {
 		return s.genShadowsocksLink(inbound, email)
 	case "hysteria":
 		return s.genHysteriaLink(inbound, email)
+	case "mtproto":
+		return s.genMtprotoLink(inbound, email)
 	}
 	return ""
 }
 
+// genMtprotoLink builds a Telegram proxy deep link for an mtproto inbound:
+// tg://proxy?server=<addr>&port=<port>&secret=<ee FakeTLS secret>.
+func (s *SubService) genMtprotoLink(inbound *model.Inbound, email string) string {
+	if inbound.Protocol != model.MTProto {
+		return ""
+	}
+	settings := map[string]any{}
+	json.Unmarshal([]byte(inbound.Settings), &settings)
+	secret, _ := settings["secret"].(string)
+	if secret == "" {
+		if healed, ok := model.HealMtprotoSecret(inbound.Settings); ok {
+			_ = json.Unmarshal([]byte(healed), &settings)
+			secret, _ = settings["secret"].(string)
+		}
+	}
+	if secret == "" {
+		return ""
+	}
+	params := map[string]string{
+		"server": s.resolveInboundAddress(inbound),
+		"port":   fmt.Sprintf("%d", inbound.Port),
+		"secret": secret,
+	}
+	return buildLinkWithParams("tg://proxy", params, s.genRemark(inbound, email, ""))
+}
+
 // Protocol link generators are intentionally ordered as:
 // vmess -> vless -> trojan -> shadowsocks -> hysteria.
 func (s *SubService) genVmessLink(inbound *model.Inbound, email string) string {
@@ -717,9 +745,10 @@ func (s *SubService) loadNodes() {
 }
 
 // resolveInboundAddress picks the host an external client should connect to:
-//   1. node-managed inbound -> the node's address
-//   2. an explicit, client-reachable bind Listen -> that Listen
-//   3. otherwise the subscriber's request host (s.address)
+//  1. node-managed inbound -> the node's address
+//  2. an explicit, client-reachable bind Listen -> that Listen
+//  3. otherwise the subscriber's request host (s.address)
+//
 // A loopback/wildcard bind or a unix-domain-socket listen is a server-side
 // detail and is never advertised; External Proxy remains the way to advertise
 // an arbitrary endpoint. Mirrors the frontend's resolveAddr so the panel QR and

+ 62 - 0
web/job/mtproto_job.go

@@ -0,0 +1,62 @@
+package job
+
+import (
+	"github.com/mhsanaei/3x-ui/v3/database/model"
+	"github.com/mhsanaei/3x-ui/v3/logger"
+	"github.com/mhsanaei/3x-ui/v3/mtproto"
+	"github.com/mhsanaei/3x-ui/v3/web/service"
+	"github.com/mhsanaei/3x-ui/v3/xray"
+)
+
+// MtprotoJob reconciles the running mtg sidecar processes against the enabled
+// mtproto inbounds in the database, restarts any that crashed, and folds the
+// per-inbound traffic scraped from each mtg metrics endpoint into the usual
+// inbound traffic accounting.
+type MtprotoJob struct {
+	inboundService service.InboundService
+}
+
+// NewMtprotoJob creates a new mtproto reconcile/traffic job instance.
+func NewMtprotoJob() *MtprotoJob {
+	return new(MtprotoJob)
+}
+
+// Run reconciles desired mtproto inbounds with running mtg processes and
+// records traffic deltas.
+func (j *MtprotoJob) Run() {
+	inbounds, err := j.inboundService.GetAllInbounds()
+	if err != nil {
+		logger.Warning("mtproto job: get inbounds failed:", err)
+		return
+	}
+
+	var desired []mtproto.Instance
+	for _, ib := range inbounds {
+		if ib.Protocol != model.MTProto || !ib.Enable || ib.NodeID != nil {
+			continue
+		}
+		if inst, ok := mtproto.InstanceFromInbound(ib); ok {
+			desired = append(desired, inst)
+		}
+	}
+
+	mgr := mtproto.GetManager()
+	mgr.Reconcile(desired)
+
+	deltas := mgr.CollectTraffic()
+	if len(deltas) == 0 {
+		return
+	}
+	traffics := make([]*xray.Traffic, 0, len(deltas))
+	for _, d := range deltas {
+		traffics = append(traffics, &xray.Traffic{
+			IsInbound: true,
+			Tag:       d.Tag,
+			Up:        d.Up,
+			Down:      d.Down,
+		})
+	}
+	if _, _, err := j.inboundService.AddTraffic(traffics, nil); err != nil {
+		logger.Warning("mtproto job: add traffic failed:", err)
+	}
+}

+ 18 - 0
web/runtime/local.go

@@ -8,6 +8,7 @@ import (
 	"sync"
 
 	"github.com/mhsanaei/3x-ui/v3/database/model"
+	"github.com/mhsanaei/3x-ui/v3/mtproto"
 	"github.com/mhsanaei/3x-ui/v3/xray"
 )
 
@@ -44,6 +45,13 @@ func (l *Local) withAPI(fn func(api *xray.XrayAPI) error) error {
 }
 
 func (l *Local) AddInbound(_ context.Context, ib *model.Inbound) error {
+	if ib.Protocol == model.MTProto {
+		inst, ok := mtproto.InstanceFromInbound(ib)
+		if !ok {
+			return nil
+		}
+		return mtproto.GetManager().Ensure(inst)
+	}
 	body, err := json.MarshalIndent(ib.GenXrayInboundConfig(), "", "  ")
 	if err != nil {
 		return err
@@ -54,6 +62,10 @@ func (l *Local) AddInbound(_ context.Context, ib *model.Inbound) error {
 }
 
 func (l *Local) DelInbound(_ context.Context, ib *model.Inbound) error {
+	if ib.Protocol == model.MTProto {
+		mtproto.GetManager().Remove(ib.Id)
+		return nil
+	}
 	return l.withAPI(func(api *xray.XrayAPI) error {
 		return api.DelInbound(ib.Tag)
 	})
@@ -68,12 +80,18 @@ func (l *Local) UpdateInbound(ctx context.Context, oldIb, newIb *model.Inbound)
 }
 
 func (l *Local) AddUser(_ context.Context, ib *model.Inbound, userMap map[string]any) error {
+	if ib.Protocol == model.MTProto {
+		return nil
+	}
 	return l.withAPI(func(api *xray.XrayAPI) error {
 		return api.AddUser(string(ib.Protocol), ib.Tag, userMap)
 	})
 }
 
 func (l *Local) RemoveUser(_ context.Context, ib *model.Inbound, email string) error {
+	if ib.Protocol == model.MTProto {
+		return nil
+	}
 	return l.withAPI(func(api *xray.XrayAPI) error {
 		return api.RemoveUser(ib.Tag, email)
 	})

+ 4 - 1
web/service/api_scale_postgres_test.go

@@ -101,7 +101,10 @@ func TestAllAPIsPostgresScale(t *testing.T) {
 			run("GetInboundsSlim", func() error { _, err := inboundSvc.GetInboundsSlim(userId); return err })
 			run("GetInboundDetail", func() error { _, err := inboundSvc.GetInboundDetail(ib.Id); return err })
 			run("GetInboundOptions", func() error { _, err := inboundSvc.GetInboundOptions(userId); return err })
-			run("ListPaged", func() error { _, err := svc.ListPaged(inboundSvc, settingSvc, ClientPageParams{Page: 1, PageSize: 25}); return err })
+			run("ListPaged", func() error {
+				_, err := svc.ListPaged(inboundSvc, settingSvc, ClientPageParams{Page: 1, PageSize: 25})
+				return err
+			})
 			run("ListPaged+search", func() error {
 				_, err := svc.ListPaged(inboundSvc, settingSvc, ClientPageParams{Page: 1, PageSize: 25, Search: "user-0012345"})
 				return err

+ 13 - 0
web/service/inbound.go

@@ -580,6 +580,17 @@ func (s *InboundService) normalizeStreamSettings(inbound *model.Inbound) {
 	}
 }
 
+// normalizeMtprotoSecret rebuilds an mtproto inbound's FakeTLS secret so it is
+// always valid and matches the configured domain before the row is persisted.
+func (s *InboundService) normalizeMtprotoSecret(inbound *model.Inbound) {
+	if inbound.Protocol != model.MTProto {
+		return
+	}
+	if healed, ok := model.HealMtprotoSecret(inbound.Settings); ok {
+		inbound.Settings = healed
+	}
+}
+
 // AddInbound creates a new inbound configuration.
 // It validates port uniqueness, client email uniqueness, and required fields,
 // then saves the inbound to the database and optionally adds it to the running Xray instance.
@@ -587,6 +598,7 @@ func (s *InboundService) normalizeStreamSettings(inbound *model.Inbound) {
 func (s *InboundService) AddInbound(inbound *model.Inbound) (*model.Inbound, bool, error) {
 	// Normalize streamSettings based on protocol
 	s.normalizeStreamSettings(inbound)
+	s.normalizeMtprotoSecret(inbound)
 
 	conflict, err := s.checkPortConflict(inbound, 0)
 	if err != nil {
@@ -902,6 +914,7 @@ func (s *InboundService) SetInboundEnable(id int, enable bool) (bool, error) {
 func (s *InboundService) UpdateInbound(inbound *model.Inbound) (*model.Inbound, bool, error) {
 	// Normalize streamSettings based on protocol
 	s.normalizeStreamSettings(inbound)
+	s.normalizeMtprotoSecret(inbound)
 
 	conflict, err := s.checkPortConflict(inbound, inbound.Id)
 	if err != nil {

+ 2 - 0
web/service/port_conflict.go

@@ -22,6 +22,8 @@ func inboundTransports(protocol model.Protocol, streamSettings, settings string)
 	switch protocol {
 	case model.Hysteria, model.WireGuard:
 		return transportUDP
+	case model.MTProto:
+		return transportTCP
 	}
 
 	var bits transportBits

+ 3 - 0
web/service/xray.go

@@ -122,6 +122,9 @@ func (s *XrayService) GetXrayConfig() (*xray.Config, error) {
 		if inbound.NodeID != nil {
 			continue
 		}
+		if inbound.Protocol == model.MTProto {
+			continue
+		}
 		settings := map[string]any{}
 		json.Unmarshal([]byte(inbound.Settings), &settings)
 

+ 3 - 0
web/translation/ar-EG.json

@@ -501,6 +501,9 @@
         "accounts": "الحسابات",
         "allowTransparent": "السماح بالشفاف",
         "encryptionMethod": "طريقة التشفير",
+        "fakeTlsDomain": "نطاق FakeTLS (SNI)",
+        "mtprotoSecret": "المفتاح السري",
+        "mtprotoHint": "يتم تقديم MTProto عبر عملية mtg منفصلة وليس Xray. إعدادات النقل والعملاء لا تنطبق هنا — شارك الرابط أدناه مع تيليجرام.",
         "visionTestseed": "Vision testseed",
         "version": "الإصدار",
         "udpIdleTimeout": "UDP idle timeout (ثانية)",

+ 3 - 0
web/translation/en-US.json

@@ -502,6 +502,9 @@
         "accounts": "Accounts",
         "allowTransparent": "Allow transparent",
         "encryptionMethod": "Encryption method",
+        "fakeTlsDomain": "FakeTLS domain (SNI)",
+        "mtprotoSecret": "Secret",
+        "mtprotoHint": "MTProto is served by a separate mtg process, not Xray. Stream settings and clients do not apply here — share the link below with Telegram.",
         "visionTestseed": "Vision testseed",
         "version": "Version",
         "udpIdleTimeout": "UDP idle timeout (s)",

+ 3 - 0
web/translation/es-ES.json

@@ -501,6 +501,9 @@
         "accounts": "Cuentas",
         "allowTransparent": "Permitir transparente",
         "encryptionMethod": "Método de cifrado",
+        "fakeTlsDomain": "Dominio FakeTLS (SNI)",
+        "mtprotoSecret": "Secreto",
+        "mtprotoHint": "MTProto se sirve mediante un proceso mtg independiente, no Xray. Los ajustes de transporte y los clientes no aplican aquí; comparte el enlace de abajo con Telegram.",
         "visionTestseed": "Vision testseed",
         "version": "Versión",
         "udpIdleTimeout": "UDP idle timeout (s)",

+ 3 - 0
web/translation/fa-IR.json

@@ -501,6 +501,9 @@
         "accounts": "حساب‌ها",
         "allowTransparent": "اجازه شفاف",
         "encryptionMethod": "روش رمزنگاری",
+        "fakeTlsDomain": "دامنه FakeTLS (SNI)",
+        "mtprotoSecret": "کلید مخفی",
+        "mtprotoHint": "پروتکل MTProto توسط یک پردازش جداگانه mtg ارائه می‌شود، نه Xray. تنظیمات انتقال و کلاینت‌ها اینجا کاربرد ندارند — لینک زیر را با تلگرام به اشتراک بگذارید.",
         "visionTestseed": "Vision testseed",
         "version": "نسخه",
         "udpIdleTimeout": "UDP idle timeout (s)",

+ 3 - 0
web/translation/id-ID.json

@@ -501,6 +501,9 @@
         "accounts": "Akun",
         "allowTransparent": "Izinkan transparan",
         "encryptionMethod": "Metode enkripsi",
+        "fakeTlsDomain": "Domain FakeTLS (SNI)",
+        "mtprotoSecret": "Secret",
+        "mtprotoHint": "MTProto dijalankan oleh proses mtg terpisah, bukan Xray. Pengaturan stream dan klien tidak berlaku di sini — bagikan tautan di bawah ke Telegram.",
         "visionTestseed": "Vision testseed",
         "version": "Versi",
         "udpIdleTimeout": "UDP idle timeout (d)",

+ 3 - 0
web/translation/ja-JP.json

@@ -501,6 +501,9 @@
         "accounts": "アカウント",
         "allowTransparent": "透過を許可",
         "encryptionMethod": "暗号化方式",
+        "fakeTlsDomain": "FakeTLS ドメイン (SNI)",
+        "mtprotoSecret": "シークレット",
+        "mtprotoHint": "MTProto は Xray ではなく独立した mtg プロセスで提供されます。ストリーム設定とクライアントはここでは適用されません。下のリンクを Telegram で共有してください。",
         "visionTestseed": "Vision testseed",
         "version": "バージョン",
         "udpIdleTimeout": "UDP idle timeout (秒)",

+ 3 - 0
web/translation/pt-BR.json

@@ -501,6 +501,9 @@
         "accounts": "Contas",
         "allowTransparent": "Permitir transparente",
         "encryptionMethod": "Método de criptografia",
+        "fakeTlsDomain": "Domínio FakeTLS (SNI)",
+        "mtprotoSecret": "Segredo",
+        "mtprotoHint": "O MTProto é servido por um processo mtg separado, não pelo Xray. As configurações de transporte e os clientes não se aplicam aqui — compartilhe o link abaixo com o Telegram.",
         "visionTestseed": "Vision testseed",
         "version": "Versão",
         "udpIdleTimeout": "UDP idle timeout (s)",

+ 3 - 0
web/translation/ru-RU.json

@@ -501,6 +501,9 @@
         "accounts": "Аккаунты",
         "allowTransparent": "Разрешить прозрачный",
         "encryptionMethod": "Метод шифрования",
+        "fakeTlsDomain": "Домен FakeTLS (SNI)",
+        "mtprotoSecret": "Секрет",
+        "mtprotoHint": "MTProto обслуживается отдельным процессом mtg, а не Xray. Настройки транспорта и клиенты здесь не применяются — поделитесь ссылкой ниже в Telegram.",
         "visionTestseed": "Vision testseed",
         "version": "Версия",
         "udpIdleTimeout": "UDP idle timeout (с)",

+ 3 - 0
web/translation/tr-TR.json

@@ -501,6 +501,9 @@
         "accounts": "Hesaplar",
         "allowTransparent": "Şeffafa izin ver",
         "encryptionMethod": "Şifreleme yöntemi",
+        "fakeTlsDomain": "FakeTLS alan adı (SNI)",
+        "mtprotoSecret": "Gizli anahtar",
+        "mtprotoHint": "MTProto, Xray değil ayrı bir mtg işlemi tarafından sunulur. Aktarım ayarları ve istemciler burada geçerli değildir — aşağıdaki bağlantıyı Telegram ile paylaşın.",
         "visionTestseed": "Vision testseed",
         "version": "Sürüm",
         "udpIdleTimeout": "UDP idle timeout (s)",

+ 3 - 0
web/translation/uk-UA.json

@@ -501,6 +501,9 @@
         "accounts": "Акаунти",
         "allowTransparent": "Дозволити прозорий",
         "encryptionMethod": "Метод шифрування",
+        "fakeTlsDomain": "Домен FakeTLS (SNI)",
+        "mtprotoSecret": "Секрет",
+        "mtprotoHint": "MTProto обслуговується окремим процесом mtg, а не Xray. Налаштування транспорту та клієнти тут не застосовуються — поділіться посиланням нижче в Telegram.",
         "visionTestseed": "Vision testseed",
         "version": "Версія",
         "udpIdleTimeout": "UDP idle timeout (с)",

+ 3 - 0
web/translation/vi-VN.json

@@ -501,6 +501,9 @@
         "accounts": "Tài khoản",
         "allowTransparent": "Cho phép trong suốt",
         "encryptionMethod": "Phương thức mã hóa",
+        "fakeTlsDomain": "Tên miền FakeTLS (SNI)",
+        "mtprotoSecret": "Khóa bí mật",
+        "mtprotoHint": "MTProto được phục vụ bởi một tiến trình mtg riêng, không phải Xray. Cài đặt truyền tải và máy khách không áp dụng ở đây — hãy chia sẻ liên kết bên dưới với Telegram.",
         "visionTestseed": "Vision testseed",
         "version": "Phiên bản",
         "udpIdleTimeout": "UDP idle timeout (s)",

+ 3 - 0
web/translation/zh-CN.json

@@ -501,6 +501,9 @@
         "accounts": "账户",
         "allowTransparent": "允许透明",
         "encryptionMethod": "加密方法",
+        "fakeTlsDomain": "FakeTLS 域名 (SNI)",
+        "mtprotoSecret": "密钥",
+        "mtprotoHint": "MTProto 由独立的 mtg 进程提供服务,而非 Xray。传输设置和客户端在此不适用——请将下方链接分享到 Telegram。",
         "visionTestseed": "Vision testseed",
         "version": "版本",
         "udpIdleTimeout": "UDP 空闲超时 (s)",

+ 3 - 0
web/translation/zh-TW.json

@@ -501,6 +501,9 @@
         "accounts": "帳號",
         "allowTransparent": "允許透明",
         "encryptionMethod": "加密方法",
+        "fakeTlsDomain": "FakeTLS 網域 (SNI)",
+        "mtprotoSecret": "金鑰",
+        "mtprotoHint": "MTProto 由獨立的 mtg 程序提供服務,而非 Xray。傳輸設定與用戶端在此不適用——請將下方連結分享至 Telegram。",
         "visionTestseed": "Vision testseed",
         "version": "版本",
         "udpIdleTimeout": "UDP 閒置逾時 (s)",

+ 6 - 0
web/web.go

@@ -17,6 +17,7 @@ import (
 
 	"github.com/mhsanaei/3x-ui/v3/config"
 	"github.com/mhsanaei/3x-ui/v3/logger"
+	"github.com/mhsanaei/3x-ui/v3/mtproto"
 	"github.com/mhsanaei/3x-ui/v3/util/common"
 	"github.com/mhsanaei/3x-ui/v3/web/controller"
 	"github.com/mhsanaei/3x-ui/v3/web/job"
@@ -281,6 +282,10 @@ func (s *Server) startTask(restartXray bool) {
 		s.cron.AddJob("@every 5s", job.NewXrayTrafficJob())
 	}()
 
+	// Reconcile mtproto (mtg) sidecars and scrape their traffic
+	s.cron.AddJob("@every 10s", job.NewMtprotoJob())
+	go job.NewMtprotoJob().Run()
+
 	// check client ips from log file every 10 sec
 	s.cron.AddJob("@every 10s", job.NewCheckClientIpJob())
 
@@ -465,6 +470,7 @@ func (s *Server) stop(stopXray bool, stopTgBot bool) error {
 	s.cancel()
 	if stopXray {
 		s.xrayService.StopXray()
+		mtproto.GetManager().StopAll()
 	}
 	if s.cron != nil {
 		s.cron.Stop()