فهرست منبع

fix(mtproto): stop dropping connections on client/inbound edits; add live updates + ad-tag (#5838)

* fix(mtproto): split the mtg fingerprint into structural and secrets parts

A reordered clients array in the stored settings used to read as a config
change because the fingerprint concatenated secrets in array order, and one
opaque fingerprint could not tell a restart-worthy change (bind address,
fronting, throttle) from a secret-set change a reload-capable mtg can absorb
in place. Sort the secret pairs so order stops mattering, and split the value
so the upcoming hot-reload path can decide between keeping, reloading, and
restarting the process.

* fix(mtproto): stop restarting mtg on every inbound edit

Saving an mtproto inbound tore down and respawned its mtg sidecar even when
nothing material changed, dropping every live Telegram connection: the update
path pushed DelInbound+AddInbound, and Remove deletes the manager's map entry,
so Ensure's fingerprint no-op gate could never fire. Route mtproto updates
through a single Ensure call so an edit that leaves the generated TOML alone
keeps the process, and only real config changes restart it.

Capturing the pre-edit protocol also fixes a latent leak: changing an
inbound's protocol away from mtproto never stopped the sidecar, because the
snapshot handed to the runtime already carried the new protocol and the
removal took the xray branch, leaving an orphaned mtg holding the port.

An mtproto push failure no longer requests an xray restart - xray cannot fix
the sidecar, and the 10s reconcile job self-heals it.

The regression test fakes mtg by re-executing the test binary, counting
spawns through a pid file: an unchanged save and a remark-only edit must keep
the process, a re-keyed secret must restart it.

* fix(mtproto): exclude depleted clients from the reconcile job to match the sync push

The 10s reconcile job derived mtg secret sets from raw inbound settings while
the interactive push filtered clients through buildRuntimeInboundForAPI, which
drops client_traffics-disabled (depleted or expired) clients. The two paths
therefore disagreed on the fingerprint - each disagreement one needless mtg
restart dropping live connections - and worse, the job kept serving depleted
clients' secrets indefinitely, so running out of traffic never actually cut an
mtproto client's access.

DesiredMtprotoInstances now builds the job's desired state with the same
depletion overlay the push uses (one bulk client_traffics query), drops
inbounds whose every secret is filtered away so their sidecar stops, and
AddInbound pushes the filtered payload too so an imported inbound carrying
disabled stats does not seed a fingerprint the next reconcile disagrees with.

* feat(mtproto): hot-reload mtg secrets in place instead of restarting

A client add, removal, re-key, or enable-toggle changes only the [secrets]
section of the generated config, yet the panel could apply it only by killing
and respawning the mtg sidecar, dropping every Telegram connection on that
inbound. Split the ensure decision three ways: an identical config is a no-op,
a secrets-only change rewrites the TOML on the same api port and asks mtg to
hot-swap it via POST /reload, and a structural change (or a failed reload)
falls back to the full stop-and-start.

The reload endpoint is served by the mhsanaei/mtg-multi fork; against an older
binary the POST 404s and the manager restarts exactly as before, so panel and
binary upgrades stay order-independent.

* feat(mtproto): apply single-client edits to the sidecar immediately

Client CRUD on an mtproto inbound was a runtime no-op, so an add, delete,
re-key, or enable-toggle only reached mtg on the next 10s reconcile. With the
sidecar now able to hot-reload, push the change straight after the edit commits:
applyLocalMtproto rebuilds the inbound's filtered client set and re-applies it,
so a new client works within a moment (and, on a reload-capable binary, without
disturbing the others) and deleting the last client stops the process.

The three interactive single-client paths (add, update, delete) call it; bulk
operations still ride the reconcile job, which converges to the same state.

* chore(mtproto): pin mtg-multi to the mhsanaei fork v1.13.3

The reload endpoint the panel now uses lives in the mhsanaei/mtg-multi fork, so
point the source-build pin (DockerInit.sh + both release.yml matrices) at it and
bump to v1.13.3. The install still produces the same mtg-multi binary name, so
the mtg-<os>-<arch> rename and everything downstream are unchanged. Docs and the
package comment note the hot-reload path and its restart fallback.

* feat(mtproto): apply live secret updates via the management API and add ad-tag

Two capabilities the mhsanaei/mtg-multi v1.13.3 fork exposes are now surfaced by
the sidecar manager.

Live updates go through PUT /secrets on the fork's management API instead of
POST /reload: the panel already holds the whole desired set per inbound, so it
sends secrets and the advertising tag as one JSON call that mtg applies
atomically, keeping every unchanged connection and closing only removed or
re-keyed ones. The config file is still written first so a restart or crash
recovery reproduces the state, and any non-200 (an older binary, a refused
connection) still falls back to a full restart.

Per-inbound ad-tag adds an optional 32-hex Telegram advertising tag plus
public-ipv4/public-ipv6 overrides. The ad-tag rides the reloadable secrets
fingerprint, so changing it hot-applies without dropping connections; the public
IPs are proxy-construction parameters and sit in the structural fingerprint, so a
change there restarts the process. Empty public IPs are omitted so mtg
auto-detects the reachable address.

* feat(inbounds): expose the mtproto ad-tag and public IP in the inbound form

Adds an Ad-tag field (validated as 32 hex characters) plus optional Public IPv4
and Public IPv6 overrides to the MTProto inbound form, backed by the same-named
settings the sidecar writes into the mtg config. The public IPs are optional —
left blank, mtg auto-detects the reachable address the ad-tag middle proxy needs.
English strings are added to every locale; the non-English ones carry the
English text until translated and fall back to it meanwhile.

* ci(mtproto): install mtg-multi from prebuilt release binaries

The fork now publishes release archives for every platform we package, so
download and unpack the matching mtg-multi-<ver>-<os>-<arch> binary instead of
compiling it from source with go install. Faster builds and no toolchain step,
and the archive's platform labels line up with our matrix; the produced
mtg-<os>-<arch> filenames are unchanged.

* i18n(mtproto): localize the ad-tag and public IP strings

The six mtgAdTag*/mtgPublicIp* keys shipped with English text in every locale as
a placeholder. Translate them into the twelve non-English locales (Arabic,
Spanish, Persian, Indonesian, Japanese, Portuguese-BR, Russian, Turkish,
Ukrainian, Vietnamese, and Simplified/Traditional Chinese); en-US is unchanged.

* retired goreportcard.com
Sanaei 23 ساعت پیش
والد
کامیت
6214ff4edc
40فایلهای تغییر یافته به همراه1163 افزوده شده و 94 حذف شده
  1. 17 20
      .github/workflows/release.yml
  2. 4 2
      CLAUDE.md
  3. 13 5
      DockerInit.sh
  4. 0 1
      README.ar_EG.md
  5. 0 1
      README.es_ES.md
  6. 0 1
      README.fa_IR.md
  7. 0 1
      README.md
  8. 0 1
      README.ru_RU.md
  9. 0 1
      README.tr_TR.md
  10. 0 1
      README.zh_CN.md
  11. 4 2
      docs/architecture.md
  12. 24 0
      frontend/src/pages/inbounds/form/protocols/mtproto.tsx
  13. 11 0
      frontend/src/schemas/protocols/inbound/mtproto.ts
  14. 146 20
      internal/mtproto/manager.go
  15. 252 0
      internal/mtproto/manager_reload_test.go
  16. 51 9
      internal/mtproto/manager_test.go
  17. 3 1
      internal/mtproto/process.go
  18. 7 15
      internal/web/job/mtproto_job.go
  19. 32 0
      internal/web/runtime/local.go
  20. 12 3
      internal/web/service/client_inbound_apply.go
  21. 47 10
      internal/web/service/inbound.go
  22. 105 0
      internal/web/service/inbound_mtproto.go
  23. 99 0
      internal/web/service/inbound_mtproto_apply_test.go
  24. 67 0
      internal/web/service/inbound_mtproto_client_test.go
  25. 70 0
      internal/web/service/inbound_mtproto_desired_test.go
  26. 116 0
      internal/web/service/mtproto_fake_test.go
  27. 5 0
      internal/web/service/xray_config_inject_test.go
  28. 6 0
      internal/web/translation/ar-EG.json
  29. 6 0
      internal/web/translation/en-US.json
  30. 6 0
      internal/web/translation/es-ES.json
  31. 6 0
      internal/web/translation/fa-IR.json
  32. 6 0
      internal/web/translation/id-ID.json
  33. 6 0
      internal/web/translation/ja-JP.json
  34. 6 0
      internal/web/translation/pt-BR.json
  35. 6 0
      internal/web/translation/ru-RU.json
  36. 6 0
      internal/web/translation/tr-TR.json
  37. 6 0
      internal/web/translation/uk-UA.json
  38. 6 0
      internal/web/translation/vi-VN.json
  39. 6 0
      internal/web/translation/zh-CN.json
  40. 6 0
      internal/web/translation/zh-TW.json

+ 17 - 20
.github/workflows/release.yml

@@ -156,19 +156,17 @@ 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-multi (MTProto sidecar) is pure Go, so build it from source for the
-          # target arch (GOOS/GOARCH/GOARM are already exported above). Its release
-          # binaries only cover linux/darwin amd64/arm64; skip s390x/armv5 as before.
-          # go install rejects GOBIN for cross-compiled targets, so let it install
-          # into GOPATH/bin (a GOOS_GOARCH subdir when cross-compiling) and fetch
-          # the binary from there.
-          MTG_MULTI_VER="v1.11.0"
+          # mtg-multi (MTProto sidecar) ships prebuilt release binaries whose
+          # platform labels match our matrix, so download and unpack the matching
+          # archive. Only the platforms the fork publishes are packaged.
+          MTG_MULTI_VER="v1.13.3"
           case "${{ matrix.platform }}" in
             amd64|arm64|armv7|armv6|386)
-              CGO_ENABLED=0 go install -trimpath -ldflags "-s -w" "github.com/dolonet/mtg-multi@${MTG_MULTI_VER}"
-              MTG_BIN=$(find "$(go env GOPATH)/bin" -type f -name mtg-multi | head -n1)
-              [ -n "$MTG_BIN" ] || { echo "mtg-multi binary not found under $(go env GOPATH)/bin" >&2; exit 1; }
-              mv "$MTG_BIN" "mtg-linux-${{ matrix.platform }}"
+              MTG_PKG="mtg-multi-${MTG_MULTI_VER#v}-linux-${{ matrix.platform }}"
+              curl -sfLRO "https://github.com/mhsanaei/mtg-multi/releases/download/${MTG_MULTI_VER}/${MTG_PKG}.tar.gz"
+              tar -xzf "${MTG_PKG}.tar.gz"
+              mv "${MTG_PKG}/mtg-multi" "mtg-linux-${{ matrix.platform }}"
+              rm -rf "${MTG_PKG}" "${MTG_PKG}.tar.gz"
               ;;
           esac
           cd ../..
@@ -285,15 +283,14 @@ jobs:
           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
 
-          # mtg-multi (MTProto sidecar) is pure Go; build it from source since the
-          # fork publishes no Windows release binary.
-          $MTG_MULTI_VER = "v1.11.0"
-          $env:CGO_ENABLED = "0"
-          $env:GOOS = "windows"
-          $env:GOARCH = "amd64"
-          $env:GOBIN = (Get-Location).Path
-          go install -trimpath -ldflags "-s -w" "github.com/dolonet/mtg-multi@$MTG_MULTI_VER"
-          Move-Item "mtg-multi.exe" "mtg-windows-amd64.exe"
+          # mtg-multi (MTProto sidecar) publishes a prebuilt Windows binary, so
+          # download and unpack it instead of compiling.
+          $MTG_MULTI_VER = "v1.13.3"
+          $MTG_PKG = "mtg-multi-$($MTG_MULTI_VER.TrimStart('v'))-windows-amd64"
+          curl.exe -sfLRO "https://github.com/mhsanaei/mtg-multi/releases/download/$MTG_MULTI_VER/$MTG_PKG.zip"
+          Expand-Archive -Path "$MTG_PKG.zip" -DestinationPath "mtg-tmp" -Force
+          Move-Item "mtg-tmp/$MTG_PKG/mtg-multi.exe" "mtg-windows-amd64.exe"
+          Remove-Item -Recurse -Force "mtg-tmp", "$MTG_PKG.zip"
 
           cd ..
           Copy-Item -Path ..\windows_files\* -Destination . -Recurse

+ 4 - 2
CLAUDE.md

