1
0

13 Коммитууд 12d51d7195 ... 09617f04f5

Эзэн SHA1 Мессеж Огноо
  MHSanaei 09617f04f5 feat(panel): let a sponsor slot start at a scheduled time 6 цаг өмнө
  MHSanaei 044e2926a0 fix(amneziawg): let the wrapped bind build peer endpoints 6 цаг өмнө
  MHSanaei 75f3702dd3 fix(amneziawg): fall back to a free egress port when 64900 is refused 6 цаг өмнө
  MHSanaei 8f47b53879 style(settings): fold the Happ settings into four tabs 6 цаг өмнө
  MHSanaei a33b2341e9 style(clients): put Traffic Reset and Auto renewal on one row 6 цаг өмнө
  MHSanaei 3fc3992a46 fix(nodetoken): stop refusing every node-token key file on Windows 6 цаг өмнө
  MHSanaei ff322f901a fix(panel): skip the proxy env forwarding test on Windows 7 цаг өмнө
  MHSanaei 249b38e156 fix(logger): reuse the open log rotator when InitLogger runs again 7 цаг өмнө
  MHSanaei 18b337d131 fix(database): close the pool a second InitDB replaces 7 цаг өмнө
  MHSanaei f6a1a3bbd1 chore(git): check out every text file with LF, not only Go and scripts 7 цаг өмнө
  MHSanaei a579357343 refactor(logger): choose the console backend with build tags 7 цаг өмнө
  pcxzs 7aa5fc085f feat(tgbot): access levels and /start account binding (#6518) 8 цаг өмнө
  mrchatam 6f40a75909 feat(inbound): excludeFromSub hides links without disabling (#6463) 10 цаг өмнө
83 өөрчлөгдсөн 1667 нэмэгдсэн , 340 устгасан
  1. 3 6
      .gitattributes
  2. 11 2
      docs/content/docs/en/operations/telegram-bot.mdx
  3. 8 8
      docs/content/docs/en/reference/api/authentication.mdx
  4. 1 1
      docs/content/docs/en/reference/env-vars.mdx
  5. 11 2
      docs/content/docs/fa/operations/telegram-bot.mdx
  6. 1 1
      docs/content/docs/fa/reference/env-vars.mdx
  7. 12 2
      docs/content/docs/ru/operations/telegram-bot.mdx
  8. 1 1
      docs/content/docs/ru/reference/env-vars.mdx
  9. 9 2
      docs/content/docs/zh/operations/telegram-bot.mdx
  10. 1 1
      docs/content/docs/zh/reference/env-vars.mdx
  11. 16 1
      docs/public/openapi.json
  12. 16 1
      frontend/public/openapi.json
  13. 3 0
      frontend/src/generated/examples.ts
  14. 12 0
      frontend/src/generated/schemas.ts
  15. 2 0
      frontend/src/generated/types.ts
  16. 2 0
      frontend/src/generated/zod.ts
  17. 4 0
      frontend/src/lib/xray/inbound-form-adapter.ts
  18. 3 0
      frontend/src/models/dbinbound.ts
  19. 1 1
      frontend/src/pages/api-docs/endpoints.ts
  20. 22 18
      frontend/src/pages/clients/ClientFormModal.tsx
  21. 1 1
      frontend/src/pages/clients/ClientQrModal.tsx
  22. 11 0
      frontend/src/pages/inbounds/form/InboundFormModal.tsx
  23. 98 131
      frontend/src/pages/settings/HappSettingsContent.tsx
  24. 0 2
      frontend/src/pages/settings/SubscriptionGeneralTab.tsx
  25. 1 0
      frontend/src/schemas/forms/inbound-form.ts
  26. 1 1
      frontend/src/test/client-qr-modal.test.tsx
  27. 29 0
      frontend/src/test/inbound-form-adapter.test.ts
  28. 3 35
      frontend/src/test/subscription-general-tab.test.tsx
  29. 6 6
      internal/amneziawgnet/bench_test.go
  30. 39 6
      internal/amneziawgnet/egress.go
  31. 5 5
      internal/amneziawgnet/egress_domain_test.go
  32. 81 0
      internal/amneziawgnet/egress_port_test.go
  33. 1 1
      internal/amneziawgnet/outbound_manager.go
  34. 7 5
      internal/amneziawgnet/outbound_manager_test.go
  35. 3 3
      internal/amneziawgnet/resolving_bind.go
  36. 34 7
      internal/amneziawgnet/resolving_bind_test.go
  37. 18 2
      internal/amneziawgnet/socks_bridge.go
  38. 4 2
      internal/crypto/nodetoken/keysource.go
  39. 28 9
      internal/crypto/nodetoken/nodetoken_test.go
  40. 16 0
      internal/database/db.go
  41. 27 0
      internal/database/db_reopen_test.go
  42. 58 0
      internal/database/inbound_exclude_from_sub_migration_test.go
  43. 1 0
      internal/database/model/model.go
  44. 20 0
      internal/logger/console_other.go
  45. 14 0
      internal/logger/console_windows.go
  46. 19 30
      internal/logger/logger.go
  47. 1 0
      internal/logger/logger_test.go
  48. 6 0
      internal/sub/clash_service.go
  49. 6 0
      internal/sub/json_service.go
  50. 18 0
      internal/sub/service.go
  51. 77 0
      internal/sub/service_exclude_from_sub_test.go
  52. 5 0
      internal/web/job/amneziawg_job.go
  53. 1 0
      internal/web/runtime/remote.go
  54. 9 0
      internal/web/runtime/remote_test.go
  55. 11 0
      internal/web/service/client_lookup.go
  56. 1 0
      internal/web/service/inbound.go
  57. 57 0
      internal/web/service/inbound_exclude_from_sub_test.go
  58. 3 0
      internal/web/service/inbound_node.go
  59. 3 0
      internal/web/service/panel/panel_test.go
  60. 5 3
      internal/web/service/panel/sponsor.go
  61. 2 0
      internal/web/service/panel/sponsor_test.go
  62. 2 2
      internal/web/service/port_conflict.go
  63. 26 0
      internal/web/service/port_conflict_test.go
  64. 1 0
      internal/web/service/tgbot/tgbot_client.go
  65. 237 0
      internal/web/service/tgbot/tgbot_invite.go
  66. 221 0
      internal/web/service/tgbot/tgbot_invite_test.go
  67. 72 0
      internal/web/service/tgbot/tgbot_level.go
  68. 100 0
      internal/web/service/tgbot/tgbot_level_test.go
  69. 21 2
      internal/web/service/tgbot/tgbot_router.go
  70. 1 1
      internal/web/service/xray_amneziawg_outbound_test.go
  71. 9 3
      internal/web/translation/ar-EG.json
  72. 9 3
      internal/web/translation/en-US.json
  73. 9 3
      internal/web/translation/es-ES.json
  74. 9 3
      internal/web/translation/fa-IR.json
  75. 9 3
      internal/web/translation/id-ID.json
  76. 9 3
      internal/web/translation/ja-JP.json
  77. 9 3
      internal/web/translation/pt-BR.json
  78. 9 3
      internal/web/translation/ru-RU.json
  79. 9 3
      internal/web/translation/tr-TR.json
  80. 9 3
      internal/web/translation/uk-UA.json
  81. 9 3
      internal/web/translation/vi-VN.json
  82. 9 3
      internal/web/translation/zh-CN.json
  83. 9 3
      internal/web/translation/zh-TW.json

+ 3 - 6
.gitattributes

@@ -1,6 +1,3 @@
-*.sh text eol=lf
-frontend/src/generated/** text eol=lf
-frontend/public/openapi.json text eol=lf
-frontend/src/test/__snapshots__/** text eol=lf
-*.go text eol=lf
-deploy/**/*.yaml text eol=lf
+# LF in every checkout, Windows included: format-check, the msw worker check and
+# tests that parse repo files compare bytes, so a CRLF working copy fails them.
+* text=auto eol=lf

+ 11 - 2
docs/content/docs/en/operations/telegram-bot.mdx

@@ -53,13 +53,22 @@ Additional commands:
 
 | Command            | Who    | Action                                                       |
 | ------------------ | ------ | ------------------------------------------------------------ |
-| `/start`, `/help`  | anyone | Greeting and the menu of inline buttons                      |
-| `/status`          | anyone | Confirm the bot is alive                                     |
+| `/start`           | anyone | Greeting and the menu of inline buttons; an unlinked account gets only its Telegram ID |
+| `/help`            | both   | The menu of inline buttons                                   |
+| `/status`          | both   | Confirm the bot is alive                                     |
 | `/id`              | anyone | Show your Telegram numeric ID                                |
 | `/usage <arg>`     | both   | Admins search clients; users look up their own usage         |
 | `/inbound <remark>`| admin  | Show an inbound's details                                    |
 | `/restart`         | admin  | Restart Xray                                                 |
 
+A user is a Telegram account linked to at least one client. Any other account
+can run only `/start` and `/id`; the bot ignores its other commands and
+button taps. To link a customer, tap **Invite Link** on the client's card in the bot and
+send them the `t.me` link: the first account to open it is linked to every
+client that shares that Subscription ID. The Subscription ID is the invite code,
+so keep it long and random. Each account gets five claim attempts an hour, and
+admins are notified when one runs out.
+
 Admins also get inline-button flows for server usage, sorted traffic reports,
 resetting traffic, DB backups, ban logs, listing inbounds/clients, online
 clients, "depleting soon", and a full **add-client** wizard. Regular users get

+ 8 - 8
docs/content/docs/en/reference/api/authentication.mdx

@@ -24,10 +24,10 @@ _openapi:
       url: '#mint-a-csrf-token-for-the-current-session-the-spa-replays-it-in-the-x-csrf-token-header-on-unsafe-requests-bearer-token-callers-can-skip-this--the-middleware-short-circuits-csrf-for-authenticated-api-requests'
     - depth: 2
       title: Public. Active paid sponsor placements read from the project
-        sponsors.json (cached for 1h); expired entries are dropped. Logos are
-        proxied by the panel at /sponsors/logo/{name}. Used by the login page
-        and panel sponsor slots.
-      url: '#public-active-paid-sponsor-placements-read-from-the-project-sponsorsjson-cached-for-1h-expired-entries-are-dropped-logos-are-proxied-by-the-panel-at-sponsorslogoname-used-by-the-login-page-and-panel-sponsor-slots'
+        sponsors.json (cached for 1h); entries outside their from/until window
+        are dropped. Logos are proxied by the panel at /sponsors/logo/{name}.
+        Used by the login page and panel sponsor slots.
+      url: '#public-active-paid-sponsor-placements-read-from-the-project-sponsorsjson-cached-for-1h-entries-outside-their-fromuntil-window-are-dropped-logos-are-proxied-by-the-panel-at-sponsorslogoname-used-by-the-login-page-and-panel-sponsor-slots'
     - depth: 2
       title: Returns whether 2FA is enabled on the panel — used by the login page to
         decide whether to show the OTP field.
@@ -46,10 +46,10 @@ _openapi:
           requests.
         id: mint-a-csrf-token-for-the-current-session-the-spa-replays-it-in-the-x-csrf-token-header-on-unsafe-requests-bearer-token-callers-can-skip-this--the-middleware-short-circuits-csrf-for-authenticated-api-requests
       - content: Public. Active paid sponsor placements read from the project
-          sponsors.json (cached for 1h); expired entries are dropped. Logos are
-          proxied by the panel at /sponsors/logo/{name}. Used by the login page
-          and panel sponsor slots.
-        id: public-active-paid-sponsor-placements-read-from-the-project-sponsorsjson-cached-for-1h-expired-entries-are-dropped-logos-are-proxied-by-the-panel-at-sponsorslogoname-used-by-the-login-page-and-panel-sponsor-slots
+          sponsors.json (cached for 1h); entries outside their from/until window
+          are dropped. Logos are proxied by the panel at /sponsors/logo/{name}.
+          Used by the login page and panel sponsor slots.
+        id: public-active-paid-sponsor-placements-read-from-the-project-sponsorsjson-cached-for-1h-entries-outside-their-fromuntil-window-are-dropped-logos-are-proxied-by-the-panel-at-sponsorslogoname-used-by-the-login-page-and-panel-sponsor-slots
       - content: Returns whether 2FA is enabled on the panel — used by the login page to
           decide whether to show the OTP field.
         id: returns-whether-2fa-is-enabled-on-the-panel--used-by-the-login-page-to-decide-whether-to-show-the-otp-field

+ 1 - 1
docs/content/docs/en/reference/env-vars.mdx

@@ -42,7 +42,7 @@ default. Encryption at rest is opt-in and fails closed: with any mode other than
 | Variable                  | Default                         | Description                                                                                                                                                            |
 | ------------------------- | ------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
 | `NODE_TOKEN_ENCRYPTION`   | `off`                           | `off`, `migration` (reads accept plaintext or ciphertext, writes encrypt), or `required` (same writes, startup fails without a key). Note the missing `XUI_` prefix.    |
-| `XUI_NODE_TOKEN_KEY_FILE` | `/etc/x-ui/node_token_key.json` | JSON keyring, mode `0600` or stricter. Loaded first.                                                                                                                   |
+| `XUI_NODE_TOKEN_KEY_FILE` | `/etc/x-ui/node_token_key.json` | JSON keyring, mode `0600` or stricter (not checked on Windows, where NTFS permissions protect it). Loaded first.                                                       |
 | `XUI_NODE_TOKEN_KEY`      | —                               | A single base64 32-byte key, read only when the key file fails to load. Its key id is fixed to `env`, so it cannot rotate.                                              |
 
 The key file names the active key plus every older key still needed to decrypt:

+ 11 - 2
docs/content/docs/fa/operations/telegram-bot.mdx

@@ -53,13 +53,22 @@ icon: Send
 
 | فرمان              | چه کسی | عملکرد                                                       |
 | ------------------ | ------ | ------------------------------------------------------------ |
-| `/start`، `/help`  | همه    | پیام خوش‌آمدگویی و منوی دکمه‌های درون‌خطی                      |
-| `/status`          | همه    | تأیید فعال بودن ربات                                          |
+| `/start`           | همه    | پیام خوش‌آمدگویی و منوی دکمه‌های درون‌خطی؛ حساب متصل‌نشده فقط شناسه‌ی Telegram خود را می‌گیرد |
+| `/help`            | هر دو  | منوی دکمه‌های درون‌خطی                                         |
+| `/status`          | هر دو  | تأیید فعال بودن ربات                                          |
 | `/id`              | همه    | نمایش شناسه‌ی عددی Telegram شما                               |
 | `/usage <arg>`     | هر دو  | ادمین‌ها کلاینت‌ها را جست‌وجو می‌کنند؛ کاربران مصرف خود را می‌بینند |
 | `/inbound <remark>`| ادمین  | نمایش جزئیات یک ورودی                                         |
 | `/restart`         | ادمین  | راه‌اندازی مجدد Xray                                          |
 
+کاربر یعنی حساب Telegramی که دست‌کم به یک کلاینت متصل است. هر حساب دیگری فقط
+`/start` و `/id` را می‌تواند اجرا کند و ربات فرمان‌ها و دکمه‌های دیگر آن را نادیده
+می‌گیرد. برای اتصال یک مشتری، در کارت کلاینت در ربات روی **لینک دعوت** بزنید و لینک `t.me`
+را برایش بفرستید: نخستین حسابی که آن را باز کند به همه‌ی کلاینت‌هایی که آن شناسه
+اشتراک را دارند متصل می‌شود. شناسه اشتراک همان کد دعوت است، پس آن را طولانی و
+تصادفی نگه دارید. هر حساب در هر ساعت پنج بار می‌تواند تلاش کند و پس از آن به
+ادمین‌ها اطلاع داده می‌شود.
+
 ادمین‌ها همچنین جریان‌های دکمه‌ی درون‌خطی برای مصرف سرور، گزارش‌های ترافیک مرتب‌شده،
 بازنشانی ترافیک، پشتیبان‌گیری از DB، گزارش‌های مسدودسازی، فهرست کردن ورودی‌ها/کلاینت‌ها،
 کلاینت‌های آنلاین، «به‌زودی تمام‌شونده» و یک جادوگر کامل **افزودن کلاینت** را در اختیار دارند.

+ 1 - 1
docs/content/docs/fa/reference/env-vars.mdx

@@ -42,7 +42,7 @@ icon: Variable
 | Variable                  | Default                         | Description                                                                                                                                                            |
 | ------------------------- | ------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
 | `NODE_TOKEN_ENCRYPTION`   | `off`                           | ‏`off`، `migration` (خواندن هم متن ساده و هم متن رمزشده را می‌پذیرد، نوشتن همیشه رمز می‌کند) یا `required` (نوشتن یکسان، اما بدون کلید اجرا شکست می‌خورد). به نبودِ پیشوند `XUI_` توجه کنید. |
-| `XUI_NODE_TOKEN_KEY_FILE` | `/etc/x-ui/node_token_key.json` | حلقه‌کلید JSON با دسترسی `0600` یا محدودتر. نخست همین بارگذاری می‌شود.                                                                                                 |
+| `XUI_NODE_TOKEN_KEY_FILE` | `/etc/x-ui/node_token_key.json` | حلقه‌کلید JSON با دسترسی `0600` یا محدودتر (در ویندوز بررسی نمی‌شود و مجوزهای NTFS از آن محافظت می‌کنند). نخست همین بارگذاری می‌شود.                                     |
 | `XUI_NODE_TOKEN_KEY`      | —                               | یک کلید ۳۲ بایتی base64 که فقط هنگام شکست بارگذاری فایل کلید خوانده می‌شود. شناسه‌ی کلید آن ثابت و برابر `env` است، پس امکان چرخش ندارد.                                |
 
 فایل کلید، کلید فعال به‌همراه هر کلید قدیمی‌ای را که هنوز برای رمزگشایی لازم است نام می‌برد:

+ 12 - 2
docs/content/docs/ru/operations/telegram-bot.mdx

@@ -55,13 +55,23 @@ chat ID** (через запятую). Сохраните, затем напиш
 
 | Команда            | Кому   | Действие                                                     |
 | ------------------ | ------ | ------------------------------------------------------------ |
-| `/start`, `/help`  | всем   | Приветствие и меню встроенных кнопок                         |
-| `/status`          | всем   | Подтверждает, что бот работает                               |
+| `/start`           | всем   | Приветствие и меню встроенных кнопок; непривязанный аккаунт получает только свой Telegram ID |
+| `/help`            | обоим  | Меню встроенных кнопок                                       |
+| `/status`          | обоим  | Подтверждает, что бот работает                               |
 | `/id`              | всем   | Показывает ваш числовой Telegram ID                          |
 | `/usage <arg>`     | обоим  | Администраторы ищут клиентов; пользователи смотрят свой расход |
 | `/inbound <remark>`| админ  | Показывает сведения о входящем подключении                   |
 | `/restart`         | админ  | Перезапускает Xray                                          |
 
+Пользователь — это аккаунт Telegram, привязанный хотя бы к одному клиенту. Любой
+другой аккаунт может выполнять только `/start` и `/id`; остальные его команды и
+нажатия кнопок бот игнорирует. Чтобы привязать клиента, нажмите
+**Ссылка-приглашение** в карточке клиента в боте и отправьте ему ссылку `t.me`: первый
+открывший её аккаунт привязывается ко всем клиентам с этим ID подписки. ID
+подписки служит кодом приглашения, поэтому делайте его длинным и случайным.
+У каждого аккаунта пять попыток в час, после чего администраторы получают
+уведомление.
+
 Администраторам также доступны сценарии со встроенными кнопками: использование
 сервера, отсортированные отчёты по трафику, сброс трафика, резервные копии БД,
 журналы блокировок, список входящих подключений/клиентов, онлайн-клиенты,

+ 1 - 1
docs/content/docs/ru/reference/env-vars.mdx

@@ -43,7 +43,7 @@ API-токены узлов — и сохранённый токен PIA — п
 | Variable                  | Default                         | Description                                                                                                                                                            |
 | ------------------------- | ------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
 | `NODE_TOKEN_ENCRYPTION`   | `off`                           | `off`, `migration` (чтение принимает открытый текст или шифротекст, запись всегда шифрует) или `required` (запись та же, но без ключа запуск не удастся). Префикса `XUI_` здесь нет. |
-| `XUI_NODE_TOKEN_KEY_FILE` | `/etc/x-ui/node_token_key.json` | JSON-связка ключей с правами `0600` или строже. Загружается первой.                                                                                                    |
+| `XUI_NODE_TOKEN_KEY_FILE` | `/etc/x-ui/node_token_key.json` | JSON-связка ключей с правами `0600` или строже (в Windows не проверяется: файл защищают права NTFS). Загружается первой.                                               |
 | `XUI_NODE_TOKEN_KEY`      | —                               | Один 32-байтный ключ в base64, читается только при неудачной загрузке файла ключей. Его идентификатор фиксирован (`env`), поэтому ротация невозможна.                   |
 
 Файл ключей задаёт активный ключ и все прежние ключи, ещё нужные для расшифровки:

+ 9 - 2
docs/content/docs/zh/operations/telegram-bot.mdx

@@ -51,13 +51,20 @@ icon: Send
 
 | 命令               | 适用对象 | 作用                                                         |
 | ------------------ | ------ | ------------------------------------------------------------ |
-| `/start`、`/help`  | 任何人 | 问候语以及内联按钮菜单                                       |
-| `/status`          | 任何人 | 确认机器人在线                                               |
+| `/start`           | 任何人 | 问候语以及内联按钮菜单;未绑定的账号只会收到自己的 Telegram ID |
+| `/help`            | 两者   | 内联按钮菜单                                                 |
+| `/status`          | 两者   | 确认机器人在线                                               |
 | `/id`              | 任何人 | 显示你的 Telegram 数字 ID                                    |
 | `/usage <arg>`     | 两者   | 管理员可搜索客户端;用户则查询自己的用量                     |
 | `/inbound <remark>`| 管理员 | 显示某个入站的详情                                           |
 | `/restart`         | 管理员 | 重启 Xray                                                    |
 
+用户是指至少绑定了一个客户端的 Telegram 账号。其他账号只能使用 `/start` 和
+`/id`,机器人会忽略它们的其他命令和按钮点击。要绑定客户,请在机器人的客户端卡片上点击
+**邀请链接**,并把 `t.me` 链接发给对方:第一个打开该链接的账号会绑定到共用该订阅
+ID 的所有客户端。订阅 ID 就是邀请码,因此请保持其足够长且随机。每个账号每小时
+可尝试五次,用完后会通知管理员。
+
 管理员还可通过内联按钮使用一系列功能:服务器用量、按流量排序的报告、
 重置流量、数据库备份、封禁日志、列出入站/客户端、在线客户端、
 “即将耗尽”,以及完整的**添加客户端**向导。普通用户则可以使用按钮查看

+ 1 - 1
docs/content/docs/zh/reference/env-vars.mdx

@@ -40,7 +40,7 @@ icon: Variable
 | Variable                  | Default                         | Description                                                                                                                                                            |
 | ------------------------- | ------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
 | `NODE_TOKEN_ENCRYPTION`   | `off`                           | `off`、`migration`(读取时接受明文或密文,写入一律加密)或 `required`(写入相同,但缺少密钥时启动失败)。注意此处没有 `XUI_` 前缀。                                     |
-| `XUI_NODE_TOKEN_KEY_FILE` | `/etc/x-ui/node_token_key.json` | JSON 密钥环,权限须为 `0600` 或更严格。优先加载。                                                                                                                      |
+| `XUI_NODE_TOKEN_KEY_FILE` | `/etc/x-ui/node_token_key.json` | JSON 密钥环,权限须为 `0600` 或更严格(Windows 上不检查,由 NTFS 权限保护)。优先加载。                                                                                |
 | `XUI_NODE_TOKEN_KEY`      | —                               | 单个 base64 编码的 32 字节密钥,仅在密钥文件加载失败时读取。其密钥 ID 固定为 `env`,因此无法轮换。                                                                      |
 
 密钥文件同时记录活动密钥和所有仍需用于解密的旧密钥:

+ 16 - 1
docs/public/openapi.json

@@ -3054,6 +3054,11 @@
             "example": true,
             "type": "boolean"
           },
+          "excludeFromSub": {
+            "description": "Whether to omit this inbound from subscription output while keeping it operational",
+            "example": false,
+            "type": "boolean"
+          },
           "expiryTime": {
             "description": "Expiration timestamp",
             "format": "int64",
@@ -3177,6 +3182,7 @@
           "disableFlow",
           "down",
           "enable",
+          "excludeFromSub",
           "expiryTime",
           "id",
           "lastTrafficResetTime",
@@ -4496,6 +4502,12 @@
             "nullable": true,
             "type": "boolean"
           },
+          "from": {
+            "example": "2026-10-01T00:00:00Z",
+            "format": "date-time",
+            "nullable": true,
+            "type": "string"
+          },
           "id": {
             "example": "acme-2026-10",
             "type": "string"
@@ -5023,7 +5035,7 @@
         "tags": [
           "Authentication"
         ],
-        "summary": "Public. Active paid sponsor placements read from the project sponsors.json (cached for 1h); expired entries are dropped. Logos are proxied by the panel at /sponsors/logo/{name}. Used by the login page and panel sponsor slots.",
+        "summary": "Public. Active paid sponsor placements read from the project sponsors.json (cached for 1h); entries outside their from/until window are dropped. Logos are proxied by the panel at /sponsors/logo/{name}. Used by the login page and panel sponsor slots.",
         "operationId": "get_sponsors",
         "responses": {
           "200": {
@@ -5051,6 +5063,7 @@
                     "sponsors": [
                       {
                         "enable": true,
+                        "from": "2026-10-01T00:00:00Z",
                         "id": "acme-2026-10",
                         "link": "https://acme.example/?utm_source=3x-ui",
                         "logo": "/sponsors/logo/acme.png",
@@ -5162,6 +5175,7 @@
                       "disableFlow": false,
                       "down": 0,
                       "enable": true,
+                      "excludeFromSub": false,
                       "expiryTime": 0,
                       "fallbackParent": null,
                       "id": 1,
@@ -16250,6 +16264,7 @@
                   "disableFlow": false,
                   "down": 0,
                   "enable": true,
+                  "excludeFromSub": false,
                   "expiryTime": 0,
                   "fallbackParent": null,
                   "id": 1,

+ 16 - 1
frontend/public/openapi.json

@@ -3054,6 +3054,11 @@
             "example": true,
             "type": "boolean"
           },
+          "excludeFromSub": {
+            "description": "Whether to omit this inbound from subscription output while keeping it operational",
+            "example": false,
+            "type": "boolean"
+          },
           "expiryTime": {
             "description": "Expiration timestamp",
             "format": "int64",
@@ -3177,6 +3182,7 @@
           "disableFlow",
           "down",
           "enable",
+          "excludeFromSub",
           "expiryTime",
           "id",
           "lastTrafficResetTime",
@@ -4496,6 +4502,12 @@
             "nullable": true,
             "type": "boolean"
           },
+          "from": {
+            "example": "2026-10-01T00:00:00Z",
+            "format": "date-time",
+            "nullable": true,
+            "type": "string"
+          },
           "id": {
             "example": "acme-2026-10",
             "type": "string"
@@ -5023,7 +5035,7 @@
         "tags": [
           "Authentication"
         ],
-        "summary": "Public. Active paid sponsor placements read from the project sponsors.json (cached for 1h); expired entries are dropped. Logos are proxied by the panel at /sponsors/logo/{name}. Used by the login page and panel sponsor slots.",
+        "summary": "Public. Active paid sponsor placements read from the project sponsors.json (cached for 1h); entries outside their from/until window are dropped. Logos are proxied by the panel at /sponsors/logo/{name}. Used by the login page and panel sponsor slots.",
         "operationId": "get_sponsors",
         "responses": {
           "200": {
@@ -5051,6 +5063,7 @@
                     "sponsors": [
                       {
                         "enable": true,
+                        "from": "2026-10-01T00:00:00Z",
                         "id": "acme-2026-10",
                         "link": "https://acme.example/?utm_source=3x-ui",
                         "logo": "/sponsors/logo/acme.png",
@@ -5162,6 +5175,7 @@
                       "disableFlow": false,
                       "down": 0,
                       "enable": true,
+                      "excludeFromSub": false,
                       "expiryTime": 0,
                       "fallbackParent": null,
                       "id": 1,
@@ -16250,6 +16264,7 @@
                   "disableFlow": false,
                   "down": 0,
                   "enable": true,
+                  "excludeFromSub": false,
                   "expiryTime": 0,
                   "fallbackParent": null,
                   "id": 1,

+ 3 - 0
frontend/src/generated/examples.ts

@@ -796,6 +796,7 @@ export const EXAMPLES: Record<string, unknown> = {
     "disableFlow": false,
     "down": 0,
     "enable": true,
+    "excludeFromSub": false,
     "expiryTime": 0,
     "fallbackParent": null,
     "id": 1,
@@ -1106,6 +1107,7 @@ export const EXAMPLES: Record<string, unknown> = {
   },
   "Sponsor": {
     "enable": true,
+    "from": "2026-10-01T00:00:00Z",
     "id": "acme-2026-10",
     "link": "https://acme.example/?utm_source=3x-ui",
     "logo": "/sponsors/logo/acme.png",
@@ -1122,6 +1124,7 @@ export const EXAMPLES: Record<string, unknown> = {
     "sponsors": [
       {
         "enable": true,
+        "from": "2026-10-01T00:00:00Z",
         "id": "acme-2026-10",
         "link": "https://acme.example/?utm_source=3x-ui",
         "logo": "/sponsors/logo/acme.png",

+ 12 - 0
frontend/src/generated/schemas.ts

@@ -3028,6 +3028,11 @@ export const SCHEMAS: Record<string, unknown> = {
         "example": true,
         "type": "boolean"
       },
+      "excludeFromSub": {
+        "description": "Whether to omit this inbound from subscription output while keeping it operational",
+        "example": false,
+        "type": "boolean"
+      },
       "expiryTime": {
         "description": "Expiration timestamp",
         "format": "int64",
@@ -3151,6 +3156,7 @@ export const SCHEMAS: Record<string, unknown> = {
       "disableFlow",
       "down",
       "enable",
+      "excludeFromSub",
       "expiryTime",
       "id",
       "lastTrafficResetTime",
@@ -4470,6 +4476,12 @@ export const SCHEMAS: Record<string, unknown> = {
         "nullable": true,
         "type": "boolean"
       },
+      "from": {
+        "example": "2026-10-01T00:00:00Z",
+        "format": "date-time",
+        "nullable": true,
+        "type": "string"
+      },
       "id": {
         "example": "acme-2026-10",
         "type": "string"

+ 2 - 0
frontend/src/generated/types.ts

@@ -705,6 +705,7 @@ export interface Inbound {
   disableFlow: boolean;
   down: number;
   enable: boolean;
+  excludeFromSub: boolean;
   expiryTime: number;
   fallbackParent?: FallbackParentInfo | null;
   id: number;
@@ -1027,6 +1028,7 @@ export interface Setting {
 
 export interface Sponsor {
   enable?: boolean | null;
+  from?: string | null;
   id: string;
   link: string;
   logo?: string;

+ 2 - 0
frontend/src/generated/zod.ts

@@ -748,6 +748,7 @@ export const InboundSchema = z.object({
   disableFlow: z.boolean(),
   down: z.number().int(),
   enable: z.boolean(),
+  excludeFromSub: z.boolean(),
   expiryTime: z.number().int(),
   fallbackParent: z.lazy(() => FallbackParentInfoSchema).nullable().optional(),
   id: z.number().int(),
@@ -1090,6 +1091,7 @@ export type Setting = z.infer<typeof SettingSchema>;
 
 export const SponsorSchema = z.object({
   enable: z.boolean().nullable().optional(),
+  from: z.string().nullable().optional(),
   id: z.string(),
   link: z.string(),
   logo: z.string().optional(),

+ 4 - 0
frontend/src/lib/xray/inbound-form-adapter.ts

@@ -54,6 +54,7 @@ export interface RawInboundRow {
   shareAddrStrategy?: string;
   shareAddr?: string;
   subSortIndex?: number;
+  excludeFromSub?: boolean;
   disableFlow?: boolean;
   clientStats?: unknown;
 }
@@ -83,6 +84,7 @@ export interface WireInboundPayload {
   shareAddrStrategy: ShareAddrStrategy;
   shareAddr: string;
   subSortIndex: number;
+  excludeFromSub: boolean;
   disableFlow: boolean;
 }
 
@@ -219,6 +221,7 @@ export function rawInboundToFormValues(row: RawInboundRow): InboundFormValues {
     shareAddrStrategy: coerceShareAddrStrategy(row.shareAddrStrategy),
     shareAddr: row.shareAddr ?? '',
     subSortIndex: row.subSortIndex == null || row.subSortIndex === 0 ? 1 : row.subSortIndex,
+    excludeFromSub: row.excludeFromSub ?? false,
     disableFlow: row.disableFlow ?? false,
     protocol,
     settings,
@@ -387,6 +390,7 @@ export function formValuesToWirePayload(values: InboundFormValues): WireInboundP
     shareAddrStrategy: values.shareAddrStrategy,
     shareAddr: values.shareAddr,
     subSortIndex: values.subSortIndex,
+    excludeFromSub: values.excludeFromSub,
     disableFlow: values.disableFlow,
   };
   if (values.nodeId != null) payload.nodeId = values.nodeId;

+ 3 - 0
frontend/src/models/dbinbound.ts

@@ -44,6 +44,7 @@ export type DBInboundInit = Partial<{
   shareAddrStrategy: string;
   shareAddr: string;
   subSortIndex: number;
+  excludeFromSub: boolean;
   disableFlow: boolean;
   originNodeGuid: string;
   fallbackParent: FallbackParentRef | null;
@@ -93,6 +94,7 @@ export class DBInbound {
   shareAddrStrategy: string;
   shareAddr: string;
   subSortIndex: number;
+  excludeFromSub: boolean;
   disableFlow: boolean;
   originNodeGuid: string;
   fallbackParent: FallbackParentRef | null;
@@ -124,6 +126,7 @@ export class DBInbound {
     this.shareAddrStrategy = 'node';
     this.shareAddr = '';
     this.subSortIndex = 1;
+    this.excludeFromSub = false;
     this.disableFlow = false;
     this.originNodeGuid = '';
     this.fallbackParent = null;

+ 1 - 1
frontend/src/pages/api-docs/endpoints.ts

@@ -240,7 +240,7 @@ export const sections: readonly Section[] = [
         method: 'GET',
         path: '/sponsors',
         summary:
-          'Public. Active paid sponsor placements read from the project sponsors.json (cached for 1h); expired entries are dropped. Logos are proxied by the panel at /sponsors/logo/{name}. Used by the login page and panel sponsor slots.',
+          'Public. Active paid sponsor placements read from the project sponsors.json (cached for 1h); entries outside their from/until window are dropped. Logos are proxied by the panel at /sponsors/logo/{name}. Used by the login page and panel sponsor slots.',
         responseSchema: 'SponsorList',
       },
       {

+ 22 - 18
frontend/src/pages/clients/ClientFormModal.tsx

@@ -991,18 +991,10 @@ export default function ClientFormModal({
                             />
                           </Form.Item>
                         </Col>
-                        <Col xs={24}>
-                          <ClientRenewalFields
-                            active={open}
-                            delayedStart={delayedStart}
-                            expiryTime={
-                              delayedStart ? -86400000 * (delayedDays || 0) : expiryDate || 0
-                            }
-                            resetCount={client?.traffic?.resetCount || 0}
-                            setExpiry={(expiry) => methods.setValue('expiryDate', expiry)}
-                          />
-                        </Col>
-                        <Col xs={12} md={6}>
+                      </Row>
+
+                      <Row gutter={16}>
+                        <Col xs={24} md={12}>
                           <FormField
                             name="trafficReset"
                             label={t('pages.inbounds.periodicTrafficResetTitle')}
@@ -1014,9 +1006,7 @@ export default function ClientFormModal({
                               }))}
                             />
                           </FormField>
-                        </Col>
-                        {trafficReset === 'monthly' && (
-                          <Col xs={12} md={6}>
+                          {trafficReset === 'monthly' && (
                             <FormField
                               name="trafficResetDay"
                               label={t('pages.inbounds.periodicTrafficResetDay')}
@@ -1024,8 +1014,19 @@ export default function ClientFormModal({
                             >
                               <InputNumber min={1} max={31} style={{ width: '100%' }} />
                             </FormField>
-                          </Col>
-                        )}
+                          )}
+                        </Col>
+                        <Col xs={24} md={12}>
+                          <ClientRenewalFields
+                            active={open}
+                            delayedStart={delayedStart}
+                            expiryTime={
+                              delayedStart ? -86400000 * (delayedDays || 0) : expiryDate || 0
+                            }
+                            resetCount={client?.traffic?.resetCount || 0}
+                            setExpiry={(expiry) => methods.setValue('expiryDate', expiry)}
+                          />
+                        </Col>
                       </Row>
 
                       <Row gutter={16}>
@@ -1151,7 +1152,10 @@ export default function ClientFormModal({
                         </Space.Compact>
                       </Form.Item>
 
-                      <Form.Item label={t('pages.clients.subId')}>
+                      <Form.Item
+                        label={t('pages.clients.subId')}
+                        tooltip={t('pages.clients.subIdDesc')}
+                      >
                         <Space.Compact style={{ display: 'flex' }}>
                           <Input
                             value={subId}

+ 1 - 1
frontend/src/pages/clients/ClientQrModal.tsx

@@ -50,7 +50,7 @@ type QrVariant = 'standard' | 'happ';
 type HappError = 'too_long' | 'unavailable' | null;
 
 const HAPP_CRYPT5_PREFIX = 'happ://crypt5/';
-const HAPP_SETTINGS_PATH = '/settings?subscriptionTab=happ&happTab=links#subscription';
+const HAPP_SETTINGS_PATH = '/settings?subscriptionTab=happ#subscription';
 // QrPanel encodes at error level L; QR version 40 holds 2953 UTF-8 bytes at that level.
 const HAPP_QR_MAX_BYTES = 2953;
 const UTF8_ENCODER = new TextEncoder();

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

@@ -700,6 +700,17 @@ export default function InboundFormModal({
         <InputNumber />
       </FormField>
 
+      <FormField
+        name="excludeFromSub"
+        valueProp="checked"
+        label={labelWithHint(
+          t('pages.inbounds.form.excludeFromSub'),
+          t('pages.inbounds.form.excludeFromSubHelp'),
+        )}
+      >
+        <Switch />
+      </FormField>
+
       {protocol === Protocols.VLESS && (
         <FormField
           name="disableFlow"

+ 98 - 131
frontend/src/pages/settings/HappSettingsContent.tsx

@@ -6,9 +6,6 @@ import {
   BuildOutlined,
   CloudSyncOutlined,
   DesktopOutlined,
-  LinkOutlined,
-  MobileOutlined,
-  NotificationOutlined,
   ThunderboltOutlined,
 } from '@ant-design/icons';
 import type { AllSetting } from '@/models/setting';
@@ -22,7 +19,6 @@ interface HappSettingsContentProps {
   updateSetting: (patch: Partial<AllSetting>) => void;
   isMobile: boolean;
   remoteSourceBadge: (val: string) => React.ReactNode;
-  defaultActiveTab?: 'routing' | 'links';
 }
 
 export default function HappSettingsContent({
@@ -30,7 +26,6 @@ export default function HappSettingsContent({
   updateSetting,
   isMobile,
   remoteSourceBadge,
-  defaultActiveTab = 'routing',
 }: HappSettingsContentProps) {
   const { t } = useTranslation();
   // Generator choices stay local until Apply updates the draft; page Save persists it.
@@ -65,17 +60,27 @@ export default function HappSettingsContent({
         />
       </SettingListItem>
 
+      <SettingListItem
+        paddings="small"
+        title={t('pages.settings.happLinkEnable')}
+        description={t('pages.settings.happLinkEnableDesc')}
+      >
+        <Switch
+          checked={allSetting.happLinkEnable}
+          onChange={(v) => updateSetting({ happLinkEnable: v })}
+        />
+      </SettingListItem>
+
       <Tabs
         type="card"
         size="small"
-        defaultActiveKey={defaultActiveTab}
         items={[
           {
             key: 'routing',
-            label: (
-              <span>
-                <BranchesOutlined /> {!isMobile && t('pages.settings.subHappGroupRouting')}
-              </span>
+            label: catTabLabel(
+              <BranchesOutlined />,
+              t('pages.settings.subHappGroupRouting'),
+              isMobile,
             ),
             children: (
               <>
@@ -173,27 +178,11 @@ export default function HappSettingsContent({
             ),
           },
           {
-            key: 'links',
-            label: catTabLabel(<LinkOutlined />, t('pages.settings.subHappGroupLinks'), isMobile),
-            children: (
-              <SettingListItem
-                paddings="small"
-                title={t('pages.settings.happLinkEnable')}
-                description={t('pages.settings.happLinkEnableDesc')}
-              >
-                <Switch
-                  checked={allSetting.happLinkEnable}
-                  onChange={(v) => updateSetting({ happLinkEnable: v })}
-                />
-              </SettingListItem>
-            ),
-          },
-          {
-            key: 'banners',
-            label: (
-              <span>
-                <NotificationOutlined /> {!isMobile && t('pages.settings.subHappGroupBanners')}
-              </span>
+            key: 'appearance',
+            label: catTabLabel(
+              <DesktopOutlined />,
+              t('pages.settings.subHappGroupThemes'),
+              isMobile,
             ),
             children: (
               <>
@@ -285,15 +274,59 @@ export default function HappSettingsContent({
                     onChange={(v) => updateSetting({ subHappNotificationExpire: v })}
                   />
                 </SettingListItem>
+
+                <SettingListItem
+                  paddings="small"
+                  title={t('pages.settings.subHappColorProfile')}
+                  description={t('pages.settings.subHappColorProfileDesc')}
+                >
+                  <Space orientation="vertical" style={{ width: '100%' }}>
+                    <Input
+                      value={allSetting.subHappColorProfile}
+                      placeholder='{"serverRowBackgroundColor":"#21003D67"} or resetcolors'
+                      onChange={(e) => updateSetting({ subHappColorProfile: e.target.value })}
+                    />
+                    <Space wrap size="small">
+                      <Button
+                        size="small"
+                        onClick={() => updateSetting({ subHappColorProfile: 'resetcolors' })}
+                      >
+                        {t('reset')}
+                      </Button>
+                      <Button
+                        size="small"
+                        onClick={() =>
+                          updateSetting({
+                            subHappColorProfile:
+                              '{"serverRowBackgroundColor":"#21003D67","cardBackgroundColor":"#120023B3"}',
+                          })
+                        }
+                      >
+                        Violet
+                      </Button>
+                      <Button
+                        size="small"
+                        onClick={() =>
+                          updateSetting({
+                            subHappColorProfile:
+                              '{"serverRowBackgroundColor":"#002B3667","cardBackgroundColor":"#001F27B3"}',
+                          })
+                        }
+                      >
+                        Turquoise
+                      </Button>
+                    </Space>
+                  </Space>
+                </SettingListItem>
               </>
             ),
           },
           {
             key: 'network',
-            label: (
-              <span>
-                <ThunderboltOutlined /> {!isMobile && t('pages.settings.subHappGroupNetwork')}
-              </span>
+            label: catTabLabel(
+              <ThunderboltOutlined />,
+              t('pages.settings.subHappGroupNetwork'),
+              isMobile,
             ),
             children: (
               <>
@@ -422,70 +455,45 @@ export default function HappSettingsContent({
                     ]}
                   />
                 </SettingListItem>
-              </>
-            ),
-          },
-          {
-            key: 'themes',
-            label: (
-              <span>
-                <DesktopOutlined /> {!isMobile && t('pages.settings.subHappGroupThemes')}
-              </span>
-            ),
-            children: (
-              <>
+
                 <SettingListItem
                   paddings="small"
-                  title={t('pages.settings.subHappColorProfile')}
-                  description={t('pages.settings.subHappColorProfileDesc')}
+                  title={t('pages.settings.subHappPerAppMode')}
+                  description={t('pages.settings.subHappPerAppModeDesc')}
                 >
-                  <Space orientation="vertical" style={{ width: '100%' }}>
-                    <Input
-                      value={allSetting.subHappColorProfile}
-                      placeholder='{"serverRowBackgroundColor":"#21003D67"} or resetcolors'
-                      onChange={(e) => updateSetting({ subHappColorProfile: e.target.value })}
-                    />
-                    <Space wrap size="small">
-                      <Button
-                        size="small"
-                        onClick={() => updateSetting({ subHappColorProfile: 'resetcolors' })}
-                      >
-                        {t('reset')}
-                      </Button>
-                      <Button
-                        size="small"
-                        onClick={() =>
-                          updateSetting({
-                            subHappColorProfile:
-                              '{"serverRowBackgroundColor":"#21003D67","cardBackgroundColor":"#120023B3"}',
-                          })
-                        }
-                      >
-                        Violet
-                      </Button>
-                      <Button
-                        size="small"
-                        onClick={() =>
-                          updateSetting({
-                            subHappColorProfile:
-                              '{"serverRowBackgroundColor":"#002B3667","cardBackgroundColor":"#001F27B3"}',
-                          })
-                        }
-                      >
-                        Turquoise
-                      </Button>
-                    </Space>
-                  </Space>
+                  <Select
+                    value={allSetting.subHappPerAppMode || 'off'}
+                    style={{ width: '100%' }}
+                    onChange={(v) => updateSetting({ subHappPerAppMode: v })}
+                    options={[
+                      { value: 'off', label: t('pages.settings.subHappPerAppOff') },
+                      { value: 'on', label: t('pages.settings.subHappPerAppOn') },
+                      { value: 'bypass', label: t('pages.settings.subHappPerAppBypass') },
+                    ]}
+                  />
+                </SettingListItem>
+
+                <SettingListItem
+                  paddings="small"
+                  title={t('pages.settings.subHappPerAppList')}
+                  description={t('pages.settings.subHappPerAppListDesc')}
+                >
+                  <Input.TextArea
+                    value={allSetting.subHappPerAppList}
+                    rows={4}
+                    placeholder="org.telegram.messenger, com.google.android.youtube"
+                    onChange={(e) => updateSetting({ subHappPerAppList: e.target.value })}
+                  />
                 </SettingListItem>
               </>
             ),
           },
           {
             key: 'failover',
-            label: (
-              <span>
-                <CloudSyncOutlined /> {!isMobile && t('pages.settings.subHappGroupFailover')}
-              </span>
+            label: catTabLabel(
+              <CloudSyncOutlined />,
+              t('pages.settings.subHappGroupFailover'),
+              isMobile,
             ),
             children: (
               <>
@@ -538,47 +546,6 @@ export default function HappSettingsContent({
               </>
             ),
           },
-          {
-            key: 'android',
-            label: (
-              <span>
-                <MobileOutlined /> {!isMobile && t('pages.settings.subHappGroupAndroid')}
-              </span>
-            ),
-            children: (
-              <>
-                <SettingListItem
-                  paddings="small"
-                  title={t('pages.settings.subHappPerAppMode')}
-                  description={t('pages.settings.subHappPerAppModeDesc')}
-                >
-                  <Select
-                    value={allSetting.subHappPerAppMode || 'off'}
-                    style={{ width: '100%' }}
-                    onChange={(v) => updateSetting({ subHappPerAppMode: v })}
-                    options={[
-                      { value: 'off', label: t('pages.settings.subHappPerAppOff') },
-                      { value: 'on', label: t('pages.settings.subHappPerAppOn') },
-                      { value: 'bypass', label: t('pages.settings.subHappPerAppBypass') },
-                    ]}
-                  />
-                </SettingListItem>
-
-                <SettingListItem
-                  paddings="small"
-                  title={t('pages.settings.subHappPerAppList')}
-                  description={t('pages.settings.subHappPerAppListDesc')}
-                >
-                  <Input.TextArea
-                    value={allSetting.subHappPerAppList}
-                    rows={4}
-                    placeholder="org.telegram.messenger, com.google.android.youtube"
-                    onChange={(e) => updateSetting({ subHappPerAppList: e.target.value })}
-                  />
-                </SettingListItem>
-              </>
-            ),
-          },
         ]}
       />
 

+ 0 - 2
frontend/src/pages/settings/SubscriptionGeneralTab.tsx

@@ -414,8 +414,6 @@ export default function SubscriptionGeneralTab({
               updateSetting={updateSetting}
               isMobile={isMobile}
               remoteSourceBadge={remoteSourceBadge}
-              // QR settings links select the link control; ordinary Happ visits still start on routing.
-              defaultActiveTab={searchParams.get('happTab') === 'links' ? 'links' : 'routing'}
             />
           ),
         },

+ 1 - 0
frontend/src/schemas/forms/inbound-form.ts

@@ -81,6 +81,7 @@ export const InboundDbFieldsSchema = z.object({
   shareAddrStrategy: ShareAddrStrategySchema.default('node'),
   shareAddr: z.string().default(''),
   subSortIndex: z.number().int().default(1),
+  excludeFromSub: z.boolean().default(false),
   disableFlow: z.boolean().default(false),
 });
 export type InboundDbFields = z.infer<typeof InboundDbFieldsSchema>;

+ 1 - 1
frontend/src/test/client-qr-modal.test.tsx

@@ -241,7 +241,7 @@ describe('ClientQrModal Happ presentation', () => {
     expect(view.onOpenChange).toHaveBeenCalledOnce();
     expect(view.onOpenChange).toHaveBeenCalledWith(false);
     expect(screen.getByTestId('location').textContent).toBe(
-      '/settings?subscriptionTab=happ&happTab=links#subscription',
+      '/settings?subscriptionTab=happ#subscription',
     );
     expect(HttpUtil.post).not.toHaveBeenCalled();
   });

+ 29 - 0
frontend/src/test/inbound-form-adapter.test.ts

@@ -294,6 +294,35 @@ describe('formValuesToWirePayload', () => {
   });
 });
 
+describe('excludeFromSub', () => {
+  it('DBInbound constructor preserves excludeFromSub from the API row', () => {
+    expect(new DBInbound({ excludeFromSub: true }).excludeFromSub).toBe(true);
+    expect(new DBInbound({ excludeFromSub: false }).excludeFromSub).toBe(false);
+  });
+
+  it('DBInbound defaults excludeFromSub to false when the API omits it', () => {
+    expect(new DBInbound({ protocol: 'vless' }).excludeFromSub).toBe(false);
+    expect(new DBInbound().excludeFromSub).toBe(false);
+  });
+
+  it('rawInboundToFormValues reads excludeFromSub and defaults to false', () => {
+    expect(rawInboundToFormValues({ ...vlessRow, excludeFromSub: true }).excludeFromSub).toBe(true);
+    expect(rawInboundToFormValues(vlessRow).excludeFromSub).toBe(false);
+  });
+
+  it('formValuesToWirePayload includes excludeFromSub', () => {
+    const values = rawInboundToFormValues({ ...vlessRow, excludeFromSub: true });
+    expect(formValuesToWirePayload(values).excludeFromSub).toBe(true);
+  });
+
+  it('excludeFromSub survives raw → DBInbound → values → payload (the edit round-trip)', () => {
+    const db = new DBInbound({ ...vlessRow, excludeFromSub: true } as unknown as DBInboundInit);
+    const values = rawInboundToFormValues(db as unknown as RawInboundRow);
+    const payload = formValuesToWirePayload(values);
+    expect(payload.excludeFromSub).toBe(true);
+  });
+});
+
 describe('disableFlow', () => {
   it('DBInbound constructor preserves disableFlow from the API row', () => {
     expect(new DBInbound({ disableFlow: true }).disableFlow).toBe(true);

+ 3 - 35
frontend/src/test/subscription-general-tab.test.tsx

@@ -144,7 +144,7 @@ describe('SubscriptionGeneralTab', () => {
   });
 
   it.each([false, true])(
-    'updates the Happ link gate from its own tab when stored as %s',
+    'updates the Happ link gate beside auto-detection when stored as %s',
     (enabled) => {
       const updateSetting = vi.fn();
 
@@ -158,11 +158,6 @@ describe('SubscriptionGeneralTab', () => {
       );
 
       fireEvent.click(screen.getByRole('tab', { name: /Happ/ }));
-      expect(
-        screen.getByRole('tab', { name: /Routing & Rules/ }).getAttribute('aria-selected'),
-      ).toBe('true');
-      expect(screen.queryByRole('switch', { name: 'Encrypted subscription links' })).toBeNull();
-      fireEvent.click(screen.getByRole('tab', { name: /Subscription Links/ }));
       const linkSwitch = screen.getByRole('switch', { name: 'Encrypted subscription links' });
       expect(linkSwitch.getAttribute('aria-checked')).toBe(String(enabled));
       expect(updateSetting).not.toHaveBeenCalled();
@@ -172,51 +167,24 @@ describe('SubscriptionGeneralTab', () => {
     },
   );
 
-  it('opens the Happ link tab from the QR settings deep link without enabling generation', () => {
+  it('shows the Happ link gate from the QR settings deep link without enabling generation', () => {
     const updateSetting = vi.fn();
 
     renderWithProviders(
-      <MemoryRouter initialEntries={['/settings?subscriptionTab=happ&happTab=links#subscription']}>
+      <MemoryRouter initialEntries={['/settings?subscriptionTab=happ#subscription']}>
         <SubscriptionGeneralTab
           allSetting={new AllSetting({ happLinkEnable: false })}
           updateSetting={updateSetting}
         />
-        <LocationProbe />
       </MemoryRouter>,
     );
 
     expect(screen.getByRole('tab', { name: /Happ/ }).getAttribute('aria-selected')).toBe('true');
-    expect(
-      screen.getByRole('tab', { name: /Subscription Links/ }).getAttribute('aria-selected'),
-    ).toBe('true');
     expect(
       screen
         .getByRole('switch', { name: 'Encrypted subscription links' })
         .getAttribute('aria-checked'),
     ).toBe('false');
-    expect(screen.getByTestId('location').textContent).toBe(
-      '/settings?subscriptionTab=happ&happTab=links#subscription',
-    );
     expect(updateSetting).not.toHaveBeenCalled();
   });
-
-  it.each(['', '&happTab=unknown'])(
-    'keeps the routing default for a general Happ deep link %s',
-    (query) => {
-      const updateSetting = vi.fn();
-
-      renderWithProviders(
-        <MemoryRouter initialEntries={['/settings?subscriptionTab=happ' + query + '#subscription']}>
-          <SubscriptionGeneralTab allSetting={new AllSetting()} updateSetting={updateSetting} />
-        </MemoryRouter>,
-      );
-
-      expect(screen.getByRole('tab', { name: /Happ/ }).getAttribute('aria-selected')).toBe('true');
-      expect(
-        screen.getByRole('tab', { name: /Routing & Rules/ }).getAttribute('aria-selected'),
-      ).toBe('true');
-      expect(screen.queryByRole('switch', { name: 'Encrypted subscription links' })).toBeNull();
-      expect(updateSetting).not.toHaveBeenCalled();
-    },
-  );
 });

+ 6 - 6
internal/amneziawgnet/bench_test.go

@@ -31,8 +31,8 @@ func BenchmarkStackTunWrite(b *testing.B) {
 
 	b.SetBytes(int64(len(packet)))
 	b.ReportAllocs()
-	b.ResetTimer()
-	for range b.N {
+
+	for b.Loop() {
 		if _, err := tun.Write(bufs, 0); err != nil {
 			b.Fatalf("Write: %v", err)
 		}
@@ -49,8 +49,8 @@ func BenchmarkStackTunRead(b *testing.B) {
 
 	b.SetBytes(int64(len(packet)))
 	b.ReportAllocs()
-	b.ResetTimer()
-	for range b.N {
+
+	for b.Loop() {
 		tun.incomingPacket <- buffer.NewViewWithData(packet)
 		if _, err := tun.Read(buf, sizes, 0); err != nil {
 			b.Fatalf("Read: %v", err)
@@ -77,8 +77,8 @@ func BenchmarkUDPDatagramDelivery(b *testing.B) {
 
 	b.SetBytes(int64(len(payload)))
 	b.ReportAllocs()
-	b.ResetTimer()
-	for range b.N {
+
+	for b.Loop() {
 		if _, err := st.Write(bufs, 0); err != nil {
 			b.Fatalf("Write: %v", err)
 		}

+ 39 - 6
internal/amneziawgnet/egress.go

@@ -4,6 +4,7 @@ import (
 	"context"
 	"crypto/hmac"
 	"encoding/binary"
+	"errors"
 	"fmt"
 	"io"
 	"net"
@@ -18,8 +19,8 @@ import (
 	"github.com/mhsanaei/3x-ui/v3/internal/logger"
 )
 
-// EgressBasePort is the fixed loopback port of the panel's SOCKS5 egress
-// server; it appears in every generated amneziawg socks bridge.
+// EgressBasePort is the loopback port the panel's SOCKS5 egress server tries
+// first; generated amneziawg socks bridges dial whichever port it holds.
 const EgressBasePort = 64900
 
 // socks5EgressServer is a minimal loopback SOCKS5 server routing Xray's
@@ -105,17 +106,22 @@ func (s *socks5EgressServer) DeleteStack(tag string) {
 	flushTunnelDNSCacheForTag(tag)
 }
 
-// Listen starts accepting on the loopback listener. Idempotent; a bind
-// failure is returned and retried by the caller's reconcile tick.
+// Listen starts accepting on EgressBasePort, or on any free loopback port when
+// the OS refuses it. Idempotent; the caller's reconcile tick retries a failure.
 func (s *socks5EgressServer) Listen() error {
 	s.mu.Lock()
 	defer s.mu.Unlock()
 	if s.listener != nil {
 		return nil
 	}
-	ln, err := (&net.ListenConfig{}).Listen(context.Background(), "tcp", fmt.Sprintf("127.0.0.1:%d", EgressBasePort))
+	lc := &net.ListenConfig{}
+	ln, err := lc.Listen(context.Background(), "tcp", fmt.Sprintf("127.0.0.1:%d", EgressBasePort))
 	if err != nil {
-		return fmt.Errorf("amneziawgnet: egress listen: %w", err)
+		var fallbackErr error
+		if ln, fallbackErr = lc.Listen(context.Background(), "tcp", "127.0.0.1:0"); fallbackErr != nil {
+			return fmt.Errorf("amneziawgnet: egress listen: %w", errors.Join(err, fallbackErr))
+		}
+		logger.Warningf("amneziawgnet: egress port %d unavailable, using a free port: %v", EgressBasePort, err)
 	}
 	s.listener = ln
 	s.closing = make(chan struct{})
@@ -125,6 +131,33 @@ func (s *socks5EgressServer) Listen() error {
 	return nil
 }
 
+// Port is the port generated socks bridges must dial: the bound one, or
+// EgressBasePort while nothing is bound, since Listen tries it first.
+func (s *socks5EgressServer) Port() int {
+	if port, ok := s.boundPort(); ok {
+		return port
+	}
+	return EgressBasePort
+}
+
+func (s *socks5EgressServer) boundPort() (int, bool) {
+	s.mu.Lock()
+	defer s.mu.Unlock()
+	if s.listener == nil {
+		return 0, false
+	}
+	addr, ok := s.listener.Addr().(*net.TCPAddr)
+	if !ok {
+		return 0, false
+	}
+	return addr.Port, true
+}
+
+// EgressPort is the process-wide egress server's Port.
+func EgressPort() int {
+	return GetEgressServer().Port()
+}
+
 // Close stops the listener and in-flight handlers; signal first so an accept
 // error always observes closing.
 func (s *socks5EgressServer) Close() {

+ 5 - 5
internal/amneziawgnet/egress_domain_test.go

@@ -358,7 +358,7 @@ func TestEgressGreetingRejectsNoAuthClient(t *testing.T) {
 	tun := newPairedTunnelForTest(t)
 	registerEgressDeviceForTest(t, tun.client)
 
-	ctl, err := (&net.Dialer{Timeout: egressTestDialTimeout}).Dial("tcp", net.JoinHostPort("127.0.0.1", strconv.Itoa(EgressBasePort)))
+	ctl, err := (&net.Dialer{Timeout: egressTestDialTimeout}).Dial("tcp", net.JoinHostPort("127.0.0.1", strconv.Itoa(GetEgressServer().Port())))
 	if err != nil {
 		t.Fatal(err)
 	}
@@ -384,7 +384,7 @@ func TestEgressConnectDomainResolvesThroughTunnel(t *testing.T) {
 	// fail fast (nothing listens on :80), while proving resolution happened.
 	gotQuery := tun.overrideDNS(t, tun.serverIP)
 
-	ctl, err := (&net.Dialer{Timeout: egressTestDialTimeout}).Dial("tcp", net.JoinHostPort("127.0.0.1", strconv.Itoa(EgressBasePort)))
+	ctl, err := (&net.Dialer{Timeout: egressTestDialTimeout}).Dial("tcp", net.JoinHostPort("127.0.0.1", strconv.Itoa(GetEgressServer().Port())))
 	if err != nil {
 		t.Fatal(err)
 	}
@@ -436,7 +436,7 @@ func TestEgressConnectDomainIPv6OnlyTunnelResolvesThroughTunnel(t *testing.T) {
 	resetTunnelDNSCacheForTest()
 	gotQuery := tun.startDNS(t, tun.serverIP)
 
-	ctl, err := (&net.Dialer{Timeout: egressTestDialTimeout}).Dial("tcp", net.JoinHostPort("127.0.0.1", strconv.Itoa(EgressBasePort)))
+	ctl, err := (&net.Dialer{Timeout: egressTestDialTimeout}).Dial("tcp", net.JoinHostPort("127.0.0.1", strconv.Itoa(GetEgressServer().Port())))
 	if err != nil {
 		t.Fatal(err)
 	}
@@ -482,7 +482,7 @@ func TestEgressUDPDatagramDomainForwardedIntoTunnel(t *testing.T) {
 	}
 	defer in.Close()
 
-	ctl, err := (&net.Dialer{Timeout: egressTestDialTimeout}).Dial("tcp", net.JoinHostPort("127.0.0.1", strconv.Itoa(EgressBasePort)))
+	ctl, err := (&net.Dialer{Timeout: egressTestDialTimeout}).Dial("tcp", net.JoinHostPort("127.0.0.1", strconv.Itoa(GetEgressServer().Port())))
 	if err != nil {
 		t.Fatal(err)
 	}
@@ -585,7 +585,7 @@ func TestEgressUDPDatagramDomainInterleavedClients(t *testing.T) {
 	}()
 
 	dialUDP := func() *net.UDPConn {
-		ctl, err := (&net.Dialer{Timeout: egressTestDialTimeout}).Dial("tcp", net.JoinHostPort("127.0.0.1", strconv.Itoa(EgressBasePort)))
+		ctl, err := (&net.Dialer{Timeout: egressTestDialTimeout}).Dial("tcp", net.JoinHostPort("127.0.0.1", strconv.Itoa(GetEgressServer().Port())))
 		if err != nil {
 			t.Fatal(err)
 		}

+ 81 - 0
internal/amneziawgnet/egress_port_test.go

@@ -0,0 +1,81 @@
+package amneziawgnet
+
+import (
+	"encoding/json"
+	"net"
+	"strconv"
+	"testing"
+)
+
+// holdEgressBasePort occupies EgressBasePort the way another service would; a
+// port the OS already refuses, such as a Windows reservation, needs no holder.
+func holdEgressBasePort(t *testing.T) {
+	t.Helper()
+	ln, err := net.Listen("tcp", net.JoinHostPort("127.0.0.1", strconv.Itoa(EgressBasePort)))
+	if err != nil {
+		return
+	}
+	t.Cleanup(func() { ln.Close() })
+}
+
+// bridgePort is the port a socks bridge generated right now dials.
+func bridgePort(t *testing.T) int {
+	t.Helper()
+	out, ok := BuildSocksBridge([]byte(`{"protocol":"amneziawg","tag":"awg-hop","settings":{}}`))
+	if !ok {
+		t.Fatal("bridge rejected")
+	}
+	var got struct {
+		Settings struct {
+			Port int `json:"port"`
+		} `json:"settings"`
+	}
+	if err := json.Unmarshal(out, &got); err != nil {
+		t.Fatal(err)
+	}
+	return got.Settings.Port
+}
+
+// Windows can reserve a port range covering EgressBasePort, and any host can run
+// another service on it; the egress must still come up and report where.
+func TestEgressListenFallsBackWhenBasePortIsTaken(t *testing.T) {
+	srv := GetEgressServer()
+	srv.Close()
+	t.Cleanup(srv.Close)
+	holdEgressBasePort(t)
+
+	if err := srv.Listen(); err != nil {
+		t.Fatalf("Listen with EgressBasePort taken: %v", err)
+	}
+	port := srv.Port()
+	if port == EgressBasePort {
+		t.Fatalf("Port() = %d, the taken EgressBasePort", port)
+	}
+	conn, err := net.Dial("tcp", net.JoinHostPort("127.0.0.1", strconv.Itoa(port)))
+	if err != nil {
+		t.Fatalf("egress not accepting on Port() %d: %v", port, err)
+	}
+	conn.Close()
+}
+
+// Xray's bridges are generated apart from the listener, so a listener that came
+// up elsewhere must be reported until the bridges are regenerated for it.
+func TestBridgesStaleUntilRegeneratedForTheBoundPort(t *testing.T) {
+	srv := GetEgressServer()
+	srv.Close()
+	t.Cleanup(srv.Close)
+	holdEgressBasePort(t)
+
+	if got := bridgePort(t); got != EgressBasePort {
+		t.Fatalf("bridge generated before Listen dials %d, want %d", got, EgressBasePort)
+	}
+	if err := srv.Listen(); err != nil {
+		t.Fatal(err)
+	}
+	if !BridgesStale() {
+		t.Fatalf("bridges dial %d while the egress listens on %d, but BridgesStale() = false", EgressBasePort, srv.Port())
+	}
+	if got := bridgePort(t); got != srv.Port() || BridgesStale() {
+		t.Fatalf("regenerated bridge dials %d with BridgesStale() = %v, want %d and false", got, BridgesStale(), srv.Port())
+	}
+}

+ 1 - 1
internal/amneziawgnet/outbound_manager.go

@@ -59,7 +59,7 @@ func (m *OutboundManager) Reconcile(desired []OutboundDesired) {
 	defer m.mu.Unlock()
 
 	// Empty desired converges to "no tunnels": close egress listener so
-	// 127.0.0.1:64900 stays free on installs without AWG outbounds.
+	// the egress port stays free on installs without AWG outbounds.
 	if len(desired) == 0 {
 		for tag, cur := range m.iface {
 			cur.dev.Close()

+ 7 - 5
internal/amneziawgnet/outbound_manager_test.go

@@ -10,10 +10,10 @@ import (
 	"github.com/mhsanaei/3x-ui/v3/internal/util/wireguard"
 )
 
-// egressPortBound reports whether 127.0.0.1:<EgressBasePort> accepts TCP.
+// egressPortBound reports whether the egress server's Port accepts TCP.
 func egressPortBound(t *testing.T) bool {
 	t.Helper()
-	conn, err := net.DialTimeout("tcp", net.JoinHostPort("127.0.0.1", itoa(int(EgressBasePort))), 500*time.Millisecond)
+	conn, err := net.DialTimeout("tcp", net.JoinHostPort("127.0.0.1", itoa(GetEgressServer().Port())), 500*time.Millisecond)
 	if err != nil {
 		return false
 	}
@@ -54,7 +54,7 @@ func newTestOutboundDesired(t *testing.T, tag string) OutboundDesired {
 }
 
 // TestOutboundManagerReconcileEmptyDesiredClosesEgress verifies that an empty
-// desired set tears down interfaces and releases 127.0.0.1:64900.
+// desired set tears down interfaces and releases the egress port.
 func TestOutboundManagerReconcileEmptyDesiredClosesEgress(t *testing.T) {
 	m := &OutboundManager{iface: map[string]*managedOutbound{}}
 	defer m.Reconcile(nil)
@@ -72,13 +72,14 @@ func TestOutboundManagerReconcileEmptyDesiredClosesEgress(t *testing.T) {
 	if !egressPortBound(t) {
 		t.Fatal("egress port not bound after Reconcile with a desired outbound")
 	}
+	held := GetEgressServer().Port()
 
 	// Empty: listener must be released so other listeners can take the port.
 	m.Reconcile(nil)
 	if egressPortBound(t) {
 		t.Fatal("egress port still bound after Reconcile(nil)")
 	}
-	ln, err := net.Listen("tcp", net.JoinHostPort("127.0.0.1", itoa(int(EgressBasePort))))
+	ln, err := net.Listen("tcp", net.JoinHostPort("127.0.0.1", itoa(held)))
 	if err != nil {
 		t.Fatalf("egress port must be free after Reconcile(nil): %v", err)
 	}
@@ -102,6 +103,7 @@ func TestEgressServerCloseDuringConcurrentAccepts(t *testing.T) {
 	if err := srv.Listen(); err != nil {
 		t.Fatal(err)
 	}
+	addr := net.JoinHostPort("127.0.0.1", itoa(srv.Port()))
 
 	stop := make(chan struct{})
 	done := make(chan struct{})
@@ -113,7 +115,7 @@ func TestEgressServerCloseDuringConcurrentAccepts(t *testing.T) {
 			case <-stop:
 				return
 			default:
-				c, err := net.DialTimeout("tcp", net.JoinHostPort("127.0.0.1", itoa(int(EgressBasePort))), 50*time.Millisecond)
+				c, err := net.DialTimeout("tcp", addr, 50*time.Millisecond)
 				if err == nil {
 					clientWg.Add(1)
 					go func(conn net.Conn) {

+ 3 - 3
internal/amneziawgnet/resolving_bind.go

@@ -39,8 +39,8 @@ func newResolvingBind(listen string) *resolvingBind {
 	return &resolvingBind{Bind: newListenBind(listen)}
 }
 
-// ParseEndpoint resolves hostnames before handing the address to amneziawg-go
-// (whose own implementation accepts literal IPs only).
+// ParseEndpoint resolves hostnames, then lets the wrapped bind build the endpoint:
+// its own parser takes literal IPs only, and WinRingBind sends to its own type only.
 func (b *resolvingBind) ParseEndpoint(s string) (awgconn.Endpoint, error) {
 	host, portStr, err := net.SplitHostPort(strings.TrimSpace(s))
 	if err != nil {
@@ -63,5 +63,5 @@ func (b *resolvingBind) ParseEndpoint(s string) (awgconn.Endpoint, error) {
 		}
 		addr = addrs[0]
 	}
-	return &awgconn.StdNetEndpoint{AddrPort: netip.AddrPortFrom(addr.Unmap(), uint16(port64))}, nil
+	return b.Bind.ParseEndpoint(netip.AddrPortFrom(addr.Unmap(), uint16(port64)).String())
 }

+ 34 - 7
internal/amneziawgnet/resolving_bind_test.go

@@ -14,12 +14,39 @@ func mustResolvingBind(t *testing.T) *resolvingBind {
 	return newResolvingBind("")
 }
 
-func endpointAddrPort(ep awgconn.Endpoint) netip.AddrPort {
-	std, ok := ep.(*awgconn.StdNetEndpoint)
-	if !ok {
-		panic("unexpected endpoint type")
+// endpointAddrPort reads any bind's endpoint; Windows' default bind has its own type.
+func endpointAddrPort(t *testing.T, ep awgconn.Endpoint) netip.AddrPort {
+	t.Helper()
+	ap, err := netip.ParseAddrPort(ep.DstToString())
+	if err != nil {
+		t.Fatalf("endpoint %q: %v", ep.DstToString(), err)
+	}
+	return ap
+}
+
+// ownEndpointBind accepts only endpoints it parsed itself, as WinRingBind does.
+type ownEndpointBind struct{ awgconn.Bind }
+
+type ownEndpoint struct{ awgconn.StdNetEndpoint }
+
+func (ownEndpointBind) ParseEndpoint(s string) (awgconn.Endpoint, error) {
+	ap, err := netip.ParseAddrPort(s)
+	if err != nil {
+		return nil, err
+	}
+	return &ownEndpoint{awgconn.StdNetEndpoint{AddrPort: ap}}, nil
+}
+
+// WinRingBind, the default bind on Windows, refuses to send to an endpoint of any
+// other type, so a hand-built StdNetEndpoint killed every handshake there.
+func TestResolvingBind_ParseEndpointComesFromTheWrappedBind(t *testing.T) {
+	ep, err := (&resolvingBind{Bind: ownEndpointBind{}}).ParseEndpoint("203.0.113.7:51820")
+	if err != nil {
+		t.Fatalf("ParseEndpoint: %v", err)
+	}
+	if _, ok := ep.(*ownEndpoint); !ok {
+		t.Fatalf("endpoint is %T, not the wrapped bind's own type", ep)
 	}
-	return std.AddrPort
 }
 
 func TestResolvingBind_ParseEndpointIPLiteral(t *testing.T) {
@@ -28,7 +55,7 @@ func TestResolvingBind_ParseEndpointIPLiteral(t *testing.T) {
 	if err != nil {
 		t.Fatalf("IP endpoint rejected: %v", err)
 	}
-	got := endpointAddrPort(ep)
+	got := endpointAddrPort(t, ep)
 	if got.Addr().String() != "203.0.113.7" || got.Port() != 51820 {
 		t.Fatalf("endpoint = %v, want 203.0.113.7:51820", got)
 	}
@@ -49,7 +76,7 @@ func TestResolvingBind_ParseEndpointHostnameResolves(t *testing.T) {
 	if err != nil {
 		t.Fatalf("hostname endpoint rejected: %v", err)
 	}
-	if got := endpointAddrPort(ep); got.Addr().String() != "198.51.100.9" || got.Port() != 443 {
+	if got := endpointAddrPort(t, ep); got.Addr().String() != "198.51.100.9" || got.Port() != 443 {
 		t.Fatalf("endpoint = %v, want 198.51.100.9:443", got)
 	}
 }

+ 18 - 2
internal/amneziawgnet/socks_bridge.go

@@ -1,6 +1,12 @@
 package amneziawgnet
 
-import "encoding/json"
+import (
+	"encoding/json"
+	"sync/atomic"
+)
+
+// bridgedPort is the port the last generated socks bridge dials; 0 before any.
+var bridgedPort atomic.Int64
 
 // BuildSocksBridge swaps an "amneziawg" outbound for its loopback socks
 // form, preserving sibling keys; false = unbridgeable, fail loudly upstream.
@@ -13,9 +19,10 @@ func BuildSocksBridge(raw []byte) ([]byte, bool) {
 	if tag == "" {
 		return nil, false
 	}
+	port := EgressPort()
 	settings := map[string]any{
 		"address": "127.0.0.1",
-		"port":    EgressBasePort,
+		"port":    port,
 		"user":    tag,
 		"pass":    SocksPassword(),
 	}
@@ -29,5 +36,14 @@ func BuildSocksBridge(raw []byte) ([]byte, bool) {
 	if err != nil {
 		return nil, false
 	}
+	bridgedPort.Store(int64(port))
 	return out, true
 }
+
+// BridgesStale reports that the egress listener holds another port than the last
+// generated socks bridge dials, so Xray has to regenerate its config.
+func BridgesStale() bool {
+	bound, listening := GetEgressServer().boundPort()
+	bridged := int(bridgedPort.Load())
+	return listening && bridged != 0 && bridged != bound
+}

+ 4 - 2
internal/crypto/nodetoken/keysource.go

@@ -6,6 +6,7 @@ import (
 	"errors"
 	"fmt"
 	"os"
+	"runtime"
 	"strings"
 )
 
@@ -76,7 +77,8 @@ func decodeKey(b64 string) ([keyLen]byte, error) {
 	return out, nil
 }
 
-// FileKeySource accepts only key files that are mode 0600 or stricter.
+// FileKeySource accepts only key files that are mode 0600 or stricter. Windows has
+// no such bits (Stat reports 0666), so there the file's NTFS ACL is what guards it.
 type FileKeySource struct {
 	Path string
 }
@@ -86,7 +88,7 @@ func (f FileKeySource) Load() (*Keyring, error) {
 	if err != nil {
 		return nil, fmt.Errorf("nodetoken: stat key file %s: %w", f.Path, err)
 	}
-	if perm := info.Mode().Perm(); perm&0o077 != 0 {
+	if perm := info.Mode().Perm(); runtime.GOOS != "windows" && perm&0o077 != 0 {
 		return nil, fmt.Errorf("nodetoken: key file %s has insecure mode %#o (want 0600)", f.Path, perm)
 	}
 	data, err := os.ReadFile(f.Path)

+ 28 - 9
internal/crypto/nodetoken/nodetoken_test.go

@@ -3,8 +3,10 @@ package nodetoken
 import (
 	"encoding/base64"
 	"encoding/json"
+	"fmt"
 	"os"
 	"path/filepath"
+	"runtime"
 	"strings"
 	"testing"
 )
@@ -212,21 +214,26 @@ func TestParseMode(t *testing.T) {
 	}
 }
 
-func TestFileKeySourceRejectsLoosePerms(t *testing.T) {
-	dir := t.TempDir()
-	p := filepath.Join(dir, "k.json")
+// writeKeyFile writes a one-key keyring and chmods it, since WriteFile's mode
+// passes through the umask.
+func writeKeyFile(t *testing.T, mode os.FileMode) string {
+	t.Helper()
+	p := filepath.Join(t.TempDir(), "k.json")
 	key := make([]byte, keyLen)
 	body, _ := json.Marshal(keyFile{Active: "k1", Keys: map[string]string{"k1": base64.StdEncoding.EncodeToString(key)}})
-	if err := os.WriteFile(p, body, 0o644); err != nil {
+	if err := os.WriteFile(p, body, mode); err != nil {
 		t.Fatal(err)
 	}
-	if _, err := (FileKeySource{Path: p}).Load(); err == nil {
-		t.Fatal("0644 key file must be rejected")
-	}
-	if err := os.Chmod(p, 0o600); err != nil {
+	if err := os.Chmod(p, mode); err != nil {
 		t.Fatal(err)
 	}
-	kr, err := (FileKeySource{Path: p}).Load()
+	return p
+}
+
+// Windows reports every writable file as 0666, so a mode check there refused
+// every key file, an owner-only one included.
+func TestFileKeySourceLoadsOwnerOnlyKeyFile(t *testing.T) {
+	kr, err := (FileKeySource{Path: writeKeyFile(t, 0o600)}).Load()
 	if err != nil {
 		t.Fatalf("0600 key file should load: %v", err)
 	}
@@ -235,6 +242,18 @@ func TestFileKeySourceRejectsLoosePerms(t *testing.T) {
 	}
 }
 
+func TestFileKeySourceRejectsLoosePerms(t *testing.T) {
+	if runtime.GOOS == "windows" {
+		t.Skip("POSIX permission bits are not meaningful on Windows")
+	}
+	p := writeKeyFile(t, 0o644)
+	_, err := (FileKeySource{Path: p}).Load()
+	want := fmt.Sprintf("nodetoken: key file %s has insecure mode 0644 (want 0600)", p)
+	if err == nil || err.Error() != want {
+		t.Fatalf("Load() error = %v, want %q", err, want)
+	}
+}
+
 func TestEnvKeySource(t *testing.T) {
 	key := make([]byte, keyLen)
 	for i := range key {

+ 16 - 0
internal/database/db.go

@@ -104,6 +104,14 @@ func migrateOutboundSubscriptionUserAgentColumn() error {
 	return migrator.AddColumn(&model.OutboundSubscription{}, "UserAgent")
 }
 
+func migrateInboundExcludeFromSubColumn() error {
+	migrator := db.Migrator()
+	if !migrator.HasTable(&model.Inbound{}) || migrator.HasColumn(&model.Inbound{}, "exclude_from_sub") {
+		return nil
+	}
+	return migrator.AddColumn(&model.Inbound{}, "ExcludeFromSub")
+}
+
 func initModels() error {
 	if err := migrateClientTrafficLastSubFetchColumn(); err != nil {
 		return err
@@ -111,6 +119,9 @@ func initModels() error {
 	if err := migrateOutboundSubscriptionUserAgentColumn(); err != nil {
 		return err
 	}
+	if err := migrateInboundExcludeFromSubColumn(); err != nil {
+		return err
+	}
 	models := allModels()
 	for _, mdl := range models {
 		if IsPostgres() && postgresModelSettled(mdl) {
@@ -2687,6 +2698,11 @@ func InitDB(dbPath string) error {
 	}
 	c := &gorm.Config{Logger: gormLogger, DisableForeignKeyConstraintWhenMigrating: true}
 
+	// Reopening replaces the process pool; the replaced one would keep its file open.
+	if err := CloseDB(); err != nil {
+		log.Printf("close the replaced database pool: %v", err)
+	}
+
 	var err error
 	switch config.GetDBKind() {
 	case "postgres":

+ 27 - 0
internal/database/db_reopen_test.go

@@ -0,0 +1,27 @@
+package database
+
+import (
+	"path/filepath"
+	"testing"
+)
+
+// A replaced pool that stays open keeps its database file open; Windows then
+// cannot delete or replace that file.
+func TestInitDBClosesThePoolItReplaces(t *testing.T) {
+	dbPath := filepath.Join(t.TempDir(), "x-ui.db")
+	if err := InitDB(dbPath); err != nil {
+		t.Fatalf("first InitDB: %v", err)
+	}
+	t.Cleanup(func() { _ = CloseDB() })
+	replaced, err := GetDB().DB()
+	if err != nil {
+		t.Fatalf("first pool: %v", err)
+	}
+
+	if err := InitDB(dbPath); err != nil {
+		t.Fatalf("second InitDB: %v", err)
+	}
+	if err := replaced.Ping(); err == nil || err.Error() != "sql: database is closed" {
+		t.Fatalf("replaced pool Ping() = %v, want sql: database is closed", err)
+	}
+}

+ 58 - 0
internal/database/inbound_exclude_from_sub_migration_test.go

@@ -0,0 +1,58 @@
+package database
+
+import (
+	"path/filepath"
+	"testing"
+
+	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
+
+	"gorm.io/driver/sqlite"
+	"gorm.io/gorm"
+)
+
+// Legacy inbounds schema without exclude_from_sub — the upgrade path AddColumn must cover.
+const legacyInboundNoExcludeFromSubDDL = "CREATE TABLE `inbounds` (`id` integer PRIMARY KEY AUTOINCREMENT,`user_id` integer,`up` integer,`down` integer,`total` integer,`remark` text,`enable` numeric,`expiry_time` integer,`listen` text,`port` integer,`protocol` text,`settings` text,`stream_settings` text,`tag` text UNIQUE,`sniffing` text)"
+
+func TestMigrateInboundExcludeFromSubColumn(t *testing.T) {
+	dbPath := filepath.Join(t.TempDir(), "x-ui.db")
+	legacy, err := gorm.Open(sqlite.Open(dbPath), &gorm.Config{})
+	if err != nil {
+		t.Fatalf("open legacy db: %v", err)
+	}
+	if err := legacy.Exec(legacyInboundNoExcludeFromSubDDL).Error; err != nil {
+		t.Fatalf("create legacy inbounds: %v", err)
+	}
+	if err := legacy.Exec(
+		`INSERT INTO inbounds (user_id, remark, enable, port, protocol, settings, stream_settings, tag, sniffing)
+		 VALUES (1, 'preexisting', 1, 443, 'vless', '{"clients":[]}', '{}', 'in-443-tcp', '{}')`,
+	).Error; err != nil {
+		t.Fatalf("seed legacy inbound: %v", err)
+	}
+	sqlDB, err := legacy.DB()
+	if err != nil {
+		t.Fatalf("legacy db handle: %v", err)
+	}
+	if err := sqlDB.Close(); err != nil {
+		t.Fatalf("close legacy db: %v", err)
+	}
+
+	if err := InitDB(dbPath); err != nil {
+		t.Fatalf("InitDB over legacy schema: %v", err)
+	}
+	t.Cleanup(func() { _ = CloseDB() })
+
+	if !GetDB().Migrator().HasColumn(&model.Inbound{}, "exclude_from_sub") {
+		t.Fatal("exclude_from_sub column missing after migrateInboundExcludeFromSubColumn")
+	}
+
+	var row model.Inbound
+	if err := GetDB().Where("tag = ?", "in-443-tcp").First(&row).Error; err != nil {
+		t.Fatalf("preexisting inbound lost: %v", err)
+	}
+	if row.ExcludeFromSub {
+		t.Fatal("preexisting row must default exclude_from_sub to false, got true")
+	}
+	if err := migrateInboundExcludeFromSubColumn(); err != nil {
+		t.Fatalf("idempotent migrate: %v", err)
+	}
+}

+ 1 - 0
internal/database/model/model.go

@@ -53,6 +53,7 @@ type Inbound struct {
 	Total                int64                `json:"total" form:"total"`                                                                                                                                           // Total traffic limit in bytes
 	Remark               string               `json:"remark" form:"remark" example:"VLESS-443"`                                                                                                                     // Human-readable remark
 	SubSortIndex         int                  `json:"subSortIndex" form:"subSortIndex" gorm:"default:1" validate:"omitempty" example:"1"`                                                                           // Sort order of this inbound's links in subscription output only (lower first; negatives allowed; 0/omitted → 1; ties by id)
+	ExcludeFromSub       bool                 `json:"excludeFromSub" form:"excludeFromSub" gorm:"column:exclude_from_sub;default:false" example:"false"`                                                            // Whether to omit this inbound from subscription output while keeping it operational
 	Enable               bool                 `json:"enable" form:"enable" gorm:"index:idx_enable_traffic_reset,priority:1" example:"true"`                                                                         // Whether the inbound is enabled
 	ExpiryTime           int64                `json:"expiryTime" form:"expiryTime"`                                                                                                                                 // Expiration timestamp
 	TrafficReset         string               `json:"trafficReset" form:"trafficReset" gorm:"default:never;index:idx_enable_traffic_reset,priority:2" validate:"omitempty,oneof=never hourly daily weekly monthly"` // Traffic reset schedule

+ 20 - 0
internal/logger/console_other.go

@@ -0,0 +1,20 @@
+//go:build !windows
+
+package logger
+
+import (
+	"fmt"
+	"os"
+
+	"github.com/op/go-logging"
+)
+
+// newConsoleBackend prefers syslog and falls back to stderr when it is unavailable.
+func newConsoleBackend() (backend logging.Backend, includeTime bool) {
+	syslogBackend, err := logging.NewSyslogBackend("")
+	if err == nil {
+		return syslogBackend, false
+	}
+	fmt.Fprintf(os.Stderr, "syslog backend disabled: %v\n", err)
+	return logging.NewLogBackend(os.Stderr, "", 0), os.Getppid() > 0
+}

+ 14 - 0
internal/logger/console_windows.go

@@ -0,0 +1,14 @@
+//go:build windows
+
+package logger
+
+import (
+	"os"
+
+	"github.com/op/go-logging"
+)
+
+// newConsoleBackend logs to stderr: go-logging has no syslog on Windows.
+func newConsoleBackend() (backend logging.Backend, includeTime bool) {
+	return logging.NewLogBackend(os.Stderr, "", 0), true
+}

+ 19 - 30
internal/logger/logger.go

@@ -6,7 +6,6 @@ import (
 	"fmt"
 	"os"
 	"path/filepath"
-	"runtime"
 	"sync"
 	"sync/atomic"
 	"time"
@@ -79,28 +78,9 @@ func InitLogger(level logging.Level) {
 	logger.Store(newLogger)
 }
 
-// initDefaultBackend creates the console/syslog logging backend.
-// Windows: Uses stderr directly (no syslog support)
-// Unix-like: Attempts syslog, falls back to stderr
+// initDefaultBackend creates the console logging backend: syslog where the platform has it, else stderr.
 func initDefaultBackend() logging.Backend {
-	var backend logging.Backend
-	includeTime := false
-
-	if runtime.GOOS == "windows" {
-		// Windows: Use stderr directly (no syslog support)
-		backend = logging.NewLogBackend(os.Stderr, "", 0)
-		includeTime = true
-	} else {
-		// Unix-like: Try syslog, fallback to stderr
-		if syslogBackend, err := logging.NewSyslogBackend(""); err != nil {
-			fmt.Fprintf(os.Stderr, "syslog backend disabled: %v\n", err)
-			backend = logging.NewLogBackend(os.Stderr, "", 0)
-			includeTime = os.Getppid() > 0
-		} else {
-			backend = syslogBackend
-		}
-	}
-
+	backend, includeTime := newConsoleBackend()
 	return logging.NewBackendFormatter(backend, newFormatter(includeTime))
 }
 
@@ -113,8 +93,22 @@ func initFileBackend() logging.Backend {
 		return nil
 	}
 
-	logPath := filepath.Join(logDir, logFileName)
-	rotate := &lumberjack.Logger{
+	backend := logging.NewLogBackend(fileRotateFor(filepath.Join(logDir, logFileName)), "", 0)
+	return logging.NewBackendFormatter(backend, newFormatter(true))
+}
+
+// fileRotateFor reuses the open rotator for logPath: a re-init that swapped in a
+// new one would leave the old one holding the file, and loggers still writing to it.
+func fileRotateFor(logPath string) *lumberjack.Logger {
+	fileRotateMu.Lock()
+	defer fileRotateMu.Unlock()
+	if fileRotate != nil && fileRotate.Filename == logPath {
+		return fileRotate
+	}
+	if fileRotate != nil {
+		_ = fileRotate.Close()
+	}
+	fileRotate = &lumberjack.Logger{
 		Filename:   logPath,
 		MaxSize:    maxLogFileMB,
 		MaxBackups: maxLogBackups,
@@ -122,12 +116,7 @@ func initFileBackend() logging.Backend {
 		LocalTime:  true,
 		Compress:   compressRotated,
 	}
-	fileRotateMu.Lock()
-	fileRotate = rotate
-	fileRotateMu.Unlock()
-
-	backend := logging.NewLogBackend(rotate, "", 0)
-	return logging.NewBackendFormatter(backend, newFormatter(true))
+	return fileRotate
 }
 
 // newFormatter creates a log formatter with optional timestamp.

+ 1 - 0
internal/logger/logger_test.go

@@ -36,6 +36,7 @@ func TestGetLogs_ReturnsAtMostC(t *testing.T) {
 // logging — CI caught that as a data race between InitLogger and Warningf.
 func TestInitLoggerConcurrentWithLogging(t *testing.T) {
 	t.Setenv("XUI_LOG_FOLDER", t.TempDir())
+	t.Cleanup(CloseLogger)
 
 	stop := make(chan struct{})
 	var logging sync.WaitGroup

+ 6 - 0
internal/sub/clash_service.go

@@ -63,6 +63,12 @@ func (s *SubClashService) getClash(subId string, host string, legacy bool) (stri
 		if len(clients) == 0 {
 			continue
 		}
+		if inbound.ExcludeFromSub {
+			if countHiddenClients(clients, seenEmails) {
+				hasEnabledClient = true
+			}
+			continue
+		}
 		subReq.projectThroughFallbackMaster(inbound)
 		if hostEps := subReq.hostEndpoints(inbound, "clash"); len(hostEps) > 0 {
 			injectExternalProxy(inbound, hostEps)

+ 6 - 0
internal/sub/json_service.go

@@ -141,6 +141,12 @@ func (s *SubJsonService) GetJson(subId string, host string, alwaysReturnArray bo
 		if len(clients) == 0 {
 			continue
 		}
+		if inbound.ExcludeFromSub {
+			if countHiddenClients(clients, seenEmails) {
+				hasEnabledClient = true
+			}
+			continue
+		}
 		subReq.projectThroughFallbackMaster(inbound)
 		if hostEps := subReq.hostEndpoints(inbound, "json"); len(hostEps) > 0 {
 			injectExternalProxy(inbound, hostEps)

+ 18 - 0
internal/sub/service.go

@@ -330,6 +330,18 @@ func (s *SubService) matchingClients(inbound *model.Inbound, subId string) []mod
 	return out
 }
 
+// countHiddenClients adds an excludeFromSub inbound's clients to the usage set:
+// the inbound still serves them, so only its links leave the subscription.
+func countHiddenClients(clients []model.Client, seenEmails map[string]struct{}) (anyEnabled bool) {
+	for _, client := range clients {
+		seenEmails[client.Email] = struct{}{}
+		if client.Enable {
+			anyEnabled = true
+		}
+	}
+	return anyEnabled
+}
+
 // overlayInboundTunnelIdentity copies per-inbound tunnel fields from settings.
 // An unmatched peer is dropped, malformed settings yield nothing, and empty optional secrets replace shared values (#6641).
 func (s *SubService) overlayInboundTunnelIdentity(inbound *model.Inbound, clients []model.Client) ([]model.Client, error) {
@@ -473,6 +485,12 @@ func (s *SubService) getSubs(subId string) ([]string, []string, int64, xray.Clie
 		if len(clients) == 0 {
 			continue
 		}
+		if inbound.ExcludeFromSub {
+			if countHiddenClients(clients, seenEmails) {
+				hasEnabledClient = true
+			}
+			continue
+		}
 		s.projectThroughFallbackMaster(inbound)
 		// Host overrides apply AFTER fallback projection so a host's
 		// address/TLS wins over the projected master stream.

+ 77 - 0
internal/sub/service_exclude_from_sub_test.go

@@ -0,0 +1,77 @@
+package sub
+
+import (
+	"strings"
+	"testing"
+
+	"github.com/mhsanaei/3x-ui/v3/internal/database"
+	"github.com/mhsanaei/3x-ui/v3/internal/xray"
+)
+
+// An excluded inbound keeps serving its clients, so every renderer must drop
+// its links yet still count its clients' usage in the Subscription-Userinfo header.
+func TestExcludedInboundHidesLinksButKeepsUsage(t *testing.T) {
+	seedSubDB(t)
+	db := database.GetDB()
+
+	shown := seedSubInbound(t, "sub-excl", "shown", 24401, 1, `{"network":"tcp","security":"none"}`)
+	hidden := seedSubInbound(t, "sub-excl", "hidden", 24402, 2, `{"network":"tcp","security":"none"}`)
+	if err := db.Model(hidden).Update("exclude_from_sub", true).Error; err != nil {
+		t.Fatalf("mark excluded: %v", err)
+	}
+	for _, row := range []*xray.ClientTraffic{
+		{InboundId: shown.Id, Email: "shown@e", Up: 100, Down: 200, Enable: true},
+		{InboundId: hidden.Id, Email: "hidden@e", Up: 1000, Down: 2000, Enable: true},
+	} {
+		if err := db.Create(row).Error; err != nil {
+			t.Fatalf("seed traffic %s: %v", row.Email, err)
+		}
+	}
+
+	const wantHeader = "upload=1100; download=2200; "
+	assertOnlyShown := func(t *testing.T, out string) {
+		t.Helper()
+		if !strings.Contains(out, "24401") {
+			t.Fatalf("output lost the shown inbound:\n%s", out)
+		}
+		if strings.Contains(out, "24402") {
+			t.Fatalf("output leaked the excluded inbound:\n%s", out)
+		}
+	}
+
+	t.Run("raw", func(t *testing.T) {
+		links, _, _, traffic, err := NewSubService("").GetSubs("sub-excl", "req.example.com")
+		if err != nil {
+			t.Fatalf("GetSubs: %v", err)
+		}
+		if len(links) != 1 {
+			t.Fatalf("links = %q, want only the shown inbound's link", links)
+		}
+		assertOnlyShown(t, links[0])
+		if traffic.Up != 1100 || traffic.Down != 2200 {
+			t.Fatalf("usage = up %d/down %d, want 1100/2200 including the excluded inbound's client", traffic.Up, traffic.Down)
+		}
+	})
+
+	t.Run("clash", func(t *testing.T) {
+		out, header, err := NewSubClashService(false, "", NewSubService("")).GetClash("sub-excl", "req.example.com")
+		if err != nil {
+			t.Fatalf("GetClash: %v", err)
+		}
+		assertOnlyShown(t, out)
+		if !strings.HasPrefix(header, wantHeader) {
+			t.Fatalf("header = %q, want prefix %q", header, wantHeader)
+		}
+	})
+
+	t.Run("json", func(t *testing.T) {
+		out, header, err := NewSubJsonService("", "", "", "", NewSubService("")).GetJson("sub-excl", "req.example.com", false)
+		if err != nil {
+			t.Fatalf("GetJson: %v", err)
+		}
+		assertOnlyShown(t, out)
+		if !strings.HasPrefix(header, wantHeader) {
+			t.Fatalf("header = %q, want prefix %q", header, wantHeader)
+		}
+	})
+}

+ 5 - 0
internal/web/job/amneziawg_job.go

@@ -15,6 +15,7 @@ import (
 type AmneziaWGJob struct {
 	inboundService service.InboundService
 	settingService service.SettingService
+	xrayService    service.XrayService
 }
 
 // NewAmneziaWGJob creates a new AmneziaWG reconcile job instance.
@@ -55,6 +56,10 @@ func (j *AmneziaWGJob) Run() {
 		return
 	}
 	amneziawgnet.GetOutboundManager().Reconcile(outboundDesired)
+	// Xray's bridges are generated apart from the listener; one that moved needs them regenerated.
+	if amneziawgnet.BridgesStale() {
+		j.xrayService.SetToNeedRestart()
+	}
 }
 
 // desiredOutboundInstances derives client instances per template "amneziawg" outbound.

+ 1 - 0
internal/web/runtime/remote.go

@@ -810,6 +810,7 @@ func wireInbound(ib *model.Inbound, remoteNodeID int) url.Values {
 	v.Set("total", strconv.FormatInt(ib.Total, 10))
 	v.Set("remark", ib.Remark)
 	v.Set("subSortIndex", strconv.Itoa(ib.SubSortIndex))
+	v.Set("excludeFromSub", strconv.FormatBool(ib.ExcludeFromSub))
 	v.Set("enable", strconv.FormatBool(ib.Enable))
 	v.Set("expiryTime", strconv.FormatInt(ib.ExpiryTime, 10))
 	v.Set("listen", ib.Listen)

+ 9 - 0
internal/web/runtime/remote_test.go

@@ -196,6 +196,15 @@ func TestWireInboundCarriesDisableFlow(t *testing.T) {
 	}
 }
 
+func TestWireInboundCarriesExcludeFromSub(t *testing.T) {
+	if got := wireInbound(&model.Inbound{ExcludeFromSub: true}, 0).Get("excludeFromSub"); got != "true" {
+		t.Fatalf("excludeFromSub = %q, want true", got)
+	}
+	if got := wireInbound(&model.Inbound{}, 0).Get("excludeFromSub"); got != "false" {
+		t.Fatalf("excludeFromSub = %q, want false", got)
+	}
+}
+
 func TestRemoteHTTPClientEgressProxy(t *testing.T) {
 	// OutboundTag + a resolver → a dedicated proxy client (not the shared default).
 	withTag := NewRemote(&model.Node{Id: 1, Scheme: "https", TlsVerifyMode: "verify", OutboundTag: "warp"}, stubEgress{url: "socks5://127.0.0.1:1080"})

+ 11 - 0
internal/web/service/client_lookup.go

@@ -104,6 +104,17 @@ func (s *ClientService) GetInboundIdsForEmail(tx *gorm.DB, email string) ([]int,
 	return ids, nil
 }
 
+// sub_id carries a plain index, not a unique one: one subscription can cover
+// several clients, so callers acting on a subId must handle all of them.
+func (s *ClientService) GetRecordsBySubID(subId string) ([]*model.ClientRecord, error) {
+	if subId == "" {
+		return nil, errors.New("sub_id must not be empty")
+	}
+	var rows []*model.ClientRecord
+	err := database.GetDB().Where("sub_id = ?", subId).Order("id ASC").Find(&rows).Error
+	return rows, err
+}
+
 func (s *ClientService) GetRecordsByTgID(tgId int64) ([]*model.ClientRecord, error) {
 	if tgId <= 0 {
 		return nil, errors.New("tg_id must be a positive integer")

+ 1 - 0
internal/web/service/inbound.go

@@ -1864,6 +1864,7 @@ func (s *InboundService) UpdateInbound(inbound *model.Inbound) (*model.Inbound,
 		oldInbound.Total = inbound.Total
 		oldInbound.Remark = inbound.Remark
 		oldInbound.SubSortIndex = inbound.SubSortIndex
+		oldInbound.ExcludeFromSub = inbound.ExcludeFromSub
 		oldInbound.Enable = inbound.Enable
 		oldInbound.ExpiryTime = inbound.ExpiryTime
 		oldInbound.TrafficReset = inbound.TrafficReset

+ 57 - 0
internal/web/service/inbound_exclude_from_sub_test.go

@@ -0,0 +1,57 @@
+package service
+
+import (
+	"testing"
+
+	"github.com/mhsanaei/3x-ui/v3/internal/database"
+	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
+)
+
+func TestUpdateInbound_PersistsExcludeFromSub(t *testing.T) {
+	setupConflictDB(t)
+
+	ib := makeInboundWithSubSortIndex("in-7004-tcp", 7004, 1)
+	if err := database.GetDB().Create(ib).Error; err != nil {
+		t.Fatalf("create inbound: %v", err)
+	}
+
+	update := *ib
+	update.ExcludeFromSub = true
+	got, _, err := (&InboundService{}).UpdateInbound(&update)
+	if err != nil {
+		t.Fatalf("UpdateInbound: %v", err)
+	}
+	if !got.ExcludeFromSub {
+		t.Fatal("returned ExcludeFromSub = false, want true")
+	}
+
+	var reloaded model.Inbound
+	if err := database.GetDB().First(&reloaded, ib.Id).Error; err != nil {
+		t.Fatalf("reload: %v", err)
+	}
+	if !reloaded.ExcludeFromSub {
+		t.Fatal("persisted ExcludeFromSub = false, want true")
+	}
+}
+
+func TestAddInbound_PersistsExcludeFromSub(t *testing.T) {
+	setupConflictDB(t)
+
+	ib := makeInboundWithSubSortIndex("in-7005-tcp", 7005, 1)
+	ib.ExcludeFromSub = true
+	got, _, err := (&InboundService{}).AddInbound(ib)
+	if err != nil {
+		t.Fatalf("AddInbound: %v", err)
+	}
+	if !got.ExcludeFromSub {
+		t.Fatal("returned ExcludeFromSub = false, want true")
+	}
+
+	var reloaded model.Inbound
+	if err := database.GetDB().First(&reloaded, got.Id).Error; err != nil {
+		t.Fatalf("reload: %v", err)
+	}
+	if !reloaded.ExcludeFromSub {
+		t.Fatal("persisted ExcludeFromSub = false, want true")
+	}
+}

+ 3 - 0
internal/web/service/inbound_node.go

@@ -412,6 +412,8 @@ func adoptedWireInbound(c, snapIb *model.Inbound, adoptedSettings string) *model
 	a.Enable = snapIb.Enable
 	a.Remark = snapIb.Remark
 	a.SubSortIndex = normalizeSubSortIndex(snapIb.SubSortIndex)
+	// ExcludeFromSub stays master-authored: older nodes omit the field and
+	// would otherwise reset it to false on every heartbeat mirror.
 	a.Listen = snapIb.Listen
 	a.Port = snapIb.Port
 	a.Protocol = snapIb.Protocol
@@ -736,6 +738,7 @@ func (s *InboundService) setRemoteTrafficLocked(nodeID int, snap *runtime.Traffi
 				Enable:               snapIb.Enable,
 				Remark:               snapIb.Remark,
 				SubSortIndex:         normalizeSubSortIndex(snapIb.SubSortIndex),
+				ExcludeFromSub:       snapIb.ExcludeFromSub,
 				Total:                snapIb.Total,
 				ExpiryTime:           snapIb.ExpiryTime,
 				Up:                   snapIb.Up,

+ 3 - 0
internal/web/service/panel/panel_test.go

@@ -54,6 +54,9 @@ func TestShellQuote(t *testing.T) {
 // TestUpdateProxyEnvVars covers the bug this function fixes: ambient proxy
 // vars must reach update.sh's systemd-run child, which inherits nothing.
 func TestUpdateProxyEnvVars(t *testing.T) {
+	if runtime.GOOS == "windows" {
+		t.Skip("Windows env var names are case-insensitive, so both spellings resolve; the updater runs only on Linux")
+	}
 	allKeys := []string{"https_proxy", "HTTPS_PROXY", "all_proxy", "ALL_PROXY", "http_proxy", "HTTP_PROXY", "no_proxy", "NO_PROXY"}
 	clearAll := func(t *testing.T) {
 		t.Helper()

+ 5 - 3
internal/web/service/panel/sponsor.go

@@ -26,6 +26,7 @@ type Sponsor struct {
 	Name   string            `json:"name" example:"Acme VPS"`
 	Enable *bool             `json:"enable,omitempty" example:"true"`
 	Slots  []string          `json:"slots"`
+	From   *time.Time        `json:"from,omitempty" example:"2026-10-01T00:00:00Z"`
 	Until  time.Time         `json:"until" example:"2026-11-01T00:00:00Z"`
 	Logo   string            `json:"logo,omitempty" example:"/sponsors/logo/acme.png"`
 	Title  map[string]string `json:"title"`
@@ -77,8 +78,8 @@ var (
 	logos   = map[string]sponsorLogo{}
 )
 
-// GetSponsors returns the currently active sponsors. The remote file is cached,
-// but expiry is re-checked on every call so a slot ends exactly at Until.
+// GetSponsors returns the currently active sponsors. The remote file is cached, but the
+// window is re-checked on every call so a slot starts exactly at From and ends at Until.
 func (s *PanelService) GetSponsors() (*SponsorList, error) {
 	raw, err := cachedSponsors()
 	if err != nil {
@@ -220,7 +221,8 @@ func activeSponsors(raw *SponsorList, now time.Time) *SponsorList {
 	for _, sp := range raw.Sponsors {
 		// A missing enable counts as on, so a forgotten field never hides a paid slot.
 		disabled := sp.Enable != nil && !*sp.Enable
-		if disabled || sp.ID == "" || !now.Before(sp.Until) || !strings.HasPrefix(sp.Link, "https://") {
+		notStarted := sp.From != nil && now.Before(*sp.From)
+		if disabled || notStarted || sp.ID == "" || !now.Before(sp.Until) || !strings.HasPrefix(sp.Link, "https://") {
 			continue
 		}
 		// A bad logo name drops only the logo; the paid slot still renders with its initial.

+ 2 - 0
internal/web/service/panel/sponsor_test.go

@@ -37,6 +37,8 @@ func TestActiveSponsorsFilters(t *testing.T) {
 	}{
 		{"valid", func(*Sponsor) {}, true},
 		{"expired", func(s *Sponsor) { s.Until = sponsorTestNow }, false},
+		{"not started yet", func(s *Sponsor) { s.From = new(sponsorTestNow.Add(time.Hour)) }, false},
+		{"starts exactly now", func(s *Sponsor) { s.From = new(sponsorTestNow) }, true},
 		{"enable false with future until", func(s *Sponsor) { s.Enable = new(false) }, false},
 		{"enable true without until", func(s *Sponsor) { s.Enable, s.Until = new(true), time.Time{} }, false},
 		{"enable true with future until", func(s *Sponsor) { s.Enable = new(true) }, true},

+ 2 - 2
internal/web/service/port_conflict.go

@@ -256,9 +256,9 @@ func checkPortConflictTx(db *gorm.DB, inbound *model.Inbound, ignoreId int) (*po
 		}, nil
 	}
 
-	// Egress SOCKS server holds loopback EgressBasePort when AWG outbounds are
+	// Egress SOCKS server holds loopback EgressPort when AWG outbounds are
 	// active; conflict check prevents inbounds from colliding with it.
-	if inbound.NodeID == nil && inbound.Port == int(amneziawgnet.EgressBasePort) &&
+	if inbound.NodeID == nil && inbound.Port == amneziawgnet.EgressPort() &&
 		newBits&transportTCP != 0 && listenOverlaps(loopbackBind, inboundBindAddr(inbound)) {
 		return &portConflictDetail{
 			Tag:        "amneziawg-egress",

+ 26 - 0
internal/web/service/port_conflict_test.go

@@ -1,7 +1,9 @@
 package service
 
 import (
+	"net"
 	"path/filepath"
+	"strconv"
 	"strings"
 	"sync"
 	"testing"
@@ -754,6 +756,30 @@ func TestCheckPortConflict_EgressPortBlockedLocal(t *testing.T) {
 	}
 }
 
+// Where EgressBasePort is taken the egress listens on another port, and that
+// is the port an inbound must not collide with.
+func TestCheckPortConflict_EgressPortFollowsTheListener(t *testing.T) {
+	setupConflictDB(t)
+	if ln, err := net.Listen("tcp", net.JoinHostPort("127.0.0.1", strconv.Itoa(amneziawgnet.EgressBasePort))); err == nil {
+		t.Cleanup(func() { ln.Close() })
+	}
+	egress := amneziawgnet.GetEgressServer()
+	if err := egress.Listen(); err != nil {
+		t.Fatal(err)
+	}
+	t.Cleanup(egress.Close)
+
+	svc := &InboundService{}
+	candidate := &model.Inbound{Tag: "vless-bridge", Listen: "0.0.0.0", Port: egress.Port(), Protocol: model.VLESS}
+	got, err := svc.checkPortConflict(candidate, 0)
+	if err != nil {
+		t.Fatalf("checkPortConflict: %v", err)
+	}
+	if got == nil || got.Tag != "amneziawg-egress" {
+		t.Fatalf("an inbound on the egress's port %d must conflict with amneziawg-egress, got %+v", egress.Port(), got)
+	}
+}
+
 func TestCheckPortConflict_AmneziawgnetSocksRelayBlockedLocal(t *testing.T) {
 	setupConflictDB(t)
 	seedInboundConflict(t, "awg-1", "0.0.0.0", 51820, model.AmneziaWG, ``, amneziawgRoutedSettings)

+ 1 - 0
internal/web/service/tgbot/tgbot_client.go

@@ -745,6 +745,7 @@ func (t *Tgbot) searchClient(chatId int64, email string, messageID ...int) {
 		),
 		tu.InlineKeyboardRow(
 			tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.setTGUser")).WithCallbackData(t.encodeQuery("tg_user "+email)),
+			tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.inviteLink")).WithCallbackData(t.encodeQuery("client_invite_link "+email)),
 		),
 		tu.InlineKeyboardRow(
 			tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.toggle")).WithCallbackData(t.encodeQuery("toggle_enable "+email)),

+ 237 - 0
internal/web/service/tgbot/tgbot_invite.go

@@ -0,0 +1,237 @@
+package tgbot
+
+import (
+	"encoding/base64"
+	"html"
+	"strconv"
+	"strings"
+	"sync"
+	"time"
+
+	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
+	"github.com/mhsanaei/3x-ui/v3/internal/logger"
+	"github.com/mhsanaei/3x-ui/v3/internal/util/common"
+
+	"github.com/mymmrac/telego"
+)
+
+type inviteOutcome int
+
+const (
+	inviteInvalid inviteOutcome = iota
+	inviteTaken
+	inviteAlreadyOwned
+	inviteBindable
+)
+
+// A client's SubID doubles as its invite token: whoever holds it can already
+// fetch the subscription, so binding grants no access the token did not.
+func (t *Tgbot) resolveInviteToken(token string, fromID int64) (inviteOutcome, []*model.ClientRecord) {
+	token = strings.TrimSpace(token)
+	if token == "" || fromID <= 0 {
+		return inviteInvalid, nil
+	}
+	records, err := t.clientService.GetRecordsBySubID(token)
+	if err != nil || len(records) == 0 {
+		return inviteInvalid, nil
+	}
+	return classifyInvite(records, fromID), records
+}
+
+// One subscription can span several clients, so a token is claimable only when
+// no part of it belongs to someone else.
+func classifyInvite(records []*model.ClientRecord, fromID int64) inviteOutcome {
+	unbound := false
+	for _, record := range records {
+		switch record.TgID {
+		case 0:
+			unbound = true
+		case fromID:
+		default:
+			return inviteTaken
+		}
+	}
+	if unbound {
+		return inviteBindable
+	}
+	return inviteAlreadyOwned
+}
+
+// Claims run on concurrent handlers, so resolving and binding happen under one
+// lock: a second claimant must see the first one's binding, not the rows it read.
+var inviteClaimMu sync.Mutex
+
+// claimInvite reports the outcome it told the user, bindErr aside, so a caller
+// can tell a bind that landed from a refusal without reading the reply.
+func (t *Tgbot) claimInvite(chatId int64, fromID int64, payload string) inviteOutcome {
+	token, ok := decodeInvitePayload(payload)
+	if !ok {
+		t.SendMsgToTgbot(chatId, t.I18nBot("tgbot.messages.inviteInvalid"))
+		return inviteInvalid
+	}
+
+	inviteClaimMu.Lock()
+	outcome, records := t.resolveInviteToken(token, fromID)
+	var bindErr error
+	if outcome == inviteBindable {
+		bindErr = t.bindRecordsToUser(records, fromID)
+	}
+	inviteClaimMu.Unlock()
+
+	switch outcome {
+	case inviteAlreadyOwned:
+		t.SendMsgToTgbot(chatId, t.I18nBot("tgbot.messages.inviteBound", "Email=="+recordEmails(records)))
+	case inviteBindable:
+		if bindErr != nil {
+			logger.Warning("tgbot: invite bind failed:", bindErr)
+			t.SendMsgToTgbot(chatId, t.I18nBot("tgbot.answers.errorOperation"))
+			return inviteInvalid
+		}
+		t.SendMsgToTgbot(chatId, t.I18nBot("tgbot.messages.inviteBound", "Email=="+recordEmails(records)))
+	default:
+		// Unknown and already-claimed tokens share one reply, so a prober cannot
+		// tell a valid SubID from an invalid one.
+		t.SendMsgToTgbot(chatId, t.I18nBot("tgbot.messages.inviteInvalid"))
+	}
+	return outcome
+}
+
+func recordEmails(records []*model.ClientRecord) string {
+	emails := make([]string, 0, len(records))
+	for _, record := range records {
+		emails = append(emails, record.Email)
+	}
+	return strings.Join(emails, ", ")
+}
+
+// Every unbound client behind the token is bound, so a subscription spanning
+// several inbounds does not leave the customer holding only part of it. A failure
+// part-way undoes this claim's bindings, so the reply never hides a half-bind.
+func (t *Tgbot) bindRecordsToUser(records []*model.ClientRecord, tgID int64) error {
+	var bound []int
+	for _, record := range records {
+		if record.TgID != 0 {
+			continue
+		}
+		traffic, err := t.inboundService.GetClientTrafficByEmail(record.Email)
+		if err == nil && traffic == nil {
+			err = common.NewError("no traffic record for client:", record.Email)
+		}
+		if err == nil {
+			err = t.setClientTgID(traffic.Id, tgID)
+		}
+		if err != nil {
+			for _, trafficID := range bound {
+				if undoErr := t.setClientTgID(trafficID, EmptyTelegramUserID); undoErr != nil {
+					logger.Warning("tgbot: undoing partial invite bind failed:", undoErr)
+				}
+			}
+			return err
+		}
+		bound = append(bound, traffic.Id)
+	}
+	return nil
+}
+
+func (t *Tgbot) setClientTgID(trafficID int, tgID int64) error {
+	needRestart, err := t.clientService.SetClientTelegramUserID(&t.inboundService, trafficID, tgID)
+	if needRestart {
+		t.xrayService.SetToNeedRestart()
+	}
+	return err
+}
+
+// Telegram accepts only A-Za-z0-9_- in a start payload, at most 64 characters,
+// while a subId may hold '#', '&' or non-ASCII; base64url carries any subId
+// that fits intact instead of letting the link truncate it into another one.
+const maxInvitePayload = 64
+
+func encodeInvitePayload(subID string) (string, bool) {
+	payload := base64.RawURLEncoding.EncodeToString([]byte(subID))
+	return payload, len(payload) <= maxInvitePayload
+}
+
+func decodeInvitePayload(payload string) (string, bool) {
+	raw, err := base64.RawURLEncoding.DecodeString(strings.TrimSpace(payload))
+	if err != nil || len(raw) == 0 {
+		return "", false
+	}
+	return string(raw), true
+}
+
+func (t *Tgbot) sendInviteLink(chatId int64, email string) {
+	record, err := t.clientService.GetRecordByEmail(nil, email)
+	username := botUsername()
+	if err != nil || record.SubID == "" || username == "" {
+		logger.Warning("tgbot: invite link unavailable for", email, err)
+		t.SendMsgToTgbot(chatId, t.I18nBot("tgbot.answers.errorOperation"))
+		return
+	}
+	payload, ok := encodeInvitePayload(record.SubID)
+	if !ok {
+		logger.Warning("tgbot: subId of", email, "is too long for a Telegram invite link")
+		t.SendMsgToTgbot(chatId, t.I18nBot("tgbot.answers.errorOperation"))
+		return
+	}
+	link := "https://t.me/" + username + "?start=" + payload
+	t.SendMsgToTgbot(chatId, t.I18nBot("tgbot.messages.inviteLink", "Email=="+email, "Link=="+link))
+}
+
+// A subId can be short or human-readable, so claim attempts are capped per
+// Telegram account: guessing stays slow, and admins hear about whoever tries.
+const (
+	inviteAttemptLimit  = 5
+	inviteAttemptWindow = time.Hour
+)
+
+type inviteAttempts struct {
+	windowStart time.Time
+	count       int
+}
+
+var (
+	inviteAttemptsMu  sync.Mutex
+	inviteAttemptsBy  = map[int64]*inviteAttempts{}
+	inviteAttemptsNow = time.Now
+)
+
+// allowInviteAttempt counts one claim attempt and reports whether it may run.
+// Admins are told once per window, on the first attempt past the limit.
+func (t *Tgbot) allowInviteAttempt(from *telego.User) bool {
+	now := inviteAttemptsNow()
+	inviteAttemptsMu.Lock()
+	for id, a := range inviteAttemptsBy {
+		if now.Sub(a.windowStart) >= inviteAttemptWindow {
+			delete(inviteAttemptsBy, id)
+		}
+	}
+	a, ok := inviteAttemptsBy[from.ID]
+	if !ok {
+		a = &inviteAttempts{windowStart: now}
+		inviteAttemptsBy[from.ID] = a
+	}
+	a.count++
+	count := a.count
+	inviteAttemptsMu.Unlock()
+
+	if count == inviteAttemptLimit+1 {
+		t.SendMsgToTgbotAdmins(t.I18nBot("tgbot.messages.inviteRateLimitedAdmin",
+			"User=="+tgUserMention(from),
+			"ID=="+strconv.FormatInt(from.ID, 10),
+			"Limit=="+strconv.Itoa(inviteAttemptLimit)))
+	}
+	return count <= inviteAttemptLimit
+}
+
+func tgUserMention(from *telego.User) string {
+	id := strconv.FormatInt(from.ID, 10)
+	name := strings.TrimSpace(from.FirstName + " " + from.LastName)
+	if name == "" {
+		name = id
+	}
+	mention := `<a href="tg://user?id=` + id + `">` + html.EscapeString(name) + `</a>`
+	if from.Username != "" {
+		mention += " @" + html.EscapeString(from.Username)
+	}
+	return mention
+}

+ 221 - 0
internal/web/service/tgbot/tgbot_invite_test.go

@@ -0,0 +1,221 @@
+package tgbot
+
+import (
+	"strings"
+	"sync"
+	"testing"
+	"time"
+
+	"github.com/mhsanaei/3x-ui/v3/internal/database"
+	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
+
+	"github.com/mymmrac/telego"
+)
+
+func seedClientRecord(t *testing.T, email, subID string, tgID int64) {
+	t.Helper()
+	rec := &model.ClientRecord{Email: email, SubID: subID, TgID: tgID, Enable: true}
+	if err := database.GetDB().Create(rec).Error; err != nil {
+		t.Fatalf("seed client %s: %v", email, err)
+	}
+}
+
+// Binding is first-claim-wins: the owner re-tapping is idempotent, and no part of
+// a subscription held by someone else is ever reassigned.
+func TestResolveInviteToken(t *testing.T) {
+	tb, _ := newLinksCallbackTgbot(t, ownerMail)
+	seedClientRecord(t, "free@x", "sub-free", 0)
+	seedClientRecord(t, "held@x", "sub-held", 5150)
+	seedClientRecord(t, "shared-a@x", "sub-shared", 0)
+	seedClientRecord(t, "shared-b@x", "sub-shared", 0)
+	seedClientRecord(t, "part-mine@x", "sub-part-mine", 7000)
+	seedClientRecord(t, "part-free@x", "sub-part-mine", 0)
+	seedClientRecord(t, "part-free2@x", "sub-part-held", 0)
+	seedClientRecord(t, "part-held@x", "sub-part-held", 9999)
+
+	cases := []struct {
+		name    string
+		token   string
+		from    int64
+		want    inviteOutcome
+		records int
+	}{
+		{"unclaimed binds", "sub-free", 7000, inviteBindable, 1},
+		{"token is trimmed", "  sub-free\n", 7000, inviteBindable, 1},
+		{"owner is idempotent", "sub-held", 5150, inviteAlreadyOwned, 1},
+		{"someone else's is refused", "sub-held", 7000, inviteTaken, 1},
+		{"shared subscription binds whole", "sub-shared", 7000, inviteBindable, 2},
+		{"finishing a partly owned one binds", "sub-part-mine", 7000, inviteBindable, 2},
+		{"partly held by another is refused", "sub-part-held", 7000, inviteTaken, 2},
+		{"unknown token", "sub-nope", 7000, inviteInvalid, 0},
+		{"empty token", "", 7000, inviteInvalid, 0},
+		{"missing sender", "sub-free", 0, inviteInvalid, 0},
+	}
+	for _, c := range cases {
+		got, records := tb.resolveInviteToken(c.token, c.from)
+		if got != c.want || len(records) != c.records {
+			t.Errorf("%s: got (%d, %d records), want (%d, %d records)", c.name, got, len(records), c.want, c.records)
+		}
+	}
+}
+
+func mustInvitePayload(t *testing.T, subID string) string {
+	t.Helper()
+	payload, ok := encodeInvitePayload(subID)
+	if !ok {
+		t.Fatalf("encodeInvitePayload(%q) refused", subID)
+	}
+	return payload
+}
+
+// newInviteTgbot seeds one unbound client whose subId is sub-invite.
+func newInviteTgbot(t *testing.T, email string) (*Tgbot, func(string) int) {
+	t.Helper()
+	tb, calls := newLinksCallbackTgbot(t, email)
+	if err := database.GetDB().Model(&model.Inbound{}).Where("1 = 1").
+		Update("settings", `{"clients":[{"id":"6f1d2c3e-8a4b-4c5d-9e6f-7a8b9c0d1e2f","email":"`+email+`","subId":"sub-invite"}]}`).Error; err != nil {
+		t.Fatalf("unbind seeded client: %v", err)
+	}
+	seedClientRecord(t, email, "sub-invite", 0)
+	withAdmins(t, 1)
+	return tb, calls
+}
+
+// Regression test: a subId with URL metacharacters was pasted raw into the link,
+// so Telegram truncated it; every legal subId that fits must survive the trip.
+func TestInvitePayloadRoundTrip(t *testing.T) {
+	for _, subID := range []string{"a1B2c3D4e5F6g7H8", "team#1", "alice&bob", "x?y=z", "کاربر", strings.Repeat("s", 48)} {
+		payload, ok := encodeInvitePayload(subID)
+		if !ok {
+			t.Errorf("encodeInvitePayload(%q) refused", subID)
+			continue
+		}
+		if strings.Trim(payload, "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789_-") != "" {
+			t.Errorf("payload %q for %q has characters Telegram rejects", payload, subID)
+		}
+		if got, ok := decodeInvitePayload(payload); !ok || got != subID {
+			t.Errorf("round trip of %q = (%q, %v)", subID, got, ok)
+		}
+	}
+	if _, ok := encodeInvitePayload(strings.Repeat("s", 49)); ok {
+		t.Error("a subId past 64 payload characters must be refused, not truncated")
+	}
+	for _, payload := range []string{"", "not base64!", "   "} {
+		if _, ok := decodeInvitePayload(payload); ok {
+			t.Errorf("decodeInvitePayload(%q) accepted", payload)
+		}
+	}
+}
+
+// Regression test: accounts opening one link at once all read TgID == 0, all bound
+// and were all told so, while only the last write held; exactly one may succeed.
+func TestConcurrentClaimsBindOnlyOneAccount(t *testing.T) {
+	tb, _ := newInviteTgbot(t, "raced@x")
+	payload := mustInvitePayload(t, "sub-invite")
+
+	claimants := []int64{8101, 8102, 8103, 8104, 8105, 8106}
+	outcomes := make([]inviteOutcome, len(claimants))
+	start := make(chan struct{})
+	var wg sync.WaitGroup
+	for i, id := range claimants {
+		wg.Add(1)
+		go func() {
+			defer wg.Done()
+			<-start
+			outcomes[i] = tb.claimInvite(id, id, payload)
+		}()
+	}
+	// Hold the inbound write every bind needs, so all claimants resolve before any
+	// bind lands; otherwise the first bind outruns the rest and hides the race.
+	hold := database.GetDB().Begin()
+	if err := hold.Exec("UPDATE inbounds SET remark = remark").Error; err != nil {
+		t.Fatalf("hold inbound write: %v", err)
+	}
+	close(start)
+	time.Sleep(300 * time.Millisecond)
+	if err := hold.Commit().Error; err != nil {
+		t.Fatalf("release inbound write: %v", err)
+	}
+	wg.Wait()
+
+	told, holders := 0, 0
+	for i, id := range claimants {
+		if outcomes[i] == inviteBindable {
+			told++
+		}
+		if tb.levelOf(id) == levelClient {
+			holders++
+		}
+	}
+	if told != 1 || holders != 1 {
+		t.Errorf("%d accounts told they bound, %d holding the client; want 1 and 1", told, holders)
+	}
+}
+
+// A stranger opening a valid invite link must come out of it a client.
+func TestClaimInvitePromotesStrangerToClient(t *testing.T) {
+	const email = "invitee@x"
+	tb, calls := newInviteTgbot(t, email)
+
+	const newcomer = int64(8080)
+	if got := tb.levelOf(newcomer); got != levelStranger {
+		t.Fatalf("levelOf before claim = %d, want stranger", got)
+	}
+
+	tb.claimInvite(newcomer, newcomer, mustInvitePayload(t, "sub-invite"))
+
+	if got := tb.levelOf(newcomer); got != levelClient {
+		t.Errorf("levelOf after claim = %d, want client", got)
+	}
+	if n := calls("sendMessage"); n != 1 {
+		t.Errorf("sendMessage calls = %d, want 1 confirmation", n)
+	}
+	if outcome, _ := tb.resolveInviteToken("sub-invite", 9999); outcome != inviteTaken {
+		t.Errorf("second claimant outcome = %d, want taken", outcome)
+	}
+}
+
+// Guessing a subId must stay slow: past five attempts an hour an account is
+// refused, and admins are told once per window rather than once per attempt.
+func TestInviteAttemptLimit(t *testing.T) {
+	_, calls := newLinksCallbackTgbot(t, ownerMail)
+	withAdmins(t, 1, 2)
+	tb := &Tgbot{}
+
+	now := time.Unix(1_700_000_000, 0)
+	origNow, origBy := inviteAttemptsNow, inviteAttemptsBy
+	inviteAttemptsNow = func() time.Time { return now }
+	inviteAttemptsBy = map[int64]*inviteAttempts{}
+	t.Cleanup(func() { inviteAttemptsNow, inviteAttemptsBy = origNow, origBy })
+
+	guesser := &telego.User{ID: 6666, FirstName: "<b>x</b>"}
+	for i := 1; i <= inviteAttemptLimit; i++ {
+		if !tb.allowInviteAttempt(guesser) {
+			t.Fatalf("attempt %d refused, want allowed", i)
+		}
+	}
+	for range 3 {
+		if tb.allowInviteAttempt(guesser) {
+			t.Fatal("attempt past the limit allowed")
+		}
+	}
+	if n := calls("sendMessage"); n != 2 {
+		t.Errorf("sendMessage calls = %d, want 2: one notice per admin, once per window", n)
+	}
+	if !tb.allowInviteAttempt(&telego.User{ID: 7777}) {
+		t.Error("another account was refused by the guesser's limit")
+	}
+
+	now = now.Add(inviteAttemptWindow)
+	if !tb.allowInviteAttempt(guesser) {
+		t.Error("attempt after the window refused, want allowed")
+	}
+}
+
+func TestTgUserMentionEscapesName(t *testing.T) {
+	got := tgUserMention(&telego.User{ID: 42, FirstName: "<b>Eve</b>", Username: "eve"})
+	want := `<a href="tg://user?id=42">&lt;b&gt;Eve&lt;/b&gt;</a> @eve`
+	if got != want {
+		t.Errorf("tgUserMention = %q, want %q", got, want)
+	}
+}

+ 72 - 0
internal/web/service/tgbot/tgbot_level.go

@@ -0,0 +1,72 @@
+package tgbot
+
+import (
+	"github.com/mymmrac/telego"
+	tu "github.com/mymmrac/telego/telegoutil"
+)
+
+// userLevel decides what the bot admits to existing at all: a Telegram account
+// that no admin has bound to a client must not be able to explore the bot.
+type userLevel int
+
+const (
+	levelStranger userLevel = iota
+	levelClient
+	levelAdmin
+)
+
+// levelOf runs on every update, a stranger's included, so it reads the indexed
+// tg_id column of the clients table rather than expanding every inbound's JSON.
+func (t *Tgbot) levelOf(tgUserID int64) userLevel {
+	if checkAdmin(tgUserID) {
+		return levelAdmin
+	}
+	if tgUserID <= 0 {
+		return levelStranger
+	}
+	records, err := t.clientService.GetRecordsByTgID(tgUserID)
+	if err != nil || len(records) == 0 {
+		return levelStranger
+	}
+	return levelClient
+}
+
+// Commands are allowlisted rather than denied one by one: a command added later
+// stays out of reach of non-admins until it is deliberately listed here.
+var commandsByLevel = map[userLevel]map[string]bool{
+	// /id stays open because an admin binding by hand still asks for the ChatID.
+	levelStranger: {"start": true, "id": true},
+	levelClient:   {"start": true, "help": true, "status": true, "id": true, "usage": true},
+}
+
+func commandAllowed(level userLevel, command string) bool {
+	if level == levelAdmin {
+		return true
+	}
+	return commandsByLevel[level][command]
+}
+
+// gateCommand reports whether a command reaches answerCommand, and as whom. A
+// stranger's refused command gets no reply, so the bot reveals nothing to probe.
+func (t *Tgbot) gateCommand(message *telego.Message) (isAdmin bool, ok bool) {
+	level := t.levelOf(message.From.ID)
+	command, _, _ := tu.ParseCommand(message.Text)
+	if commandAllowed(level, command) {
+		return level == levelAdmin, true
+	}
+	if level == levelClient {
+		t.SendMsgToTgbot(message.Chat.ID, t.I18nBot("tgbot.commands.unknown"))
+	}
+	return false, false
+}
+
+// gateCallback answers a stranger's tap without acting on it: a stranger holds
+// no keyboard of ours, so any callback data from one is forged or stale.
+func (t *Tgbot) gateCallback(query *telego.CallbackQuery) (isAdmin bool, ok bool) {
+	level := t.levelOf(query.From.ID)
+	if level == levelStranger {
+		t.sendCallbackAnswerTgBot(query.ID, "")
+		return false, false
+	}
+	return level == levelAdmin, true
+}

+ 100 - 0
internal/web/service/tgbot/tgbot_level_test.go

@@ -0,0 +1,100 @@
+package tgbot
+
+import (
+	"testing"
+
+	"github.com/mymmrac/telego"
+)
+
+func withAdmins(t *testing.T, ids ...int64) {
+	t.Helper()
+	tgBotMutex.Lock()
+	orig := adminIds
+	adminIds = ids
+	tgBotMutex.Unlock()
+	t.Cleanup(func() {
+		tgBotMutex.Lock()
+		adminIds = orig
+		tgBotMutex.Unlock()
+	})
+}
+
+// newLevelTgbot binds ownerMail to ownerTgID in both the inbound settings and
+// the clients table, and makes account 1 the only admin.
+func newLevelTgbot(t *testing.T) (*Tgbot, func(string) int) {
+	t.Helper()
+	tb, calls := newLinksCallbackTgbot(t, ownerMail)
+	seedClientRecord(t, ownerMail, "sub-owned", ownerTgID)
+	withAdmins(t, 1)
+	return tb, calls
+}
+
+func commandFrom(tgUserID int64, text string) *telego.Message {
+	return &telego.Message{
+		From: &telego.User{ID: tgUserID},
+		Chat: telego.Chat{ID: tgUserID, Type: telego.ChatTypePrivate},
+		Text: text,
+	}
+}
+
+func TestLevelOfFollowsTheClientBinding(t *testing.T) {
+	tb, _ := newLevelTgbot(t)
+
+	for id, want := range map[int64]userLevel{1: levelAdmin, ownerTgID: levelClient, 777: levelStranger, 0: levelStranger} {
+		if got := tb.levelOf(id); got != want {
+			t.Errorf("levelOf(%d) = %d, want %d", id, got, want)
+		}
+	}
+}
+
+// A stranger's refused command must get no reply at all, while a bound client
+// is still told the command is unknown, as before the gate existed.
+func TestGateCommand(t *testing.T) {
+	cases := []struct {
+		name      string
+		from      int64
+		text      string
+		wantOK    bool
+		wantAdmin bool
+		wantSends int
+	}{
+		{"stranger start", 777, "/start", true, false, 0},
+		{"stranger help", 777, "/help", false, false, 0},
+		{"stranger admin command", 777, "/restart", false, false, 0},
+		{"client usage", ownerTgID, "/usage", true, false, 0},
+		{"client admin command", ownerTgID, "/clearall", false, false, 1},
+		{"admin", 1, "/clearall", true, true, 0},
+	}
+	for _, c := range cases {
+		t.Run(c.name, func(t *testing.T) {
+			tb, calls := newLevelTgbot(t)
+
+			isAdmin, ok := tb.gateCommand(commandFrom(c.from, c.text))
+			if ok != c.wantOK || isAdmin != c.wantAdmin {
+				t.Errorf("gateCommand = (%v, %v), want (%v, %v)", isAdmin, ok, c.wantAdmin, c.wantOK)
+			}
+			if n := calls("sendMessage"); n != c.wantSends {
+				t.Errorf("sendMessage calls = %d, want %d", n, c.wantSends)
+			}
+		})
+	}
+}
+
+// Regression test: a stranger's forged callback must be answered, so the button
+// stops spinning, and must never reach answerCallback.
+func TestGateCallbackStopsStrangers(t *testing.T) {
+	tb, calls := newLevelTgbot(t)
+
+	query := &telego.CallbackQuery{ID: "q1", From: telego.User{ID: 777}, Data: "client_sub_links " + ownerMail}
+	if _, ok := tb.gateCallback(query); ok {
+		t.Fatal("gateCallback admitted a stranger")
+	}
+	if n := calls("answerCallbackQuery"); n != 1 {
+		t.Errorf("answerCallbackQuery calls = %d, want 1", n)
+	}
+
+	query.From.ID = ownerTgID
+	if isAdmin, ok := tb.gateCallback(query); !ok || isAdmin {
+		t.Errorf("gateCallback(client) = (%v, %v), want (false, true)", isAdmin, ok)
+	}
+}

+ 21 - 2
internal/web/service/tgbot/tgbot_router.go

@@ -96,7 +96,9 @@ func (t *Tgbot) OnReceive() {
 			// Use goroutine with worker pool for concurrent command processing
 			go runBotHandler(func() {
 				userStateMgr.clear(messageActor(message))
-				t.answerCommand(&message, message.Chat.ID, checkAdmin(message.From.ID))
+				if isAdmin, ok := t.gateCommand(&message); ok {
+					t.answerCommand(&message, message.Chat.ID, isAdmin)
+				}
 			})
 			return nil
 		}, th.AnyCommand())
@@ -105,7 +107,9 @@ func (t *Tgbot) OnReceive() {
 			// Use goroutine with worker pool for concurrent callback processing
 			go runBotHandler(func() {
 				userStateMgr.clear(callbackActor(&query))
-				t.answerCallback(&query, checkAdmin(query.From.ID))
+				if isAdmin, ok := t.gateCallback(&query); ok {
+					t.answerCallback(&query, isAdmin)
+				}
 			})
 			return nil
 		}, th.AnyCallbackQueryWithMessage())
@@ -227,6 +231,18 @@ func (t *Tgbot) answerCommand(message *telego.Message, chatId int64, isAdmin boo
 		msg += t.I18nBot("tgbot.commands.help")
 		msg += t.I18nBot("tgbot.commands.pleaseChoose")
 	case "start":
+		if len(commandArgs) > 0 {
+			if !isAdmin && !t.allowInviteAttempt(message.From) {
+				t.SendMsgToTgbot(chatId, t.I18nBot("tgbot.messages.inviteRateLimited"))
+				return
+			}
+			t.claimInvite(chatId, message.From.ID, commandArgs[0])
+		}
+		// A stranger learns only its ChatID, which is what an admin needs to bind it.
+		if !isAdmin && t.levelOf(message.From.ID) == levelStranger {
+			t.SendMsgToTgbot(chatId, t.I18nBot("tgbot.answers.askToAddUserId", "TgUserID=="+strconv.FormatInt(message.From.ID, 10)))
+			return
+		}
 		msg += t.I18nBot("tgbot.commands.start", "Firstname=="+html.EscapeString(message.From.FirstName))
 		if isAdmin {
 			msg += t.I18nBot("tgbot.commands.welcome", "Hostname=="+hostname)
@@ -792,6 +808,9 @@ func (t *Tgbot) answerCallback(callbackQuery *telego.CallbackQuery, isAdmin bool
 			case "tg_user":
 				t.sendCallbackAnswerTgBot(callbackQuery.ID, t.I18nBot("tgbot.answers.getUserInfo", "Email=="+email))
 				t.clientTelegramUserInfo(chatId, email)
+			case "client_invite_link":
+				t.sendCallbackAnswerTgBot(callbackQuery.ID, t.I18nBot("tgbot.buttons.inviteLink"))
+				t.sendInviteLink(chatId, email)
 			case "tgid_remove":
 				inlineKeyboard := tu.InlineKeyboard(
 					tu.InlineKeyboardRow(

+ 1 - 1
internal/web/service/xray_amneziawg_outbound_test.go

@@ -10,7 +10,7 @@ import (
 	"github.com/mhsanaei/3x-ui/v3/internal/xray"
 )
 
-func amneziawgnetEgressPortForTest() int { return amneziawgnet.EgressBasePort }
+func amneziawgnetEgressPortForTest() int { return amneziawgnet.EgressPort() }
 
 func wgKeypairForTest() (priv, pub string, err error) {
 	return wgutil.GenerateWireguardKeypair()

+ 9 - 3
internal/web/translation/ar-EG.json

@@ -664,6 +664,8 @@
         "shareAddrHelp": "يُستخدم فقط عندما تكون استراتيجية عنوان المشاركة مخصصة. أدخل اسم مضيف أو عنوان IP بدون بروتوكول أو منفذ.",
         "subSortIndex": "ترتيب الروابط في الاشتراك",
         "subSortIndexHelp": "موضع روابط هذا الوارد في مخرجات الاشتراك (صفحة الاشتراك وتطبيقات العملاء). القيم الأقل تظهر أولاً، والقيم المتساوية تحافظ على ترتيب الإنشاء. لا يؤثر على قائمة الواردات في اللوحة.",
+        "excludeFromSub": "استبعاد من الاشتراكات",
+        "excludeFromSubHelp": "إخفاء روابط هذا الإدخال من مخرجات الاشتراك مع الإبقاء على الإدخال مفعّلاً ويعمل.",
         "disableFlow": "تعطيل تدفق XTLS",
         "disableFlowHelp": "استثناء هذا الـ inbound من الحقن التلقائي لـ xtls-rprx-vision، حتى عندما يكون النقل قادرًا على الـ flow (مثل inbound من نوع XHTTP عبر نفق مع تشفير VLESS). يحتفظ العملاء بـ Vision على باقي الـ inbounds القادرة ضمن نفس الاشتراك. لـ VLESS فقط.",
         "shareAddrStrategyOptions": {
@@ -789,6 +791,7 @@
       "password": "كلمة المرور",
       "passwordDesc": "تُستخدم فقط من قبل عملاء Trojan و Shadowsocks؛ ويتم تجاهلها لـ VLESS و VMess و Hysteria و WireGuard.",
       "subId": "معرّف الاشتراك",
+      "subIdDesc": "يُستخدم أيضًا كرمز دعوة لهذا العميل في بوت تيليجرام: من يرسله إلى البوت يُربط بهذا العميل. اجعله طويلًا وعشوائيًا — فالمعرّف القصير أو السهل التخمين قد يستولي عليه شخص آخر.",
       "online": "متصل",
       "email": "البريد",
       "emailInvalidChars": "لا يمكن أن يحتوي البريد الإلكتروني على مسافات أو '/' أو '\\' أو أحرف تحكم",
@@ -1640,13 +1643,10 @@
       "subHappProxyIPs": "IPs / CIDRs عبر البروكسي",
       "subHappBlockIPs": "IPs / CIDRs محظورة",
       "subHappDeeplinkGenerated": "تم إنشاء الرابط العميق وتطبيقه على قواعد التوجيه",
-      "subHappGroupLinks": "روابط الاشتراك",
       "subHappGroupRouting": "التوجيه والقواعد",
-      "subHappGroupBanners": "اللافتات والإعلانات",
       "subHappGroupNetwork": "الشبكة ومحرك TUN",
       "subHappGroupThemes": "المظهر والسمة",
       "subHappGroupFailover": "الترحيل وإدارة التطبيق",
-      "subHappGroupAndroid": "البروكسي لكل تطبيق على Android",
       "discordSettings": "بوت ديسكورد",
       "discordBotEnable": "تفعيل إشعارات ديسكورد",
       "discordBotEnableDesc": "إرسال تنبيهات النظام والأحداث إلى قناة ديسكورد عبر البوت",
@@ -2466,6 +2466,11 @@
       "download": "🔽 التنزيل: ↓{{ .Download }}\r\n",
       "total": "📊 الإجمالي: ↑↓{{ .UpDown }} / {{ .Total }}\r\n",
       "TGUser": "👤 مستخدم Telegram: {{ .TelegramID }}\r\n",
+      "inviteBound": "✅ تم ربط حساب تيليجرام الخاص بك بـ <code>{{ .Email }}</code>.\r\n",
+      "inviteInvalid": "❗ رابط الدعوة هذا غير صالح أو تم استخدامه بالفعل.\r\n",
+      "inviteRateLimited": "⏳ محاولات دعوة كثيرة جدًا. يرجى المحاولة لاحقًا.\r\n",
+      "inviteRateLimitedAdmin": "⚠️ تم حظر محاولات الدعوة لـ {{ .User }} (<code>{{ .ID }}</code>): أكثر من {{ .Limit }} محاولات خلال ساعة. ربما يحاول أحدهم تخمين معرّفات الاشتراك.",
+      "inviteLink": "🔗 رابط الدعوة لـ <code>{{ .Email }}</code>:\r\n<code>{{ .Link }}</code>\r\n\r\nأول شخص يفتحه سيتم ربطه بهذا العميل.\r\n",
       "exhaustedCount": "🚨 عدد النفاذ لـ {{ .Type }}:\r\n",
       "onlinesCount": "🌐 العملاء الأونلاين: {{ .Count }}\r\n",
       "disabled": "🛑 معطل: {{ .Disabled }}\r\n",
@@ -2533,6 +2538,7 @@
       "ipLog": "🔢 سجل الـ IP",
       "ipLimit": "🔢 حد الـ IP",
       "setTGUser": "👤 ضبط مستخدم Telegram",
+      "inviteLink": "🔗 رابط الدعوة",
       "toggle": "🔘 تفعيل / تعطيل",
       "custom": "🔢 مخصص",
       "confirmNumber": "✅ تأكيد: {{ .Num }}",

+ 9 - 3
internal/web/translation/en-US.json

@@ -687,6 +687,8 @@
         "shareAddrHelp": "Used only when the share address strategy is Custom. Enter a host or IP without a scheme or port.",
         "subSortIndex": "Subscription sort order",
         "subSortIndexHelp": "Position of this inbound's links in subscription output (sub page and client apps). Lower values come first; equal values keep creation order. Does not affect the panel inbound list.",
+        "excludeFromSub": "Exclude from subscriptions",
+        "excludeFromSubHelp": "Hide this inbound's links from subscription output while keeping the inbound enabled and operational.",
         "disableFlow": "Disable XTLS flow",
         "disableFlowHelp": "Opt this inbound out of automatic xtls-rprx-vision injection, even when its transport is flow-capable (e.g. a tunneled XHTTP inbound with VLESS encryption). Clients keep Vision on your other capable inbounds in the same subscription. VLESS only.",
         "shareAddrStrategyOptions": {
@@ -789,6 +791,7 @@
       "password": "Password",
       "passwordDesc": "Used by Trojan, Shadowsocks, and TUIC clients; ignored for VLESS, VMess, Hysteria, and WireGuard.",
       "subId": "Subscription ID",
+      "subIdDesc": "Also works as this client's Telegram bot invite code: whoever sends it to the bot is linked to this client. Keep it long and random — a short or guessable ID can be claimed by someone else.",
       "online": "Online",
       "email": "Email",
       "emailInvalidChars": "Email cannot contain spaces, '/', '\\', or control characters",
@@ -1758,13 +1761,10 @@
       "subHappProxyIPs": "Proxy IPs / CIDRs",
       "subHappBlockIPs": "Blocked IPs / CIDRs",
       "subHappDeeplinkGenerated": "Deeplink generated and applied to routing rules",
-      "subHappGroupLinks": "Subscription Links",
       "subHappGroupRouting": "Routing & Rules",
-      "subHappGroupBanners": "Banners & Announcements",
       "subHappGroupNetwork": "Network & TUN Engine",
       "subHappGroupThemes": "Appearance & Theme",
       "subHappGroupFailover": "Migration & App Management",
-      "subHappGroupAndroid": "Android Per-App Proxy",
       "discordSettings": "Discord Bot",
       "discordBotEnable": "Enable Discord Notifications",
       "discordBotEnableDesc": "Send system and event alerts to a Discord channel via bot",
@@ -2466,6 +2466,11 @@
       "download": "🔽 Download: ↓{{ .Download }}\r\n",
       "total": "📊 Total: ↑↓{{ .UpDown }} / {{ .Total }}\r\n",
       "TGUser": "👤 Telegram User: {{ .TelegramID }}\r\n",
+      "inviteBound": "✅ Your Telegram account is now linked to <code>{{ .Email }}</code>.\r\n",
+      "inviteInvalid": "❗ This invite link is invalid or has already been used.\r\n",
+      "inviteRateLimited": "⏳ Too many invite attempts. Please try again later.\r\n",
+      "inviteRateLimitedAdmin": "⚠️ Invite attempts blocked for {{ .User }} (<code>{{ .ID }}</code>): more than {{ .Limit }} tries in an hour. Someone may be guessing Subscription IDs.",
+      "inviteLink": "🔗 Invite link for <code>{{ .Email }}</code>:\r\n<code>{{ .Link }}</code>\r\n\r\nThe first person to open it is linked to this client.\r\n",
       "exhaustedCount": "🚨 Exhausted {{ .Type }} count:\r\n",
       "onlinesCount": "🌐 Online Clients: {{ .Count }}\r\n",
       "disabled": "🛑 Disabled: {{ .Disabled }}\r\n",
@@ -2533,6 +2538,7 @@
       "ipLog": "🔢 IP Log",
       "ipLimit": "🔢 IP Limit",
       "setTGUser": "👤 Set Telegram User",
+      "inviteLink": "🔗 Invite Link",
       "toggle": "🔘 Enable / Disable",
       "custom": "🔢 Custom",
       "confirmNumber": "✅ Confirm: {{ .Num }}",

+ 9 - 3
internal/web/translation/es-ES.json

@@ -685,6 +685,8 @@
         "shareAddrHelp": "Solo se usa cuando la estrategia de dirección para compartir es Personalizada. Introduce un host o IP sin esquema ni puerto.",
         "subSortIndex": "Orden en la suscripción",
         "subSortIndexHelp": "Posición de los enlaces de esta entrada en la salida de la suscripción (página de suscripción y apps cliente). Los valores más bajos van primero; con valores iguales se mantiene el orden de creación. No afecta a la lista de entradas del panel.",
+        "excludeFromSub": "Excluir de las suscripciones",
+        "excludeFromSubHelp": "Oculta los enlaces de esta entrada en la salida de suscripción manteniendo la entrada habilitada y operativa.",
         "disableFlow": "Desactivar el flujo XTLS",
         "disableFlowHelp": "Excluye este inbound de la inyección automática de xtls-rprx-vision, incluso cuando su transporte admite flow (p. ej. un inbound XHTTP tunelizado con cifrado VLESS). Los clientes mantienen Vision en tus demás inbounds compatibles de la misma suscripción. Solo VLESS.",
         "shareAddrStrategyOptions": {
@@ -789,6 +791,7 @@
       "password": "Contraseña",
       "passwordDesc": "Solo la usan los clientes Trojan y Shadowsocks; se ignora para VLESS, VMess, Hysteria y WireGuard.",
       "subId": "ID de suscripción",
+      "subIdDesc": "También sirve como código de invitación de este cliente en el bot de Telegram: quien lo envíe al bot queda vinculado a este cliente. Mantenlo largo y aleatorio: un ID corto o fácil de adivinar puede ser reclamado por otra persona.",
       "online": "En línea",
       "email": "Email",
       "emailInvalidChars": "El correo no puede contener espacios, '/', '\\' ni caracteres de control",
@@ -1640,13 +1643,10 @@
       "subHappProxyIPs": "IPs / CIDR por proxy",
       "subHappBlockIPs": "IPs / CIDR bloqueados",
       "subHappDeeplinkGenerated": "Deeplink generado y aplicado a las reglas de enrutamiento",
-      "subHappGroupLinks": "Enlaces de suscripción",
       "subHappGroupRouting": "Enrutamiento y reglas",
-      "subHappGroupBanners": "Banners y anuncios",
       "subHappGroupNetwork": "Red y motor TUN",
       "subHappGroupThemes": "Apariencia y tema",
       "subHappGroupFailover": "Migración y gestión de la app",
-      "subHappGroupAndroid": "Proxy por aplicación en Android",
       "discordSettings": "Bot de Discord",
       "discordBotEnable": "Habilitar notificaciones de Discord",
       "discordBotEnableDesc": "Enviar alertas del sistema y eventos a un canal de Discord a través del bot",
@@ -2466,6 +2466,11 @@
       "download": "🔽 Descarga: ↓{{ .Download }}\r\n",
       "total": "📊 Total: ↑↓{{ .UpDown }} / {{ .Total }}\r\n",
       "TGUser": "👤 Usuario de Telegram: {{ .TelegramID }}\r\n",
+      "inviteBound": "✅ Tu cuenta de Telegram ahora está vinculada a <code>{{ .Email }}</code>.\r\n",
+      "inviteInvalid": "❗ Este enlace de invitación no es válido o ya se ha utilizado.\r\n",
+      "inviteRateLimited": "⏳ Demasiados intentos de invitación. Inténtalo más tarde.\r\n",
+      "inviteRateLimitedAdmin": "⚠️ Intentos de invitación bloqueados para {{ .User }} (<code>{{ .ID }}</code>): más de {{ .Limit }} intentos en una hora. Alguien podría estar adivinando IDs de suscripción.",
+      "inviteLink": "🔗 Enlace de invitación para <code>{{ .Email }}</code>:\r\n<code>{{ .Link }}</code>\r\n\r\nLa primera persona que lo abra quedará vinculada a este cliente.\r\n",
       "exhaustedCount": "🚨 Cantidad de Agotados {{ .Type }}:\r\n",
       "onlinesCount": "🌐 Clientes en línea: {{ .Count }}\r\n",
       "disabled": "🛑 Desactivado: {{ .Disabled }}\r\n",
@@ -2533,6 +2538,7 @@
       "ipLog": "🔢 Registro de IP",
       "ipLimit": "🔢 Límite de IP",
       "setTGUser": "👤 Establecer Usuario de Telegram",
+      "inviteLink": "🔗 Enlace de invitación",
       "toggle": "🔘 Habilitar / Deshabilitar",
       "custom": "🔢 Personalizado",
       "confirmNumber": "✅ Confirmar: {{ .Num }}",

+ 9 - 3
internal/web/translation/fa-IR.json

@@ -685,6 +685,8 @@
         "shareAddrHelp": "فقط زمانی استفاده می‌شود که راهبرد آدرس اشتراک‌گذاری روی سفارشی باشد. میزبان یا IP را بدون طرح و پورت وارد کنید.",
         "subSortIndex": "ترتیب در اشتراک",
         "subSortIndexHelp": "جایگاه لینک‌های این ورودی در خروجی اشتراک (صفحه اشتراک و برنامه‌های کلاینت). مقدار کمتر اول می‌آید و مقدارهای برابر ترتیب ایجاد را حفظ می‌کنند. روی فهرست ورودی‌های پنل تأثیری ندارد.",
+        "excludeFromSub": "حذف از اشتراک‌ها",
+        "excludeFromSubHelp": "لینک‌های این ورودی را از خروجی اشتراک پنهان می‌کند، در حالی که ورودی همچنان فعال و عملیاتی می‌ماند.",
         "disableFlow": "غیرفعال‌کردن جریان XTLS",
         "disableFlowHelp": "این inbound را از تزریق خودکار xtls-rprx-vision کنار بگذارید، حتی وقتی ترنسپورت آن از flow پشتیبانی می‌کند (مثلاً یک inbound از نوع XHTTP تونل‌شده با رمزنگاری VLESS). کلاینت‌ها Vision را روی سایر inboundهای سازگار در همان اشتراک حفظ می‌کنند. فقط برای VLESS.",
         "shareAddrStrategyOptions": {
@@ -789,6 +791,7 @@
       "password": "رمز عبور",
       "passwordDesc": "فقط توسط کلاینت‌های Trojan و Shadowsocks استفاده می‌شود؛ برای VLESS، VMess، Hysteria و WireGuard نادیده گرفته می‌شود.",
       "subId": "شناسه اشتراک",
+      "subIdDesc": "این شناسه، کد دعوت این کاربر در ربات تلگرام هم هست: هر کسی آن را به ربات بفرستد به این کاربر متصل می‌شود. آن را طولانی و تصادفی نگه دارید؛ شناسهٔ کوتاه یا قابل حدس ممکن است توسط شخص دیگری تصاحب شود.",
       "online": "آنلاین",
       "email": "ایمیل",
       "emailInvalidChars": "ایمیل نمی‌تواند شامل فاصله، '/'، '\\' یا کاراکترهای کنترلی باشد",
@@ -1640,13 +1643,10 @@
       "subHappProxyIPs": "آی‌پی‌های پراکسی (Proxy CIDRs)",
       "subHappBlockIPs": "آی‌پی‌های مسدود (Block CIDRs)",
       "subHappDeeplinkGenerated": "دیپ‌لینک تولید و در قوانین روتینگ اعمال شد",
-      "subHappGroupLinks": "لینک‌های اشتراک",
       "subHappGroupRouting": "قوانین و روتینگ",
-      "subHappGroupBanners": "بنرها و اعلانات",
       "subHappGroupNetwork": "تنظیمات شبکه و TUN",
       "subHappGroupThemes": "ظاهر و پوسته برنامه",
       "subHappGroupFailover": "مهاجرت و مدیریت برنامه",
-      "subHappGroupAndroid": "پراکسی برنامه‌های اندروید",
       "discordSettings": "ربات دیسکورد",
       "discordBotEnable": "فعال‌سازی اعلان‌های دیسکورد",
       "discordBotEnableDesc": "ارسال هشدارهای سیستم و رویدادها به کانال دیسکورد از طریق ربات",
@@ -2466,6 +2466,11 @@
       "download": "🔽 دانلود: ↓{{ .Download }}\r\n",
       "total": "📊 کل: ↑↓{{ .UpDown }} / {{ .Total }}\r\n",
       "TGUser": "👤 کاربر تلگرام: {{ .TelegramID }}\r\n",
+      "inviteBound": "✅ حساب تلگرام شما به <code>{{ .Email }}</code> متصل شد.\r\n",
+      "inviteInvalid": "❗ این لینک دعوت نامعتبر است یا قبلاً استفاده شده است.\r\n",
+      "inviteRateLimited": "⏳ تعداد تلاش‌های دعوت بیش از حد مجاز است. لطفاً بعداً دوباره امتحان کنید.\r\n",
+      "inviteRateLimitedAdmin": "⚠️ تلاش‌های دعوت برای {{ .User }} (<code>{{ .ID }}</code>) مسدود شد: بیش از {{ .Limit }} تلاش در یک ساعت. ممکن است کسی در حال حدس زدن شناسه‌های اشتراک باشد.",
+      "inviteLink": "🔗 لینک دعوت برای <code>{{ .Email }}</code>:\r\n<code>{{ .Link }}</code>\r\n\r\nاولین کسی که آن را باز کند به این کاربر متصل می‌شود.\r\n",
       "exhaustedCount": "🚨 تعداد {{ .Type }} به‌اتمام‌رسیده‌است:\r\n",
       "onlinesCount": "🌐 کاربران‌آنلاین: {{ .Count }}\r\n",
       "disabled": "🛑 غیرفعال: {{ .Disabled }}\r\n",
@@ -2533,6 +2538,7 @@
       "ipLog": "🔢 لاگ آدرس‌های IP",
       "ipLimit": "🔢 محدودیت IP",
       "setTGUser": "👤 تنظیم کاربر تلگرام",
+      "inviteLink": "🔗 لینک دعوت",
       "toggle": "🔘 فعال / غیرفعال",
       "custom": "🔢 سفارشی",
       "confirmNumber": "✅ تایید: {{ .Num }}",

+ 9 - 3
internal/web/translation/id-ID.json

@@ -664,6 +664,8 @@
         "shareAddrHelp": "Hanya digunakan saat strategi alamat berbagi adalah Kustom. Masukkan host atau IP tanpa skema atau port.",
         "subSortIndex": "Urutan dalam langganan",
         "subSortIndexHelp": "Posisi tautan inbound ini dalam keluaran langganan (halaman langganan dan aplikasi klien). Nilai lebih kecil tampil lebih dulu; nilai sama mempertahankan urutan pembuatan. Tidak memengaruhi daftar inbound di panel.",
+        "excludeFromSub": "Kecualikan dari langganan",
+        "excludeFromSubHelp": "Sembunyikan tautan inbound ini dari keluaran langganan sambil tetap menjaga inbound tetap aktif dan beroperasi.",
         "disableFlow": "Nonaktifkan flow XTLS",
         "disableFlowHelp": "Kecualikan inbound ini dari injeksi otomatis xtls-rprx-vision, meskipun transport-nya mendukung flow (mis. inbound XHTTP yang dituneling dengan enkripsi VLESS). Klien tetap memakai Vision pada inbound lain yang mendukung dalam langganan yang sama. Hanya VLESS.",
         "shareAddrStrategyOptions": {
@@ -789,6 +791,7 @@
       "password": "Kata sandi",
       "passwordDesc": "Hanya digunakan oleh klien Trojan dan Shadowsocks; diabaikan untuk VLESS, VMess, Hysteria, dan WireGuard.",
       "subId": "ID Langganan",
+      "subIdDesc": "Juga berfungsi sebagai kode undangan bot Telegram untuk klien ini: siapa pun yang mengirimkannya ke bot akan ditautkan ke klien ini. Buat panjang dan acak — ID yang pendek atau mudah ditebak bisa diklaim orang lain.",
       "online": "Online",
       "email": "Email",
       "emailInvalidChars": "Email tidak boleh mengandung spasi, '/', '\\', atau karakter kontrol",
@@ -1640,13 +1643,10 @@
       "subHappProxyIPs": "IP / CIDR Proxy",
       "subHappBlockIPs": "IP / CIDR Diblokir",
       "subHappDeeplinkGenerated": "Deeplink dibuat dan diterapkan ke aturan routing",
-      "subHappGroupLinks": "Tautan Langganan",
       "subHappGroupRouting": "Routing & Aturan",
-      "subHappGroupBanners": "Banner & Pengumuman",
       "subHappGroupNetwork": "Jaringan & Mesin TUN",
       "subHappGroupThemes": "Tampilan & Tema",
       "subHappGroupFailover": "Migrasi & Manajemen Aplikasi",
-      "subHappGroupAndroid": "Proxy Per-Aplikasi Android",
       "discordSettings": "Bot Discord",
       "discordBotEnable": "Aktifkan Notifikasi Discord",
       "discordBotEnableDesc": "Kirim peringatan sistem dan acara ke saluran Discord melalui bot",
@@ -2466,6 +2466,11 @@
       "download": "🔽 Unduh: ↓{{ .Download }}\r\n",
       "total": "📊 Total: ↑↓{{ .UpDown }} / {{ .Total }}\r\n",
       "TGUser": "👤 Pengguna Telegram: {{ .TelegramID }}\r\n",
+      "inviteBound": "✅ Akun Telegram Anda kini tertaut ke <code>{{ .Email }}</code>.\r\n",
+      "inviteInvalid": "❗ Tautan undangan ini tidak valid atau sudah digunakan.\r\n",
+      "inviteRateLimited": "⏳ Terlalu banyak percobaan undangan. Silakan coba lagi nanti.\r\n",
+      "inviteRateLimitedAdmin": "⚠️ Percobaan undangan diblokir untuk {{ .User }} (<code>{{ .ID }}</code>): lebih dari {{ .Limit }} percobaan dalam satu jam. Seseorang mungkin sedang menebak ID langganan.",
+      "inviteLink": "🔗 Tautan undangan untuk <code>{{ .Email }}</code>:\r\n<code>{{ .Link }}</code>\r\n\r\nOrang pertama yang membukanya akan tertaut ke klien ini.\r\n",
       "exhaustedCount": "🚨 Jumlah Habis {{ .Type }}:\r\n",
       "onlinesCount": "🌐 Klien Online: {{ .Count }}\r\n",
       "disabled": "🛑 Dinonaktifkan: {{ .Disabled }}\r\n",
@@ -2533,6 +2538,7 @@
       "ipLog": "🔢 Log IP",
       "ipLimit": "🔢 Batas IP",
       "setTGUser": "👤 Set Pengguna Telegram",
+      "inviteLink": "🔗 Tautan Undangan",
       "toggle": "🔘 Aktifkan / Nonaktifkan",
       "custom": "🔢 Kustom",
       "confirmNumber": "✅ Konfirmasi: {{ .Num }}",

+ 9 - 3
internal/web/translation/ja-JP.json

@@ -685,6 +685,8 @@
         "shareAddrHelp": "共有アドレス戦略がカスタムの場合のみ使用されます。スキームやポートを含めずにホスト名またはIPを入力してください。",
         "subSortIndex": "サブスクリプションでの並び順",
         "subSortIndexHelp": "サブスクリプション出力(サブスクリプションページおよびクライアントアプリ)におけるこのインバウンドのリンクの位置。値が小さいほど先頭に表示され、同じ値の場合は作成順が維持されます。パネルのインバウンド一覧には影響しません。",
+        "excludeFromSub": "サブスクリプションから除外",
+        "excludeFromSubHelp": "このインバウンドを有効なまま運用しつつ、サブスクリプション出力からリンクを隠します。",
         "disableFlow": "XTLS フローを無効化",
         "disableFlowHelp": "トランスポートが flow に対応している場合でも(例: VLESS 暗号化付きのトンネル化された XHTTP インバウンド)、このインバウンドを xtls-rprx-vision の自動付与から除外します。クライアントは同じサブスクリプション内の他の対応インバウンドでは Vision を維持します。VLESS のみ。",
         "shareAddrStrategyOptions": {
@@ -789,6 +791,7 @@
       "password": "パスワード",
       "passwordDesc": "Trojan と Shadowsocks のクライアントのみが使用します。VLESS、VMess、Hysteria、WireGuard では無視されます。",
       "subId": "サブスクリプション ID",
+      "subIdDesc": "このクライアントの Telegram ボット招待コードとしても使われます。ボットに送信した人がこのクライアントに紐付けられます。長くランダムな値にしてください。短い、または推測しやすい ID は他人に取得されるおそれがあります。",
       "online": "オンライン",
       "email": "メール",
       "emailInvalidChars": "メールアドレスにスペース、'/'、'\\'、または制御文字を含めることはできません",
@@ -1640,13 +1643,10 @@
       "subHappProxyIPs": "プロキシする IP / CIDR",
       "subHappBlockIPs": "ブロックする IP / CIDR",
       "subHappDeeplinkGenerated": "ディープリンクを生成し、ルーティングルールに適用しました",
-      "subHappGroupLinks": "サブスクリプションリンク",
       "subHappGroupRouting": "ルーティングとルール",
-      "subHappGroupBanners": "バナーとお知らせ",
       "subHappGroupNetwork": "ネットワークと TUN エンジン",
       "subHappGroupThemes": "外観とテーマ",
       "subHappGroupFailover": "移行とアプリ管理",
-      "subHappGroupAndroid": "Android アプリ別プロキシ",
       "discordSettings": "Discord Bot",
       "discordBotEnable": "Discord通知を有効化",
       "discordBotEnableDesc": "Botを介してDiscordチャンネルにシステムおよびイベント通知を送信します",
@@ -2466,6 +2466,11 @@
       "download": "🔽 ダウンロード: ↓{{ .Download }}\r\n",
       "total": "📊 合計: ↑↓{{ .UpDown }} / {{ .Total }}\r\n",
       "TGUser": "👤 Telegramユーザー:{{ .TelegramID }}\r\n",
+      "inviteBound": "✅ あなたの Telegram アカウントは <code>{{ .Email }}</code> にリンクされました。\r\n",
+      "inviteInvalid": "❗ この招待リンクは無効か、すでに使用されています。\r\n",
+      "inviteRateLimited": "⏳ 招待の試行回数が多すぎます。しばらくしてからもう一度お試しください。\r\n",
+      "inviteRateLimitedAdmin": "⚠️ {{ .User }} (<code>{{ .ID }}</code>) の招待の試行をブロックしました:1 時間に {{ .Limit }} 回を超えました。誰かがサブスクリプション ID を推測している可能性があります。",
+      "inviteLink": "🔗 <code>{{ .Email }}</code> の招待リンク:\r\n<code>{{ .Link }}</code>\r\n\r\n最初に開いた人がこのクライアントにリンクされます。\r\n",
       "exhaustedCount": "🚨 消耗済みの {{ .Type }} 数量:\r\n",
       "onlinesCount": "🌐 オンラインクライアント:{{ .Count }}\r\n",
       "disabled": "🛑 無効化:{{ .Disabled }}\r\n",
@@ -2533,6 +2538,7 @@
       "ipLog": "🔢 IPログ",
       "ipLimit": "🔢 IP制限",
       "setTGUser": "👤 Telegramユーザーを設定",
+      "inviteLink": "🔗 招待リンク",
       "toggle": "🔘 有効/無効",
       "custom": "🔢 カスタム",
       "confirmNumber": "✅ 確認: {{ .Num }}",

+ 9 - 3
internal/web/translation/pt-BR.json

@@ -685,6 +685,8 @@
         "shareAddrHelp": "Usado apenas quando a estratégia de endereço de compartilhamento é Personalizada. Informe um host ou IP sem esquema nem porta.",
         "subSortIndex": "Ordem na assinatura",
         "subSortIndexHelp": "Posição dos links desta entrada na saída da assinatura (página de assinatura e aplicativos cliente). Valores menores vêm primeiro; valores iguais mantêm a ordem de criação. Não afeta a lista de entradas do painel.",
+        "excludeFromSub": "Excluir das assinaturas",
+        "excludeFromSubHelp": "Oculta os links deste inbound na saída de assinatura, mantendo o inbound habilitado e operacional.",
         "disableFlow": "Desativar o flow XTLS",
         "disableFlowHelp": "Exclui este inbound da injeção automática de xtls-rprx-vision, mesmo quando o transporte suporta flow (ex.: um inbound XHTTP tunelado com criptografia VLESS). Os clientes mantêm o Vision nos seus outros inbounds compatíveis da mesma assinatura. Somente VLESS.",
         "shareAddrStrategyOptions": {
@@ -789,6 +791,7 @@
       "password": "Senha",
       "passwordDesc": "Usada apenas pelos clientes Trojan e Shadowsocks; ignorada para VLESS, VMess, Hysteria e WireGuard.",
       "subId": "ID da assinatura",
+      "subIdDesc": "Também funciona como código de convite deste cliente no bot do Telegram: quem enviá-lo ao bot fica vinculado a este cliente. Mantenha-o longo e aleatório — um ID curto ou fácil de adivinhar pode ser reivindicado por outra pessoa.",
       "online": "Online",
       "email": "Email",
       "emailInvalidChars": "O e-mail não pode conter espaços, '/', '\\' ou caracteres de controle",
@@ -1640,13 +1643,10 @@
       "subHappProxyIPs": "IPs / CIDRs via proxy",
       "subHappBlockIPs": "IPs / CIDRs bloqueados",
       "subHappDeeplinkGenerated": "Deeplink gerado e aplicado às regras de roteamento",
-      "subHappGroupLinks": "Links de assinatura",
       "subHappGroupRouting": "Roteamento e regras",
-      "subHappGroupBanners": "Banners e anúncios",
       "subHappGroupNetwork": "Rede e mecanismo TUN",
       "subHappGroupThemes": "Aparência e tema",
       "subHappGroupFailover": "Migração e gerenciamento do app",
-      "subHappGroupAndroid": "Proxy por aplicativo no Android",
       "discordSettings": "Bot do Discord",
       "discordBotEnable": "Ativar notificações do Discord",
       "discordBotEnableDesc": "Enviar alertas de sistema e eventos para um canal do Discord via bot",
@@ -2466,6 +2466,11 @@
       "download": "🔽 Download: ↓{{ .Download }}\r\n",
       "total": "📊 Total: ↑↓{{ .UpDown }} / {{ .Total }}\r\n",
       "TGUser": "👤 Usuário do Telegram: {{ .TelegramID }}\r\n",
+      "inviteBound": "✅ Sua conta do Telegram agora está vinculada a <code>{{ .Email }}</code>.\r\n",
+      "inviteInvalid": "❗ Este link de convite é inválido ou já foi utilizado.\r\n",
+      "inviteRateLimited": "⏳ Muitas tentativas de convite. Tente novamente mais tarde.\r\n",
+      "inviteRateLimitedAdmin": "⚠️ Tentativas de convite bloqueadas para {{ .User }} (<code>{{ .ID }}</code>): mais de {{ .Limit }} tentativas em uma hora. Alguém pode estar tentando adivinhar IDs de assinatura.",
+      "inviteLink": "🔗 Link de convite para <code>{{ .Email }}</code>:\r\n<code>{{ .Link }}</code>\r\n\r\nA primeira pessoa que abri-lo será vinculada a este cliente.\r\n",
       "exhaustedCount": "🚨 Contagem de {{ .Type }} esgotado:\r\n",
       "onlinesCount": "🌐 Clientes online: {{ .Count }}\r\n",
       "disabled": "🛑 Desativado: {{ .Disabled }}\r\n",
@@ -2533,6 +2538,7 @@
       "ipLog": "🔢 Log de IP",
       "ipLimit": "🔢 Limite de IP",
       "setTGUser": "👤 Definir usuário do Telegram",
+      "inviteLink": "🔗 Link de convite",
       "toggle": "🔘 Ativar / Desativar",
       "custom": "🔢 Personalizado",
       "confirmNumber": "✅ Confirmar: {{ .Num }}",

+ 9 - 3
internal/web/translation/ru-RU.json

@@ -687,6 +687,8 @@
         "shareAddrHelp": "Используется только когда стратегия адреса для ссылок — пользовательская. Укажите хост или IP без схемы и порта.",
         "subSortIndex": "Порядок в подписке",
         "subSortIndexHelp": "Позиция ссылок этого входящего в выдаче подписки (страница подписки и клиентские приложения). Меньшие значения идут первыми; при равных значениях сохраняется порядок создания. Не влияет на список входящих в панели.",
+        "excludeFromSub": "Исключить из подписок",
+        "excludeFromSubHelp": "Скрывает ссылки этого inbound из вывода подписки, оставляя inbound включённым и рабочим.",
         "disableFlow": "Отключить поток XTLS",
         "disableFlowHelp": "Исключить этот inbound из автоматического добавления xtls-rprx-vision, даже если его транспорт поддерживает flow (например, туннелированный XHTTP inbound с шифрованием VLESS). Клиенты сохраняют Vision на других подходящих inbound в той же подписке. Только VLESS.",
         "shareAddrStrategyOptions": {
@@ -789,6 +791,7 @@
       "password": "Пароль",
       "passwordDesc": "Используется клиентами Trojan, Shadowsocks и TUIC; игнорируется для VLESS, VMess, Hysteria и WireGuard.",
       "subId": "ID подписки",
+      "subIdDesc": "Также служит кодом приглашения этого клиента в Telegram-боте: тот, кто отправит его боту, будет привязан к этому клиенту. Используйте длинное случайное значение — короткий или легко угадываемый ID может присвоить кто-то другой.",
       "online": "В сети",
       "email": "Email",
       "emailInvalidChars": "Email не может содержать пробелы, '/', '\\' или управляющие символы",
@@ -1640,13 +1643,10 @@
       "subHappProxyIPs": "Проксируемые IP / CIDR",
       "subHappBlockIPs": "Заблокированные IP / CIDR",
       "subHappDeeplinkGenerated": "Диплинк сгенерирован и применен к правилам маршрутизации",
-      "subHappGroupLinks": "Ссылки подписки",
       "subHappGroupRouting": "Маршрутизация и правила",
-      "subHappGroupBanners": "Баннеры и уведомления",
       "subHappGroupNetwork": "Сетевые настройки и TUN",
       "subHappGroupThemes": "Внешний вид и темы",
       "subHappGroupFailover": "Миграция и управление",
-      "subHappGroupAndroid": "Прокси приложений Android",
       "discordSettings": "Discord бот",
       "discordBotEnable": "Включить уведомления в Discord",
       "discordBotEnableDesc": "Отправлять оповещения о событиях в канал Discord через бота",
@@ -2466,6 +2466,11 @@
       "download": "🔽 Загрузка: ↓{{ .Download }}\r\n",
       "total": "📊 Всего: ↑↓{{ .UpDown }} / {{ .Total }}\r\n",
       "TGUser": "👤 Telegram User ID: {{ .TelegramID }}\r\n",
+      "inviteBound": "✅ Ваш аккаунт Telegram привязан к <code>{{ .Email }}</code>.\r\n",
+      "inviteInvalid": "❗ Эта ссылка-приглашение недействительна или уже использована.\r\n",
+      "inviteRateLimited": "⏳ Слишком много попыток приглашения. Попробуйте позже.\r\n",
+      "inviteRateLimitedAdmin": "⚠️ Попытки приглашения заблокированы для {{ .User }} (<code>{{ .ID }}</code>): более {{ .Limit }} попыток за час. Возможно, кто-то подбирает ID подписок.",
+      "inviteLink": "🔗 Ссылка-приглашение для <code>{{ .Email }}</code>:\r\n<code>{{ .Link }}</code>\r\n\r\nПервый, кто её откроет, будет привязан к этому клиенту.\r\n",
       "exhaustedCount": "🚨 Количество исчерпанных {{ .Type }}:\r\n",
       "onlinesCount": "🌐 Клиентов онлайн: {{ .Count }}\r\n",
       "disabled": "🛑 Отключено: {{ .Disabled }}\r\n",
@@ -2533,6 +2538,7 @@
       "ipLog": "🔢 Лог IP",
       "ipLimit": "🔢 Лимит IP",
       "setTGUser": "👤 Установить пользователя Telegram",
+      "inviteLink": "🔗 Ссылка-приглашение",
       "toggle": "🔘 Вкл./Выкл.",
       "custom": "🔢 Своё",
       "confirmNumber": "✅ Подтвердить: {{ .Num }}",

+ 9 - 3
internal/web/translation/tr-TR.json

@@ -664,6 +664,8 @@
         "shareAddrHelp": "Yalnızca paylaşım adresi stratejisi Özel olduğunda kullanılır. Şema veya port olmadan bir ana makine ya da IP girin.",
         "subSortIndex": "Abonelikte sıralama",
         "subSortIndexHelp": "Bu gelen bağlantının linklerinin abonelik çıktısındaki (abonelik sayfası ve istemci uygulamaları) konumu. Küçük değerler önce gelir; eşit değerlerde oluşturulma sırası korunur. Paneldeki gelen bağlantı listesini etkilemez.",
+        "excludeFromSub": "Aboneliklerden hariç tut",
+        "excludeFromSubHelp": "Bu inbound bağlantılarını abonelik çıktısından gizler; inbound etkin ve çalışır durumda kalır.",
         "disableFlow": "XTLS akışını devre dışı bırak",
         "disableFlowHelp": "Taşıması flow destekliyor olsa bile (ör. VLESS şifrelemeli, tünellenmiş bir XHTTP inbound) bu inbound'u otomatik xtls-rprx-vision eklemenin dışında tut. İstemciler aynı abonelikteki diğer uygun inbound'larda Vision'ı korur. Yalnızca VLESS.",
         "shareAddrStrategyOptions": {
@@ -789,6 +791,7 @@
       "password": "Şifre",
       "passwordDesc": "Yalnızca Trojan ve Shadowsocks istemcileri tarafından kullanılır; VLESS, VMess, Hysteria ve WireGuard için yok sayılır.",
       "subId": "Abonelik ID'si",
+      "subIdDesc": "Bu istemcinin Telegram botu davet kodu olarak da kullanılır: bunu bota gönderen kişi bu istemciye bağlanır. Uzun ve rastgele tutun — kısa veya tahmin edilebilir bir kimlik başkası tarafından sahiplenilebilir.",
       "online": "Çevrimiçi",
       "email": "E-posta",
       "emailInvalidChars": "E-posta boşluk, '/', '\\' veya kontrol karakterleri içeremez",
@@ -1640,13 +1643,10 @@
       "subHappProxyIPs": "Proxy IP'ler / CIDR'ler",
       "subHappBlockIPs": "Engellenen IP'ler / CIDR'ler",
       "subHappDeeplinkGenerated": "Derin bağlantı oluşturuldu ve yönlendirme kurallarına uygulandı",
-      "subHappGroupLinks": "Abonelik Bağlantıları",
       "subHappGroupRouting": "Yönlendirme ve Kurallar",
-      "subHappGroupBanners": "Afişler ve Duyurular",
       "subHappGroupNetwork": "Ağ ve TUN Motoru",
       "subHappGroupThemes": "Görünüm ve Tema",
       "subHappGroupFailover": "Geçiş ve Uygulama Yönetimi",
-      "subHappGroupAndroid": "Android Uygulama Başına Proxy",
       "discordSettings": "Discord Botu",
       "discordBotEnable": "Discord Bildirimlerini Etkinleştir",
       "discordBotEnableDesc": "Sistem ve olay uyarılarını bot aracılığıyla bir Discord kanalına gönderin",
@@ -2466,6 +2466,11 @@
       "download": "🔽 İndirme: ↓{{ .Download }}\r\n",
       "total": "📊 Toplam: ↑↓{{ .UpDown }} / {{ .Total }}\r\n",
       "TGUser": "👤 Telegram Kullanıcısı: {{ .TelegramID }}\r\n",
+      "inviteBound": "✅ Telegram hesabınız <code>{{ .Email }}</code> ile bağlandı.\r\n",
+      "inviteInvalid": "❗ Bu davet bağlantısı geçersiz veya daha önce kullanılmış.\r\n",
+      "inviteRateLimited": "⏳ Çok fazla davet denemesi. Lütfen daha sonra tekrar deneyin.\r\n",
+      "inviteRateLimitedAdmin": "⚠️ {{ .User }} (<code>{{ .ID }}</code>) için davet denemeleri engellendi: bir saatte {{ .Limit }} denemeden fazla. Birisi abonelik kimliklerini tahmin etmeye çalışıyor olabilir.",
+      "inviteLink": "🔗 <code>{{ .Email }}</code> için davet bağlantısı:\r\n<code>{{ .Link }}</code>\r\n\r\nBağlantıyı ilk açan kişi bu istemciye bağlanır.\r\n",
       "exhaustedCount": "🚨 Limiti Dolan {{ .Type }} sayısı:\r\n",
       "onlinesCount": "🌐 Çevrimiçi Kullanıcılar: {{ .Count }}\r\n",
       "disabled": "🛑 Devre Dışı: {{ .Disabled }}\r\n",
@@ -2533,6 +2538,7 @@
       "ipLog": "🔢 IP Günlüğü",
       "ipLimit": "🔢 IP Limiti",
       "setTGUser": "👤 Telegram Kullanıcısını Ayarla",
+      "inviteLink": "🔗 Davet Bağlantısı",
       "toggle": "🔘 Etkinleştir / Devre Dışı Bırak",
       "custom": "🔢 Özel",
       "confirmNumber": "✅ Onayla: {{ .Num }}",

+ 9 - 3
internal/web/translation/uk-UA.json

@@ -664,6 +664,8 @@
         "shareAddrHelp": "Використовується лише коли стратегія адреси поширення — користувацька. Введіть хост або IP без схеми та порту.",
         "subSortIndex": "Порядок у підписці",
         "subSortIndexHelp": "Позиція посилань цього вхідного у виводі підписки (сторінка підписки та клієнтські застосунки). Менші значення йдуть першими; за однакових значень зберігається порядок створення. Не впливає на список вхідних у панелі.",
+        "excludeFromSub": "Виключити з підписок",
+        "excludeFromSubHelp": "Приховує посилання цього inbound у виводі підписки, залишаючи inbound увімкненим і робочим.",
         "disableFlow": "Вимкнути потік XTLS",
         "disableFlowHelp": "Виключити цей inbound з автоматичного додавання xtls-rprx-vision, навіть якщо його транспорт підтримує flow (наприклад, тунельований XHTTP inbound із шифруванням VLESS). Клієнти зберігають Vision на інших сумісних inbound у тій самій підписці. Лише VLESS.",
         "echSockopt": "ECH Sockopt",
@@ -789,6 +791,7 @@
       "password": "Пароль",
       "passwordDesc": "Використовується лише клієнтами Trojan і Shadowsocks; ігнорується для VLESS, VMess, Hysteria та WireGuard.",
       "subId": "ID підписки",
+      "subIdDesc": "Також слугує кодом запрошення цього клієнта в Telegram-боті: той, хто надішле його боту, буде прив'язаний до цього клієнта. Використовуйте довге випадкове значення — короткий або легко вгадуваний ID може привласнити хтось інший.",
       "online": "У мережі",
       "email": "Email",
       "emailInvalidChars": "Email не може містити пробіли, '/', '\\' або керуючі символи",
@@ -1640,13 +1643,10 @@
       "subHappProxyIPs": "IP / CIDR через проксі",
       "subHappBlockIPs": "Заблоковані IP / CIDR",
       "subHappDeeplinkGenerated": "Діплінк згенеровано та застосовано до правил маршрутизації",
-      "subHappGroupLinks": "Посилання на підписку",
       "subHappGroupRouting": "Маршрутизація та правила",
-      "subHappGroupBanners": "Банери та оголошення",
       "subHappGroupNetwork": "Мережа та рушій TUN",
       "subHappGroupThemes": "Зовнішній вигляд і тема",
       "subHappGroupFailover": "Міграція та керування програмою",
-      "subHappGroupAndroid": "Проксі для окремих програм Android",
       "discordSettings": "Discord бот",
       "discordBotEnable": "Увімкнути сповіщення Discord",
       "discordBotEnableDesc": "Надсилати системні сповіщення та події в канал Discord через бота",
@@ -2466,6 +2466,11 @@
       "download": "🔽 Завантаження: ↓{{ .Download }}\r\n",
       "total": "📊 Усього: ↑↓{{ .UpDown }} / {{ .Total }}\r\n",
       "TGUser": "👤 Користувач Telegram: {{ .TelegramID }}\r\n",
+      "inviteBound": "✅ Ваш акаунт Telegram прив'язано до <code>{{ .Email }}</code>.\r\n",
+      "inviteInvalid": "❗ Це посилання-запрошення недійсне або вже використане.\r\n",
+      "inviteRateLimited": "⏳ Забагато спроб запрошення. Спробуйте пізніше.\r\n",
+      "inviteRateLimitedAdmin": "⚠️ Спроби запрошення заблоковано для {{ .User }} (<code>{{ .ID }}</code>): понад {{ .Limit }} спроб за годину. Можливо, хтось підбирає ID підписок.",
+      "inviteLink": "🔗 Посилання-запрошення для <code>{{ .Email }}</code>:\r\n<code>{{ .Link }}</code>\r\n\r\nПерший, хто його відкриє, буде прив'язаний до цього клієнта.\r\n",
       "exhaustedCount": "🚨 Вичерпано кількість {{ .Type }} count:\r\n",
       "onlinesCount": "🌐 Онлайн-клієнти: {{ .Count }}\r\n",
       "disabled": "🛑 Вимкнено: {{ .Disabled }}\r\n",
@@ -2533,6 +2538,7 @@
       "ipLog": "🔢 IP журнал",
       "ipLimit": "🔢 IP Ліміт",
       "setTGUser": "👤 Встановити користувача Telegram",
+      "inviteLink": "🔗 Посилання-запрошення",
       "toggle": "🔘 Увімкнути / Вимкнути",
       "custom": "🔢 Своє",
       "confirmNumber": "✅ Підтвердити: {{ .Num }}",

+ 9 - 3
internal/web/translation/vi-VN.json

@@ -685,6 +685,8 @@
         "shareAddrHelp": "Chỉ dùng khi chiến lược địa chỉ chia sẻ là Tùy chỉnh. Nhập host hoặc IP không kèm giao thức hoặc cổng.",
         "subSortIndex": "Thứ tự trong gói đăng ký",
         "subSortIndexHelp": "Vị trí liên kết của inbound này trong nội dung gói đăng ký (trang đăng ký và ứng dụng khách). Giá trị nhỏ hơn xếp trước; giá trị bằng nhau giữ thứ tự tạo. Không ảnh hưởng đến danh sách inbound trong bảng điều khiển.",
+        "excludeFromSub": "Loại khỏi subscription",
+        "excludeFromSubHelp": "Ẩn liên kết inbound này khỏi đầu ra subscription nhưng vẫn giữ inbound được bật và hoạt động.",
         "disableFlow": "Tắt luồng XTLS",
         "disableFlowHelp": "Loại inbound này khỏi việc tự động thêm xtls-rprx-vision, ngay cả khi transport của nó hỗ trợ flow (ví dụ một inbound XHTTP đi qua tunnel với mã hóa VLESS). Client vẫn giữ Vision trên các inbound tương thích khác trong cùng subscription. Chỉ dành cho VLESS.",
         "shareAddrStrategyOptions": {
@@ -789,6 +791,7 @@
       "password": "Mật khẩu",
       "passwordDesc": "Chỉ được dùng bởi các client Trojan và Shadowsocks; bị bỏ qua đối với VLESS, VMess, Hysteria và WireGuard.",
       "subId": "ID đăng ký",
+      "subIdDesc": "Cũng được dùng làm mã mời bot Telegram của client này: ai gửi mã này cho bot sẽ được liên kết với client. Hãy để mã dài và ngẫu nhiên — ID ngắn hoặc dễ đoán có thể bị người khác chiếm.",
       "online": "Trực tuyến",
       "email": "Email",
       "emailInvalidChars": "Email không được chứa khoảng trắng, '/', '\\' hoặc ký tự điều khiển",
@@ -1640,13 +1643,10 @@
       "subHappProxyIPs": "IP / CIDR proxy",
       "subHappBlockIPs": "IP / CIDR bị chặn",
       "subHappDeeplinkGenerated": "Đã tạo deeplink và áp dụng vào quy tắc định tuyến",
-      "subHappGroupLinks": "Liên kết đăng ký",
       "subHappGroupRouting": "Định tuyến & quy tắc",
-      "subHappGroupBanners": "Biểu ngữ & thông báo",
       "subHappGroupNetwork": "Mạng & lõi TUN",
       "subHappGroupThemes": "Giao diện & chủ đề",
       "subHappGroupFailover": "Di chuyển & quản lý ứng dụng",
-      "subHappGroupAndroid": "Proxy theo ứng dụng Android",
       "discordSettings": "Bot Discord",
       "discordBotEnable": "Bật thông báo Discord",
       "discordBotEnableDesc": "Gửi cảnh báo hệ thống và sự kiện đến kênh Discord qua bot",
@@ -2466,6 +2466,11 @@
       "download": "🔽 Tải xuống: ↓{{ .Download }}\r\n",
       "total": "📊 Tổng: ↑↓{{ .UpDown }} / {{ .Total }}\r\n",
       "TGUser": "👤 Người dùng Telegram: {{ .TelegramID }}\r\n",
+      "inviteBound": "✅ Tài khoản Telegram của bạn đã được liên kết với <code>{{ .Email }}</code>.\r\n",
+      "inviteInvalid": "❗ Liên kết mời này không hợp lệ hoặc đã được sử dụng.\r\n",
+      "inviteRateLimited": "⏳ Quá nhiều lần thử lời mời. Vui lòng thử lại sau.\r\n",
+      "inviteRateLimitedAdmin": "⚠️ Đã chặn các lần thử lời mời của {{ .User }} (<code>{{ .ID }}</code>): hơn {{ .Limit }} lần trong một giờ. Có thể ai đó đang đoán ID đăng ký.",
+      "inviteLink": "🔗 Liên kết mời cho <code>{{ .Email }}</code>:\r\n<code>{{ .Link }}</code>\r\n\r\nNgười đầu tiên mở nó sẽ được liên kết với client này.\r\n",
       "exhaustedCount": "🚨 Số lần cạn kiệt {{ .Type }}:\r\n",
       "onlinesCount": "🌐 Khách hàng trực tuyến: {{ .Count }}\r\n",
       "disabled": "🛑 Vô hiệu hóa: {{ .Disabled }}\r\n",
@@ -2533,6 +2538,7 @@
       "ipLog": "🔢 Nhật ký địa chỉ IP",
       "ipLimit": "🔢 Giới Hạn địa chỉ IP",
       "setTGUser": "👤 Đặt Người Dùng Telegram",
+      "inviteLink": "🔗 Liên kết mời",
       "toggle": "🔘 Bật / Tắt",
       "custom": "🔢 Tùy chỉnh",
       "confirmNumber": "✅ Xác nhận: {{ .Num }}",

+ 9 - 3
internal/web/translation/zh-CN.json

@@ -684,6 +684,8 @@
         "shareAddrHelp": "仅在分享地址策略为自定义时使用。填写不带协议和端口的域名或 IP。",
         "subSortIndex": "订阅排序",
         "subSortIndexHelp": "此入站的链接在订阅输出(订阅页面和客户端应用)中的位置。数值越小越靠前;数值相同时保持创建顺序。不影响面板中的入站列表。",
+        "excludeFromSub": "从订阅中排除",
+        "excludeFromSubHelp": "从订阅输出中隐藏此入站的链接,同时保持入站启用并继续工作。",
         "disableFlow": "禁用 XTLS flow",
         "disableFlowHelp": "让此入站跳过自动注入 xtls-rprx-vision,即使其传输支持 flow(例如启用 VLESS 加密的隧道化 XHTTP 入站)。客户端在同一订阅中的其他可用入站上仍保留 Vision。仅限 VLESS。",
         "shareAddrStrategyOptions": {
@@ -789,6 +791,7 @@
       "password": "密码",
       "passwordDesc": "用于 Trojan、Shadowsocks 和 TUIC 客户端;对 VLESS、VMess、Hysteria 和 WireGuard 忽略。",
       "subId": "订阅 ID",
+      "subIdDesc": "同时用作该客户端的 Telegram 机器人邀请码:任何人将其发送给机器人即会绑定到此客户端。请保持足够长且随机——过短或易猜的 ID 可能被他人抢先绑定。",
       "online": "在线",
       "email": "邮箱",
       "emailInvalidChars": "邮箱不能包含空格、'/'、'\\' 或控制字符",
@@ -1640,13 +1643,10 @@
       "subHappProxyIPs": "代理 IP / CIDR",
       "subHappBlockIPs": "阻止 IP / CIDR",
       "subHappDeeplinkGenerated": "DeepLink 已生成并填入路由规则",
-      "subHappGroupLinks": "订阅链接",
       "subHappGroupRouting": "路由分流与规则",
-      "subHappGroupBanners": "横幅公告与通知",
       "subHappGroupNetwork": "网络与 TUN 引擎",
       "subHappGroupThemes": "界面与主题外观",
       "subHappGroupFailover": "迁移与客户端管理",
-      "subHappGroupAndroid": "Android 分应用代理",
       "discordSettings": "Discord 机器人",
       "discordBotEnable": "启用 Discord 通知",
       "discordBotEnableDesc": "通过机器人向 Discord 频道发送系统和事件警报",
@@ -2466,6 +2466,11 @@
       "download": "🔽 下载: ↓{{ .Download }}\r\n",
       "total": "📊 总计: ↑↓{{ .UpDown }} / {{ .Total }}\r\n",
       "TGUser": "👤 电报用户:{{ .TelegramID }}\r\n",
+      "inviteBound": "✅ 您的 Telegram 账号已绑定到 <code>{{ .Email }}</code>。\r\n",
+      "inviteInvalid": "❗ 此邀请链接无效或已被使用。\r\n",
+      "inviteRateLimited": "⏳ 邀请尝试次数过多,请稍后再试。\r\n",
+      "inviteRateLimitedAdmin": "⚠️ 已阻止 {{ .User }}(<code>{{ .ID }}</code>)的邀请尝试:一小时内超过 {{ .Limit }} 次。可能有人在猜测订阅 ID。",
+      "inviteLink": "🔗 <code>{{ .Email }}</code> 的邀请链接:\r\n<code>{{ .Link }}</code>\r\n\r\n第一个打开它的人将绑定到此客户端。\r\n",
       "exhaustedCount": "🚨 耗尽的 {{ .Type }} 数量:\r\n",
       "onlinesCount": "🌐 在线客户:{{ .Count }}\r\n",
       "disabled": "🛑 禁用:{{ .Disabled }}\r\n",
@@ -2533,6 +2538,7 @@
       "ipLog": "🔢 IP 日志",
       "ipLimit": "🔢 IP 限制",
       "setTGUser": "👤 设置 Telegram 用户",
+      "inviteLink": "🔗 邀请链接",
       "toggle": "🔘 启用/禁用",
       "custom": "🔢 自定义",
       "confirmNumber": "✅ 确认: {{ .Num }}",

+ 9 - 3
internal/web/translation/zh-TW.json

@@ -685,6 +685,8 @@
         "shareAddrHelp": "僅在分享地址策略為自訂時使用。填寫不帶協定和連接埠的網域或 IP。",
         "subSortIndex": "訂閱排序",
         "subSortIndexHelp": "此入站的連結在訂閱輸出(訂閱頁面和客戶端應用)中的位置。數值越小越靠前;數值相同時保持建立順序。不影響面板中的入站清單。",
+        "excludeFromSub": "從訂閱中排除",
+        "excludeFromSubHelp": "從訂閱輸出中隱藏此入站的連結,同時保持入站啟用並繼續運作。",
         "disableFlow": "停用 XTLS flow",
         "disableFlowHelp": "讓此入站略過自動注入 xtls-rprx-vision,即使其傳輸支援 flow(例如啟用 VLESS 加密的通道化 XHTTP 入站)。用戶端在同一訂閱中的其他可用入站上仍保留 Vision。僅限 VLESS。",
         "shareAddrStrategyOptions": {
@@ -789,6 +791,7 @@
       "password": "密碼",
       "passwordDesc": "僅 Trojan 與 Shadowsocks 用戶端使用;VLESS、VMess、Hysteria 和 WireGuard 會忽略此項。",
       "subId": "訂閱 ID",
+      "subIdDesc": "同時作為此用戶端的 Telegram 機器人邀請碼:任何人將其傳送給機器人即會綁定到此用戶端。請保持足夠長且隨機——過短或容易猜到的 ID 可能被他人搶先綁定。",
       "online": "上線",
       "email": "電子郵件",
       "emailInvalidChars": "電子郵件不能包含空格、'/'、'\\' 或控制字元",
@@ -1640,13 +1643,10 @@
       "subHappProxyIPs": "代理 IP / CIDR",
       "subHappBlockIPs": "封鎖 IP / CIDR",
       "subHappDeeplinkGenerated": "已產生深層連結並套用至路由規則",
-      "subHappGroupLinks": "訂閱連結",
       "subHappGroupRouting": "路由與規則",
-      "subHappGroupBanners": "橫幅與公告",
       "subHappGroupNetwork": "網路與 TUN 引擎",
       "subHappGroupThemes": "外觀與主題",
       "subHappGroupFailover": "遷移與應用程式管理",
-      "subHappGroupAndroid": "Android 個別應用程式代理",
       "discordSettings": "Discord 機器人",
       "discordBotEnable": "啟用 Discord 通知",
       "discordBotEnableDesc": "透過機器人向 Discord 頻道發送系統與事件警報",
@@ -2466,6 +2466,11 @@
       "download": "🔽 下載: ↓{{ .Download }}\r\n",
       "total": "📊 總計: ↑↓{{ .UpDown }} / {{ .Total }}\r\n",
       "TGUser": "👤 電報使用者:{{ .TelegramID }}\r\n",
+      "inviteBound": "✅ 您的 Telegram 帳號已綁定至 <code>{{ .Email }}</code>。\r\n",
+      "inviteInvalid": "❗ 此邀請連結無效或已被使用。\r\n",
+      "inviteRateLimited": "⏳ 邀請嘗試次數過多,請稍後再試。\r\n",
+      "inviteRateLimitedAdmin": "⚠️ 已封鎖 {{ .User }}(<code>{{ .ID }}</code>)的邀請嘗試:一小時內超過 {{ .Limit }} 次。可能有人在猜測訂閱 ID。",
+      "inviteLink": "🔗 <code>{{ .Email }}</code> 的邀請連結:\r\n<code>{{ .Link }}</code>\r\n\r\n第一個開啟它的人將綁定至此客戶端。\r\n",
       "exhaustedCount": "🚨 耗盡的 {{ .Type }} 數量:\r\n",
       "onlinesCount": "🌐 線上客戶:{{ .Count }}\r\n",
       "disabled": "🛑 禁用:{{ .Disabled }}\r\n",
@@ -2533,6 +2538,7 @@
       "ipLog": "🔢 IP 日誌",
       "ipLimit": "🔢 IP 限制",
       "setTGUser": "👤 設定 Telegram 使用者",
+      "inviteLink": "🔗 邀請連結",
       "toggle": "🔘 啟用/禁用",
       "custom": "🔢 自訂",
       "confirmNumber": "✅ 確認: {{ .Num }}",