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

* 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

* fix

* fix

* fix: address Copilot review comments on mtproto PR

- web/web.go: create NewMtprotoJob once and reuse for cron + initial run
- mtproto/manager.go: StopAll cleans up per-inbound config files on shutdown
- mtproto/manager.go: CollectTraffic releases mutex before HTTP scrapes to
  avoid blocking Ensure/Reconcile/Remove during network I/O
- database/model/model.go: panic on crypto/rand failure in mtprotoRandomMiddle
  instead of silently producing a weak all-zero secret
- install.sh: fix chmod to handle renamed bin/mtg-linux-arm on armv5/v6/v7
This commit is contained in:
Sanaei
2026-06-08 14:28:19 +02:00
committed by GitHub
parent af3c808444
commit 1ca5924a44
46 changed files with 1381 additions and 9 deletions

62
web/job/mtproto_job.go Normal file
View File

@@ -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)
}
}

View File

@@ -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)
})

View File

@@ -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

View File

@@ -621,6 +621,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.
@@ -628,6 +639,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 {
@@ -943,6 +955,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 {

View File

@@ -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

View File

@@ -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)

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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,11 @@ func (s *Server) startTask(restartXray bool) {
s.cron.AddJob("@every 5s", job.NewXrayTrafficJob())
}()
// Reconcile mtproto (mtg) sidecars and scrape their traffic
mtJob := job.NewMtprotoJob()
s.cron.AddJob("@every 10s", mtJob)
go mtJob.Run()
// check client ips from log file every 10 sec
s.cron.AddJob("@every 10s", job.NewCheckClientIpJob())
@@ -465,6 +471,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()