@@ -12,9 +12,11 @@ file locations when it can answer in one hop.
   Runs Xray-core as a managed child process (`internal/xray/process.go`) and
   imports `github.com/xtls/xray-core` for config types + gRPC stats/handler/router
   API. MTProto inbounds run a second managed child — the `mtg-multi` binary
-  (`github.com/dolonet/mtg-multi`, a multi-secret fork built from source;
+  (`github.com/mhsanaei/mtg-multi`, a multi-secret fork built from source;
   `internal/mtproto/`) — outside Xray, one process per inbound serving each
-  client's FakeTLS secret via the fork's `[secrets]` section.
+  client's FakeTLS secret via the fork's `[secrets]` section. Client edits are
+  hot-applied through the fork's `POST /reload` so connections survive; the
+  manager falls back to a process restart on older binaries.
 - Storage: SQLite by default (`/etc/x-ui/x-ui.db` on Linux; the executable dir on
   Windows), PostgreSQL optional (`XUI_DB_TYPE` / `XUI_DB_DSN`). The CGo SQLite
   driver (`mattn/go-sqlite3`) needs a C compiler — `CGO_ENABLED=0` builds fail.

+ 13 - 5
DockerInit.sh

@@ -25,17 +25,25 @@ case $1 in
         FNAME="amd64"
         ;;
 esac
-MTG_MULTI_VER="v1.11.0"
+MTG_MULTI_VER="v1.13.3"
 mkdir -p build/bin
 cd build/bin
 curl -sfLRO "https://github.com/XTLS/Xray-core/releases/download/v26.6.27/Xray-linux-${ARCH}.zip"
 unzip "Xray-linux-${ARCH}.zip"
 rm -f "Xray-linux-${ARCH}.zip" geoip.dat geosite.dat
 mv xray "xray-linux-${FNAME}"
-# mtg-multi (MTProto sidecar) is pure Go, so build it from source for the target
-# arch — its release binaries only cover linux/darwin amd64/arm64.
-CGO_ENABLED=0 GOBIN="$(pwd)" go install -trimpath -ldflags "-s -w" "github.com/dolonet/mtg-multi@${MTG_MULTI_VER}"
-mv mtg-multi "mtg-linux-${FNAME}"
+# mtg-multi (MTProto sidecar) ships prebuilt release binaries for every target
+# we package, so download and unpack the matching one instead of compiling.
+case $FNAME in
+    i386) MTGARCH="386" ;;
+    arm32) MTGARCH="armv7" ;;
+    *) MTGARCH="$FNAME" ;;
+esac
+MTG_PKG="mtg-multi-${MTG_MULTI_VER#v}-linux-${MTGARCH}"
+curl -sfLRO "https://github.com/mhsanaei/mtg-multi/releases/download/${MTG_MULTI_VER}/${MTG_PKG}.tar.gz"
+tar -xzf "${MTG_PKG}.tar.gz"
+mv "${MTG_PKG}/mtg-multi" "mtg-linux-${FNAME}"
+rm -rf "${MTG_PKG}" "${MTG_PKG}.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

+ 0 - 1
README.ar_EG.md

@@ -14,7 +14,6 @@
   <a href="https://github.com/MHSanaei/3x-ui/releases/latest"><img src="https://img.shields.io/github/downloads/mhsanaei/3x-ui/total.svg" alt="Downloads"></a>
   <a href="https://www.gnu.org/licenses/gpl-3.0.en.html"><img src="https://img.shields.io/badge/license-GPL%20V3-blue.svg?longCache=true" alt="License"></a>
   <a href="https://pkg.go.dev/github.com/mhsanaei/3x-ui/v3"><img src="https://pkg.go.dev/badge/github.com/mhsanaei/3x-ui/v3.svg" alt="Go Reference"></a>
-  <a href="https://goreportcard.com/report/github.com/mhsanaei/3x-ui/v3"><img src="https://goreportcard.com/badge/github.com/mhsanaei/3x-ui/v3" alt="Go Report Card"></a>
 </p>
 
 **3X-UI** هي لوحة تحكم ويب متقدمة ومفتوحة المصدر لإدارة خوادم [Xray-core](https://github.com/XTLS/Xray-core). توفّر واجهة نظيفة ومتعددة اللغات لنشر وتكوين ومراقبة مجموعة واسعة من بروتوكولات الوكيل وVPN — من خادم VPS واحد إلى عمليات النشر متعددة العقد.

+ 0 - 1
README.es_ES.md

@@ -14,7 +14,6 @@
   <a href="https://github.com/MHSanaei/3x-ui/releases/latest"><img src="https://img.shields.io/github/downloads/mhsanaei/3x-ui/total.svg" alt="Downloads"></a>
   <a href="https://www.gnu.org/licenses/gpl-3.0.en.html"><img src="https://img.shields.io/badge/license-GPL%20V3-blue.svg?longCache=true" alt="License"></a>
   <a href="https://pkg.go.dev/github.com/mhsanaei/3x-ui/v3"><img src="https://pkg.go.dev/badge/github.com/mhsanaei/3x-ui/v3.svg" alt="Go Reference"></a>
-  <a href="https://goreportcard.com/report/github.com/mhsanaei/3x-ui/v3"><img src="https://goreportcard.com/badge/github.com/mhsanaei/3x-ui/v3" alt="Go Report Card"></a>
 </p>
 
 **3X-UI** es un panel de control web avanzado y de código abierto para gestionar servidores [Xray-core](https://github.com/XTLS/Xray-core). Ofrece una interfaz limpia y multilingüe para desplegar, configurar y monitorear una amplia gama de protocolos de proxy y VPN — desde un único VPS hasta despliegues multinodo.

+ 0 - 1
README.fa_IR.md

@@ -14,7 +14,6 @@
   <a href="https://github.com/MHSanaei/3x-ui/releases/latest"><img src="https://img.shields.io/github/downloads/mhsanaei/3x-ui/total.svg" alt="Downloads"></a>
   <a href="https://www.gnu.org/licenses/gpl-3.0.en.html"><img src="https://img.shields.io/badge/license-GPL%20V3-blue.svg?longCache=true" alt="License"></a>
   <a href="https://pkg.go.dev/github.com/mhsanaei/3x-ui/v3"><img src="https://pkg.go.dev/badge/github.com/mhsanaei/3x-ui/v3.svg" alt="Go Reference"></a>
-  <a href="https://goreportcard.com/report/github.com/mhsanaei/3x-ui/v3"><img src="https://goreportcard.com/badge/github.com/mhsanaei/3x-ui/v3" alt="Go Report Card"></a>
 </p>
 
 **3X-UI** یک پنل کنترل وب پیشرفته و متن‌باز برای مدیریت سرورهای [Xray-core](https://github.com/XTLS/Xray-core) است. این پنل یک رابط کاربری تمیز و چندزبانه برای استقرار، پیکربندی و نظارت بر طیف گسترده‌ای از پروتکل‌های پراکسی و VPN ارائه می‌دهد — از یک VPS تکی تا استقرارهای چندنودی.

+ 0 - 1
README.md

@@ -14,7 +14,6 @@
   <a href="https://github.com/MHSanaei/3x-ui/releases/latest"><img src="https://img.shields.io/github/downloads/mhsanaei/3x-ui/total.svg" alt="Downloads"></a>
   <a href="https://www.gnu.org/licenses/gpl-3.0.en.html"><img src="https://img.shields.io/badge/license-GPL%20V3-blue.svg?longCache=true" alt="License"></a>
   <a href="https://pkg.go.dev/github.com/mhsanaei/3x-ui/v3"><img src="https://pkg.go.dev/badge/github.com/mhsanaei/3x-ui/v3.svg" alt="Go Reference"></a>
-  <a href="https://goreportcard.com/report/github.com/mhsanaei/3x-ui/v3"><img src="https://goreportcard.com/badge/github.com/mhsanaei/3x-ui/v3" alt="Go Report Card"></a>
 </p>
 
 **3X-UI** is an advanced, open-source web control panel for managing [Xray-core](https://github.com/XTLS/Xray-core) servers. It provides a clean, multi-language interface for deploying, configuring, and monitoring a wide range of proxy and VPN protocols — from a single VPS to multi-node deployments.

+ 0 - 1
README.ru_RU.md

@@ -14,7 +14,6 @@
   <a href="https://github.com/MHSanaei/3x-ui/releases/latest"><img src="https://img.shields.io/github/downloads/mhsanaei/3x-ui/total.svg" alt="Downloads"></a>
   <a href="https://www.gnu.org/licenses/gpl-3.0.en.html"><img src="https://img.shields.io/badge/license-GPL%20V3-blue.svg?longCache=true" alt="License"></a>
   <a href="https://pkg.go.dev/github.com/mhsanaei/3x-ui/v3"><img src="https://pkg.go.dev/badge/github.com/mhsanaei/3x-ui/v3.svg" alt="Go Reference"></a>
-  <a href="https://goreportcard.com/report/github.com/mhsanaei/3x-ui/v3"><img src="https://goreportcard.com/badge/github.com/mhsanaei/3x-ui/v3" alt="Go Report Card"></a>
 </p>
 
 **3X-UI** — продвинутая веб-панель управления с открытым исходным кодом для управления серверами [Xray-core](https://github.com/XTLS/Xray-core). Она предоставляет аккуратный многоязычный интерфейс для развёртывания, настройки и мониторинга широкого спектра протоколов прокси и VPN — от одного VPS до развёртываний с несколькими узлами.

+ 0 - 1
README.tr_TR.md

@@ -14,7 +14,6 @@
   <a href="https://github.com/MHSanaei/3x-ui/releases/latest"><img src="https://img.shields.io/github/downloads/mhsanaei/3x-ui/total.svg" alt="Downloads"></a>
   <a href="https://www.gnu.org/licenses/gpl-3.0.en.html"><img src="https://img.shields.io/badge/license-GPL%20V3-blue.svg?longCache=true" alt="License"></a>
   <a href="https://pkg.go.dev/github.com/mhsanaei/3x-ui/v3"><img src="https://pkg.go.dev/badge/github.com/mhsanaei/3x-ui/v3.svg" alt="Go Reference"></a>
-  <a href="https://goreportcard.com/report/github.com/mhsanaei/3x-ui/v3"><img src="https://goreportcard.com/badge/github.com/mhsanaei/3x-ui/v3" alt="Go Report Card"></a>
 </p>
 
 **3X-UI**, [Xray-core](https://github.com/XTLS/Xray-core) sunucularını yönetmek için geliştirilmiş profesyonel, açık kaynaklı bir web kontrol panelidir. Tek bir sanal sunucudan (VPS) çok düğümlü (multi-node) dağıtımlara kadar çok çeşitli proxy ve VPN protokollerini kurmak, yapılandırmak ve izlemek için temiz, çok dilli bir arayüz sağlar.

+ 0 - 1
README.zh_CN.md

@@ -14,7 +14,6 @@
   <a href="https://github.com/MHSanaei/3x-ui/releases/latest"><img src="https://img.shields.io/github/downloads/mhsanaei/3x-ui/total.svg" alt="Downloads"></a>
   <a href="https://www.gnu.org/licenses/gpl-3.0.en.html"><img src="https://img.shields.io/badge/license-GPL%20V3-blue.svg?longCache=true" alt="License"></a>
   <a href="https://pkg.go.dev/github.com/mhsanaei/3x-ui/v3"><img src="https://pkg.go.dev/badge/github.com/mhsanaei/3x-ui/v3.svg" alt="Go Reference"></a>
-  <a href="https://goreportcard.com/report/github.com/mhsanaei/3x-ui/v3"><img src="https://goreportcard.com/badge/github.com/mhsanaei/3x-ui/v3" alt="Go Report Card"></a>
 </p>
 
 **3X-UI** 是一个先进的开源 Web 控制面板,用于管理 [Xray-core](https://github.com/XTLS/Xray-core) 服务器。它提供简洁、多语言的界面,用于部署、配置和监控各种代理与 VPN 协议——从单台 VPS 到多节点部署。

+ 4 - 2
docs/architecture.md

@@ -20,9 +20,11 @@ WebSocket API. A React SPA (built by Vite, embedded into the Go binary) is the U
 separate HTTP server serves **subscription links** to end users.
 
 The panel supervises **two managed child processes**: Xray-core itself and — when MTProto
-inbounds exist — the `mtg-multi` Telegram-proxy binary (`github.com/dolonet/mtg-multi`, a
+inbounds exist — the `mtg-multi` Telegram-proxy binary (`github.com/mhsanaei/mtg-multi`, a
 multi-secret fork built from source; `internal/mtproto/`). One process per inbound serves
-every attached client's FakeTLS secret through the fork's `[secrets]` section.
+every attached client's FakeTLS secret through the fork's `[secrets]` section. A client edit
+is hot-applied via the fork's `POST /reload` endpoint (connections survive), with a process
+restart as the fallback on older binaries.
 
 Servers and processes, all launched from `main.go`:
 

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

@@ -2,6 +2,8 @@ import { useTranslation } from 'react-i18next';
 import { Form, Input, InputNumber, Select, Switch } from 'antd';
 
 import { useOutboundTags } from '@/api/queries/useOutboundTags';
+import { MtprotoInboundSettingsSchema } from '@/schemas/protocols/inbound/mtproto';
+import { antdRule } from '@/utils/zodForm';
 
 export default function MtprotoFields() {
   const { t } = useTranslation();
@@ -85,6 +87,28 @@ export default function MtprotoFields() {
           />
         </Form.Item>
       )}
+      <Form.Item
+        name={['settings', 'adTag']}
+        label={t('pages.inbounds.form.mtgAdTag')}
+        tooltip={t('pages.inbounds.form.mtgAdTagHint')}
+        rules={[antdRule(MtprotoInboundSettingsSchema.shape.adTag, t)]}
+      >
+        <Input allowClear placeholder="0123456789abcdef0123456789abcdef" />
+      </Form.Item>
+      <Form.Item
+        name={['settings', 'publicIpv4']}
+        label={t('pages.inbounds.form.mtgPublicIpv4')}
+        tooltip={t('pages.inbounds.form.mtgPublicIpHint')}
+      >
+        <Input allowClear placeholder="1.2.3.4" />
+      </Form.Item>
+      <Form.Item
+        name={['settings', 'publicIpv6']}
+        label={t('pages.inbounds.form.mtgPublicIpv6')}
+        tooltip={t('pages.inbounds.form.mtgPublicIpHint')}
+      >
+        <Input allowClear placeholder="2001:db8::1" />
+      </Form.Item>
     </>
   );
 }

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

@@ -52,5 +52,16 @@ export const MtprotoInboundSettingsSchema = z.object({
   routeThroughXray: z.boolean().optional(),
   outboundTag: z.string().optional(),
   routeXrayPort: z.number().int().min(0).max(65535).optional(),
+  // A 32-hex Telegram advertising tag: when set, mtg routes clients through
+  // Telegram middle proxies so a sponsored channel appears in their chat list.
+  // publicIpv4/publicIpv6 pin this server's reachable address the middle proxy
+  // needs; leave them blank to let mtg auto-detect it.
+  adTag: z
+    .string()
+    .regex(/^[0-9a-fA-F]{32}$/, 'pages.inbounds.form.mtgAdTagInvalid')
+    .or(z.literal(''))
+    .optional(),
+  publicIpv4: z.string().optional(),
+  publicIpv6: z.string().optional(),
 });
 export type MtprotoInboundSettings = z.infer<typeof MtprotoInboundSettingsSchema>;

+ 146 - 20
internal/mtproto/manager.go

@@ -1,12 +1,14 @@
 package mtproto
 
 import (
+	"bytes"
 	"context"
 	"encoding/json"
 	"fmt"
 	"net"
 	"net/http"
 	"os"
+	"slices"
 	"strconv"
 	"strings"
 	"sync"
@@ -47,6 +49,16 @@ type Instance struct {
 	// fair-share algorithm; zero disables throttling.
 	ThrottleMaxConnections int
 
+	// AdTag is a 32-hex Telegram advertising tag; when set, mtg routes clients
+	// through Telegram middle proxies so a sponsored channel shows in their chat
+	// list. It is part of the reloadable secret config, so a change is applied
+	// via /reload without dropping connections. PublicIPv4/PublicIPv6 pin the
+	// proxy's reachable address the middle proxy needs; they are omitted when
+	// empty so mtg auto-detects, and a change forces a restart.
+	AdTag      string
+	PublicIPv4 string
+	PublicIPv6 string
+
 	// When RouteThroughXray is set, mtg dials Telegram through the loopback
 	// SOCKS bridge the panel injects into the Xray config at XrayRoutePort, so
 	// the egress obeys the core's routing rules instead of going out directly.
@@ -62,10 +74,11 @@ func (inst Instance) bindTo() string {
 	return fmt.Sprintf("%s:%d", listen, inst.Port)
 }
 
-// fingerprint changes whenever any value that ends up in the generated TOML
-// changes, so ensureLocked restarts mtg when the operator edits a setting or a
-// client is added, removed, disabled, or re-keyed.
-func (inst Instance) fingerprint() string {
+// structuralFingerprint changes whenever a value outside the [secrets] section
+// of the generated TOML changes. Such a change can only be applied by
+// restarting mtg, unlike a secrets-only change, which a reload-capable mtg can
+// absorb in place.
+func (inst Instance) structuralFingerprint() string {
 	parts := []string{
 		inst.bindTo(),
 		strconv.FormatBool(inst.Debug),
@@ -77,11 +90,24 @@ func (inst Instance) fingerprint() string {
 		strconv.Itoa(inst.ThrottleMaxConnections),
 		strconv.FormatBool(inst.RouteThroughXray),
 		strconv.Itoa(inst.XrayRoutePort),
+		inst.PublicIPv4,
+		inst.PublicIPv6,
 	}
+	return strings.Join(parts, "|")
+}
+
+// secretsFingerprint identifies the reloadable secret config regardless of
+// client order, so a reordered clients array in the stored settings does not
+// read as a change. It moves whenever a client is added, removed, disabled, or
+// re-keyed, or the advertising tag changes — all of which mtg applies through
+// /reload without dropping connections.
+func (inst Instance) secretsFingerprint() string {
+	pairs := make([]string, 0, len(inst.Secrets))
 	for _, e := range inst.Secrets {
-		parts = append(parts, e.Name+"="+e.Secret)
+		pairs = append(pairs, e.Name+"="+e.Secret)
 	}
-	return strings.Join(parts, "|")
+	slices.Sort(pairs)
+	return "adtag=" + inst.AdTag + "|" + strings.Join(pairs, "|")
 }
 
 // Traffic is a per-client traffic delta scraped from an mtg /stats endpoint. Tag
@@ -99,11 +125,12 @@ type clientCounters struct {
 }
 
 type managed struct {
-	proc        *Process
-	tag         string
-	fingerprint string
-	apiPort     int
-	last        map[string]clientCounters
+	proc         *Process
+	tag          string
+	structuralFP string
+	secretsFP    string
+	apiPort      int
+	last         map[string]clientCounters
 }
 
 // Manager owns the set of running mtg processes keyed by inbound id.
@@ -150,6 +177,9 @@ func InstanceFromInbound(ib *model.Inbound) (Instance, bool) {
 		ThrottleMaxConnections int    `json:"throttleMaxConnections"`
 		RouteThroughXray       bool   `json:"routeThroughXray"`
 		RouteXrayPort          int    `json:"routeXrayPort"`
+		AdTag                  string `json:"adTag"`
+		PublicIPv4             string `json:"publicIpv4"`
+		PublicIPv6             string `json:"publicIpv6"`
 		Clients                []struct {
 			Email  string `json:"email"`
 			Secret string `json:"secret"`
@@ -184,6 +214,9 @@ func InstanceFromInbound(ib *model.Inbound) (Instance, bool) {
 		ThrottleMaxConnections: parsed.ThrottleMaxConnections,
 		RouteThroughXray:       parsed.RouteThroughXray,
 		XrayRoutePort:          parsed.RouteXrayPort,
+		AdTag:                  strings.TrimSpace(parsed.AdTag),
+		PublicIPv4:             strings.TrimSpace(parsed.PublicIPv4),
+		PublicIPv6:             strings.TrimSpace(parsed.PublicIPv6),
 	}, true
 }
 
@@ -210,15 +243,55 @@ func (m *Manager) sweepOrphansLocked() {
 	}
 }
 
+// ensureAction is what ensureLocked must do to move a running mtg process to a
+// desired instance: leave it alone, hot-reload only its secrets, or fully
+// restart it.
+type ensureAction int
+
+const (
+	ensureNoop ensureAction = iota
+	ensureReload
+	ensureRestart
+)
+
+// ensureActionFor decides how to apply a desired instance to the currently
+// managed process. A structural change (or a dead process) forces a restart; a
+// secrets-only change is a candidate for an in-place reload; identical
+// fingerprints on a live process need nothing.
+func ensureActionFor(running bool, curStructFP, curSecretsFP, newStructFP, newSecretsFP string) ensureAction {
+	if !running || curStructFP != newStructFP {
+		return ensureRestart
+	}
+	if curSecretsFP != newSecretsFP {
+		return ensureReload
+	}
+	return ensureNoop
+}
+
 func (m *Manager) ensureLocked(inst Instance) error {
-	fp := inst.fingerprint()
+	structFP := inst.structuralFingerprint()
+	secFP := inst.secretsFingerprint()
 	if cur, ok := m.procs[inst.Id]; ok {
-		if cur.fingerprint == fp && cur.proc.IsRunning() {
+		switch ensureActionFor(cur.proc.IsRunning(), cur.structuralFP, cur.secretsFP, structFP, secFP) {
+		case ensureNoop:
 			cur.tag = inst.Tag
 			return nil
+		case ensureReload:
+			if err := writeConfig(configPathForID(inst.Id), inst, cur.apiPort); err != nil {
+				return err
+			}
+			if applySecrets(cur.apiPort, inst) {
+				cur.tag = inst.Tag
+				cur.secretsFP = secFP
+				logger.Infof("mtproto: applied secret update to inbound %d in place", inst.Id)
+				return nil
+			}
+			logger.Warningf("mtproto: live secret update unavailable for inbound %d, restarting", inst.Id)
+			fallthrough
+		case ensureRestart:
+			_ = cur.proc.Stop()
+			delete(m.procs, inst.Id)
 		}
-		_ = cur.proc.Stop()
-		delete(m.procs, inst.Id)
 	}
 	apiPort, err := FreeLocalPort()
 	if err != nil {
@@ -233,11 +306,12 @@ func (m *Manager) ensureLocked(inst Instance) error {
 		return err
 	}
 	m.procs[inst.Id] = &managed{
-		proc:        proc,
-		tag:         inst.Tag,
-		fingerprint: fp,
-		apiPort:     apiPort,
-		last:        map[string]clientCounters{},
+		proc:         proc,
+		tag:          inst.Tag,
+		structuralFP: structFP,
+		secretsFP:    secFP,
+		apiPort:      apiPort,
+		last:         map[string]clientCounters{},
 	}
 	logger.Infof("mtproto: started mtg for inbound %d on %s", inst.Id, inst.bindTo())
 	return nil
@@ -386,6 +460,15 @@ func renderConfig(inst Instance, apiPort int) string {
 		fmt.Fprintf(&b, "prefer-ip = %q\n", inst.PreferIP)
 	}
 	fmt.Fprintf(&b, "api-bind-to = \"127.0.0.1:%d\"\n", apiPort)
+	if inst.AdTag != "" {
+		fmt.Fprintf(&b, "ad-tag = %q\n", inst.AdTag)
+	}
+	if inst.PublicIPv4 != "" {
+		fmt.Fprintf(&b, "public-ipv4 = %q\n", inst.PublicIPv4)
+	}
+	if inst.PublicIPv6 != "" {
+		fmt.Fprintf(&b, "public-ipv6 = %q\n", inst.PublicIPv6)
+	}
 	if inst.FrontingIP != "" || inst.FrontingPort > 0 || inst.FrontingProxyProtocol {
 		b.WriteString("\n[domain-fronting]\n")
 		if inst.FrontingIP != "" {
@@ -430,6 +513,49 @@ type statsUser struct {
 	BytesOut    int64 `json:"bytes_out"`
 }
 
+type secretPutEntry struct {
+	Secret string `json:"secret"`
+}
+
+type secretsPutBody struct {
+	Secrets map[string]secretPutEntry `json:"secrets"`
+	AdTag   string                    `json:"ad_tag,omitempty"`
+}
+
+func secretsPayload(inst Instance) secretsPutBody {
+	secrets := make(map[string]secretPutEntry, len(inst.Secrets))
+	for _, e := range inst.Secrets {
+		secrets[e.Name] = secretPutEntry{Secret: e.Secret}
+	}
+	return secretsPutBody{Secrets: secrets, AdTag: inst.AdTag}
+}
+
+// applySecrets pushes the desired secret set and advertising tag to a running
+// mtg-multi through its management API (PUT /secrets on the same loopback port
+// that serves /stats), so a client add, removal, re-key, or ad-tag change is
+// applied in place. mtg keeps every connection whose secret is unchanged and
+// closes only the removed or re-keyed ones. It returns true only on a 200: an
+// older binary without the endpoint (404), a refused connection, or any other
+// status yields false, so the caller falls back to a full restart.
+func applySecrets(port int, inst Instance) bool {
+	body, err := json.Marshal(secretsPayload(inst))
+	if err != nil {
+		return false
+	}
+	client := http.Client{Timeout: 3 * time.Second}
+	req, err := http.NewRequestWithContext(context.Background(), http.MethodPut, fmt.Sprintf("http://127.0.0.1:%d/secrets", port), bytes.NewReader(body))
+	if err != nil {
+		return false
+	}
+	req.Header.Set("Content-Type", "application/json")
+	resp, err := client.Do(req)
+	if err != nil {
+		return false
+	}
+	defer resp.Body.Close()
+	return resp.StatusCode == http.StatusOK
+}
+
 // scrapeStats reads the mtg-multi /stats JSON API and returns the per-user
 // cumulative counters. Best-effort: an unreachable endpoint or unparseable body
 // yields ok=false.

+ 252 - 0
internal/mtproto/manager_reload_test.go

@@ -0,0 +1,252 @@
+package mtproto
+
+import (
+	"encoding/json"
+	"fmt"
+	"net/http"
+	"net/http/httptest"
+	"os"
+	"path/filepath"
+	"strings"
+	"testing"
+	"time"
+)
+
+// TestMain lets the test binary re-exec itself as a stand-in for the mtg
+// child process: with MTG_FAKE_CHILD=1 it records its pid and blocks, so the
+// manager can start and stop it without a real mtg-multi binary.
+func TestMain(m *testing.M) {
+	if os.Getenv("MTG_FAKE_CHILD") == "1" {
+		if f, err := os.OpenFile(os.Getenv("MTG_FAKE_PIDFILE"), os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644); err == nil {
+			fmt.Fprintf(f, "%d\n", os.Getpid())
+			f.Close()
+		}
+		select {}
+	}
+	os.Exit(m.Run())
+}
+
+func installFakeMtg(t *testing.T) string {
+	t.Helper()
+	binDir := t.TempDir()
+	self, err := os.Executable()
+	if err != nil {
+		t.Fatalf("locate test binary: %v", err)
+	}
+	payload, err := os.ReadFile(self)
+	if err != nil {
+		t.Fatalf("read test binary: %v", err)
+	}
+	if err := os.WriteFile(filepath.Join(binDir, GetBinaryName()), payload, 0o755); err != nil {
+		t.Fatalf("install fake mtg: %v", err)
+	}
+	pidFile := filepath.Join(binDir, "mtg-pids.txt")
+	t.Setenv("XUI_BIN_FOLDER", binDir)
+	t.Setenv("MTG_FAKE_CHILD", "1")
+	t.Setenv("MTG_FAKE_PIDFILE", pidFile)
+	return pidFile
+}
+
+func spawnCount(t *testing.T, pidFile string) int {
+	t.Helper()
+	data, err := os.ReadFile(pidFile)
+	if os.IsNotExist(err) {
+		return 0
+	}
+	if err != nil {
+		t.Fatalf("read pid file: %v", err)
+	}
+	return len(strings.Fields(string(data)))
+}
+
+func waitSpawnCount(t *testing.T, pidFile string, want int) {
+	t.Helper()
+	deadline := time.Now().Add(5 * time.Second)
+	for {
+		got := spawnCount(t, pidFile)
+		if got == want {
+			return
+		}
+		if got > want {
+			t.Fatalf("expected %d spawn(s), got %d", want, got)
+		}
+		if time.Now().After(deadline) {
+			t.Fatalf("expected %d spawn(s), still %d after timeout", want, got)
+		}
+		time.Sleep(20 * time.Millisecond)
+	}
+}
+
+func mtgInst(id int, secrets ...SecretEntry) Instance {
+	return Instance{Id: id, Tag: fmt.Sprintf("inbound-%d", id), Listen: "127.0.0.1", Port: 24000 + id, Secrets: secrets}
+}
+
+func TestEnsureActionFor(t *testing.T) {
+	cases := []struct {
+		name                                         string
+		running                                      bool
+		curStruct, curSecrets, newStruct, newSecrets string
+		want                                         ensureAction
+	}{
+		{"dead process restarts", false, "s", "a", "s", "a", ensureRestart},
+		{"structural change restarts", true, "s1", "a", "s2", "a", ensureRestart},
+		{"secrets change reloads", true, "s", "a", "s", "b", ensureReload},
+		{"identical is a noop", true, "s", "a", "s", "a", ensureNoop},
+		{"dead beats a secrets-only change", false, "s", "a", "s", "b", ensureRestart},
+	}
+	for _, tc := range cases {
+		t.Run(tc.name, func(t *testing.T) {
+			if got := ensureActionFor(tc.running, tc.curStruct, tc.curSecrets, tc.newStruct, tc.newSecrets); got != tc.want {
+				t.Fatalf("ensureActionFor = %d, want %d", got, tc.want)
+			}
+		})
+	}
+}
+
+func TestApplySecrets(t *testing.T) {
+	cases := []struct {
+		name   string
+		status int
+		want   bool
+	}{
+		{"ok", http.StatusOK, true},
+		{"not found on old binary", http.StatusNotFound, false},
+		{"bad request", http.StatusBadRequest, false},
+		{"unavailable", http.StatusServiceUnavailable, false},
+	}
+	for _, tc := range cases {
+		t.Run(tc.name, func(t *testing.T) {
+			var gotMethod, gotPath string
+			var gotBody secretsPutBody
+			srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+				gotMethod, gotPath = r.Method, r.URL.Path
+				_ = json.NewDecoder(r.Body).Decode(&gotBody)
+				w.WriteHeader(tc.status)
+			}))
+			defer srv.Close()
+
+			inst := mtgInst(1, SecretEntry{Name: "alice", Secret: "ee01"})
+			inst.AdTag = "0123456789abcdef0123456789abcdef"
+			if got := applySecrets(serverPort(t, srv), inst); got != tc.want {
+				t.Fatalf("applySecrets = %v, want %v", got, tc.want)
+			}
+			if gotMethod != http.MethodPut || gotPath != "/secrets" {
+				t.Fatalf("expected PUT /secrets, got %s %s", gotMethod, gotPath)
+			}
+			if gotBody.Secrets["alice"].Secret != "ee01" || gotBody.AdTag != "0123456789abcdef0123456789abcdef" {
+				t.Fatalf("payload must carry the secret and ad-tag: %+v", gotBody)
+			}
+		})
+	}
+
+	t.Run("refused connection", func(t *testing.T) {
+		srv := httptest.NewServer(http.NotFoundHandler())
+		port := serverPort(t, srv)
+		srv.Close()
+		if applySecrets(port, mtgInst(1, SecretEntry{Name: "a", Secret: "ee"})) {
+			t.Fatal("a refused connection must yield false")
+		}
+	})
+}
+
+func TestEnsureHotReloadKeepsProcess(t *testing.T) {
+	pidFile := installFakeMtg(t)
+	mgr := &Manager{procs: map[int]*managed{}, swept: true}
+
+	inst := mtgInst(1, SecretEntry{Name: "alice", Secret: "ee01"})
+	if err := mgr.Ensure(inst); err != nil {
+		t.Fatalf("initial ensure: %v", err)
+	}
+	waitSpawnCount(t, pidFile, 1)
+	orig := mgr.procs[1].proc
+
+	reloaded := make(chan struct{}, 1)
+	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		if r.Method == http.MethodPut && r.URL.Path == "/secrets" {
+			reloaded <- struct{}{}
+			w.WriteHeader(http.StatusOK)
+			return
+		}
+		http.NotFound(w, r)
+	}))
+	defer srv.Close()
+	mgr.procs[1].apiPort = serverPort(t, srv)
+
+	rekeyed := mtgInst(1, SecretEntry{Name: "alice", Secret: "ee01"}, SecretEntry{Name: "bob", Secret: "ee02"})
+	if err := mgr.Ensure(rekeyed); err != nil {
+		t.Fatalf("reload ensure: %v", err)
+	}
+
+	select {
+	case <-reloaded:
+	case <-time.After(3 * time.Second):
+		t.Fatal("expected a PUT /secrets request")
+	}
+	if got := spawnCount(t, pidFile); got != 1 {
+		t.Fatalf("hot reload must not spawn a new process, got %d", got)
+	}
+	if mgr.procs[1].proc != orig {
+		t.Fatal("hot reload must keep the same process")
+	}
+	if mgr.procs[1].secretsFP != rekeyed.secretsFingerprint() {
+		t.Fatal("stored secrets fingerprint must advance after a reload")
+	}
+	cfg, err := os.ReadFile(configPathForID(1))
+	if err != nil {
+		t.Fatalf("read config: %v", err)
+	}
+	if !strings.Contains(string(cfg), `"bob" = "ee02"`) {
+		t.Fatalf("reloaded config must carry the new secret:\n%s", cfg)
+	}
+	if !strings.Contains(string(cfg), fmt.Sprintf("api-bind-to = \"127.0.0.1:%d\"", serverPort(t, srv))) {
+		t.Fatalf("reload must reuse the same api port:\n%s", cfg)
+	}
+	mgr.StopAll()
+}
+
+func TestEnsureReloadFallbackRestarts(t *testing.T) {
+	pidFile := installFakeMtg(t)
+	mgr := &Manager{procs: map[int]*managed{}, swept: true}
+
+	if err := mgr.Ensure(mtgInst(2, SecretEntry{Name: "alice", Secret: "ee01"})); err != nil {
+		t.Fatalf("initial ensure: %v", err)
+	}
+	waitSpawnCount(t, pidFile, 1)
+
+	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		w.WriteHeader(http.StatusNotFound)
+	}))
+	defer srv.Close()
+	mgr.procs[2].apiPort = serverPort(t, srv)
+
+	if err := mgr.Ensure(mtgInst(2, SecretEntry{Name: "carol", Secret: "ee03"})); err != nil {
+		t.Fatalf("fallback ensure: %v", err)
+	}
+	waitSpawnCount(t, pidFile, 2)
+	mgr.StopAll()
+}
+
+func TestEnsureNoopKeepsProcess(t *testing.T) {
+	pidFile := installFakeMtg(t)
+	mgr := &Manager{procs: map[int]*managed{}, swept: true}
+
+	inst := mtgInst(3, SecretEntry{Name: "alice", Secret: "ee01"}, SecretEntry{Name: "bob", Secret: "ee02"})
+	if err := mgr.Ensure(inst); err != nil {
+		t.Fatalf("initial ensure: %v", err)
+	}
+	waitSpawnCount(t, pidFile, 1)
+
+	if err := mgr.Ensure(inst); err != nil {
+		t.Fatalf("repeat ensure: %v", err)
+	}
+	reordered := mtgInst(3, SecretEntry{Name: "bob", Secret: "ee02"}, SecretEntry{Name: "alice", Secret: "ee01"})
+	if err := mgr.Ensure(reordered); err != nil {
+		t.Fatalf("reordered ensure: %v", err)
+	}
+
+	time.Sleep(300 * time.Millisecond)
+	if got := spawnCount(t, pidFile); got != 1 {
+		t.Fatalf("an unchanged instance must keep the one process, got %d spawns", got)
+	}
+	mgr.StopAll()
+}

+ 51 - 9
internal/mtproto/manager_test.go

@@ -94,11 +94,17 @@ func TestRenderConfig(t *testing.T) {
 		Debug: true, ProxyProtocolListener: true, PreferIP: "only-ipv6",
 		FrontingIP: "127.0.0.1", FrontingPort: 9443, FrontingProxyProtocol: true,
 		ThrottleMaxConnections: 5000,
+		AdTag:                  "0123456789abcdef0123456789abcdef",
+		PublicIPv4:             "1.2.3.4",
+		PublicIPv6:             "2001:db8::1",
 	}, 6000)
 	for _, want := range []string{
 		"debug = true\n",
 		"proxy-protocol-listener = true\n",
 		`prefer-ip = "only-ipv6"`,
+		`ad-tag = "0123456789abcdef0123456789abcdef"`,
+		`public-ipv4 = "1.2.3.4"`,
+		`public-ipv6 = "2001:db8::1"`,
 		"[domain-fronting]",
 		`host = "127.0.0.1"`,
 		"port = 9443",
@@ -153,8 +159,9 @@ func TestRenderConfigXrayEgress(t *testing.T) {
 	}
 }
 
-func TestFingerprintReactsToOptions(t *testing.T) {
+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 },
@@ -165,16 +172,51 @@ func TestFingerprintReactsToOptions(t *testing.T) {
 		"throttle":      func(i *Instance) { i.ThrottleMaxConnections = 5000 },
 		"routeXray":     func(i *Instance) { i.RouteThroughXray = true },
 		"routeXrayPort": func(i *Instance) { i.XrayRoutePort = 50000 },
-		"addSecret":     func(i *Instance) { i.Secrets = append(i.Secrets, SecretEntry{Name: "b", Secret: "ff"}) },
-		"changeSecret":  func(i *Instance) { i.Secrets = []SecretEntry{{Name: "a", Secret: "ee99"}} },
+		"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
+			mutate(&changed)
+			if base.structuralFingerprint() == changed.structuralFingerprint() {
+				t.Fatalf("structural fingerprint must change when %s changes", name)
+			}
+			if base.secretsFingerprint() != changed.secretsFingerprint() {
+				t.Fatalf("secrets fingerprint must stay put when %s changes", name)
+			}
+		})
+	}
+
+	for name, mutate := range map[string]func(*Instance){
+		"add":    func(i *Instance) { i.Secrets = append(i.Secrets, SecretEntry{Name: "b", Secret: "ff"}) },
+		"rekey":  func(i *Instance) { i.Secrets = []SecretEntry{{Name: "a", Secret: "ee99"}} },
+		"remove": func(i *Instance) { i.Secrets = nil },
+		"rename": func(i *Instance) { i.Secrets = []SecretEntry{{Name: "a2", Secret: "ee"}} },
+		"adTag":  func(i *Instance) { i.AdTag = "0123456789abcdef0123456789abcdef" },
 	} {
-		changed := base
-		if strings.HasPrefix(name, "addSecret") || strings.HasPrefix(name, "changeSecret") {
+		t.Run("secrets/"+name, func(t *testing.T) {
+			changed := base
 			changed.Secrets = append([]SecretEntry(nil), base.Secrets...)
+			mutate(&changed)
+			if base.secretsFingerprint() == changed.secretsFingerprint() {
+				t.Fatalf("secrets fingerprint must change on a %s", name)
+			}
+			if base.structuralFingerprint() != changed.structuralFingerprint() {
+				t.Fatalf("structural fingerprint must stay put on a %s", name)
+			}
+		})
+	}
+
+	t.Run("orderInsensitive", func(t *testing.T) {
+		forward := Instance{Secrets: []SecretEntry{{Name: "alice", Secret: "ee11"}, {Name: "bob", Secret: "ee22"}}}
+		reversed := Instance{Secrets: []SecretEntry{{Name: "bob", Secret: "ee22"}, {Name: "alice", Secret: "ee11"}}}
+		if got, want := forward.secretsFingerprint(), "adtag=|alice=ee11|bob=ee22"; got != want {
+			t.Fatalf("secrets fingerprint must join sorted pairs: got %q, want %q", got, want)
 		}
-		mutate(&changed)
-		if base.fingerprint() == changed.fingerprint() {
-			t.Fatalf("fingerprint must change when %s changes", name)
+		if forward.secretsFingerprint() != reversed.secretsFingerprint() {
+			t.Fatal("secrets fingerprint must not depend on client order")
 		}
-	}
+	})
 }

+ 3 - 1
internal/mtproto/process.go

@@ -1,8 +1,10 @@
-// Package mtproto manages mtg-multi (github.com/dolonet/mtg-multi) sidecar
+// Package mtproto manages mtg-multi (github.com/mhsanaei/mtg-multi) 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, each serving every active client's secret through the
 // mtg-multi [secrets] section — entirely outside the Xray config and lifecycle.
+// A client edit is hot-applied via the fork's POST /reload endpoint so live
+// connections survive; the manager falls back to a restart on older binaries.
 package mtproto
 
 import (

+ 7 - 15
internal/web/job/mtproto_job.go

@@ -1,7 +1,6 @@
 package job
 
 import (
-	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
 	"github.com/mhsanaei/3x-ui/v3/internal/logger"
 	"github.com/mhsanaei/3x-ui/v3/internal/mtproto"
 	"github.com/mhsanaei/3x-ui/v3/internal/web/service"
@@ -24,25 +23,18 @@ func NewMtprotoJob() *MtprotoJob {
 // Run reconciles desired mtproto inbounds with running mtg processes and records
 // per-client traffic deltas and online status.
 func (j *MtprotoJob) Run() {
-	inbounds, err := j.inboundService.GetAllInbounds()
+	desired, err := j.inboundService.DesiredMtprotoInstances()
 	if err != nil {
-		logger.Warning("mtproto job: get inbounds failed:", err)
+		logger.Warning("mtproto job: get desired instances failed:", err)
 		return
 	}
 
-	var desired []mtproto.Instance
 	routedTags := make(map[string]bool)
-	activeTags := make([]string, 0)
-	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)
-			activeTags = append(activeTags, inst.Tag)
-			if inst.RouteThroughXray {
-				routedTags[inst.Tag] = true
-			}
+	activeTags := make([]string, 0, len(desired))
+	for _, inst := range desired {
+		activeTags = append(activeTags, inst.Tag)
+		if inst.RouteThroughXray {
+			routedTags[inst.Tag] = true
 		}
 	}
 

+ 32 - 0
internal/web/runtime/local.go

@@ -73,6 +73,9 @@ func (l *Local) DelInbound(_ context.Context, ib *model.Inbound) error {
 }
 
 func (l *Local) UpdateInbound(ctx context.Context, oldIb, newIb *model.Inbound) error {
+	if oldIb.Protocol == model.MTProto || newIb.Protocol == model.MTProto {
+		return l.updateMtprotoInbound(ctx, oldIb, newIb)
+	}
 	_ = l.DelInbound(ctx, oldIb)
 	if !newIb.Enable {
 		return nil
@@ -80,6 +83,35 @@ func (l *Local) UpdateInbound(ctx context.Context, oldIb, newIb *model.Inbound)
 	return l.AddInbound(ctx, newIb)
 }
 
+// updateMtprotoInbound applies an inbound update without the Del+Add sequence
+// the xray path uses: Remove would drop the manager's fingerprint state, which
+// is what lets Ensure keep the running mtg process (and its live connections)
+// when nothing in the generated config changed. The sidecar is only stopped
+// when the inbound is disabled, loses its last active secret, or moves to a
+// different protocol.
+func (l *Local) updateMtprotoInbound(ctx context.Context, oldIb, newIb *model.Inbound) error {
+	if oldIb.Protocol == model.MTProto && newIb.Protocol != model.MTProto {
+		mtproto.GetManager().Remove(oldIb.Id)
+		if !newIb.Enable {
+			return nil
+		}
+		return l.AddInbound(ctx, newIb)
+	}
+	if oldIb.Protocol != model.MTProto {
+		_ = l.DelInbound(ctx, oldIb)
+	}
+	if !newIb.Enable {
+		mtproto.GetManager().Remove(newIb.Id)
+		return nil
+	}
+	inst, ok := mtproto.InstanceFromInbound(newIb)
+	if !ok {
+		mtproto.GetManager().Remove(newIb.Id)
+		return nil
+	}
+	return mtproto.GetManager().Ensure(inst)
+}
+
 func (l *Local) AddUser(_ context.Context, ib *model.Inbound, userMap map[string]any) error {
 	if ib.Protocol == model.MTProto {
 		return nil

+ 12 - 3
internal/web/service/client_inbound_apply.go

@@ -460,6 +460,8 @@ func (s *ClientService) addInboundClient(inboundSvc *InboundService, data *model
 	if oldInbound.NodeID == nil {
 		if !push {
 			needRestart = true
+		} else if oldInbound.Protocol == model.MTProto {
+			inboundSvc.applyLocalMtproto(oldInbound.Id)
 		} else {
 			for _, client := range clients {
 				if len(client.Email) == 0 {
@@ -818,6 +820,8 @@ func (s *ClientService) UpdateInboundClient(inboundSvc *InboundService, data *mo
 		if oldInbound.NodeID == nil {
 			if !push {
 				needRestart = true
+			} else if oldInbound.Protocol == model.MTProto {
+				inboundSvc.applyLocalMtproto(oldInbound.Id)
 			} else {
 				if oldClients[clientIndex].Enable {
 					err1 := rt.RemoveUser(context.Background(), oldInbound, oldEmail)
@@ -994,9 +998,14 @@ func (s *ClientService) DelInboundClientByEmail(inboundSvc *InboundService, inbo
 	// inbound's runtime even when the same email survives in another inbound.
 	if len(email) > 0 {
 		if oldInbound.NodeID == nil {
-			// Local inbound: a disabled client isn't in the running Xray, so only
-			// a live one (needApiDel) needs an API removal.
-			if needApiDel {
+			if oldInbound.Protocol == model.MTProto {
+				// mtg serves the full secret set, so any client delete re-applies
+				// it (removing the last client stops the sidecar) regardless of the
+				// client's enable state.
+				inboundSvc.applyLocalMtproto(oldInbound.Id)
+			} else if needApiDel {
+				// Local inbound: a disabled client isn't in the running Xray, so only
+				// a live one (needApiDel) needs an API removal.
 				if !push {
 					needRestart = true
 				} else if err1 := rt.RemoveUser(context.Background(), oldInbound, email); err1 == nil {

+ 47 - 10
internal/web/service/inbound.go

@@ -832,14 +832,26 @@ func (s *InboundService) AddInbound(inbound *model.Inbound) (*model.Inbound, boo
 			markDirty = true
 		}
 		if push {
-			if err1 := rt.AddInbound(context.Background(), inbound); err1 == nil {
-				logger.Debug("New inbound added on", rt.Name(), ":", inbound.Tag)
-			} else {
-				logger.Debug("Unable to add inbound on", rt.Name(), ":", err1)
-				if inbound.NodeID != nil {
-					markDirty = true
+			payload := inbound
+			pushable := true
+			if inbound.NodeID == nil && inbound.Protocol == model.MTProto {
+				if built, bErr := s.buildRuntimeInboundForAPI(tx, inbound); bErr == nil {
+					payload = built
 				} else {
-					needRestart = true
+					logger.Debug("Unable to prepare runtime inbound config:", bErr)
+					pushable = false
+				}
+			}
+			if pushable {
+				if err1 := rt.AddInbound(context.Background(), payload); err1 == nil {
+					logger.Debug("New inbound added on", rt.Name(), ":", inbound.Tag)
+				} else {
+					logger.Debug("Unable to add inbound on", rt.Name(), ":", err1)
+					if inbound.NodeID != nil {
+						markDirty = true
+					} else if inbound.Protocol != model.MTProto {
+						needRestart = true
+					}
 				}
 			}
 		}
@@ -1080,9 +1092,10 @@ func (s *InboundService) UpdateInbound(inbound *model.Inbound) (*model.Inbound,
 		return inbound, false, err
 	}
 	inbound.NodeID = oldInbound.NodeID
-	// Capture the pre-edit routing state before oldInbound.Settings is replaced
-	// with the new settings further down, then ensure a routed inbound keeps a
-	// stable egress port (reusing the one already stored).
+	// Capture the pre-edit protocol and routing state before oldInbound is
+	// overwritten with the new values further down, then ensure a routed
+	// inbound keeps a stable egress port (reusing the one already stored).
+	oldProtocol := oldInbound.Protocol
 	oldRoutedMtproto := mtprotoRoutesThroughXray(oldInbound)
 	if err := s.normalizeMtprotoXrayPort(inbound, oldInbound.Settings); err != nil {
 		return inbound, false, err
@@ -1225,6 +1238,30 @@ func (s *InboundService) UpdateInbound(inbound *model.Inbound) (*model.Inbound,
 		if oldInbound.NodeID == nil {
 			if !push {
 				needRestart = true
+			} else if oldProtocol == model.MTProto || oldInbound.Protocol == model.MTProto {
+				oldSnapshot := *oldInbound
+				oldSnapshot.Tag = tag
+				oldSnapshot.Protocol = oldProtocol
+				payload := oldInbound
+				pushable := true
+				if inbound.Enable {
+					if built, err2 := s.buildRuntimeInboundForAPI(tx, oldInbound); err2 == nil {
+						payload = built
+					} else {
+						logger.Debug("Unable to prepare runtime inbound config:", err2)
+						pushable = false
+					}
+				}
+				if pushable {
+					if err2 := rt.UpdateInbound(context.Background(), &oldSnapshot, payload); err2 == nil {
+						logger.Debug("Updated inbound applied on", rt.Name(), ":", oldInbound.Tag)
+					} else {
+						logger.Debug("Unable to update inbound on", rt.Name(), ":", err2)
+						if oldInbound.Protocol != model.MTProto {
+							needRestart = true
+						}
+					}
+				}
 			} else {
 				oldSnapshot := *oldInbound
 				oldSnapshot.Tag = tag

+ 105 - 0
internal/web/service/inbound_mtproto.go

@@ -0,0 +1,105 @@
+package service
+
+import (
+	"context"
+
+	"github.com/mhsanaei/3x-ui/v3/internal/database"
+	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
+	"github.com/mhsanaei/3x-ui/v3/internal/logger"
+	"github.com/mhsanaei/3x-ui/v3/internal/mtproto"
+	"github.com/mhsanaei/3x-ui/v3/internal/xray"
+)
+
+// DesiredMtprotoInstances derives the mtg sidecar configs this panel should be
+// running: one instance per enabled local mtproto inbound, serving only the
+// secrets of clients that are both enabled in the inbound settings and not
+// depletion-disabled in client_traffics. That is the same effective client set
+// buildRuntimeInboundForAPI pushes on interactive edits, so the reconcile job
+// and the push paths agree on one fingerprint — a disagreement would surface
+// as a needless mtg restart, and a job that read only the raw settings would
+// keep serving depleted clients until an unrelated restart. Inbounds whose
+// every secret is filtered away are omitted so Reconcile stops their sidecar.
+func (s *InboundService) DesiredMtprotoInstances() ([]mtproto.Instance, error) {
+	db := database.GetDB()
+	var inbounds []*model.Inbound
+	err := db.Model(model.Inbound{}).
+		Where("protocol = ? AND enable = ? AND node_id IS NULL", model.MTProto, true).
+		Find(&inbounds).Error
+	if err != nil {
+		return nil, err
+	}
+	if len(inbounds) == 0 {
+		return nil, nil
+	}
+
+	ids := make([]int, 0, len(inbounds))
+	for _, ib := range inbounds {
+		ids = append(ids, ib.Id)
+	}
+	var disabledRows []xray.ClientTraffic
+	err = db.Model(xray.ClientTraffic{}).
+		Where("inbound_id IN ? AND enable = ?", ids, false).
+		Select("inbound_id", "email").
+		Find(&disabledRows).Error
+	if err != nil {
+		return nil, err
+	}
+	disabled := make(map[int]map[string]struct{}, len(disabledRows))
+	for _, row := range disabledRows {
+		if disabled[row.InboundId] == nil {
+			disabled[row.InboundId] = map[string]struct{}{}
+		}
+		disabled[row.InboundId][row.Email] = struct{}{}
+	}
+
+	instances := make([]mtproto.Instance, 0, len(inbounds))
+	for _, ib := range inbounds {
+		inst, ok := mtproto.InstanceFromInbound(ib)
+		if !ok {
+			continue
+		}
+		if off := disabled[ib.Id]; len(off) > 0 {
+			kept := make([]mtproto.SecretEntry, 0, len(inst.Secrets))
+			for _, sec := range inst.Secrets {
+				if _, skip := off[sec.Name]; !skip {
+					kept = append(kept, sec)
+				}
+			}
+			inst.Secrets = kept
+		}
+		if len(inst.Secrets) == 0 {
+			continue
+		}
+		instances = append(instances, inst)
+	}
+	return instances, nil
+}
+
+// applyLocalMtproto pushes a single local mtproto inbound's current client set
+// to its mtg sidecar right after a client edit commits, so an add, removal,
+// re-key or enable-toggle takes effect immediately instead of waiting up to
+// 10s for the reconcile job. With a reload-capable mtg the change is applied in
+// place without dropping other clients; older binaries fall back to a restart
+// inside the manager. It re-reads the inbound so it sees the committed settings,
+// filters depleted clients exactly like the reconcile job, and is a no-op for
+// node-owned or non-mtproto inbounds. Failures are logged and swallowed: the
+// reconcile job is the backstop, and an xray restart cannot help the sidecar.
+func (s *InboundService) applyLocalMtproto(inboundId int) {
+	inbound, err := s.GetInbound(inboundId)
+	if err != nil || inbound == nil || inbound.Protocol != model.MTProto || inbound.NodeID != nil {
+		return
+	}
+	rt, err := s.runtimeFor(inbound)
+	if err != nil {
+		return
+	}
+	payload := inbound
+	if inbound.Enable {
+		if built, bErr := s.buildRuntimeInboundForAPI(database.GetDB(), inbound); bErr == nil {
+			payload = built
+		}
+	}
+	if err := rt.UpdateInbound(context.Background(), inbound, payload); err != nil {
+		logger.Debug("mtproto: immediate client apply failed for inbound", inboundId, ":", err)
+	}
+}

+ 99 - 0
internal/web/service/inbound_mtproto_apply_test.go

@@ -0,0 +1,99 @@
+package service
+
+import (
+	"fmt"
+	"os"
+	"path/filepath"
+	"strings"
+	"testing"
+
+	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
+	"github.com/mhsanaei/3x-ui/v3/internal/mtproto"
+	"github.com/mhsanaei/3x-ui/v3/internal/web/runtime"
+)
+
+func mtgConfigPath(t *testing.T, inboundId int) string {
+	t.Helper()
+	return filepath.Join(os.Getenv("XUI_BIN_FOLDER"), "mtproto", fmt.Sprintf("mtg-%d.toml", inboundId))
+}
+
+func readMtgConfig(t *testing.T, inboundId int) string {
+	t.Helper()
+	data, err := os.ReadFile(mtgConfigPath(t, inboundId))
+	if err != nil {
+		t.Fatalf("read mtg config: %v", err)
+	}
+	return string(data)
+}
+
+func TestUpdateInboundMtprotoUnchangedDoesNotRestart(t *testing.T) {
+	setupConflictDB(t)
+	pidFile := installFakeMtg(t)
+	runtime.SetManager(runtime.NewManager(runtime.LocalDeps{APIPort: func() int { return 0 }}))
+	t.Cleanup(func() { runtime.SetManager(nil) })
+
+	seedInboundConflict(t, "mt-apply", "", 46101, model.MTProto,
+		"",
+		`{"clients":[`+
+			`{"email":"mtga","secret":"`+mtprotoTestSecretA+`","enable":true},`+
+			`{"email":"mtgb","secret":"`+mtprotoTestSecretB+`","enable":true}]}`)
+	seeded := loadInboundByTag(t, "mt-apply")
+	seedClientTraffic(t, seeded.Id, "mtga", true)
+	seedClientTraffic(t, seeded.Id, "mtgb", true)
+
+	svc := &InboundService{}
+	primed, ok := mtproto.InstanceFromInbound(seeded)
+	if !ok {
+		t.Fatal("seed inbound must produce an mtg instance")
+	}
+	if err := mtproto.GetManager().Ensure(primed); err != nil {
+		t.Fatalf("prime mtg: %v", err)
+	}
+	t.Cleanup(func() { mtproto.GetManager().Remove(seeded.Id) })
+	waitForSpawns(t, pidFile, 1)
+	primedConfig := readMtgConfig(t, seeded.Id)
+
+	saveAndAssertKept := func(t *testing.T, mutate func(*model.Inbound)) {
+		t.Helper()
+		update := *loadInboundByTag(t, "mt-apply")
+		mutate(&update)
+		_, needRestart, err := svc.UpdateInbound(&update)
+		if err != nil {
+			t.Fatalf("UpdateInbound: %v", err)
+		}
+		if needRestart {
+			t.Fatal("an mtproto-only edit must not request an xray restart")
+		}
+		assertNoNewSpawns(t, pidFile, 1)
+		if got := readMtgConfig(t, seeded.Id); got != primedConfig {
+			t.Fatalf("config rewritten on a no-op edit:\nbefore:\n%s\nafter:\n%s", primedConfig, got)
+		}
+	}
+
+	t.Run("unchangedSaveKeepsProcess", func(t *testing.T) {
+		saveAndAssertKept(t, func(*model.Inbound) {})
+	})
+
+	t.Run("remarkOnlyEditKeepsProcess", func(t *testing.T) {
+		saveAndAssertKept(t, func(ib *model.Inbound) { ib.Remark = "renamed while users stay connected" })
+	})
+
+	t.Run("rekeyedSecretRestartsProcess", func(t *testing.T) {
+		update := *loadInboundByTag(t, "mt-apply")
+		update.Settings = strings.Replace(update.Settings, mtprotoTestSecretA, mtprotoTestSecretD, 1)
+		if !strings.Contains(update.Settings, mtprotoTestSecretD) {
+			t.Fatal("fixture must contain the re-keyed secret")
+		}
+		_, needRestart, err := svc.UpdateInbound(&update)
+		if err != nil {
+			t.Fatalf("UpdateInbound: %v", err)
+		}
+		if needRestart {
+			t.Fatal("an mtproto secret change must not request an xray restart")
+		}
+		waitForSpawns(t, pidFile, 2)
+		if got := readMtgConfig(t, seeded.Id); !strings.Contains(got, mtprotoTestSecretD) {
+			t.Fatalf("restarted config must carry the new secret:\n%s", got)
+		}
+	})
+}

+ 67 - 0
internal/web/service/inbound_mtproto_client_test.go

@@ -0,0 +1,67 @@
+package service
+
+import (
+	"strings"
+	"testing"
+
+	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
+	"github.com/mhsanaei/3x-ui/v3/internal/mtproto"
+	"github.com/mhsanaei/3x-ui/v3/internal/web/runtime"
+)
+
+func TestClientCrudMtprotoAppliesImmediately(t *testing.T) {
+	setupConflictDB(t)
+	pidFile := installFakeMtg(t)
+	runtime.SetManager(runtime.NewManager(runtime.LocalDeps{APIPort: func() int { return 0 }}))
+	t.Cleanup(func() { runtime.SetManager(nil) })
+
+	inboundSvc := &InboundService{}
+	clientSvc := &ClientService{}
+
+	created, _, err := inboundSvc.AddInbound(&model.Inbound{
+		Enable:   true,
+		Listen:   "",
+		Port:     46201,
+		Protocol: model.MTProto,
+		Settings: `{"clients":[{"email":"first","secret":"` + mtprotoTestSecretA + `","enable":true}]}`,
+	})
+	if err != nil {
+		t.Fatalf("AddInbound: %v", err)
+	}
+	t.Cleanup(func() { mtproto.GetManager().Remove(created.Id) })
+	waitForSpawns(t, pidFile, 1)
+
+	t.Run("add client rewrites the served config", func(t *testing.T) {
+		payload := &model.Inbound{
+			Id:       created.Id,
+			Settings: `{"clients":[{"email":"second","secret":"` + mtprotoTestSecretB + `","enable":true}]}`,
+		}
+		needRestart, err := clientSvc.AddInboundClient(inboundSvc, payload)
+		if err != nil {
+			t.Fatalf("AddInboundClient: %v", err)
+		}
+		if needRestart {
+			t.Fatal("adding an mtproto client must not request an xray restart")
+		}
+		cfg := readMtgConfig(t, created.Id)
+		if !strings.Contains(cfg, `"second" = "`+mtprotoTestSecretB+`"`) {
+			t.Fatalf("new client must be in the served config:\n%s", cfg)
+		}
+		if !strings.Contains(cfg, `"first" = "`+mtprotoTestSecretA+`"`) {
+			t.Fatalf("existing client must remain served:\n%s", cfg)
+		}
+	})
+
+	t.Run("delete client drops it from the served config", func(t *testing.T) {
+		if _, err := clientSvc.DelInboundClientByEmail(inboundSvc, created.Id, "second", false, true); err != nil {
+			t.Fatalf("DelInboundClientByEmail: %v", err)
+		}
+		cfg := readMtgConfig(t, created.Id)
+		if strings.Contains(cfg, mtprotoTestSecretB) {
+			t.Fatalf("deleted client must leave the served config:\n%s", cfg)
+		}
+		if !strings.Contains(cfg, mtprotoTestSecretA) {
+			t.Fatalf("surviving client must stay served:\n%s", cfg)
+		}
+	})
+}

+ 70 - 0
internal/web/service/inbound_mtproto_desired_test.go

@@ -0,0 +1,70 @@
+package service
+
+import (
+	"reflect"
+	"testing"
+
+	"github.com/mhsanaei/3x-ui/v3/internal/database"
+	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
+	"github.com/mhsanaei/3x-ui/v3/internal/mtproto"
+)
+
+func TestDesiredMtprotoInstancesFiltersDepleted(t *testing.T) {
+	setupConflictDB(t)
+	svc := &InboundService{}
+
+	seedInboundConflict(t, "mt-desired", "", 46001, model.MTProto,
+		"",
+		`{"clients":[`+
+			`{"email":"alice","secret":"`+mtprotoTestSecretA+`","enable":true},`+
+			`{"email":"bob","secret":"`+mtprotoTestSecretB+`","enable":true},`+
+			`{"email":"carol","secret":"`+mtprotoTestSecretC+`","enable":false}]}`)
+	served := loadInboundByTag(t, "mt-desired")
+	seedClientTraffic(t, served.Id, "alice", true)
+	seedClientTraffic(t, served.Id, "bob", false)
+	seedClientTraffic(t, served.Id, "carol", true)
+
+	seedInboundConflict(t, "mt-all-depleted", "", 46002, model.MTProto,
+		"",
+		`{"clients":[{"email":"dave","secret":"`+mtprotoTestSecretA+`","enable":true}]}`)
+	depleted := loadInboundByTag(t, "mt-all-depleted")
+	seedClientTraffic(t, depleted.Id, "dave", false)
+
+	nodeID := 5
+	seedInboundConflictNode(t, "mt-node-owned", "", 46003, model.MTProto,
+		"",
+		`{"clients":[{"email":"erin","secret":"`+mtprotoTestSecretB+`","enable":true}]}`,
+		&nodeID)
+
+	instances, err := svc.DesiredMtprotoInstances()
+	if err != nil {
+		t.Fatalf("DesiredMtprotoInstances: %v", err)
+	}
+
+	t.Run("depletedAndDisabledClientsExcluded", func(t *testing.T) {
+		if len(instances) != 1 {
+			t.Fatalf("expected exactly the served inbound, got %d instances: %+v", len(instances), instances)
+		}
+		if instances[0].Id != served.Id {
+			t.Fatalf("expected inbound %d, got %d", served.Id, instances[0].Id)
+		}
+		want := []mtproto.SecretEntry{{Name: "alice", Secret: mtprotoTestSecretA}}
+		if !reflect.DeepEqual(instances[0].Secrets, want) {
+			t.Fatalf("served secrets: got %+v, want %+v", instances[0].Secrets, want)
+		}
+	})
+
+	t.Run("matchesInteractivePushFiltering", func(t *testing.T) {
+		built, err := svc.buildRuntimeInboundForAPI(database.GetDB(), served)
+		if err != nil {
+			t.Fatalf("buildRuntimeInboundForAPI: %v", err)
+		}
+		pushInst, ok := mtproto.InstanceFromInbound(built)
+		if !ok {
+			t.Fatal("push path must produce an instance")
+		}
+		if !reflect.DeepEqual(pushInst.Secrets, instances[0].Secrets) {
+			t.Fatalf("push and job secret sets diverge: push %+v, job %+v", pushInst.Secrets, instances[0].Secrets)
+		}
+	})
+}

+ 116 - 0
internal/web/service/mtproto_fake_test.go

@@ -0,0 +1,116 @@
+package service
+
+import (
+	"fmt"
+	"os"
+	"path/filepath"
+	"strings"
+	"testing"
+	"time"
+
+	"github.com/mhsanaei/3x-ui/v3/internal/database"
+	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
+	"github.com/mhsanaei/3x-ui/v3/internal/mtproto"
+	"github.com/mhsanaei/3x-ui/v3/internal/xray"
+)
+
+const (
+	mtprotoTestSecretA = "ee00112233445566778899aabbccddeeff6578616d706c652e636f6d"
+	mtprotoTestSecretB = "ee101112131415161718191a1b1c1d1e1f6578616d706c652e636f6d"
+	mtprotoTestSecretC = "ee202122232425262728292a2b2c2d2e2f6578616d706c652e636f6d"
+	mtprotoTestSecretD = "ee303132333435363738393a3b3c3d3e3f6578616d706c652e636f6d"
+)
+
+func seedClientTraffic(t *testing.T, inboundId int, email string, enable bool) {
+	t.Helper()
+	row := xray.ClientTraffic{InboundId: inboundId, Email: email, Enable: enable}
+	if err := database.GetDB().Create(&row).Error; err != nil {
+		t.Fatalf("seed traffic %s: %v", email, err)
+	}
+}
+
+func loadInboundByTag(t *testing.T, tag string) *model.Inbound {
+	t.Helper()
+	var ib model.Inbound
+	if err := database.GetDB().Where("tag = ?", tag).First(&ib).Error; err != nil {
+		t.Fatalf("load inbound %s: %v", tag, err)
+	}
+	return &ib
+}
+
+// fakeMtgChildMain is what the re-executed test binary runs when posing as an
+// mtg child process: it appends its pid to the file named by MTG_FAKE_PIDFILE
+// so tests can count spawns, then blocks until the manager kills it.
+func fakeMtgChildMain() {
+	if f, err := os.OpenFile(os.Getenv("MTG_FAKE_PIDFILE"), os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644); err == nil {
+		fmt.Fprintf(f, "%d\n", os.Getpid())
+		f.Close()
+	}
+	select {}
+}
+
+// installFakeMtg points the mtproto manager at a copy of the running test
+// binary posing as mtg (via the MTG_FAKE_CHILD gate in TestMain) and returns
+// the pid file whose line count equals the number of processes spawned so far.
+func installFakeMtg(t *testing.T) string {
+	t.Helper()
+	binDir := t.TempDir()
+	self, err := os.Executable()
+	if err != nil {
+		t.Fatalf("locate test binary: %v", err)
+	}
+	payload, err := os.ReadFile(self)
+	if err != nil {
+		t.Fatalf("read test binary: %v", err)
+	}
+	if err := os.WriteFile(filepath.Join(binDir, mtproto.GetBinaryName()), payload, 0o755); err != nil {
+		t.Fatalf("install fake mtg: %v", err)
+	}
+	pidFile := filepath.Join(binDir, "mtg-pids.txt")
+	t.Setenv("XUI_BIN_FOLDER", binDir)
+	t.Setenv("MTG_FAKE_CHILD", "1")
+	t.Setenv("MTG_FAKE_PIDFILE", pidFile)
+	return pidFile
+}
+
+func countSpawns(t *testing.T, pidFile string) int {
+	t.Helper()
+	data, err := os.ReadFile(pidFile)
+	if os.IsNotExist(err) {
+		return 0
+	}
+	if err != nil {
+		t.Fatalf("read pid file: %v", err)
+	}
+	return len(strings.Fields(string(data)))
+}
+
+// waitForSpawns polls until exactly want processes have registered, failing
+// fast when the count overshoots and on timeout.
+func waitForSpawns(t *testing.T, pidFile string, want int) {
+	t.Helper()
+	deadline := time.Now().Add(5 * time.Second)
+	for {
+		got := countSpawns(t, pidFile)
+		if got == want {
+			return
+		}
+		if got > want {
+			t.Fatalf("expected %d mtg spawn(s), got %d", want, got)
+		}
+		if time.Now().After(deadline) {
+			t.Fatalf("expected %d mtg spawn(s), still %d after timeout", want, got)
+		}
+		time.Sleep(20 * time.Millisecond)
+	}
+}
+
+// assertNoNewSpawns gives a wrongly spawned child time to register, then
+// asserts the spawn count is still exactly want.
+func assertNoNewSpawns(t *testing.T, pidFile string, want int) {
+	t.Helper()
+	time.Sleep(500 * time.Millisecond)
+	if got := countSpawns(t, pidFile); got != want {
+		t.Fatalf("expected the mtg process to be kept (%d spawn(s)), got %d", want, got)
+	}
+}

+ 5 - 0
internal/web/service/xray_config_inject_test.go

@@ -14,6 +14,11 @@ import (
 )
 
 func TestMain(m *testing.M) {
+	// A test binary re-executed with MTG_FAKE_CHILD=1 poses as an mtg child
+	// process (see mtproto_fake_test.go) and never reaches the test runner.
+	if os.Getenv("MTG_FAKE_CHILD") == "1" {
+		fakeMtgChildMain()
+	}
 	// injectPanelEgress logs when it skips injection; the package logger must
 	// exist before any test exercises a skipped path.
 	xuilogger.InitLogger(logging.ERROR)

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

@@ -525,6 +525,12 @@
         "mtprotoFakeTlsDomainHint": "نطاق FakeTLS الافتراضي المستخدم لإنشاء سر عميل جديد. يمكن لكل عميل استخدام نطاقه الخاص.",
         "mtgThrottleMaxConnections": "الحد الأقصى للاتصالات",
         "mtgThrottleMaxConnectionsHint": "تحديد الاتصالات المتزامنة لجميع المستخدمين بتوزيع عادل. القيمة 0 تعطّل الميزة.",
+        "mtgAdTag": "علامة إعلانية (قناة مموّلة)",
+        "mtgAdTagHint": "علامة اختيارية مكوّنة من 32 حرفًا ست عشريًا يتم الحصول عليها عند تسجيل البروكسي في تلغرام. عند تعيينها، يتم توجيه العملاء عبر البروكسيات الوسيطة في تلغرام وتظهر قناة مموّلة أعلى قائمة محادثاتهم. تتطلب عنوان IP عامًا يمكن الوصول إليه - عيّنه أدناه أو اترك mtg يكتشفه تلقائيًا.",
+        "mtgAdTagInvalid": "يجب أن تتكوّن العلامة الإعلانية من 32 حرفًا ست عشريًا بالضبط.",
+        "mtgPublicIpv4": "عنوان IPv4 العام",
+        "mtgPublicIpv6": "عنوان IPv6 العام",
+        "mtgPublicIpHint": "العنوان العام لهذا الخادم الذي يمكن الوصول إليه، ويُستخدم بواسطة البروكسي الوسيط للعلامة الإعلانية. اتركه فارغًا ليكتشفه mtg تلقائيًا.",
         "visionTestseed": "Vision testseed",
         "version": "الإصدار",
         "udpIdleTimeout": "UDP idle timeout (ثانية)",

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

@@ -525,6 +525,12 @@
         "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.",
+        "mtgAdTag": "Ad-tag (sponsored channel)",
+        "mtgAdTagHint": "Optional 32-character hex tag from Telegram's proxy registration. When set, clients are routed through Telegram middle proxies and a sponsored channel appears at the top of their chat list. Requires a reachable public IP - set one below or let mtg auto-detect it.",
+        "mtgAdTagInvalid": "Ad-tag must be exactly 32 hexadecimal characters.",
+        "mtgPublicIpv4": "Public IPv4",
+        "mtgPublicIpv6": "Public IPv6",
+        "mtgPublicIpHint": "This server's reachable public address, used by the ad-tag middle proxy. Leave blank to let mtg auto-detect it.",
         "visionTestseed": "Vision testseed",
         "version": "Version",
         "udpIdleTimeout": "UDP idle timeout (s)",

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

@@ -525,6 +525,12 @@
         "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.",
+        "mtgAdTag": "Ad-tag (canal patrocinado)",
+        "mtgAdTagHint": "Etiqueta hexadecimal opcional de 32 caracteres del registro de proxy de Telegram. Si se establece, los clientes se enrutan a través de los proxies intermedios de Telegram y aparece un canal patrocinado en la parte superior de su lista de chats. Requiere una IP pública accesible: configúrala abajo o deja que mtg la detecte automáticamente.",
+        "mtgAdTagInvalid": "El ad-tag debe tener exactamente 32 caracteres hexadecimales.",
+        "mtgPublicIpv4": "IPv4 pública",
+        "mtgPublicIpv6": "IPv6 pública",
+        "mtgPublicIpHint": "La dirección pública accesible de este servidor, usada por el proxy intermedio del ad-tag. Déjalo en blanco para que mtg la detecte automáticamente.",
         "visionTestseed": "Vision testseed",
         "version": "Versión",
         "udpIdleTimeout": "UDP idle timeout (s)",

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

@@ -525,6 +525,12 @@
         "mtprotoFakeTlsDomainHint": "دامنه پیش‌فرض FakeTLS برای ساخت سکرت کلاینت جدید. هر کلاینت می‌تواند دامنه مخصوص خود را داشته باشد.",
         "mtgThrottleMaxConnections": "حداکثر اتصالات",
         "mtgThrottleMaxConnectionsHint": "محدود کردن اتصالات همزمان همه کاربران با تقسیم منصفانه. مقدار ۰ غیرفعال است.",
+        "mtgAdTag": "برچسب تبلیغاتی (کانال حامی)",
+        "mtgAdTagHint": "برچسب هگزادسیمال اختیاری ۳۲ کاراکتری که هنگام ثبت پروکسی در تلگرام دریافت می‌شود. با تنظیم آن، کاربران از طریق پروکسی‌های میانی تلگرام هدایت می‌شوند و یک کانال حامی در بالای فهرست گفتگوهایشان نمایش داده می‌شود. به یک IP عمومی در دسترس نیاز دارد - آن را در پایین تنظیم کنید یا بگذارید mtg به‌طور خودکار آن را تشخیص دهد.",
+        "mtgAdTagInvalid": "برچسب تبلیغاتی باید دقیقاً ۳۲ کاراکتر هگزادسیمال باشد.",
+        "mtgPublicIpv4": "IPv4 عمومی",
+        "mtgPublicIpv6": "IPv6 عمومی",
+        "mtgPublicIpHint": "نشانی عمومی و در‌دسترس این سرور که پروکسی میانی برچسب تبلیغاتی از آن استفاده می‌کند. برای تشخیص خودکار توسط mtg، خالی بگذارید.",
         "visionTestseed": "Vision testseed",
         "version": "نسخه",
         "udpIdleTimeout": "UDP idle timeout (s)",

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

@@ -525,6 +525,12 @@
         "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.",
+        "mtgAdTag": "Ad-tag (kanal bersponsor)",
+        "mtgAdTagHint": "Tag heksadesimal 32 karakter opsional dari pendaftaran proxy Telegram. Jika diatur, klien dirutekan melalui proxy perantara Telegram dan kanal bersponsor muncul di bagian atas daftar obrolan mereka. Memerlukan IP publik yang dapat dijangkau - atur di bawah atau biarkan mtg mendeteksinya secara otomatis.",
+        "mtgAdTagInvalid": "Ad-tag harus tepat 32 karakter heksadesimal.",
+        "mtgPublicIpv4": "IPv4 Publik",
+        "mtgPublicIpv6": "IPv6 Publik",
+        "mtgPublicIpHint": "Alamat publik server ini yang dapat dijangkau, digunakan oleh proxy perantara ad-tag. Biarkan kosong agar mtg mendeteksinya secara otomatis.",
         "visionTestseed": "Vision testseed",
         "version": "Versi",
         "udpIdleTimeout": "UDP idle timeout (d)",

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

@@ -546,6 +546,12 @@
         "mtprotoFakeTlsDomainHint": "新しいクライアントのシークレット生成に使う既定の FakeTLS ドメイン。クライアントごとに別のドメインを使用できます。",
         "mtgThrottleMaxConnections": "最大接続数",
         "mtgThrottleMaxConnectionsHint": "全ユーザーの同時接続数を公平配分で制限します。0 で無効。",
+        "mtgAdTag": "広告タグ(スポンサーチャンネル)",
+        "mtgAdTagHint": "Telegram のプロキシ登録で取得する任意の 32 文字の 16 進数タグ。設定すると、クライアントは Telegram の中間プロキシ経由でルーティングされ、スポンサーチャンネルがチャット一覧の先頭に表示されます。到達可能なパブリック IP が必要です。下で設定するか、mtg に自動検出させてください。",
+        "mtgAdTagInvalid": "広告タグは正確に 32 文字の 16 進数である必要があります。",
+        "mtgPublicIpv4": "パブリック IPv4",
+        "mtgPublicIpv6": "パブリック IPv6",
+        "mtgPublicIpHint": "広告タグの中間プロキシが使用する、このサーバーの到達可能なパブリックアドレス。空欄にすると mtg が自動検出します。",
         "visionTestseed": "Vision testseed",
         "version": "バージョン",
         "udpIdleTimeout": "UDP idle timeout (秒)",

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

@@ -546,6 +546,12 @@
         "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.",
+        "mtgAdTag": "Ad-tag (canal patrocinado)",
+        "mtgAdTagHint": "Tag hexadecimal opcional de 32 caracteres do registro de proxy do Telegram. Quando definida, os clientes são roteados pelos proxies intermediários do Telegram e um canal patrocinado aparece no topo da lista de conversas. Requer um IP público acessível - defina um abaixo ou deixe o mtg detectá-lo automaticamente.",
+        "mtgAdTagInvalid": "A ad-tag deve ter exatamente 32 caracteres hexadecimais.",
+        "mtgPublicIpv4": "IPv4 público",
+        "mtgPublicIpv6": "IPv6 público",
+        "mtgPublicIpHint": "O endereço público acessível deste servidor, usado pelo proxy intermediário da ad-tag. Deixe em branco para o mtg detectá-lo automaticamente.",
         "visionTestseed": "Vision testseed",
         "version": "Versão",
         "udpIdleTimeout": "UDP idle timeout (s)",

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

@@ -546,6 +546,12 @@
         "mtprotoFakeTlsDomainHint": "Домен FakeTLS по умолчанию для генерации секрета нового клиента. Каждый клиент может использовать свой домен.",
         "mtgThrottleMaxConnections": "Макс. подключений",
         "mtgThrottleMaxConnectionsHint": "Ограничение одновременных подключений всех пользователей по справедливому распределению. 0 — отключено.",
+        "mtgAdTag": "Рекламный тег (спонсорский канал)",
+        "mtgAdTagHint": "Необязательный шестнадцатеричный тег из 32 символов, получаемый при регистрации прокси в Telegram. Если задан, клиенты маршрутизируются через промежуточные прокси Telegram, и спонсорский канал появляется вверху списка чатов. Требуется доступный публичный IP - укажите его ниже или позвольте mtg определить его автоматически.",
+        "mtgAdTagInvalid": "Рекламный тег должен содержать ровно 32 шестнадцатеричных символа.",
+        "mtgPublicIpv4": "Публичный IPv4",
+        "mtgPublicIpv6": "Публичный IPv6",
+        "mtgPublicIpHint": "Доступный публичный адрес этого сервера, используемый промежуточным прокси рекламного тега. Оставьте пустым, чтобы mtg определил его автоматически.",
         "visionTestseed": "Vision testseed",
         "version": "Версия",
         "udpIdleTimeout": "UDP idle timeout (с)",

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

@@ -525,6 +525,12 @@
         "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.",
+        "mtgAdTag": "Reklam etiketi (sponsorlu kanal)",
+        "mtgAdTagHint": "Telegram proxy kaydından alınan isteğe bağlı 32 karakterlik onaltılık etiket. Ayarlandığında istemciler Telegram ara proxy'leri üzerinden yönlendirilir ve sohbet listelerinin en üstünde sponsorlu bir kanal görünür. Erişilebilir bir genel IP gerektirir - aşağıdan ayarlayın veya mtg'nin otomatik algılamasına izin verin.",
+        "mtgAdTagInvalid": "Reklam etiketi tam olarak 32 onaltılık karakter olmalıdır.",
+        "mtgPublicIpv4": "Genel IPv4",
+        "mtgPublicIpv6": "Genel IPv6",
+        "mtgPublicIpHint": "Bu sunucunun erişilebilir genel adresi; reklam etiketi ara proxy'si tarafından kullanılır. mtg'nin otomatik algılaması için boş bırakın.",
         "visionTestseed": "Vision Testseed",
         "version": "Sürüm",
         "udpIdleTimeout": "UDP Idle Timeout (s)",

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

@@ -525,6 +525,12 @@
         "mtprotoFakeTlsDomainHint": "Домен FakeTLS за замовчуванням для генерації секрету нового клієнта. Кожен клієнт може використовувати власний домен.",
         "mtgThrottleMaxConnections": "Макс. з'єднань",
         "mtgThrottleMaxConnectionsHint": "Обмеження одночасних з'єднань усіх користувачів зі справедливим розподілом. 0 — вимкнено.",
+        "mtgAdTag": "Рекламний тег (спонсорський канал)",
+        "mtgAdTagHint": "Необовʼязковий шістнадцятковий тег із 32 символів, який видається під час реєстрації проксі в Telegram. Якщо задано, клієнти маршрутизуються через проміжні проксі Telegram, а спонсорський канал зʼявляється вгорі списку чатів. Потрібна доступна публічна IP-адреса - вкажіть її нижче або дозвольте mtg визначити її автоматично.",
+        "mtgAdTagInvalid": "Рекламний тег має містити рівно 32 шістнадцяткові символи.",
+        "mtgPublicIpv4": "Публічний IPv4",
+        "mtgPublicIpv6": "Публічний IPv6",
+        "mtgPublicIpHint": "Доступна публічна адреса цього сервера, яку використовує проміжний проксі рекламного тега. Залиште порожнім, щоб mtg визначив її автоматично.",
         "visionTestseed": "Vision testseed",
         "version": "Версія",
         "udpIdleTimeout": "UDP idle timeout (с)",

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

@@ -546,6 +546,12 @@
         "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.",
+        "mtgAdTag": "Ad-tag (kênh tài trợ)",
+        "mtgAdTagHint": "Thẻ thập lục phân 32 ký tự tùy chọn từ đăng ký proxy của Telegram. Khi được đặt, client sẽ được định tuyến qua các proxy trung gian của Telegram và một kênh tài trợ xuất hiện ở đầu danh sách trò chuyện. Cần một IP công khai có thể truy cập - đặt bên dưới hoặc để mtg tự phát hiện.",
+        "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",
+        "mtgPublicIpHint": "Địa chỉ công khai có thể truy cập của máy chủ này, được proxy trung gian ad-tag sử dụng. Để trống để mtg tự phát hiện.",
         "visionTestseed": "Vision testseed",
         "version": "Phiên bản",
         "udpIdleTimeout": "UDP idle timeout (s)",

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

@@ -545,6 +545,12 @@
         "mtprotoFakeTlsDomainHint": "生成新客户端密钥时使用的默认 FakeTLS 域名。每个客户端可使用各自的域名。",
         "mtgThrottleMaxConnections": "最大连接数",
         "mtgThrottleMaxConnectionsHint": "按公平分配限制所有用户的并发连接数。0 表示不限制。",
+        "mtgAdTag": "广告标签(赞助频道)",
+        "mtgAdTagHint": "可选的 32 位十六进制标签,从 Telegram 代理注册处获取。设置后,客户端将通过 Telegram 中间代理路由,赞助频道会显示在其聊天列表顶部。需要可访问的公网 IP —— 在下方设置,或让 mtg 自动检测。",
+        "mtgAdTagInvalid": "广告标签必须为 32 位十六进制字符。",
+        "mtgPublicIpv4": "公网 IPv4",
+        "mtgPublicIpv6": "公网 IPv6",
+        "mtgPublicIpHint": "本服务器可访问的公网地址,供广告标签的中间代理使用。留空则由 mtg 自动检测。",
         "visionTestseed": "Vision testseed",
         "version": "版本",
         "udpIdleTimeout": "UDP 空闲超时 (s)",

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

@@ -525,6 +525,12 @@
         "mtprotoFakeTlsDomainHint": "產生新用戶端金鑰時使用的預設 FakeTLS 網域。每個用戶端可使用各自的網域。",
         "mtgThrottleMaxConnections": "最大連線數",
         "mtgThrottleMaxConnectionsHint": "以公平分配限制所有使用者的並行連線數。0 表示不限制。",
+        "mtgAdTag": "廣告標籤(贊助頻道)",
+        "mtgAdTagHint": "選填的 32 位十六進位標籤,從 Telegram 代理註冊處取得。設定後,用戶端會透過 Telegram 中間代理路由,贊助頻道會顯示在其聊天清單頂端。需要可存取的公網 IP —— 在下方設定,或讓 mtg 自動偵測。",
+        "mtgAdTagInvalid": "廣告標籤必須為 32 位十六進位字元。",
+        "mtgPublicIpv4": "公網 IPv4",
+        "mtgPublicIpv6": "公網 IPv6",
+        "mtgPublicIpHint": "本伺服器可存取的公網位址,供廣告標籤的中間代理使用。留空則由 mtg 自動偵測。",
         "visionTestseed": "Vision testseed",
         "version": "版本",
         "udpIdleTimeout": "UDP 閒置逾時 (s)",