28 Commits 43e64993fc ... 5008906c4c

Author SHA1 Message Date
  Sanaei 5008906c4c feat(clients): filter the client list by clicking a summary stat card 11 hours ago
  Sanaei 5fe4f241c1 style(logs): widen the row-count selector in the log modals 11 hours ago
  Sanaei e8bab17c2f fix(clients): stop the Edit Client modal showing a stray light scrollbar 11 hours ago
  Sanaei 14b92fbcff fix(nodes): stop flagging a node on the other update channel as outdated 11 hours ago
  NgaiYeanCoi 1d85ef138e fix(sub): prevent default profile page URL disclosure (#6538) 12 hours ago
  Sanaei 3fa44915c1 perf(nodes): keep the node table element across unrelated re-renders 12 hours ago
  Sanaei 7fc86f87de perf(inbounds): keep unchanged rows and online sets across websocket pushes 12 hours ago
  Sanaei 3c1498d806 fix(ldap): apply LDAP enable, disable and cleanup through the bulk paths 12 hours ago
  Sanaei d1c4e0261b chore(node): cover the sync tick's online prune from the job package 12 hours ago
  Sanaei bc49c1a68f fix(node): release a deleted node's metric series and HTTP client 12 hours ago
  Sanaei 3c8cf35734 perf(node): sync up to 32 nodes at once, like the heartbeat 12 hours ago
  Sanaei dea7cd9cc1 fix(traffic): reset due inbounds and clients concurrently 13 hours ago
  Sanaei 56bb876d8d fix(node): send one alert for a burst of node transitions 13 hours ago
  Sanaei eb11e8c85a fix(node): fan out operations that call every node 13 hours ago
  Sanaei a84bbeab2e fix(node): drop online clients and sub-nodes of nodes no longer synced 13 hours ago
  Sanaei ea66aa4971 fix(traffic): push depletion changes to nodes off the serial writer 14 hours ago
  BlindMaster24 cfa8350d10 fix(clients): keep a vless reverse client's handler across a re-add (#6558) 16 hours ago
  Sanaei af466b6a24 fix(node): push a node only the client IPs it hosts 16 hours ago
  Sanaei 789a03065a chore(docs): bump dependencies and adapt to fumadocs-core 16.15.11 17 hours ago
  Sanaei bc424f0968 fix(xray): stop a lone dns qType 0 from matching every query 17 hours ago
  BlindMaster24 ac3fc12077 fix(ports): refuse an inbound on a port an AmneziaWG peer forwards (#6554) 17 hours ago
  BlindMaster24 d9c7c76fb0 fix(limit-ip): leave a reverse client out of the temporary disconnect (#6553) 17 hours ago
  sdhfsl d440c2b932 fix(panel): accept 2FA codes from adjacent TOTP windows (#6546) 18 hours ago
  BlindMaster24 e790f46757 fix(xray): restart when a diff strands a client's live session (#6550) 18 hours ago
  BlindMaster24 d089adeeea docs(limit-ip): correct what the temporary disconnect can actually do (#6551) 18 hours ago
  BlindMaster24 4a8fdceed6 perf(nodes): reuse one pooled client per node instead of rebuilding it (#6548) 18 hours ago
  BlindMaster24 574caa63e9 fix(inbounds): check ports when an inbound is enabled, not only when it is saved (#6549) 18 hours ago
  BlindMaster24 baef3cdd07 fix(xray): refuse a config the running core cannot bind (#6547) 18 hours ago
100 changed files with 4095 additions and 705 deletions
  1. 2 2
      docs/app/llms.txt/route.ts
  2. 2 2
      docs/content/docs/en/config/clients.mdx
  3. 19 0
      docs/content/docs/en/config/subscription.mdx
  4. 1 1
      docs/content/docs/en/reference/meta.json
  5. 2 2
      docs/content/docs/fa/config/clients.mdx
  6. 17 0
      docs/content/docs/fa/config/subscription.mdx
  7. 1 1
      docs/content/docs/fa/reference/meta.json
  8. 3 3
      docs/content/docs/ru/config/clients.mdx
  9. 18 0
      docs/content/docs/ru/config/subscription.mdx
  10. 1 1
      docs/content/docs/ru/reference/meta.json
  11. 2 2
      docs/content/docs/zh/config/clients.mdx
  12. 12 0
      docs/content/docs/zh/config/subscription.mdx
  13. 1 1
      docs/content/docs/zh/reference/meta.json
  14. 20 20
      docs/package.json
  15. 488 327
      docs/pnpm-lock.yaml
  16. 12 5
      docs/pnpm-workspace.yaml
  17. 8 0
      docs/public/openapi.json
  18. 1 0
      frontend/.storybook/preview.tsx
  19. 8 0
      frontend/public/openapi.json
  20. 2 0
      frontend/src/generated/examples.ts
  21. 8 0
      frontend/src/generated/schemas.ts
  22. 2 0
      frontend/src/generated/types.ts
  23. 2 0
      frontend/src/generated/zod.ts
  24. 2 0
      frontend/src/hooks/useTheme.tsx
  25. 3 0
      frontend/src/lib/panel-version.ts
  26. 2 1
      frontend/src/lib/xray/outbound-form-adapter.ts
  27. 11 0
      frontend/src/models/setting.ts
  28. 5 0
      frontend/src/pages/clients/ClientFormModal.css
  29. 2 0
      frontend/src/pages/clients/ClientFormModal.tsx
  30. 17 0
      frontend/src/pages/clients/ClientsPage.css
  31. 79 64
      frontend/src/pages/clients/ClientsPage.tsx
  32. 36 5
      frontend/src/pages/inbounds/useInbounds.ts
  33. 1 1
      frontend/src/pages/index/AmneziaWGLogModal.tsx
  34. 1 1
      frontend/src/pages/index/LogModal.tsx
  35. 7 1
      frontend/src/pages/index/OverviewActionBar.tsx
  36. 1 1
      frontend/src/pages/index/XrayLogModal.tsx
  37. 56 42
      frontend/src/pages/nodes/NodeList.tsx
  38. 37 8
      frontend/src/pages/settings/SubscriptionGeneralTab.tsx
  39. 4 0
      frontend/src/schemas/setting.ts
  40. 107 0
      frontend/src/test/inbounds-ws-identity.test.tsx
  41. 68 0
      frontend/src/test/node-list-rerender.test.tsx
  42. 13 0
      frontend/src/test/outbound-form-adapter.test.ts
  43. 7 0
      frontend/src/test/panel-version.test.ts
  44. 36 0
      frontend/src/test/setting-profile-mode.test.ts
  45. 30 2
      frontend/src/test/storybook-theme.test.tsx
  46. 74 1
      frontend/src/test/subscription-general-tab.test.tsx
  47. 27 7
      internal/amneziawgnet/portfwd.go
  48. 76 3
      internal/database/db.go
  49. 99 0
      internal/database/dns_outbound_qtype_zero_test.go
  50. 16 6
      internal/sub/controller.go
  51. 12 8
      internal/sub/placeholders.go
  52. 72 11
      internal/sub/placeholders_test.go
  53. 97 0
      internal/sub/profile_mode_test.go
  54. 5 0
      internal/sub/sub.go
  55. 22 0
      internal/util/totp/totp.go
  56. 34 0
      internal/util/totp/totp_test.go
  57. 1 0
      internal/web/entity/entity.go
  58. 15 3
      internal/web/job/check_client_ip_job.go
  59. 147 0
      internal/web/job/ldap_sync_hung_node_test.go
  60. 40 51
      internal/web/job/ldap_sync_job.go
  61. 33 0
      internal/web/job/limit_ip_disconnect_test.go
  62. 98 0
      internal/web/job/node_heartbeat_descendants_test.go
  63. 53 13
      internal/web/job/node_heartbeat_job.go
  64. 88 0
      internal/web/job/node_traffic_sync_ip_push_test.go
  65. 5 2
      internal/web/job/node_traffic_sync_job.go
  66. 45 0
      internal/web/job/node_traffic_sync_online_prune_test.go
  67. 103 0
      internal/web/job/node_transition_burst_test.go
  68. 36 9
      internal/web/job/periodic_traffic_reset_job.go
  69. 134 0
      internal/web/job/periodic_traffic_reset_nodes_test.go
  70. 6 1
      internal/web/runtime/manager.go
  71. 92 26
      internal/web/runtime/tls_client.go
  72. 25 0
      internal/web/runtime/tls_client_invalidate_test.go
  73. 127 0
      internal/web/runtime/tls_client_test.go
  74. 4 0
      internal/web/service/client_bulk.go
  75. 64 0
      internal/web/service/client_disable_restart_test.go
  76. 18 1
      internal/web/service/client_inbound_apply.go
  77. 7 8
      internal/web/service/client_paging.go
  78. 17 4
      internal/web/service/client_paging_test.go
  79. 149 0
      internal/web/service/client_reverse_readd_test.go
  80. 39 7
      internal/web/service/inbound.go
  81. 27 0
      internal/web/service/inbound_client_ips.go
  82. 83 0
      internal/web/service/inbound_enable_port_test.go
  83. 36 10
      internal/web/service/inbound_node.go
  84. 21 9
      internal/web/service/inbound_traffic.go
  85. 25 10
      internal/web/service/inbound_traffic_apply.go
  86. 2 4
      internal/web/service/inbound_traffic_global.go
  87. 12 7
      internal/web/service/node.go
  88. 161 0
      internal/web/service/node_admin_fanout_test.go
  89. 43 0
      internal/web/service/node_delete_cleanup_test.go
  90. 16 0
      internal/web/service/node_tree.go
  91. 69 0
      internal/web/service/node_unsynced_online_test.go
  92. 3 2
      internal/web/service/panel/user.go
  93. 58 2
      internal/web/service/port_conflict.go
  94. 135 0
      internal/web/service/port_conflict_forwarded_test.go
  95. 3 1
      internal/web/service/server.go
  96. 49 2
      internal/web/service/setting.go
  97. 4 0
      internal/web/service/setting_security_test.go
  98. 148 0
      internal/web/service/setting_sub_profile_test.go
  99. 159 0
      internal/web/service/traffic_depletion_node_push_test.go
  100. 4 4
      internal/web/service/traffic_runtime_apply_test.go

+ 2 - 2
docs/app/llms.txt/route.ts

@@ -3,6 +3,6 @@ import { llms } from 'fumadocs-core/source';
 
 export const revalidate = false;
 
-export function GET() {
-  return new Response(llms(source).index());
+export async function GET() {
+  return new Response(await llms(source).index());
 }

+ 2 - 2
docs/content/docs/en/config/clients.mdx

@@ -27,8 +27,8 @@ inbounds** at once, with per-client traffic accounting.
 | **Comment**    | all                   | Free-text note.                                                   |
 
 <Callout type="info">
-  Reaching the **traffic** or **expiry** limit disables the client; the panel can
-  restart Xray automatically when clients are auto-disabled
+  Reaching the **traffic** or **expiry** limit disables the client, and a client
+  disabled or deleted by hand counts too; the panel restarts Xray then
   (`restartXrayOnClientDisable`, on by default).
 </Callout>
 

+ 19 - 0
docs/content/docs/en/config/subscription.mdx

@@ -94,6 +94,25 @@ Subscriptions return standard headers that compatible apps read:
 - **`Profile-Title`**, **`Support-Url`**, **`Profile-Web-Page-Url`**,
   **`Announce`** — optional branding shown by some clients.
 
+### Profile page links and upgrades
+
+In **Subscription → Profile → Profile page**, choose `subProfileMode` for all
+subscription clients:
+
+- **No link** (`none`, default): omit `Profile-Web-Page-Url`.
+- **Built-in subscription page** (`builtin`): link to the client's built-in page.
+- **Custom website** (`custom`): use `subProfileUrl`; a blank URL omits the header.
+
+**Upgrade note:** previously, an empty `subProfileUrl` automatically linked to
+the built-in page. After upgrading, an unset mode with an empty or whitespace-only
+URL becomes **No link**; an existing nonempty URL remains a **Custom website**.
+To restore the built-in link, select **Built-in subscription page** above and
+save the settings.
+
+The built-in page exposes subscription URLs and node configurations, including
+for Happ encrypted subscriptions. Enable it only if you intend to provide that
+access.
+
 ### Optional month-end expiry display
 
 Under **Subscription → Information**, **Month-end subscription expiry display**

+ 1 - 1
docs/content/docs/en/reference/meta.json

@@ -1,5 +1,5 @@
 {
   "title": "Reference",
-  "icon": "BookMarked",
+  "icon": "BookBookmark",
   "pages": ["env-vars", "database", "ports-firewall", "api"]
 }

+ 2 - 2
docs/content/docs/fa/config/clients.mdx

@@ -27,8 +27,8 @@ icon: Users
 | **Comment**    | همه                   | یادداشت متنی آزاد.                                                 |
 
 <Callout type="info">
-  رسیدن به محدودیت **ترافیک** یا **انقضا** کلاینت را غیرفعال می‌کند؛ پنل می‌تواند
-  هنگام غیرفعال‌شدن خودکار کلاینت‌ها، Xray را به‌صورت خودکار راه‌اندازی مجدد کند
+  رسیدن به محدودیت **ترافیک** یا **انقضا** کلاینت را غیرفعال می‌کند؛ غیرفعال‌سازی یا
+  حذف دستی کلاینت هم همین اثر را دارد؛ در این حالت پنل Xray را راه‌اندازی مجدد می‌کند
   (`restartXrayOnClientDisable`، به‌صورت پیش‌فرض فعال).
 </Callout>
 

+ 17 - 0
docs/content/docs/fa/config/subscription.mdx

@@ -72,6 +72,23 @@ SOCKS/HTTP روی 127.0.0.1، DNS، مسیریابی، policy) به‌علاوه
 - **`Profile-Title`**، **`Support-Url`**، **`Profile-Web-Page-Url`**،
   **`Announce`** — برندینگ اختیاری که برخی کلاینت‌ها نمایش می‌دهند.
 
+### لینک صفحه پروفایل
+
+در تنظیمات **سابسکریپشن ← پروفایل**، گزینه **صفحه پروفایل** (`subProfileMode`)
+لینک را برای همه کلاینت‌های اشتراک کنترل می‌کند:
+
+- **بدون لینک** (`none`، پیش‌فرض) — هدر `Profile-Web-Page-Url` ارسال نمی‌شود.
+- **صفحه اشتراک داخلی** (`builtin`) — لینک صفحه اشتراک داخلی ارائه می‌شود.
+- **وب‌سایت سفارشی** (`custom`) — آدرس `subProfileUrl` استفاده می‌شود؛ اگر خالی باشد، هدر ارسال نمی‌شود.
+
+**پس از ارتقا:** اگر `subProfileMode` هنوز تنظیم نشده و مقدار قبلی `subProfileUrl`
+خالی یا فقط شامل فاصله باشد، به‌جای لینک خودکار صفحه داخلی، حالت **بدون لینک**
+انتخاب می‌شود. آدرس سفارشی غیرخالی قبلی در حالت **وب‌سایت سفارشی** حفظ می‌شود.
+
+برای بازگرداندن لینک قبلی، در همین بخش **صفحه اشتراک داخلی** را انتخاب و تنظیمات
+را ذخیره کنید. این صفحه آدرس‌های اشتراک و پیکربندی گره‌ها را آشکار می‌کند، حتی
+برای اشتراک‌های رمزگذاری‌شده Happ.
+
 ## قالب‌های سفارشی صفحه
 
 برای برندینگ صفحه‌ی HTML اشتراک، `subThemeDir` را به یک پوشه‌ی حاوی قالب سفارشیِ

+ 1 - 1
docs/content/docs/fa/reference/meta.json

@@ -1,5 +1,5 @@
 {
   "title": "مرجع",
-  "icon": "BookMarked",
+  "icon": "BookBookmark",
   "pages": ["env-vars", "database", "ports-firewall", "api"]
 }

+ 3 - 3
docs/content/docs/ru/config/clients.mdx

@@ -28,9 +28,9 @@ icon: Users
 | **Comment**    | все                   | Произвольная текстовая заметка.                                  |
 
 <Callout type="info">
-  Достижение лимита **трафика** или **срока действия** отключает клиента; при
-  автоматическом отключении клиентов панель может автоматически перезапускать
-  Xray (`restartXrayOnClientDisable`, включено по умолчанию).
+  Достижение лимита **трафика** или **срока действия** отключает клиента, как и
+  ручное отключение или удаление; тогда панель перезапускает Xray
+  (`restartXrayOnClientDisable`, включено по умолчанию).
 </Callout>
 
 ## Лимиты и контроль IP

+ 18 - 0
docs/content/docs/ru/config/subscription.mdx

@@ -76,6 +76,24 @@ policy) плюс исходящее соединение `proxy`, указыва
 - **`Profile-Title`**, **`Support-Url`**, **`Profile-Web-Page-Url`**,
   **`Announce`** — необязательный брендинг, отображаемый некоторыми клиентами.
 
+### Ссылка на страницу профиля
+
+В настройках **Подписка → Профиль** поле **Страница профиля** (`subProfileMode`)
+управляет ссылкой для всех клиентов подписки:
+
+- **Без ссылки** (`none`, по умолчанию) — заголовок `Profile-Web-Page-Url` не отправляется.
+- **Встроенная страница подписки** (`builtin`) — ссылка на встроенную страницу подписки.
+- **Свой сайт** (`custom`) — адрес из `subProfileUrl`; если он пуст, заголовок не отправляется.
+
+**После обновления:** если `subProfileMode` ещё не задан, а прежний `subProfileUrl`
+пуст или содержит только пробелы, вместо автоматической ссылки на встроенную
+страницу теперь используется **Без ссылки**. Существующий непустой пользовательский
+адрес сохраняется в режиме **Свой сайт**.
+
+Чтобы вернуть прежнюю ссылку, выберите **Встроенная страница подписки** в этом поле
+и сохраните настройки. Эта страница раскрывает URL-адреса подписок и конфигурации
+узлов, в том числе для зашифрованных подписок Happ.
+
 ## Пользовательские шаблоны страниц
 
 Укажите в `subThemeDir` папку с пользовательским шаблоном информационной

+ 1 - 1
docs/content/docs/ru/reference/meta.json

@@ -1,5 +1,5 @@
 {
   "title": "Справочник",
-  "icon": "BookMarked",
+  "icon": "BookBookmark",
   "pages": ["env-vars", "database", "ports-firewall", "api"]
 }

+ 2 - 2
docs/content/docs/zh/config/clients.mdx

@@ -26,8 +26,8 @@ icon: Users
 | **Comment**    | 全部                  | 自由文本备注。                                                    |
 
 <Callout type="info">
-  达到**流量**或**到期**限制会禁用客户端;当客户端被自动禁用时,面板可以
-  自动重启 Xray(`restartXrayOnClientDisable`,默认开启)。
+  达到**流量**或**到期**限制会禁用客户端,手动禁用或删除客户端同样如此;
+  此时面板会重启 Xray(`restartXrayOnClientDisable`,默认开启)。
 </Callout>
 
 ## 限制与 IP 控制

+ 12 - 0
docs/content/docs/zh/config/subscription.mdx

@@ -73,6 +73,18 @@ Clash 格式自动识别保留原有的 `(?i)(clash|mihomo)` 默认匹配器,
 - **`Profile-Update-Interval`** —— 刷新间隔,以小时为单位(`subUpdates`)。
 - **`Profile-Title`**、**`Support-Url`**、**`Profile-Web-Page-Url`**、**`Announce`** —— 部分客户端会显示的可选品牌信息。
 
+### 资料页链接与升级说明
+
+在 **订阅 → 资料 → 资料页方式** 中选择 `subProfileMode`,对所有订阅客户端生效:
+
+- **不提供**(`none`,默认):不发送 `Profile-Web-Page-Url`。
+- **内置订阅页**(`builtin`):提供该客户端的内置订阅页链接。
+- **自定义网站**(`custom`):使用 `subProfileUrl`;地址留空时不发送该响应头。
+
+**升级提示:** 旧版在 `subProfileUrl` 留空时会自动提供内置订阅页链接。升级后,尚未设置模式且地址为空或仅含空白字符的配置会使用 **不提供**;已有非空地址继续使用 **自定义网站**。需要恢复内置入口时,在上述位置选择 **内置订阅页** 并保存设置。
+
+内置订阅页会公开订阅地址和节点配置,Happ 加密订阅也不例外;请在确定需要提供这些内容时开启。
+
 ## 自定义页面模板
 
 将 `subThemeDir` 指向一个包含自定义信息页模板的文件夹,即可为 HTML 订阅页面定制品牌。每条链接上的客户端备注完全支持模板化 —— 参见[分享链接 → 备注变量](/docs/config/share-links#remark-template-variables)。

+ 1 - 1
docs/content/docs/zh/reference/meta.json

@@ -1,5 +1,5 @@
 {
   "title": "参考",
-  "icon": "BookMarked",
+  "icon": "BookBookmark",
   "pages": ["env-vars", "database", "ports-firewall", "api"]
 }

+ 20 - 20
docs/package.json

@@ -18,34 +18,34 @@
     "test:watch": "vitest"
   },
   "dependencies": {
-    "fumadocs-core": "^16.15.5",
-    "fumadocs-docgen": "^3.1.0",
-    "fumadocs-mdx": "^15.4.0",
-    "fumadocs-openapi": "^11.4.0",
-    "fumadocs-ui": "^16.15.5",
-    "lucide-react": "^1.39.0",
-    "mermaid": "^11.17.2",
-    "next": "16.3.4",
+    "fumadocs-core": "^16.15.11",
+    "fumadocs-docgen": "^3.1.1",
+    "fumadocs-mdx": "^15.4.1",
+    "fumadocs-openapi": "^11.4.3",
+    "fumadocs-ui": "^16.15.11",
+    "lucide-react": "^1.46.0",
+    "mermaid": "^12.0.0",
+    "next": "16.3.5",
     "next-themes": "^0.4.6",
-    "react": "^19.2.8",
-    "react-dom": "^19.2.8",
+    "react": "^19.3.0",
+    "react-dom": "^19.3.0",
     "react-qr-code": "^2.2.0",
-    "tailwind-merge": "^3.6.0",
+    "tailwind-merge": "^3.7.0",
     "zbsearch": "4.0.0",
-    "zod": "^4.5.4"
+    "zod": "^4.6.5"
   },
   "devDependencies": {
     "@tailwindcss/postcss": "^4.3.3",
     "@types/mdx": "^2.0.14",
-    "@types/node": "^26.4.1",
-    "@types/react": "^19.2.18",
-    "@types/react-dom": "^19.2.5",
-    "oxfmt": "0.66.0",
-    "oxlint": "1.81.0",
-    "postcss": "^8.5.26",
+    "@types/node": "^26.5.1",
+    "@types/react": "^19.3.0",
+    "@types/react-dom": "^19.3.0",
+    "oxfmt": "0.68.0",
+    "oxlint": "1.83.0",
+    "postcss": "^8.5.28",
     "tailwindcss": "^4.3.3",
     "typescript": "7.0.2",
-    "vitest": "^4.1.11"
+    "vitest": "^5.0.1"
   },
-  "packageManager": "pnpm@11.25.0"
+  "packageManager": "pnpm@12.4.2"
 }

File diff suppressed because it is too large
+ 488 - 327
docs/pnpm-lock.yaml


+ 12 - 5
docs/pnpm-workspace.yaml

@@ -12,9 +12,16 @@ minimumReleaseAgeExclude:
   - [email protected]
   - [email protected]
   - [email protected]
-  - [email protected]
-  - '@fumadocs/[email protected]'
+  - [email protected] || 15.4.1
+  - '@fumadocs/[email protected] || 0.2.9'
   - '@types/[email protected]'
-  - [email protected]
-  - [email protected]
-  - [email protected]
+  - [email protected] || 16.15.11
+  - [email protected] || 11.4.3
+  - [email protected] || 16.15.11
+  - '@fumadocs/[email protected]'
+  - '@fumari/[email protected]'
+  - '@fumari/[email protected]'
+  - '@vitest/[email protected]'
+  - '@vitest/[email protected]'
+  - [email protected]
+  - [email protected]

+ 8 - 0
docs/public/openapi.json

@@ -395,6 +395,9 @@
             "minimum": 1,
             "type": "integer"
           },
+          "subProfileMode": {
+            "type": "string"
+          },
           "subProfileUrl": {
             "type": "string"
           },
@@ -618,6 +621,7 @@
           "subListen",
           "subPath",
           "subPort",
+          "subProfileMode",
           "subProfileUrl",
           "subRoutingRules",
           "subShowIdentityOnAllLinks",
@@ -1046,6 +1050,9 @@
             "minimum": 1,
             "type": "integer"
           },
+          "subProfileMode": {
+            "type": "string"
+          },
           "subProfileUrl": {
             "type": "string"
           },
@@ -1277,6 +1284,7 @@
           "subListen",
           "subPath",
           "subPort",
+          "subProfileMode",
           "subProfileUrl",
           "subRoutingRules",
           "subShowIdentityOnAllLinks",

+ 1 - 0
frontend/.storybook/preview.tsx

@@ -22,6 +22,7 @@ export const withTheme: Decorator = (Story, context) => {
   useLayoutEffect(() => {
     document.body.classList.remove('dark', 'light');
     document.body.classList.add(dark ? 'dark' : 'light');
+    document.documentElement.style.colorScheme = dark ? 'dark' : 'light';
     document.documentElement.removeAttribute('data-theme');
   }, [dark]);
   return (

+ 8 - 0
frontend/public/openapi.json

@@ -395,6 +395,9 @@
             "minimum": 1,
             "type": "integer"
           },
+          "subProfileMode": {
+            "type": "string"
+          },
           "subProfileUrl": {
             "type": "string"
           },
@@ -618,6 +621,7 @@
           "subListen",
           "subPath",
           "subPort",
+          "subProfileMode",
           "subProfileUrl",
           "subRoutingRules",
           "subShowIdentityOnAllLinks",
@@ -1046,6 +1050,9 @@
             "minimum": 1,
             "type": "integer"
           },
+          "subProfileMode": {
+            "type": "string"
+          },
           "subProfileUrl": {
             "type": "string"
           },
@@ -1277,6 +1284,7 @@
           "subListen",
           "subPath",
           "subPort",
+          "subProfileMode",
           "subProfileUrl",
           "subRoutingRules",
           "subShowIdentityOnAllLinks",

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

@@ -115,6 +115,7 @@ export const EXAMPLES: Record<string, unknown> = {
     "subListen": "",
     "subPath": "",
     "subPort": 1,
+    "subProfileMode": "",
     "subProfileUrl": "",
     "subRoutingRules": "",
     "subShowIdentityOnAllLinks": false,
@@ -271,6 +272,7 @@ export const EXAMPLES: Record<string, unknown> = {
     "subListen": "",
     "subPath": "",
     "subPort": 1,
+    "subProfileMode": "",
     "subProfileUrl": "",
     "subRoutingRules": "",
     "subShowIdentityOnAllLinks": false,

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

@@ -369,6 +369,9 @@ export const SCHEMAS: Record<string, unknown> = {
         "minimum": 1,
         "type": "integer"
       },
+      "subProfileMode": {
+        "type": "string"
+      },
       "subProfileUrl": {
         "type": "string"
       },
@@ -592,6 +595,7 @@ export const SCHEMAS: Record<string, unknown> = {
       "subListen",
       "subPath",
       "subPort",
+      "subProfileMode",
       "subProfileUrl",
       "subRoutingRules",
       "subShowIdentityOnAllLinks",
@@ -1020,6 +1024,9 @@ export const SCHEMAS: Record<string, unknown> = {
         "minimum": 1,
         "type": "integer"
       },
+      "subProfileMode": {
+        "type": "string"
+      },
       "subProfileUrl": {
         "type": "string"
       },
@@ -1251,6 +1258,7 @@ export const SCHEMAS: Record<string, unknown> = {
       "subListen",
       "subPath",
       "subPort",
+      "subProfileMode",
       "subProfileUrl",
       "subRoutingRules",
       "subShowIdentityOnAllLinks",

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

@@ -122,6 +122,7 @@ export interface AllSetting {
   subListen: string;
   subPath: string;
   subPort: number;
+  subProfileMode: string;
   subProfileUrl: string;
   subRoutingRules: string;
   subShowIdentityOnAllLinks: boolean;
@@ -279,6 +280,7 @@ export interface AllSettingView {
   subListen: string;
   subPath: string;
   subPort: number;
+  subProfileMode: string;
   subProfileUrl: string;
   subRoutingRules: string;
   subShowIdentityOnAllLinks: boolean;

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

@@ -136,6 +136,7 @@ export const AllSettingSchema = z.object({
   subListen: z.string(),
   subPath: z.string(),
   subPort: z.number().int().min(1).max(65535),
+  subProfileMode: z.string(),
   subProfileUrl: z.string(),
   subRoutingRules: z.string(),
   subShowIdentityOnAllLinks: z.boolean(),
@@ -294,6 +295,7 @@ export const AllSettingViewSchema = z.object({
   subListen: z.string(),
   subPath: z.string(),
   subPort: z.number().int().min(1).max(65535),
+  subProfileMode: z.string(),
   subProfileUrl: z.string(),
   subRoutingRules: z.string(),
   subShowIdentityOnAllLinks: z.boolean(),

+ 2 - 0
frontend/src/hooks/useTheme.tsx

@@ -15,6 +15,8 @@ function readBool(key: string, fallback: boolean): boolean {
 function applyDom(isDark: boolean, isUltra: boolean) {
   document.body.classList.remove('dark', 'light');
   document.body.classList.add(isDark ? 'dark' : 'light');
+  // Native scrollbars read color-scheme, not the body class.
+  document.documentElement.style.colorScheme = isDark ? 'dark' : 'light';
   if (isUltra) {
     document.documentElement.setAttribute('data-theme', 'ultra-dark');
   } else {

+ 3 - 0
frontend/src/lib/panel-version.ts

@@ -28,6 +28,9 @@ export function formatPanelVersion(version: string | undefined | null): string {
 
 export function isPanelUpdateAvailable(latest: string, current: string): boolean {
   if (!latest || !current) return false;
+  // A dev+<sha> label and a release tag sit on different channels and carry no
+  // order, so a node moved to the other channel is not "behind" the master's latest.
+  if (latest.trim().startsWith('dev+') !== current.trim().startsWith('dev+')) return false;
   const a = parseVersionParts(latest);
   const b = parseVersionParts(current);
   if (!a || !b) {

+ 2 - 1
frontend/src/lib/xray/outbound-form-adapter.ts

@@ -786,7 +786,8 @@ function dnsRuleToWire(r: DnsRuleForm) {
   const result: Raw = { action };
   const qType = r.qType.trim();
   if (qType) {
-    result.qType = /^\d+$/.test(qType) ? Number(qType) : qType;
+    // The core reads a numeric 0 as no qType at all, which matches every query.
+    result.qType = /^\d+$/.test(qType) && Number(qType) > 0 ? Number(qType) : qType;
   }
   const domains = r.domain
     .split(',')

+ 11 - 0
frontend/src/models/setting.ts

@@ -1,4 +1,5 @@
 import { ObjectUtil } from '@/utils';
+import type { SubProfileMode } from '@/schemas/setting';
 
 export class AllSetting {
   webListen = '';
@@ -46,6 +47,7 @@ export class AllSetting {
   subClashUserAgentRegex = '';
   subTitle = '';
   subSupportUrl = '';
+  subProfileMode: SubProfileMode = 'none';
   subProfileUrl = '';
   subAnnounce = '';
   subEnableRouting = false;
@@ -167,6 +169,15 @@ export class AllSetting {
     if (data != null) {
       ObjectUtil.cloneProps(this, data);
     }
+    // Legacy settings with a custom URL retain it until an explicit mode is saved.
+    if (
+      typeof data === 'object' &&
+      data !== null &&
+      (!('subProfileMode' in data) || data.subProfileMode === undefined) &&
+      this.subProfileUrl.trim() !== ''
+    ) {
+      this.subProfileMode = 'custom';
+    }
     const cpu = Math.round(Number(this.tgCpu));
     this.tgCpu = Number.isFinite(cpu) ? Math.min(100, Math.max(0, cpu)) : 80;
     const threshold = Math.round(Number(this.outboundDownThreshold));

+ 5 - 0
frontend/src/pages/clients/ClientFormModal.css

@@ -0,0 +1,5 @@
+/* The body is capped at the viewport and scrolls; a trailing item margin
+   alone must not push it past the cap and summon a scrollbar. */
+.client-form-modal .ant-tabs-content > .ant-form-item:last-child {
+  margin-bottom: 0;
+}

+ 2 - 0
frontend/src/pages/clients/ClientFormModal.tsx

@@ -49,6 +49,7 @@ import type {
 } from '@/hooks/useClients';
 import { useFail2banStatusQuery, getLimitIpNotice } from '@/api/queries/useFail2banStatusQuery';
 import { ClientFormSchema, ClientCreateFormSchema, type ClientFormValues } from '@/schemas/client';
+import './ClientFormModal.css';
 
 const FLOW_OPTIONS = Object.values(TLS_FLOW_CONTROL);
 const VMESS_SECURITY_OPTIONS = ['auto', 'aes-128-gcm', 'chacha20-poly1305'] as const;
@@ -803,6 +804,7 @@ export default function ClientFormModal({
         open={open}
         title={isEdit ? t('pages.clients.editClient') : t('pages.clients.addClient')}
         destroyOnHidden
+        className="client-form-modal"
         width={720}
         zIndex={CLIENT_FORM_MODAL_Z_INDEX}
         style={{ top: 20 }}

+ 17 - 0
frontend/src/pages/clients/ClientsPage.css

@@ -61,6 +61,23 @@
   margin: 0;
 }
 
+.summary-stat {
+  margin: -4px -8px;
+  padding: 4px 8px;
+  border-radius: 8px;
+  cursor: pointer;
+  transition: background-color 120ms ease;
+}
+
+.summary-stat:hover,
+.summary-stat:focus-visible {
+  background: var(--ant-color-fill-tertiary);
+}
+
+.summary-stat.selected {
+  background: var(--ant-color-primary-bg);
+}
+
 .dot {
   display: inline-block;
   width: 8px;

+ 79 - 64
frontend/src/pages/clients/ClientsPage.tsx

@@ -1,4 +1,5 @@
 import { lazy, useCallback, useEffect, useMemo, useRef, useState } from 'react';
+import type { ReactNode } from 'react';
 import { useLocation, useSearchParams } from 'react-router';
 import { useTranslation } from 'react-i18next';
 import {
@@ -153,6 +154,40 @@ function ClientEmailList({ emails, total }: { emails: string[]; total: number })
   );
 }
 
+interface SummaryStatProps {
+  title: string;
+  value: number;
+  prefix: ReactNode;
+  emails?: string[];
+  selected?: boolean;
+  onSelect: () => void;
+}
+
+function SummaryStat({ title, value, prefix, emails, selected, onSelect }: SummaryStatProps) {
+  const stat = (
+    <div
+      role="button"
+      tabIndex={0}
+      aria-pressed={selected}
+      className={selected ? 'summary-stat selected' : 'summary-stat'}
+      onClick={onSelect}
+      onKeyDown={activateOnKey(onSelect)}
+    >
+      <Statistic title={title} value={String(value)} prefix={prefix} />
+    </div>
+  );
+  if (!emails) return stat;
+  return (
+    <Popover
+      title={title}
+      open={value ? undefined : false}
+      content={<ClientEmailList emails={emails} total={value} />}
+    >
+      {stat}
+    </Popover>
+  );
+}
+
 type Bucket = 'active' | 'deactive' | 'depleted' | 'expiring';
 
 interface PersistedFilterState {
@@ -1224,6 +1259,15 @@ export default function ClientsPage() {
   const someSelected =
     selectedRowKeys.length > 0 && selectedRowKeys.length < filteredClients.length;
 
+  const isOnlyBucket = (bucket: string) =>
+    filters.buckets.length === 1 && filters.buckets[0] === bucket;
+
+  // Clicking the card that is already the sole status filter clears it again.
+  function selectBucket(bucket: string | null) {
+    const buckets = bucket && !isOnlyBucket(bucket) ? [bucket] : [];
+    setFilters({ ...filters, buckets });
+  }
+
   function clearOneFilter<K extends keyof ClientFilters>(key: K) {
     if (key === 'expiryFrom' || key === 'expiryTo') {
       setFilters({ ...filters, expiryFrom: undefined, expiryTo: undefined });
@@ -1265,89 +1309,60 @@ export default function ClientsPage() {
                     <Card size="small" hoverable className="summary-card">
                       <Row gutter={[16, 12]}>
                         <Col xs={12} sm={8} md={4}>
-                          <Statistic
+                          <SummaryStat
                             title={t('clients')}
-                            value={String(summary.total)}
+                            value={summary.total}
                             prefix={<TeamOutlined />}
+                            onSelect={() => selectBucket(null)}
                           />
                         </Col>
                         <Col xs={12} sm={8} md={4}>
-                          <Popover
+                          <SummaryStat
                             title={t('online')}
-                            open={summary.onlineCount ? undefined : false}
-                            content={
-                              <ClientEmailList
-                                emails={summary.online}
-                                total={summary.onlineCount}
-                              />
-                            }
-                          >
-                            <Statistic
-                              title={t('online')}
-                              value={String(summary.onlineCount)}
-                              prefix={<span className="dot dot-blue" />}
-                            />
-                          </Popover>
+                            value={summary.onlineCount}
+                            emails={summary.online}
+                            prefix={<span className="dot dot-blue" />}
+                            selected={isOnlyBucket('online')}
+                            onSelect={() => selectBucket('online')}
+                          />
                         </Col>
                         <Col xs={12} sm={8} md={4}>
-                          <Popover
+                          <SummaryStat
                             title={t('depleted')}
-                            open={summary.depletedCount ? undefined : false}
-                            content={
-                              <ClientEmailList
-                                emails={summary.depleted}
-                                total={summary.depletedCount}
-                              />
-                            }
-                          >
-                            <Statistic
-                              title={t('depleted')}
-                              value={String(summary.depletedCount)}
-                              prefix={<span className="dot dot-red" />}
-                            />
-                          </Popover>
+                            value={summary.depletedCount}
+                            emails={summary.depleted}
+                            prefix={<span className="dot dot-red" />}
+                            selected={isOnlyBucket('depleted')}
+                            onSelect={() => selectBucket('depleted')}
+                          />
                         </Col>
                         <Col xs={12} sm={8} md={4}>
-                          <Popover
+                          <SummaryStat
                             title={t('depletingSoon')}
-                            open={summary.expiringCount ? undefined : false}
-                            content={
-                              <ClientEmailList
-                                emails={summary.expiring}
-                                total={summary.expiringCount}
-                              />
-                            }
-                          >
-                            <Statistic
-                              title={t('depletingSoon')}
-                              value={String(summary.expiringCount)}
-                              prefix={<span className="dot dot-orange" />}
-                            />
-                          </Popover>
+                            value={summary.expiringCount}
+                            emails={summary.expiring}
+                            prefix={<span className="dot dot-orange" />}
+                            selected={isOnlyBucket('expiring')}
+                            onSelect={() => selectBucket('expiring')}
+                          />
                         </Col>
                         <Col xs={12} sm={8} md={4}>
-                          <Popover
+                          <SummaryStat
                             title={t('disabled')}
-                            open={summary.deactiveCount ? undefined : false}
-                            content={
-                              <ClientEmailList
-                                emails={summary.deactive}
-                                total={summary.deactiveCount}
-                              />
-                            }
-                          >
-                            <Statistic
-                              title={t('disabled')}
-                              value={String(summary.deactiveCount)}
-                              prefix={<span className="dot dot-gray" />}
-                            />
-                          </Popover>
+                            value={summary.deactiveCount}
+                            emails={summary.deactive}
+                            prefix={<span className="dot dot-gray" />}
+                            selected={isOnlyBucket('deactive')}
+                            onSelect={() => selectBucket('deactive')}
+                          />
                         </Col>
                         <Col xs={12} sm={8} md={4}>
-                          <Statistic
+                          <SummaryStat
                             title={t('subscription.active')}
-                            value={String(summary.active)}
+                            value={summary.active}
                             prefix={<span className="dot dot-green" />}
+                            selected={isOnlyBucket('active')}
+                            onSelect={() => selectBucket('active')}
                           />
                         </Col>
                       </Row>

+ 36 - 5
frontend/src/pages/inbounds/useInbounds.ts

@@ -119,6 +119,18 @@ function toGuidOnlineMap(data: Record<string, string[]>): Map<string, Set<string
   return map;
 }
 
+// Most pushes repeat the previous online sets; handing back a new Map anyway
+// re-ran the client rollup over every inbound on each traffic event.
+function sameGuidSets(a: Map<string, Set<string>>, b: Map<string, Set<string>>): boolean {
+  if (a.size !== b.size) return false;
+  for (const [key, set] of b) {
+    const prev = a.get(key);
+    if (!prev || prev.size !== set.size) return false;
+    for (const value of set) if (!prev.has(value)) return false;
+  }
+  return true;
+}
+
 async function fetchLastOnlineMap(): Promise<Record<string, number>> {
   const msg = await HttpUtil.post('/panel/api/clients/lastOnline', undefined, { silent: true });
   if (!msg?.success) throw new Error(msg?.msg || 'Failed to fetch lastOnline');
@@ -440,10 +452,12 @@ export function useInbounds() {
       setOnlineClients(p.onlineClients);
     }
     if (p.onlineByGuid && typeof p.onlineByGuid === 'object') {
-      setOnlineByGuid(toGuidOnlineMap(p.onlineByGuid));
+      const next = toGuidOnlineMap(p.onlineByGuid);
+      setOnlineByGuid((prev) => (sameGuidSets(prev, next) ? prev : next));
     }
     if (p.activeInbounds && typeof p.activeInbounds === 'object') {
-      setActiveByGuid(toGuidOnlineMap(p.activeInbounds));
+      const next = toGuidOnlineMap(p.activeInbounds);
+      setActiveByGuid((prev) => (sameGuidSets(prev, next) ? prev : next));
     }
     if (p.lastOnlineMap && typeof p.lastOnlineMap === 'object') {
       setLastOnlineMap((prev) => ({ ...prev, ...p.lastOnlineMap! }));
@@ -537,8 +551,7 @@ export function useInbounds() {
           ? stats.map((stat) => {
               const su = byEmail.get(stat.email);
               if (!su) return stat;
-              statsTouched = true;
-              return {
+              const merged = {
                 ...stat,
                 up: typeof su.up === 'number' ? su.up : stat.up,
                 down: typeof su.down === 'number' ? su.down : stat.down,
@@ -546,9 +559,27 @@ export function useInbounds() {
                 expiryTime: typeof su.expiryTime === 'number' ? su.expiryTime : stat.expiryTime,
                 enable: typeof su.enable === 'boolean' ? su.enable : stat.enable,
               } as ClientStats;
+              if (
+                merged.up === stat.up &&
+                merged.down === stat.down &&
+                merged.total === stat.total &&
+                merged.expiryTime === stat.expiryTime &&
+                merged.enable === stat.enable
+              ) {
+                return stat;
+              }
+              statsTouched = true;
+              return merged;
             })
           : null;
-      if (!upd && !statsTouched) return ib;
+      // Every push lists all inbounds' totals, so only a row whose numbers moved counts.
+      const inboundMoved =
+        !!upd &&
+        ((typeof upd.up === 'number' && upd.up !== ib.up) ||
+          (typeof upd.down === 'number' && upd.down !== ib.down) ||
+          (typeof upd.total === 'number' && upd.total !== ib.total) ||
+          (typeof upd.enable === 'boolean' && upd.enable !== ib.enable));
+      if (!inboundMoved && !statsTouched) return ib;
       touched = true;
       const row = new DBInbound(ib as DBInboundInit) as DBInboundInstance;
       if (upd) {

+ 1 - 1
frontend/src/pages/index/AmneziaWGLogModal.tsx

@@ -122,7 +122,7 @@ export default function AmneziaWGLogModal({ open, onClose }: AmneziaWGLogModalPr
           <Select
             value={rows}
             size="small"
-            style={{ width: 70 }}
+            style={{ width: 100 }}
             onChange={setRows}
             options={[
               { value: '20', label: '20' },

+ 1 - 1
frontend/src/pages/index/LogModal.tsx

@@ -107,7 +107,7 @@ export default function LogModal({ open, onClose }: LogModalProps) {
             <Select
               value={rows}
               size="small"
-              style={{ width: 70 }}
+              style={{ width: 100 }}
               onChange={setRows}
               options={[
                 { value: '20', label: '20' },

+ 7 - 1
frontend/src/pages/index/OverviewActionBar.tsx

@@ -159,7 +159,7 @@ export default function OverviewActionBar({
 
   return (
     <div className="ov-bar">
-      {status.xray.state === 'error' && status.xray.errorMsg ? (
+      {status.xray.errorMsg ? (
         <Tooltip title={<span className="ov-error-detail">{status.xray.errorMsg}</span>}>
           {statePill}
         </Tooltip>
@@ -167,6 +167,12 @@ export default function OverviewActionBar({
         statePill
       )}
 
+      {status.xray.state === 'running' && status.xray.errorMsg ? (
+        <Tooltip title={<span className="ov-error-detail">{status.xray.errorMsg}</span>}>
+          <Tag color="error">{t('pages.index.xrayStatusError')}</Tag>
+        </Tooltip>
+      ) : null}
+
       {updateAvailable ? (
         <Tag
           className="ov-update-tag"

+ 1 - 1
frontend/src/pages/index/XrayLogModal.tsx

@@ -175,7 +175,7 @@ export default function XrayLogModal({ open, onClose }: XrayLogModalProps) {
           <Select
             value={rows}
             size="small"
-            style={{ width: 70 }}
+            style={{ width: 100 }}
             onChange={setRows}
             options={[
               { value: '20', label: '20' },

+ 56 - 42
frontend/src/pages/nodes/NodeList.tsx

@@ -145,17 +145,22 @@ function formatUptime(secs?: number): string {
   return `${mins}m`;
 }
 
+// Stable per language: the columns memo depends on it, and a fresh function each
+// render rebuilt every column, re-rendering all rows on each heartbeat push.
 function useRelativeTime() {
   const { t } = useTranslation();
-  return (unixSeconds?: number) => {
-    if (!unixSeconds) return t('pages.nodes.never');
-    const diffSec = Math.max(0, Math.floor(Date.now() / 1000 - unixSeconds));
-    if (diffSec < 5) return t('pages.nodes.justNow');
-    if (diffSec < 60) return `${diffSec}s`;
-    if (diffSec < 3600) return `${Math.floor(diffSec / 60)}m`;
-    if (diffSec < 86400) return `${Math.floor(diffSec / 3600)}h`;
-    return `${Math.floor(diffSec / 86400)}d`;
-  };
+  return useMemo(
+    () => (unixSeconds?: number) => {
+      if (!unixSeconds) return t('pages.nodes.never');
+      const diffSec = Math.max(0, Math.floor(Date.now() / 1000 - unixSeconds));
+      if (diffSec < 5) return t('pages.nodes.justNow');
+      if (diffSec < 60) return `${diffSec}s`;
+      if (diffSec < 3600) return `${Math.floor(diffSec / 60)}m`;
+      if (diffSec < 86400) return `${Math.floor(diffSec / 3600)}h`;
+      return `${Math.floor(diffSec / 86400)}d`;
+    },
+    [t],
+  );
 }
 
 export default function NodeList({
@@ -530,6 +535,47 @@ export default function NodeList({
     ],
   );
 
+  // rc-table re-runs every cell renderer whenever the Table re-renders, so keep the
+  // same element until its inputs change rather than re-rendering all rows each time.
+  const nodeTable = useMemo(
+    () => (
+      <Table<NodeRow>
+        dataSource={dataSource}
+        columns={columns}
+        pagination={false}
+        loading={loading}
+        scroll={{ x: 'max-content' }}
+        size="middle"
+        rowKey="key"
+        rowSelection={
+          dataSource.length > 1
+            ? {
+                selectedRowKeys: selectedIds,
+                onChange: (keys) =>
+                  onSelectionChange(keys.filter((k) => typeof k === 'number') as number[]),
+                getCheckboxProps: (record) => ({
+                  disabled: !!record.transitive || !isUpdateEligible(record),
+                }),
+              }
+            : undefined
+        }
+        locale={{
+          emptyText: (
+            <div className="card-empty">
+              <ClusterOutlined style={{ fontSize: 32, marginBottom: 8 }} />
+              <div>{t('noData')}</div>
+            </div>
+          ),
+        }}
+        expandable={{
+          expandedRowRender: (record) => <NodeHistoryPanel node={record} />,
+          rowExpandable: (record) => !record.transitive,
+        }}
+      />
+    ),
+    [dataSource, columns, loading, selectedIds, onSelectionChange, t],
+  );
+
   return (
     <Card size="small" hoverable>
       <div className="toolbar">
@@ -806,39 +852,7 @@ export default function NodeList({
           </Modal>
         </>
       ) : (
-        <Table<NodeRow>
-          dataSource={dataSource}
-          columns={columns}
-          pagination={false}
-          loading={loading}
-          scroll={{ x: 'max-content' }}
-          size="middle"
-          rowKey="key"
-          rowSelection={
-            dataSource.length > 1
-              ? {
-                  selectedRowKeys: selectedIds,
-                  onChange: (keys) =>
-                    onSelectionChange(keys.filter((k) => typeof k === 'number') as number[]),
-                  getCheckboxProps: (record) => ({
-                    disabled: !!record.transitive || !isUpdateEligible(record),
-                  }),
-                }
-              : undefined
-          }
-          locale={{
-            emptyText: (
-              <div className="card-empty">
-                <ClusterOutlined style={{ fontSize: 32, marginBottom: 8 }} />
-                <div>{t('noData')}</div>
-              </div>
-            ),
-          }}
-          expandable={{
-            expandedRowRender: (record) => <NodeHistoryPanel node={record} />,
-            rowExpandable: (record) => !record.transitive,
-          }}
-        />
+        nodeTable
       )}
     </Card>
   );

+ 37 - 8
frontend/src/pages/settings/SubscriptionGeneralTab.tsx

@@ -1,4 +1,4 @@
-import { Alert, Button, Input, InputNumber, Switch, Tabs } from 'antd';
+import { Alert, Button, Input, InputNumber, Select, Switch, Tabs } from 'antd';
 import {
   BranchesOutlined,
   CompassOutlined,
@@ -11,6 +11,7 @@ import {
 import { useTranslation } from 'react-i18next';
 import { useNavigate, useSearchParams } from 'react-router';
 import type { AllSetting } from '@/models/setting';
+import type { SubProfileMode } from '@/schemas/setting';
 import { onNumber } from '@/utils/onNumber';
 import { DefaultSettingTag, SettingListItem } from '@/components/ui';
 import { RemarkTemplateField } from '@/components/form';
@@ -279,16 +280,44 @@ export default function SubscriptionGeneralTab({
               </SettingListItem>
               <SettingListItem
                 paddings="small"
-                title={t('pages.settings.subProfileUrl')}
-                description={t('pages.settings.subProfileUrlDesc')}
+                title={t('pages.settings.subProfileMode')}
+                description={t('pages.settings.subProfileModeDesc')}
               >
-                <RemarkTemplateField
-                  value={allSetting.subProfileUrl}
-                  placeholder="https://example.com"
-                  onChange={(v) => updateSetting({ subProfileUrl: v })}
-                  metadataOnly
+                <Select<SubProfileMode>
+                  id="sub-profile-mode"
+                  aria-label={t('pages.settings.subProfileMode')}
+                  value={allSetting.subProfileMode}
+                  style={{ width: '100%' }}
+                  onChange={(value) => updateSetting({ subProfileMode: value })}
+                  options={[
+                    { value: 'none', label: t('pages.settings.subProfileModeNone') },
+                    { value: 'builtin', label: t('pages.settings.subProfileModeBuiltin') },
+                    { value: 'custom', label: t('pages.settings.subProfileModeCustom') },
+                  ]}
                 />
               </SettingListItem>
+              {allSetting.subProfileMode === 'builtin' ? (
+                <Alert
+                  type="warning"
+                  showIcon
+                  style={{ margin: '12px 20px' }}
+                  title={t('pages.settings.subProfileBuiltinWarning')}
+                />
+              ) : null}
+              {allSetting.subProfileMode === 'custom' ? (
+                <SettingListItem
+                  paddings="small"
+                  title={t('pages.settings.subProfileUrl')}
+                  description={t('pages.settings.subProfileUrlDesc')}
+                >
+                  <RemarkTemplateField
+                    value={allSetting.subProfileUrl}
+                    placeholder="https://example.com"
+                    onChange={(v) => updateSetting({ subProfileUrl: v })}
+                    metadataOnly
+                  />
+                </SettingListItem>
+              ) : null}
               <SettingListItem
                 paddings="small"
                 title={t('pages.settings.subAnnounce')}

+ 4 - 0
frontend/src/schemas/setting.ts

@@ -4,6 +4,9 @@ const port = z.number().int().min(1).max(65535);
 const nonNegativeInt = z.number().int().min(0);
 const absolutePath = z.string().regex(/^\//, 'pages.settings.validation.pathLeadingSlash');
 
+export const SubProfileModeSchema = z.enum(['none', 'builtin', 'custom']);
+export type SubProfileMode = z.infer<typeof SubProfileModeSchema>;
+
 export const AllSettingSchema = z
   .object({
     webListen: z.string().optional(),
@@ -50,6 +53,7 @@ export const AllSettingSchema = z
     subClashUserAgentRegex: z.string().max(2048).optional(),
     subTitle: z.string().optional(),
     subSupportUrl: z.string().optional(),
+    subProfileMode: SubProfileModeSchema.optional(),
     subProfileUrl: z.string().optional(),
     subAnnounce: z.string().optional(),
     subEnableRouting: z.boolean().optional(),

+ 107 - 0
frontend/src/test/inbounds-ws-identity.test.tsx

@@ -0,0 +1,107 @@
+import type { ReactNode } from 'react';
+import { act, renderHook, waitFor } from '@testing-library/react';
+import { QueryClientProvider } from '@tanstack/react-query';
+import { describe, expect, it } from 'vitest';
+
+import { keys } from '@/api/queryKeys';
+import { useInbounds } from '@/pages/inbounds/useInbounds';
+
+import { makeTestQueryClient } from './test-utils';
+
+function seedInbounds() {
+  const rows = [1, 2].map((id) => ({
+    id,
+    protocol: 'vless',
+    tag: `in-${id}`,
+    enable: true,
+    up: 10,
+    down: 20,
+    total: 0,
+    expiryTime: 0,
+    settings: JSON.stringify({ clients: [{ email: `c${id}@x`, enable: true }] }),
+    clientStats: [
+      { email: `c${id}@x`, up: 1, down: 2, total: 0, expiryTime: 0, enable: true, inboundId: id },
+    ],
+  }));
+  const queryClient = makeTestQueryClient();
+  queryClient.setQueryData(keys.inbounds.slim(), rows);
+  queryClient.setQueryData(keys.clients.onlines(), []);
+  queryClient.setQueryData(keys.clients.onlinesByGuid(), {});
+  queryClient.setQueryData(keys.clients.activeInbounds(), {});
+  queryClient.setQueryData(keys.clients.lastOnline(), {});
+  queryClient.setQueryData(keys.settings.defaults(), {});
+  const wrapper = ({ children }: { children: ReactNode }) => (
+    <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
+  );
+  return { rows, wrapper };
+}
+
+async function renderInbounds() {
+  const { rows, wrapper } = seedInbounds();
+  const hook = renderHook(() => useInbounds(), { wrapper });
+  await waitFor(() => expect(hook.result.current.dbInbounds).toHaveLength(2));
+  return { rows, result: hook.result };
+}
+
+// Every client_stats push carries all inbounds' totals, so rebuilding a row whether or
+// not its numbers moved re-ran the client rollup and the whole table on each push.
+describe('inbound websocket merges keep unchanged state', () => {
+  it('keeps rows and the client rollup when a client_stats push changes nothing', async () => {
+    const { rows, result } = await renderInbounds();
+    const before = result.current.dbInbounds;
+    const rollup = result.current.clientCount;
+
+    act(() =>
+      result.current.applyClientStatsEvent({
+        inbounds: rows.map((r) => ({
+          id: r.id,
+          up: r.up,
+          down: r.down,
+          total: r.total,
+          enable: r.enable,
+        })),
+        clients: [{ email: 'c1@x', up: 1, down: 2, total: 0, expiryTime: 0, enable: true }],
+      }),
+    );
+
+    expect(result.current.dbInbounds).toBe(before);
+    expect(result.current.clientCount).toBe(rollup);
+  });
+
+  it('still rebuilds exactly the rows whose numbers moved', async () => {
+    const { result } = await renderInbounds();
+    const before = result.current.dbInbounds;
+
+    act(() =>
+      result.current.applyClientStatsEvent({
+        inbounds: [
+          { id: 1, up: 99, down: 20, total: 0, enable: true },
+          { id: 2, up: 10, down: 20, total: 0, enable: true },
+        ],
+        clients: [{ email: 'c2@x', up: 5, down: 2, total: 0, expiryTime: 0, enable: true }],
+      }),
+    );
+
+    const [first, second] = result.current.dbInbounds;
+    expect(first).not.toBe(before[0]);
+    expect(first.up).toBe(99);
+    expect(second).not.toBe(before[1]);
+    expect(second.clientStats?.[0]?.up).toBe(5);
+  });
+
+  it('keeps the client rollup when a traffic push repeats the same online sets', async () => {
+    const { result } = await renderInbounds();
+    const push = () =>
+      result.current.applyTrafficEvent({
+        onlineClients: ['c1@x'],
+        onlineByGuid: { 'node:1': ['c1@x'] },
+        activeInbounds: { 'node:1': ['in-1'] },
+      });
+    act(push);
+    const rollup = result.current.clientCount;
+
+    act(push);
+
+    expect(result.current.clientCount).toBe(rollup);
+  });
+});

+ 68 - 0
frontend/src/test/node-list-rerender.test.tsx

@@ -0,0 +1,68 @@
+import type { ReactNode } from 'react';
+import { render } from '@testing-library/react';
+import { QueryClientProvider } from '@tanstack/react-query';
+import { describe, expect, it, vi } from 'vitest';
+
+import { ThemeProvider } from '@/hooks/useTheme';
+import NodeList from '@/pages/nodes/NodeList';
+import type { NodeRecord } from '@/schemas/node';
+
+import { makeTestQueryClient } from './test-utils';
+
+const updateChecks = vi.hoisted(() => ({ count: 0 }));
+
+vi.mock('@/lib/panel-version', async (importOriginal) => {
+  const actual = await importOriginal<typeof import('@/lib/panel-version')>();
+  return {
+    ...actual,
+    isPanelUpdateAvailable: (...args: Parameters<typeof actual.isPanelUpdateAvailable>) => {
+      updateChecks.count++;
+      return actual.isPanelUpdateAvailable(...args);
+    },
+  };
+});
+
+// Every heartbeat push re-rendered all rows, unchanged ones too: the columns and
+// table props were rebuilt on each render, so every cell re-ran its renderer.
+describe('NodeList re-render', () => {
+  it('leaves the rows alone when its parent re-renders with the same nodes', () => {
+    const queryClient = makeTestQueryClient();
+    const wrapper = ({ children }: { children: ReactNode }) => (
+      <QueryClientProvider client={queryClient}>
+        <ThemeProvider>{children}</ThemeProvider>
+      </QueryClientProvider>
+    );
+    const nodes: NodeRecord[] = [1, 2, 3].map((id) => ({
+      id,
+      name: `node-${id}`,
+      guid: `g${id}`,
+      transitive: false,
+      enable: true,
+      status: 'online',
+      panelVersion: '3.0.0',
+    }));
+    const noop = () => {};
+    const props = {
+      nodes,
+      isMobile: false,
+      latestVersion: '3.0.1',
+      selectedIds: [] as number[],
+      onSelectionChange: noop,
+      onAdd: noop,
+      onMtls: noop,
+      onEdit: noop,
+      onDelete: noop,
+      onProbe: noop,
+      onToggleEnable: noop,
+      onUpdateNode: noop,
+      onUpdateSelected: noop,
+    };
+    const view = render(<NodeList {...props} />, { wrapper });
+    expect(updateChecks.count).toBeGreaterThan(0);
+
+    updateChecks.count = 0;
+    view.rerender(<NodeList {...props} />);
+
+    expect(updateChecks.count).toBe(0);
+  });
+});

+ 13 - 0
frontend/src/test/outbound-form-adapter.test.ts

@@ -336,6 +336,19 @@ describe('outbound-form-adapter: round-trip', () => {
     expect(rules[1]).toEqual({ action: 'return', qType: 28, domain: ['blocked.com'], rCode: 3 });
   });
 
+  it('dns rules keep qType 0 a string, since the core reads a numeric 0 as every query', () => {
+    const back = formValuesToWirePayload(
+      rawOutboundToFormValues({
+        protocol: 'dns',
+        settings: { rules: [{ action: 'drop', qType: 0 }] },
+      }),
+    );
+    const rules = (back.settings as Record<string, unknown>).rules as Array<
+      Record<string, unknown>
+    >;
+    expect(rules[0]).toEqual({ action: 'drop', qType: '0' });
+  });
+
   it('dns rules read the legacy qtype wire key for back-compat', () => {
     const wire = {
       protocol: 'dns',

+ 7 - 0
frontend/src/test/panel-version.test.ts

@@ -30,6 +30,13 @@ describe('isPanelUpdateAvailable', () => {
     expect(isPanelUpdateAvailable('nightly-2', 'nightly-1')).toBe(true);
     expect(isPanelUpdateAvailable('nightly-1', 'nightly-1')).toBe(false);
   });
+
+  it('compares dev builds by commit and never across channels', () => {
+    expect(isPanelUpdateAvailable('dev+1a2b3c4d', 'dev+0f0f0f0f')).toBe(true);
+    expect(isPanelUpdateAvailable('dev+1a2b3c4d', 'dev+1a2b3c4d')).toBe(false);
+    expect(isPanelUpdateAvailable('v3.5.0', 'dev+1a2b3c4d')).toBe(false);
+    expect(isPanelUpdateAvailable('dev+1a2b3c4d', '3.5.0')).toBe(false);
+  });
 });
 
 describe('formatPanelVersion', () => {

+ 36 - 0
frontend/src/test/setting-profile-mode.test.ts

@@ -0,0 +1,36 @@
+import { describe, expect, it } from 'vitest';
+
+import { AllSetting } from '@/models/setting';
+import { AllSettingSchema } from '@/schemas/setting';
+
+describe('subscription profile mode', () => {
+  it.each([undefined, {}, { subProfileUrl: '' }, { subProfileUrl: '   ' }])(
+    'defaults to no link without a legacy URL: %j',
+    (data) => {
+      expect(new AllSetting(data).subProfileMode).toBe('none');
+    },
+  );
+
+  it('preserves a legacy custom URL when its mode is missing', () => {
+    const setting = new AllSetting({ subProfileUrl: 'https://example.com/profile' });
+    expect(setting.subProfileMode).toBe('custom');
+    expect(setting.subProfileUrl).toBe('https://example.com/profile');
+  });
+
+  it.each(['none', 'builtin', 'custom'])(
+    'honors the explicit %s mode with a stored URL',
+    (mode) => {
+      const result = AllSettingSchema.safeParse({
+        subProfileMode: mode,
+        subProfileUrl: 'https://example.com/profile',
+      });
+      expect(result.success).toBe(true);
+      if (!result.success) return;
+      expect(new AllSetting(result.data).subProfileMode).toBe(mode);
+    },
+  );
+
+  it.each(['auto', '', null, true])('rejects an invalid mode: %j', (mode) => {
+    expect(AllSettingSchema.safeParse({ subProfileMode: mode }).success).toBe(false);
+  });
+});

+ 30 - 2
frontend/src/test/storybook-theme.test.tsx

@@ -1,8 +1,8 @@
-import { render } from '@testing-library/react';
+import { fireEvent, render } from '@testing-library/react';
 import { afterEach, expect, test } from 'vitest';
 
 import { withTheme } from '../../.storybook/preview';
-import { ThemeProvider } from '@/hooks/useTheme';
+import { ThemeProvider, useTheme } from '@/hooks/useTheme';
 
 function Story() {
   return <div>Story</div>;
@@ -14,9 +14,37 @@ function StorybookTheme({ theme }: { theme: 'light' | 'dark' }) {
   > as Parameters<typeof withTheme>[1]);
 }
 
+function ThemeToggle() {
+  const { toggleTheme } = useTheme();
+  return <button onClick={toggleTheme}>toggle</button>;
+}
+
 afterEach(() => {
   document.body.className = '';
   document.documentElement.removeAttribute('data-theme');
+  document.documentElement.style.colorScheme = '';
+});
+
+// Without color-scheme the browser paints native scrollbars light inside dark
+// modals, e.g. the Edit Client body.
+test('native scrollbars follow the panel theme', () => {
+  const { getByRole } = render(
+    <ThemeProvider>
+      <ThemeToggle />
+    </ThemeProvider>,
+  );
+  expect(document.documentElement.style.colorScheme).toBe('dark');
+
+  fireEvent.click(getByRole('button'));
+  expect(document.documentElement.style.colorScheme).toBe('light');
+});
+
+test('native scrollbars follow the Storybook theme', () => {
+  const { rerender } = render(<StorybookTheme theme="light" />);
+  expect(document.documentElement.style.colorScheme).toBe('light');
+
+  rerender(<StorybookTheme theme="dark" />);
+  expect(document.documentElement.style.colorScheme).toBe('dark');
 });
 
 test('preserves unrelated body classes when applying the Storybook theme', () => {

+ 74 - 1
frontend/src/test/subscription-general-tab.test.tsx

@@ -1,10 +1,29 @@
+import { useState } from 'react';
 import { fireEvent, screen } from '@testing-library/react';
 import { MemoryRouter, useLocation } from 'react-router';
 import { describe, expect, it, vi } from 'vitest';
 
 import { AllSetting } from '@/models/setting';
 import SubscriptionGeneralTab from '@/pages/settings/SubscriptionGeneralTab';
-import { renderWithProviders } from './test-utils';
+import { chooseSelectOption, renderWithProviders } from './test-utils';
+
+function ProfileSettingsHarness({ initial }: { initial?: unknown }) {
+  const [allSetting, setAllSetting] = useState(() => new AllSetting(initial));
+
+  return (
+    <>
+      <SubscriptionGeneralTab
+        allSetting={allSetting}
+        updateSetting={(patch) =>
+          setAllSetting((current) => new AllSetting({ ...current, ...patch }))
+        }
+      />
+      <output data-testid="profile-settings">
+        {allSetting.subProfileMode}|{allSetting.subProfileUrl}
+      </output>
+    </>
+  );
+}
 
 function LocationProbe() {
   const location = useLocation();
@@ -18,6 +37,60 @@ function LocationProbe() {
 }
 
 describe('SubscriptionGeneralTab', () => {
+  it('switches profile modes without losing the custom URL and warns only for the built-in page', () => {
+    const storedUrl = 'https://example.com/profile/{{SUB_ID}}';
+    const editedUrl = 'https://example.com/account/{{SUB_ID}}';
+    const warning =
+      'This page exposes subscription URLs and node configurations, including for Happ encrypted subscriptions.';
+
+    renderWithProviders(
+      <MemoryRouter initialEntries={['/settings#subscription']}>
+        <ProfileSettingsHarness initial={{ subProfileMode: 'none', subProfileUrl: storedUrl }} />
+      </MemoryRouter>,
+    );
+
+    fireEvent.click(screen.getByRole('tab', { name: /Profile/ }));
+    expect(screen.getByRole('combobox', { name: 'Profile page' })).toBeTruthy();
+    expect(screen.getByTestId('profile-settings').textContent).toBe(`none|${storedUrl}`);
+    expect(screen.queryByDisplayValue(storedUrl)).toBeNull();
+    expect(screen.queryByText(warning)).toBeNull();
+
+    chooseSelectOption('sub-profile-mode', 'Built-in subscription page');
+    expect(screen.getByTestId('profile-settings').textContent).toBe(`builtin|${storedUrl}`);
+    expect(screen.getByRole('alert').textContent).toContain(warning);
+    expect(screen.queryByDisplayValue(storedUrl)).toBeNull();
+
+    chooseSelectOption('sub-profile-mode', 'Custom website');
+    expect(screen.queryByText(warning)).toBeNull();
+    fireEvent.change(screen.getByDisplayValue(storedUrl), { target: { value: editedUrl } });
+    expect(screen.getByTestId('profile-settings').textContent).toBe(`custom|${editedUrl}`);
+
+    chooseSelectOption('sub-profile-mode', 'No link');
+    expect(screen.getByTestId('profile-settings').textContent).toBe(`none|${editedUrl}`);
+    expect(screen.queryByDisplayValue(editedUrl)).toBeNull();
+    expect(screen.queryByText(warning)).toBeNull();
+
+    chooseSelectOption('sub-profile-mode', 'Custom website');
+    expect(screen.getByTestId('profile-settings').textContent).toBe(`custom|${editedUrl}`);
+    expect(screen.getByDisplayValue(editedUrl)).toBeTruthy();
+  });
+
+  it('opens a legacy custom profile URL with custom mode selected', () => {
+    const storedUrl = 'https://example.com/profile/{{SUB_ID}}';
+
+    renderWithProviders(
+      <MemoryRouter initialEntries={['/settings#subscription']}>
+        <ProfileSettingsHarness initial={{ subProfileUrl: storedUrl }} />
+      </MemoryRouter>,
+    );
+
+    fireEvent.click(screen.getByRole('tab', { name: /Profile/ }));
+    expect(screen.getByRole('combobox', { name: 'Profile page' })).toBeTruthy();
+    expect(screen.getByText('Custom website')).toBeTruthy();
+    expect(screen.getByDisplayValue(storedUrl)).toBeTruthy();
+    expect(screen.getByTestId('profile-settings').textContent).toBe(`custom|${storedUrl}`);
+  });
+
   it('keeps the stored subscription port when the field is cleared', () => {
     const updateSetting = vi.fn();
 

+ 27 - 7
internal/amneziawgnet/portfwd.go

@@ -134,14 +134,11 @@ func desiredPeerTargets(inst amneziawg.Instance) map[string]netip.Addr {
 	return out
 }
 
-// desiredPortForwardKeys returns the full set of listener keys inst wants
-// right now: one tcpForward and one udpForward key per port in every peer's
-// ForwardedPorts spec, for every peer that also has a resolvable target
-// (see desiredPeerTargets) -- a key never exists without a target, so
-// Reconcile can always resolve one for any key it opens.
-func desiredPortForwardKeys(inst amneziawg.Instance) map[portForwardKey]struct{} {
-	out := map[portForwardKey]struct{}{}
+// forwardingPeers is the one gate a host listener comes from: no email, port
+// spec and resolvable target (see desiredPeerTargets), no socket.
+func forwardingPeers(inst amneziawg.Instance) []amneziawg.Peer {
 	targets := desiredPeerTargets(inst)
+	out := make([]amneziawg.Peer, 0, len(inst.Peers))
 	for _, p := range inst.Peers {
 		if p.Email == "" || p.ForwardedPorts == "" {
 			continue
@@ -149,6 +146,16 @@ func desiredPortForwardKeys(inst amneziawg.Instance) map[portForwardKey]struct{}
 		if _, ok := targets[p.Email]; !ok {
 			continue
 		}
+		out = append(out, p)
+	}
+	return out
+}
+
+// desiredPortForwardKeys returns every listener key inst wants right now: one
+// tcpForward and one udpForward per forwarded port of the forwarding peers.
+func desiredPortForwardKeys(inst amneziawg.Instance) map[portForwardKey]struct{} {
+	out := map[portForwardKey]struct{}{}
+	for _, p := range forwardingPeers(inst) {
 		for _, port := range amneziawg.ExpandForwardedPorts(p.ForwardedPorts) {
 			out[portForwardKey{email: p.Email, port: port, proto: tcpForward}] = struct{}{}
 			out[portForwardKey{email: p.Email, port: port, proto: udpForward}] = struct{}{}
@@ -157,6 +164,19 @@ func desiredPortForwardKeys(inst amneziawg.Instance) map[portForwardKey]struct{}
 	return out
 }
 
+// ForwardedPortOwner names the peer Reconcile opens a listener on port for --
+// the same peers and expansion as desiredPortForwardKeys, never a silent one.
+func ForwardedPortOwner(inst amneziawg.Instance, port int) (string, bool) {
+	for _, p := range forwardingPeers(inst) {
+		for _, candidate := range amneziawg.ExpandForwardedPorts(p.ForwardedPorts) {
+			if candidate == port {
+				return p.Email, true
+			}
+		}
+	}
+	return "", false
+}
+
 // Reconcile brings the supervisor's open listeners in line with what inst
 // currently wants: closes anything no longer desired, opens anything newly
 // desired, leaves everything else untouched. Never returns an error --

+ 76 - 3
internal/database/db.go

@@ -1254,7 +1254,7 @@ func runSeeders(isUsersEmpty bool) error {
 	}
 
 	if empty && isUsersEmpty {
-		seeders := []string{"UserPasswordHash", "ClientsTable", "InboundClientsArrayFix", "InboundClientTgIdFix2", "InboundClientSubIdFix", "FreedomFinalRulesReverseFix", "FreedomFinalRulesPrivateEgressBlock", "UppercaseFreedomFinalRulesFix", "InboundRealityFinalmaskTcpStrip", "ApiTokensHash", "LegacyProxySettingsCleanup", "OutboundRemovedKeysFix", "FreedomDomainStrategyFix", "DNSOutboundLegacyKeysFix", "WireguardPeersToClients", "MtprotoSecretsToClients", "NodeInboundsAdopted", "ResetIpLimitNoFail2ban"}
+		seeders := []string{"UserPasswordHash", "ClientsTable", "InboundClientsArrayFix", "InboundClientTgIdFix2", "InboundClientSubIdFix", "FreedomFinalRulesReverseFix", "FreedomFinalRulesPrivateEgressBlock", "UppercaseFreedomFinalRulesFix", "InboundRealityFinalmaskTcpStrip", "ApiTokensHash", "LegacyProxySettingsCleanup", "OutboundRemovedKeysFix", "FreedomDomainStrategyFix", "DNSOutboundLegacyKeysFix", "DNSOutboundQTypeZeroFix", "WireguardPeersToClients", "MtprotoSecretsToClients", "NodeInboundsAdopted", "ResetIpLimitNoFail2ban"}
 		for _, name := range seeders {
 			if err := db.Create(&model.HistoryOfSeeders{SeederName: name}).Error; err != nil {
 				return err
@@ -1383,6 +1383,12 @@ func runSeeders(isUsersEmpty bool) error {
 		}
 	}
 
+	if !slices.Contains(seedersHistory, "DNSOutboundQTypeZeroFix") {
+		if err := migrateDNSOutboundQTypeZero(); err != nil {
+			return err
+		}
+	}
+
 	if !slices.Contains(seedersHistory, "NodeInboundsAdopted") {
 		if err := seedNodeInboundsAdopted(); err != nil {
 			return err
@@ -1920,9 +1926,10 @@ func legacyDNSOutboundRules(mode string, blockTypes []int) []any {
 	return append(rules, fallback)
 }
 
-// dnsQTypeValue keeps a lone qType a number, the way the core marshals one.
+// dnsQTypeValue keeps a lone qType a number the way the core marshals one, except
+// 0: the core drops a numeric 0, and a rule with no qTypes matches every query.
 func dnsQTypeValue(blockTypes []int) any {
-	if len(blockTypes) == 1 {
+	if len(blockTypes) == 1 && blockTypes[0] != 0 {
 		return blockTypes[0]
 	}
 	parts := make([]string, 0, len(blockTypes))
@@ -1932,6 +1939,72 @@ func dnsQTypeValue(blockTypes []int) any {
 	return strings.Join(parts, ",")
 }
 
+// migrateDNSOutboundQTypeZero repairs the numeric qType 0 that 3.8.0's legacy-keys
+// seeder stored, which that seeder's own history row keeps it from revisiting.
+func migrateDNSOutboundQTypeZero() error {
+	var setting model.Setting
+	err := db.Model(model.Setting{}).Where("key = ?", "xrayTemplateConfig").First(&setting).Error
+	if errors.Is(err, gorm.ErrRecordNotFound) {
+		return db.Create(&model.HistoryOfSeeders{SeederName: "DNSOutboundQTypeZeroFix"}).Error
+	}
+	if err != nil {
+		return err
+	}
+
+	updated, changed, rErr := RewriteDNSOutboundQTypeZero(setting.Value)
+	if rErr != nil {
+		log.Printf("DNSOutboundQTypeZeroFix: skip (invalid xrayTemplateConfig json): %v", rErr)
+		return db.Create(&model.HistoryOfSeeders{SeederName: "DNSOutboundQTypeZeroFix"}).Error
+	}
+
+	return db.Transaction(func(tx *gorm.DB) error {
+		if changed {
+			if err := tx.Model(&model.Setting{}).Where("key = ?", "xrayTemplateConfig").
+				Update("value", updated).Error; err != nil {
+				return err
+			}
+		}
+		return tx.Create(&model.HistoryOfSeeders{SeederName: "DNSOutboundQTypeZeroFix"}).Error
+	})
+}
+
+// RewriteDNSOutboundQTypeZero spells a dns rule's numeric qType 0 as "0", the one
+// form the core reads as query type 0 rather than as every query.
+func RewriteDNSOutboundQTypeZero(raw string) (string, bool, error) {
+	if strings.TrimSpace(raw) == "" {
+		return raw, false, nil
+	}
+	var cfg map[string]any
+	if err := json.Unmarshal([]byte(raw), &cfg); err != nil {
+		return raw, false, err
+	}
+	outbounds, _ := cfg["outbounds"].([]any)
+	changed := false
+	for _, ob := range outbounds {
+		obj, _ := ob.(map[string]any)
+		if proto, _ := obj["protocol"].(string); !strings.EqualFold(proto, "dns") {
+			continue
+		}
+		settings, _ := obj["settings"].(map[string]any)
+		rules, _ := settings["rules"].([]any)
+		for _, r := range rules {
+			rule, _ := r.(map[string]any)
+			if qType, ok := rule["qType"].(float64); ok && qType == 0 {
+				rule["qType"] = "0"
+				changed = true
+			}
+		}
+	}
+	if !changed {
+		return raw, false, nil
+	}
+	out, err := json.MarshalIndent(cfg, "", "  ")
+	if err != nil {
+		return raw, false, err
+	}
+	return string(out), true, nil
+}
+
 func normalizeSettingPaths() error {
 	pathKeys := []string{"webBasePath", "subPath", "subJsonPath", "subClashPath"}
 	var rows []model.Setting

+ 99 - 0
internal/database/dns_outbound_qtype_zero_test.go

@@ -0,0 +1,99 @@
+package database
+
+import (
+	"encoding/json"
+	"strings"
+	"testing"
+
+	"github.com/xtls/xray-core/infra/conf"
+	"github.com/xtls/xray-core/proxy/dns"
+	"google.golang.org/protobuf/proto"
+
+	"github.com/mhsanaei/3x-ui/v3/internal/config"
+	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
+)
+
+// The core drops a lone numeric qType 0 from its PortList, and a rule with no
+// qTypes matches every query, so blockTypes [0] must not be written that way.
+func TestRewriteDNSOutboundLegacyKeysKeepsQTypeZeroPolicy(t *testing.T) {
+	for _, mode := range []string{"skip", "reject"} {
+		t.Run(mode, func(t *testing.T) {
+			legacy := `{"protocol":"dns","tag":"dns-out","settings":{"nonIPQuery":"` + mode + `","blockTypes":[0]}}`
+			updated, changed, err := rewriteDNSOutboundLegacyKeys(`{"outbounds":[` + legacy + `]}`)
+			if err != nil || !changed {
+				t.Fatalf("rewrite: changed=%v err=%v", changed, err)
+			}
+			got := coreDNSOutboundPolicy(t, firstTemplateOutbound(t, updated))
+			if want := coreDNSOutboundPolicy(t, []byte(legacy)); !proto.Equal(got, want) {
+				t.Fatalf("rewritten policy = %v, want the legacy policy %v", got, want)
+			}
+		})
+	}
+}
+
+// Installs that already ran the legacy-keys seeder store the match-all rule, and
+// that seeder never runs again, so the repair has to reach them on its own.
+func TestSeedersRepairStoredDNSQTypeZero(t *testing.T) {
+	t.Setenv("XUI_DB_FOLDER", t.TempDir())
+	if err := InitDB(config.GetDBPath()); err != nil {
+		if strings.Contains(err.Error(), "CGO_ENABLED=0") {
+			t.Skipf("sqlite needs cgo: %v", err)
+		}
+		t.Fatalf("init db: %v", err)
+	}
+	t.Cleanup(func() { _ = CloseDB() })
+
+	// The legacy-keys seeder matched the protocol id without case, so it wrote both.
+	for _, protocol := range []string{"dns", "DNS"} {
+		t.Run(protocol, func(t *testing.T) {
+			legacy := `{"protocol":"` + protocol + `","tag":"dns-out","settings":{"nonIPQuery":"drop","blockTypes":[0]}}`
+			stored := `{"protocol":"` + protocol + `","tag":"dns-out","settings":{"rules":[{"action":"drop","qType":0},{"action":"hijack","qType":"1,28"},{"action":"drop"}]}}`
+			seedDNSOutboundTemplate(t, `{"outbounds":[`+stored+`]}`)
+			if err := db.Where("seeder_name = ?", "DNSOutboundQTypeZeroFix").
+				Delete(&model.HistoryOfSeeders{}).Error; err != nil {
+				t.Fatalf("clear seeder history: %v", err)
+			}
+
+			if err := runSeeders(false); err != nil {
+				t.Fatalf("runSeeders: %v", err)
+			}
+
+			got := coreDNSOutboundPolicy(t, firstTemplateOutbound(t, storedDNSOutboundTemplate(t)))
+			if want := coreDNSOutboundPolicy(t, []byte(legacy)); !proto.Equal(got, want) {
+				t.Fatalf("repaired policy = %v, want the legacy policy %v", got, want)
+			}
+		})
+	}
+}
+
+func firstTemplateOutbound(t *testing.T, template string) []byte {
+	t.Helper()
+	var cfg struct {
+		Outbounds []json.RawMessage `json:"outbounds"`
+	}
+	if err := json.Unmarshal([]byte(template), &cfg); err != nil || len(cfg.Outbounds) == 0 {
+		t.Fatalf("template has no outbound (%v): %s", err, template)
+	}
+	return cfg.Outbounds[0]
+}
+
+func coreDNSOutboundPolicy(t *testing.T, raw []byte) *dns.Config {
+	t.Helper()
+	var outbound conf.OutboundDetourConfig
+	if err := json.Unmarshal(raw, &outbound); err != nil {
+		t.Fatalf("unmarshal outbound: %v", err)
+	}
+	handler, err := outbound.Build()
+	if err != nil {
+		t.Fatalf("core build: %v", err)
+	}
+	instance, err := handler.ProxySettings.GetInstance()
+	if err != nil {
+		t.Fatalf("core settings: %v", err)
+	}
+	cfg, ok := instance.(*dns.Config)
+	if !ok {
+		t.Fatalf("core settings type = %T, want *dns.Config", instance)
+	}
+	return cfg
+}

+ 16 - 6
internal/sub/controller.go

@@ -53,6 +53,7 @@ type cachedSubTemplate struct {
 type SUBController struct {
 	subTitle            string
 	subSupportUrl       string
+	subProfileMode      string
 	subProfileUrl       string
 	subAnnounce         string
 	subEnableRouting    bool
@@ -115,6 +116,7 @@ type subControllerConfig struct {
 
 	subTitle         string
 	subSupportURL    string
+	subProfileMode   string
 	subProfileURL    string
 	subAnnounce      string
 	subEnableRouting bool
@@ -224,6 +226,10 @@ func WithSUBProfileURL(value string) SUBControllerOption {
 	return func(config *subControllerConfig) { config.subProfileURL = value }
 }
 
+func WithSUBProfileMode(value string) SUBControllerOption {
+	return func(config *subControllerConfig) { config.subProfileMode = value }
+}
+
 func WithSUBAnnounce(value string) SUBControllerOption {
 	return func(config *subControllerConfig) { config.subAnnounce = value }
 }
@@ -260,6 +266,7 @@ func defaultSUBControllerConfig() subControllerConfig {
 		subEncrypt:     true,
 		remarkTemplate: service.DefaultRemarkTemplate,
 		updateInterval: "12",
+		subProfileMode: service.SubProfileModeNone,
 	}
 }
 
@@ -277,6 +284,7 @@ func NewSUBController(g *gin.RouterGroup, options ...SUBControllerOption) *SUBCo
 	a := &SUBController{
 		subTitle:            config.subTitle,
 		subSupportUrl:       config.subSupportURL,
+		subProfileMode:      config.subProfileMode,
 		subProfileUrl:       config.subProfileURL,
 		subAnnounce:         config.subAnnounce,
 		subEnableRouting:    config.subEnableRouting,
@@ -485,8 +493,7 @@ func (a *SUBController) subs(c *gin.Context) {
 
 		// Add headers
 		header := subReq.subscriptionUserinfo(traffic)
-		profileURL := fmt.Sprintf("%s://%s%s", scheme, hostWithPort, c.Request.RequestURI)
-		metadata := a.metadataForSubRequest(func() *SubService { return subReq }, subId, profileURL)
+		metadata := a.metadataForSubRequest(func() *SubService { return subReq }, subId, builtinProfileURL(c, scheme, hostWithPort))
 		a.ApplyCommonHeaders(c, header, a.updateInterval, metadata.Title, metadata.SupportURL, metadata.ProfileURL, metadata.Announce, a.subEnableRouting, a.subRoutingRules, a.subHideSettings)
 
 		if a.subIncyEnableRouting && a.subIncyRoutingRules != "" {
@@ -818,14 +825,13 @@ func (a *SUBController) serveJsonBody(c *gin.Context, alwaysReturnArray bool, co
 	if len(jsonSub) == 0 && header == "" {
 		return false
 	}
-	profileURL := fmt.Sprintf("%s://%s%s", scheme, hostWithPort, c.Request.RequestURI)
 	var subReq *SubService
 	metadata := a.metadataForSubRequest(func() *SubService {
 		if subReq == nil {
 			subReq = a.subService.ForRequest(host)
 		}
 		return subReq
-	}, subId, profileURL)
+	}, subId, builtinProfileURL(c, scheme, hostWithPort))
 	a.ApplyCommonHeaders(c, header, a.updateInterval, metadata.Title, metadata.SupportURL, metadata.ProfileURL, metadata.Announce, a.subEnableRouting, a.subRoutingRules, a.subHideSettings)
 	if rawDownload {
 		c.Writer.Header().Set("Content-Disposition", `attachment; filename="subscription.json"`)
@@ -887,14 +893,13 @@ func (a *SUBController) serveClashBody(c *gin.Context, rawDownload bool, legacy
 	if len(clashSub) == 0 && header == "" {
 		return false
 	}
-	profileURL := fmt.Sprintf("%s://%s%s", scheme, hostWithPort, c.Request.RequestURI)
 	var subReq *SubService
 	metadata := a.metadataForSubRequest(func() *SubService {
 		if subReq == nil {
 			subReq = a.subService.ForRequest(host)
 		}
 		return subReq
-	}, subId, profileURL)
+	}, subId, builtinProfileURL(c, scheme, hostWithPort))
 	a.ApplyCommonHeaders(c, header, a.updateInterval, metadata.Title, metadata.SupportURL, metadata.ProfileURL, metadata.Announce, a.subEnableRouting, a.subRoutingRules, a.subHideSettings)
 	if rawDownload {
 		c.Writer.Header().Set("Content-Disposition", `attachment; filename="subscription.yaml"`)
@@ -906,6 +911,11 @@ func (a *SUBController) serveClashBody(c *gin.Context, rawDownload bool, legacy
 	return true
 }
 
+func builtinProfileURL(c *gin.Context, scheme, hostWithPort string) string {
+	// Drop download/format selectors so the opt-in link always opens the HTML page.
+	return fmt.Sprintf("%s://%s%s?html=1", scheme, hostWithPort, c.Request.URL.EscapedPath())
+}
+
 // ApplyCommonHeaders sets common HTTP headers for subscription responses including user info, update interval, and profile title.
 func (a *SUBController) ApplyCommonHeaders(
 	c *gin.Context,

+ 12 - 8
internal/sub/placeholders.go

@@ -10,6 +10,7 @@ import (
 	"github.com/mhsanaei/3x-ui/v3/internal/database"
 	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
 	"github.com/mhsanaei/3x-ui/v3/internal/logger"
+	"github.com/mhsanaei/3x-ui/v3/internal/web/service"
 )
 
 type subPlaceholderData struct {
@@ -76,10 +77,17 @@ func subMetadataUsesPlaceholders(values ...string) bool {
 	return false
 }
 
-func (a *SUBController) metadataForSubRequest(getSubReq func() *SubService, subID string, fallbackProfileURL string) renderedSubMetadata {
+func (a *SUBController) metadataForSubRequest(getSubReq func() *SubService, subID, builtinURL string) renderedSubMetadata {
+	profileURL := ""
+	switch a.subProfileMode {
+	case service.SubProfileModeBuiltin:
+		profileURL = builtinURL
+	case service.SubProfileModeCustom:
+		profileURL = strings.TrimSpace(a.subProfileUrl)
+	}
 	var context remarkContext
 	var hasContext bool
-	if subMetadataUsesPlaceholders(a.subTitle, a.subSupportUrl, a.subProfileUrl, a.subAnnounce) {
+	if subMetadataUsesPlaceholders(a.subTitle, a.subSupportUrl, profileURL, a.subAnnounce) {
 		var err error
 		subReq := getSubReq()
 		context, hasContext, err = subReq.subscriptionTemplateContextBySubID(subID)
@@ -87,12 +95,8 @@ func (a *SUBController) metadataForSubRequest(getSubReq func() *SubService, subI
 			logger.Warning("sub: load template contexts for subscription metadata:", err)
 		}
 	}
-	profileURL := a.subProfileUrl
-	if profileURL == "" {
-		profileURL = fallbackProfileURL
-	} else {
-		profileURL = renderSubPlaceholders(profileURL, subPlaceholderData{SubID: subID, Context: context, HasCtx: hasContext, Escape: true})
-	}
+	// Disabled modes ignore the retained custom URL and never fall back to the request URL.
+	profileURL = renderSubPlaceholders(profileURL, subPlaceholderData{SubID: subID, Context: context, HasCtx: hasContext, Escape: true})
 	data := subPlaceholderData{SubID: subID, Context: context, HasCtx: hasContext}
 	return renderedSubMetadata{
 		Title:      renderSubPlaceholders(a.subTitle, data),

+ 72 - 11
internal/sub/placeholders_test.go

@@ -1,10 +1,15 @@
 package sub
 
 import (
+	"net/http"
+	"net/http/httptest"
 	"testing"
 
+	"github.com/gin-gonic/gin"
+
 	"github.com/mhsanaei/3x-ui/v3/internal/database"
 	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
+	"github.com/mhsanaei/3x-ui/v3/internal/web/service"
 )
 
 func TestRenderSubPlaceholders(t *testing.T) {
@@ -68,20 +73,75 @@ func TestRenderSubPlaceholders(t *testing.T) {
 	}
 }
 
-func TestMetadataForSubRequestDoesNotExpandFallbackProfileURL(t *testing.T) {
+func TestMetadataForSubRequestOmitsUnconfiguredProfileURL(t *testing.T) {
 	a := &SUBController{
 		subTitle:      "isVPN",
 		subSupportUrl: "https://support.example/",
 	}
-	fallback := "https://sub.example.com/sub/sub-123?x={{EMAIL}}"
-
 	metadata := a.metadataForSubRequest(func() *SubService {
 		t.Fatal("metadataForSubRequest loaded a subscription context without configured placeholders")
 		return nil
-	}, "sub-123", fallback)
+	}, "sub-123", "https://sub.example/sub-123")
+
+	if metadata.ProfileURL != "" {
+		t.Fatalf("ProfileURL = %q, want no link when unconfigured", metadata.ProfileURL)
+	}
+}
 
-	if metadata.ProfileURL != fallback {
-		t.Fatalf("ProfileURL = %q, want untouched fallback %q", metadata.ProfileURL, fallback)
+func TestSubscriptionProfileURLRequiresExplicitConfiguration(t *testing.T) {
+	gin.SetMode(gin.TestMode)
+	initSubDB(t)
+	seedInfoEndpointSub(t, "profile-sub", "[email protected]")
+
+	for _, config := range []struct {
+		name, profileURL, want string
+	}{
+		{name: "empty"},
+		{name: "whitespace", profileURL: "   "},
+		{name: "explicit", profileURL: "https://portal.example.com/account", want: "https://portal.example.com/account"},
+		{name: "template", profileURL: "https://portal.example.com/account?sub={{SUB_ID}}", want: "https://portal.example.com/account?sub=profile-sub"},
+	} {
+		t.Run(config.name, func(t *testing.T) {
+			for _, client := range []struct {
+				name, userAgent string
+				happAutoDetect  bool
+			}{
+				{name: "Happ", userAgent: "Happ/3.22.0 (Android)", happAutoDetect: true},
+				{name: "Happ without auto-detection", userAgent: "Happ/3.22.0 (Android)"},
+				{name: "standard", userAgent: "v2rayNG/1.8.5", happAutoDetect: true},
+			} {
+				t.Run(client.name, func(t *testing.T) {
+					// All formats must honor the opt-in, independently of Happ customization.
+					router := gin.New()
+					NewSUBController(router.Group("/"),
+						WithSUBJsonEnabled(true), WithSUBClashEnabled(true),
+						WithSUBProfileURL(config.profileURL),
+						WithSUBProfileMode(service.SubProfileModeCustom),
+						WithSUBHappConfig(HappConfig{AutoDetect: client.happAutoDetect}),
+					)
+					for _, path := range []string{"/sub/profile-sub", "/json/profile-sub", "/clash/profile-sub"} {
+						t.Run(path, func(t *testing.T) {
+							req := httptest.NewRequest(http.MethodGet, path, nil)
+							req.Host = "sub.example.com"
+							req.Header.Set("User-Agent", client.userAgent)
+							resp := httptest.NewRecorder()
+							router.ServeHTTP(resp, req)
+							if resp.Code != http.StatusOK {
+								t.Fatalf("status = %d, want 200; body=%s", resp.Code, resp.Body.String())
+							}
+							if got := resp.Header().Get("Profile-Web-Page-Url"); got != config.want {
+								t.Fatalf("Profile-Web-Page-Url = %q, want %q", got, config.want)
+							}
+							if config.want == "" {
+								if _, present := resp.Header()["Profile-Web-Page-Url"]; present {
+									t.Fatal("unconfigured profile header must be omitted")
+								}
+							}
+						})
+					}
+				})
+			}
+		})
 	}
 }
 
@@ -110,12 +170,13 @@ func TestMetadataForSubRequestUsesStableClientIdentity(t *testing.T) {
 	}
 
 	a := &SUBController{
-		subTitle:      "isVPN — {{EMAIL}}",
-		subSupportUrl: "https://support.example/?email={{EMAIL}}&tg={{TELEGRAM_ID}}",
-		subProfileUrl: "https://profile.example/account/{{ID}}",
-		subAnnounce:   "Subscription {{SUB_ID}}",
+		subTitle:       "isVPN — {{EMAIL}}",
+		subSupportUrl:  "https://support.example/?email={{EMAIL}}&tg={{TELEGRAM_ID}}",
+		subProfileUrl:  "https://profile.example/account/{{ID}}",
+		subProfileMode: service.SubProfileModeCustom,
+		subAnnounce:    "Subscription {{SUB_ID}}",
 	}
-	metadata := a.metadataForSubRequest(func() *SubService { return &SubService{} }, "sub-123", "https://fallback.example/{{EMAIL}}")
+	metadata := a.metadataForSubRequest(func() *SubService { return &SubService{} }, "sub-123", "https://sub.example/sub-123")
 
 	if metadata.Title != "isVPN — john [email protected]" {
 		t.Fatalf("Title = %q", metadata.Title)

+ 97 - 0
internal/sub/profile_mode_test.go

@@ -0,0 +1,97 @@
+package sub
+
+import (
+	"net/http"
+	"net/http/httptest"
+	"strings"
+	"testing"
+
+	"github.com/gin-gonic/gin"
+
+	"github.com/mhsanaei/3x-ui/v3/internal/database"
+	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
+)
+
+func TestSubscriptionProfileModesFromSavedSettings(t *testing.T) {
+	oldFS, oldMode := distFS, gin.Mode()
+	oldWriter, oldErrorWriter := gin.DefaultWriter, gin.DefaultErrorWriter
+	SetDistFS(testDistFS)
+	t.Cleanup(func() {
+		SetDistFS(oldFS)
+		gin.SetMode(oldMode)
+		gin.DefaultWriter, gin.DefaultErrorWriter = oldWriter, oldErrorWriter
+	})
+	for _, config := range []struct {
+		name, mode, profileURL, want string
+	}{
+		{name: "new installation"},
+		{name: "legacy whitespace", profileURL: "   "},
+		{name: "legacy custom", profileURL: "https://portal.example/account", want: "https://portal.example/account"},
+		{name: "none retains custom", mode: "none", profileURL: "https://portal.example/account"},
+		{name: "builtin", mode: "builtin", profileURL: "https://portal.example/account"},
+		{name: "custom", mode: "custom", profileURL: "https://portal.example/?sub={{SUB_ID}}", want: "https://portal.example/?sub=profile-sub"},
+		{name: "empty custom", mode: "custom"},
+		{name: "invalid mode", mode: "invalid", profileURL: "https://portal.example/account"},
+	} {
+		t.Run(config.name, func(t *testing.T) {
+			initSubDB(t)
+			seedInfoEndpointSub(t, "profile-sub", "[email protected]")
+			settings := []model.Setting{
+				{Key: "subPath", Value: "/sub/"},
+				{Key: "subJsonPath", Value: "/json/"},
+				{Key: "subClashPath", Value: "/clash/"},
+				{Key: "subJsonEnable", Value: "true"},
+				{Key: "subClashEnable", Value: "true"},
+				{Key: "subProfileUrl", Value: config.profileURL},
+			}
+			if config.mode != "" {
+				settings = append(settings, model.Setting{Key: "subProfileMode", Value: config.mode})
+			}
+			for _, setting := range settings {
+				if err := database.GetDB().Where("key = ?", setting.Key).Delete(&model.Setting{}).Error; err != nil {
+					t.Fatal(err)
+				}
+				if err := database.GetDB().Create(&setting).Error; err != nil {
+					t.Fatal(err)
+				}
+			}
+			router, err := (&Server{}).initRouter()
+			if err != nil {
+				t.Fatal(err)
+			}
+			for _, path := range []string{"/sub/profile-sub", "/json/profile-sub", "/clash/profile-sub", "/json/profile-sub?view=raw", "/clash/profile-sub?view=raw", "/mihomo/profile-sub"} {
+				for _, userAgent := range []string{"Happ/3.22.0 (Android)", "v2rayNG/1.8.5"} {
+					t.Run(path+"/"+userAgent, func(t *testing.T) {
+						req := httptest.NewRequest(http.MethodGet, "https://sub.example.com:8443"+path, nil)
+						req.Header.Set("User-Agent", userAgent)
+						resp := httptest.NewRecorder()
+						router.ServeHTTP(resp, req)
+						if resp.Code != http.StatusOK {
+							t.Fatalf("status = %d; body=%s", resp.Code, resp.Body.String())
+						}
+						want := config.want
+						if config.mode == "builtin" {
+							want = "https://sub.example.com:8443" + req.URL.EscapedPath() + "?html=1"
+						}
+						if got := resp.Header().Get("Profile-Web-Page-Url"); got != want {
+							t.Fatalf("Profile-Web-Page-Url = %q, want %q", got, want)
+						}
+						if want == "" {
+							if _, present := resp.Header()["Profile-Web-Page-Url"]; present {
+								t.Fatal("disabled profile header must be absent")
+							}
+						}
+						if config.mode == "builtin" {
+							// The restored link must open the page, even when copied from a raw download.
+							page := httptest.NewRecorder()
+							router.ServeHTTP(page, httptest.NewRequest(http.MethodGet, want, nil))
+							if page.Code != http.StatusOK || !strings.Contains(page.Header().Get("Content-Type"), "text/html") {
+								t.Fatalf("builtin link did not serve HTML: status=%d, type=%q", page.Code, page.Header().Get("Content-Type"))
+							}
+						}
+					})
+				}
+			}
+		})
+	}
+}

+ 5 - 0
internal/sub/sub.go

@@ -194,6 +194,10 @@ func (s *Server) initRouter() (*gin.Engine, error) {
 	if err != nil {
 		SubProfileUrl = ""
 	}
+	SubProfileMode, err := s.settingService.GetSubProfileMode()
+	if err != nil {
+		SubProfileMode = service.SubProfileModeNone
+	}
 
 	SubAnnounce, err := s.settingService.GetSubAnnounce()
 	if err != nil {
@@ -329,6 +333,7 @@ func (s *Server) initRouter() (*gin.Engine, error) {
 		WithSUBTitle(SubTitle),
 		WithSUBSupportURL(SubSupportUrl),
 		WithSUBProfileURL(SubProfileUrl),
+		WithSUBProfileMode(SubProfileMode),
 		WithSUBAnnounce(SubAnnounce),
 		WithSUBEnableRouting(SubEnableRouting),
 		WithSUBRoutingRules(SubRoutingRules),

+ 22 - 0
internal/util/totp/totp.go

@@ -0,0 +1,22 @@
+package totp
+
+import (
+	"time"
+
+	"github.com/xlzd/gotp"
+)
+
+// SkewWindows is how many 30s steps around now VerifyWithSkew accepts.
+// Standard TOTP clock-drift tolerance, see MHSanaei/3x-ui#6535.
+const SkewWindows = 1
+
+// VerifyWithSkew accepts the code for the current step plus/minus SkewWindows.
+func VerifyWithSkew(secret, code string, now time.Time) bool {
+	totp := gotp.NewDefaultTOTP(secret)
+	for i := -SkewWindows; i <= SkewWindows; i++ {
+		if totp.AtTime(now.Add(time.Duration(i*30)*time.Second)) == code {
+			return true
+		}
+	}
+	return false
+}

+ 34 - 0
internal/util/totp/totp_test.go

@@ -0,0 +1,34 @@
+package totp
+
+import (
+	"testing"
+	"time"
+
+	"github.com/xlzd/gotp"
+)
+
+func TestVerifyWithSkew(t *testing.T) {
+	secret := "JBSWY3DPEHPK3PXP"
+	totp := gotp.NewDefaultTOTP(secret)
+	// Anchor mid-window so a step boundary can't fall between sampling and verify.
+	now := time.Unix((time.Now().Unix()/30)*30+15, 0).UTC()
+
+	if !VerifyWithSkew(secret, totp.AtTime(now), now) {
+		t.Fatal("current window code should verify")
+	}
+	if !VerifyWithSkew(secret, totp.AtTime(now.Add(-30*time.Second)), now) {
+		t.Fatal("previous window code should verify (clock skew)")
+	}
+	if !VerifyWithSkew(secret, totp.AtTime(now.Add(30*time.Second)), now) {
+		t.Fatal("next window code should verify (clock skew)")
+	}
+	if VerifyWithSkew(secret, totp.AtTime(now.Add(-60*time.Second)), now) {
+		t.Fatal("code two windows old should not verify")
+	}
+	if VerifyWithSkew(secret, totp.AtTime(now.Add(60*time.Second)), now) {
+		t.Fatal("code two windows ahead should not verify")
+	}
+	if VerifyWithSkew(secret, "000000", now) {
+		t.Fatal("wrong code should not verify")
+	}
+}

+ 1 - 0
internal/web/entity/entity.go

@@ -94,6 +94,7 @@ type AllSetting struct {
 	SubClashUserAgentRegex      string `json:"subClashUserAgentRegex" form:"subClashUserAgentRegex"`
 	SubTitle                    string `json:"subTitle" form:"subTitle"`
 	SubSupportUrl               string `json:"subSupportUrl" form:"subSupportUrl"`
+	SubProfileMode              string `json:"subProfileMode" form:"subProfileMode"`
 	SubProfileUrl               string `json:"subProfileUrl" form:"subProfileUrl"`
 	SubAnnounce                 string `json:"subAnnounce" form:"subAnnounce"`
 	SubEnableRouting            bool   `json:"subEnableRouting" form:"subEnableRouting"`

+ 15 - 3
internal/web/job/check_client_ip_job.go

@@ -612,7 +612,8 @@ func (j *CheckClientIpJob) filterAdvancedSinceLastBan(email string, banned []IPW
 	return actionable
 }
 
-// disconnectClientTemporarily removes and re-adds a client to force disconnect banned connections
+// disconnectClientTemporarily drops a client's credential for a moment, so new
+// handshakes are refused; the fail2ban ban is what ends live traffic.
 func (j *CheckClientIpJob) disconnectClientTemporarily(inbound *model.Inbound, clientEmail string, clients []model.Client) {
 	var xrayAPI xray.XrayAPI
 	apiPort := j.resolveXrayAPIPort()
@@ -626,11 +627,13 @@ func (j *CheckClientIpJob) disconnectClientTemporarily(inbound *model.Inbound, c
 
 	// Find the client config
 	var clientConfig map[string]any
+	var reverseClient bool
 	for _, client := range clients {
 		if client.Email == clientEmail {
 			// Convert client to map for API
 			clientBytes, _ := json.Marshal(client)
 			_ = json.Unmarshal(clientBytes, &clientConfig)
+			reverseClient = client.Reverse != nil
 			break
 		}
 	}
@@ -650,6 +653,13 @@ func (j *CheckClientIpJob) disconnectClientTemporarily(inbound *model.Inbound, c
 		return
 	}
 
+	// RemoveUser drops a reverse client's outbound handler and the re-add below
+	// cannot restore it, so its tunnel would stay down until Xray restarts.
+	if reverseClient {
+		logger.Warningf("[LIMIT_IP] Not disconnecting %s: its reverse proxy config does not survive a temporary removal", clientEmail)
+		return
+	}
+
 	// For Shadowsocks, ensure the required "cipher" field is present by
 	// reading it from the inbound settings (e.g., settings["method"]).
 	if string(inbound.Protocol) == "shadowsocks" {
@@ -663,14 +673,16 @@ func (j *CheckClientIpJob) disconnectClientTemporarily(inbound *model.Inbound, c
 		}
 	}
 
-	// Remove user to disconnect all connections
+	// The core's RemoveUser clears its validator: a session already up keeps
+	// running, except a reverse vless client, which is skipped above.
 	err = xrayAPI.RemoveUser(inbound.Tag, clientEmail)
 	if err != nil {
 		logger.Warningf("[LIMIT_IP] Failed to remove user %s: %v", clientEmail, err)
 		return
 	}
 
-	// Wait a moment for disconnection to take effect
+	// Nothing is pending here: AlterInbound applies the removal inline, so this
+	// only widens the window in which new handshakes fail.
 	time.Sleep(100 * time.Millisecond)
 
 	// Re-add user to allow new connections

+ 147 - 0
internal/web/job/ldap_sync_hung_node_test.go

@@ -0,0 +1,147 @@
+package job
+
+import (
+	"encoding/json"
+	"fmt"
+	"io"
+	"net/http"
+	"net/http/httptest"
+	"strconv"
+	"strings"
+	"testing"
+	"time"
+
+	"github.com/mhsanaei/3x-ui/v3/internal/database"
+	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
+	"github.com/mhsanaei/3x-ui/v3/internal/web/runtime"
+	"github.com/mhsanaei/3x-ui/v3/internal/xray"
+)
+
+// ldapHungNodeInbound seeds an online node whose client writes hang until the gate
+// opens, plus one inbound on it holding the given enabled clients.
+func ldapHungNodeInbound(t *testing.T, emails []string) (*resetGate, *model.Inbound) {
+	t.Helper()
+	initLdapJobDB(t)
+	runtime.SetManager(runtime.NewManager(runtime.LocalDeps{APIPort: func() int { return 0 }, SetNeedRestart: func() {}}))
+	t.Cleanup(func() { runtime.SetManager(nil) })
+	gate := &resetGate{release: make(chan struct{})}
+	const tag = "ldap-node-in"
+	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		_, _ = io.Copy(io.Discard, r.Body)
+		w.Header().Set("Content-Type", "application/json")
+		if r.Method == http.MethodGet && strings.HasSuffix(r.URL.Path, "inbounds/list") {
+			_, _ = w.Write([]byte(`{"success":true,"obj":[{"id":1,"tag":"` + tag + `"}]}`))
+			return
+		}
+		if r.Method == http.MethodPost {
+			gate.entered.Add(1)
+			select {
+			case <-r.Context().Done():
+			case <-gate.release:
+			}
+		}
+		_, _ = w.Write([]byte(`{"success":true}`))
+	}))
+	t.Cleanup(srv.Close)
+	t.Cleanup(gate.open)
+	host, port, _ := strings.Cut(strings.TrimPrefix(srv.URL, "http://"), ":")
+	portNum, _ := strconv.Atoi(port)
+	db := database.GetDB()
+	node := &model.Node{
+		Name: "ldap-node", Scheme: "http", Address: host, Port: portNum, BasePath: "/", ApiToken: "tok",
+		Enable: true, Status: "online", AllowPrivateAddress: true, TlsVerifyMode: "verify",
+	}
+	if err := db.Create(node).Error; err != nil {
+		t.Fatalf("create node: %v", err)
+	}
+	clients := make([]model.Client, 0, len(emails))
+	for i, email := range emails {
+		clients = append(clients, model.Client{Email: email, ID: fmt.Sprintf("00000000-0000-4000-8000-%012d", i), Enable: true})
+	}
+	settings, _ := json.Marshal(map[string]any{"clients": clients, "decryption": "none"})
+	ib := &model.Inbound{
+		UserId: 1, Enable: true, Port: 47200, Protocol: model.VLESS, NodeID: &node.Id,
+		Tag: tag, Settings: string(settings), StreamSettings: `{"network":"tcp"}`,
+	}
+	if err := db.Create(ib).Error; err != nil {
+		t.Fatalf("create inbound: %v", err)
+	}
+	for _, c := range clients {
+		rec := model.ClientRecord{Email: c.Email, UUID: c.ID, Enable: true}
+		if err := db.Create(&rec).Error; err != nil {
+			t.Fatalf("create client record: %v", err)
+		}
+		if err := db.Create(&model.ClientInbound{ClientId: rec.Id, InboundId: ib.Id}).Error; err != nil {
+			t.Fatalf("link client: %v", err)
+		}
+		if err := db.Create(&xray.ClientTraffic{InboundId: ib.Id, Email: c.Email, Enable: true}).Error; err != nil {
+			t.Fatalf("create client traffic: %v", err)
+		}
+	}
+	return gate, ib
+}
+
+func inboundClientEnables(t *testing.T, inboundID int) map[string]bool {
+	t.Helper()
+	var ib model.Inbound
+	if err := database.GetDB().First(&ib, inboundID).Error; err != nil {
+		t.Fatalf("reload inbound: %v", err)
+	}
+	var settings struct {
+		Clients []model.Client `json:"clients"`
+	}
+	if err := json.Unmarshal([]byte(ib.Settings), &settings); err != nil {
+		t.Fatalf("parse settings: %v", err)
+	}
+	out := make(map[string]bool, len(settings.Clients))
+	for _, c := range settings.Clients {
+		out[c.Email] = c.Enable
+	}
+	return out
+}
+
+// Each LDAP user was disabled on its own, and every per-user push held the inbound
+// lock for the push timeout, so users sharing a hung node inbound queued behind it.
+func TestLdapBatchSetEnableDoesNotQueueUsersOnHungNode(t *testing.T) {
+	emails := []string{"u1@ldap", "u2@ldap", "u3@ldap", "u4@ldap", "u5@ldap"}
+	gate, ib := ldapHungNodeInbound(t, emails)
+
+	done := make(chan struct{})
+	go func() {
+		defer close(done)
+		NewLdapSyncJob().batchSetEnable(emails, false)
+	}()
+	t.Cleanup(func() { gate.open(); <-done })
+	select {
+	case <-done:
+	case <-time.After(9 * time.Second):
+		t.Fatalf("disabling %d LDAP users on one hung node inbound took over 9s (%d pushes started)", len(emails), gate.entered.Load())
+	}
+	for email, enabled := range inboundClientEnables(t, ib.Id) {
+		if enabled {
+			t.Errorf("client %s still enabled after the LDAP disable", email)
+		}
+	}
+}
+
+// Clients missing from LDAP were detached one at a time, each waiting out the push
+// timeout on a hung node inbound, so a directory cleanup could run for hours.
+func TestLdapDeleteDoesNotQueueClientsOnHungNode(t *testing.T) {
+	emails := []string{"gone1@ldap", "gone2@ldap", "gone3@ldap", "gone4@ldap", "gone5@ldap"}
+	gate, ib := ldapHungNodeInbound(t, emails)
+
+	done := make(chan struct{})
+	go func() {
+		defer close(done)
+		NewLdapSyncJob().deleteClientsNotInLDAP(ib.Tag, map[string]struct{}{})
+	}()
+	t.Cleanup(func() { gate.open(); <-done })
+	select {
+	case <-done:
+	case <-time.After(9 * time.Second):
+		t.Fatalf("detaching %d clients from one hung node inbound took over 9s (%d pushes started)", len(emails), gate.entered.Load())
+	}
+	if left := inboundClientEnables(t, ib.Id); len(left) != 0 {
+		t.Errorf("clients still on the inbound after the LDAP cleanup: %v", left)
+	}
+}

+ 40 - 51
internal/web/job/ldap_sync_job.go

@@ -131,8 +131,7 @@ func (j *LdapSyncJob) Run() {
 	}
 
 	clientsToCreate := []model.Client{}
-	clientsToEnable := map[string][]string{}  // tag -> []email
-	clientsToDisable := map[string][]string{} // tag -> []email
+	var clientsToEnable, clientsToDisable []string
 
 	for email, allowed := range flags {
 		existing := allClients[email]
@@ -142,24 +141,21 @@ func (j *LdapSyncJob) Run() {
 			}
 			continue
 		}
-		for _, tag := range resolvedTags {
-			if allowed && !existing.Enable {
-				clientsToEnable[tag] = append(clientsToEnable[tag], email)
-			} else if !allowed && existing.Enable {
-				clientsToDisable[tag] = append(clientsToDisable[tag], email)
-			}
+		if len(resolvedTags) == 0 {
+			continue
+		}
+		if allowed && !existing.Enable {
+			clientsToEnable = append(clientsToEnable, email)
+		} else if !allowed && existing.Enable {
+			clientsToDisable = append(clientsToDisable, email)
 		}
 	}
 
 	j.createClients(clientsToCreate, resolvedInboundIds, resolvedTags)
 
 	// --- Execute enable/disable batch ---
-	for tag, emails := range clientsToEnable {
-		j.batchSetEnable(inboundMap[tag], emails, true)
-	}
-	for tag, emails := range clientsToDisable {
-		j.batchSetEnable(inboundMap[tag], emails, false)
-	}
+	j.batchSetEnable(clientsToEnable, true)
+	j.batchSetEnable(clientsToDisable, false)
 
 	// --- Auto delete clients not in LDAP ---
 	autoDelete := mustGetBool(j.settingService.GetLdapAutoDelete)
@@ -257,34 +253,28 @@ func (j *LdapSyncJob) createClients(newClients []model.Client, inboundIds []int,
 	logger.Infof("LDAP auto-create: %d clients for %s", created, tagList)
 }
 
-func (j *LdapSyncJob) batchSetEnable(ib *model.Inbound, emails []string, enable bool) {
+// batchSetEnable takes the bulk path: per-user calls held each inbound's lock through
+// its node push, so users sharing a hung node inbound queued one push timeout apiece.
+func (j *LdapSyncJob) batchSetEnable(emails []string, enable bool) {
 	if len(emails) == 0 {
 		return
 	}
-	restartNeeded := false
-	changed := 0
-	for _, email := range emails {
-		ok, needRestart, err := j.clientService.SetClientEnableByEmail(&j.inboundService, email, enable)
-		if err != nil {
-			logger.Warningf("Batch set enable failed for %s in inbound %s: %v", email, ib.Tag, err)
-			continue
-		}
-		if ok {
-			changed++
-		}
-		if needRestart {
-			restartNeeded = true
-		}
+	result, needRestart, err := j.clientService.BulkSetEnable(&j.inboundService, emails, enable)
+	if err != nil {
+		logger.Warningf("Batch set enable=%v failed: %v", enable, err)
 	}
-	if changed > 0 {
-		logger.Infof("Batch set enable=%v for %d clients in inbound %s", enable, changed, ib.Tag)
+	for _, skipped := range result.Skipped {
+		logger.Warningf("Batch set enable failed for %s: %s", skipped.Email, skipped.Reason)
 	}
-	if restartNeeded {
+	if result.Changed > 0 {
+		logger.Infof("Batch set enable=%v for %d clients", enable, result.Changed)
+	}
+	if needRestart {
 		j.xrayService.SetToNeedRestart()
 	}
 }
 
-// deleteClientsNotInLDAP deletes clients not in LDAP using batches and a single restart
+// deleteClientsNotInLDAP detaches clients not in LDAP, one bulk detach per inbound
 func (j *LdapSyncJob) deleteClientsNotInLDAP(inboundTag string, ldapEmails map[string]struct{}) {
 	inbounds, err := j.inboundService.GetAllInbounds()
 	if err != nil {
@@ -292,7 +282,6 @@ func (j *LdapSyncJob) deleteClientsNotInLDAP(inboundTag string, ldapEmails map[s
 		return
 	}
 
-	batchSize := 50 //  clients in 1 batch
 	restartNeeded := false
 
 	for _, ib := range inbounds {
@@ -317,23 +306,23 @@ func (j *LdapSyncJob) deleteClientsNotInLDAP(inboundTag string, ldapEmails map[s
 			continue
 		}
 
-		for i := 0; i < len(toDelete); i += batchSize {
-			end := min(i+batchSize, len(toDelete))
-			batch := toDelete[i:end]
-
-			for _, c := range batch {
-				nr, err := j.clientService.DetachByEmail(&j.inboundService, ib.Id, c.Email)
-				if err != nil {
-					logger.Warningf("Failed to delete client %s from inbound id=%d(tag=%s): %v",
-						c.Email, ib.Id, ib.Tag, err)
-					continue
-				}
-				logger.Infof("Deleted client %s from inbound id=%d(tag=%s)",
-					c.Email, ib.Id, ib.Tag)
-				if nr {
-					restartNeeded = true
-				}
-			}
+		emails := make([]string, len(toDelete))
+		for i, c := range toDelete {
+			emails[i] = c.Email
+		}
+		result, nr, err := j.clientService.BulkDetach(&j.inboundService, emails, []int{ib.Id})
+		if err != nil {
+			logger.Warningf("Failed to delete clients from inbound id=%d(tag=%s): %v", ib.Id, ib.Tag, err)
+			continue
+		}
+		for _, msg := range result.Errors {
+			logger.Warningf("Failed to delete client from inbound id=%d(tag=%s): %s", ib.Id, ib.Tag, msg)
+		}
+		for _, email := range result.Detached {
+			logger.Infof("Deleted client %s from inbound id=%d(tag=%s)", email, ib.Id, ib.Tag)
+		}
+		if nr {
+			restartNeeded = true
 		}
 	}
 

+ 33 - 0
internal/web/job/limit_ip_disconnect_test.go

@@ -8,6 +8,39 @@ import (
 	"github.com/mhsanaei/3x-ui/v3/internal/logger"
 )
 
+// A reverse client has to be left alone: the cycle drops its reverse outbound
+// handler and re-adds an account without a reverse, which no retry restores.
+func TestDisconnectClientTemporarilySkipsReverseClient(t *testing.T) {
+	setupIntegrationDB(t)
+
+	const email = "rev-limit-probe"
+	inbound := &model.Inbound{
+		Id:       1,
+		Protocol: model.VLESS,
+		Tag:      "rev-limit-probe-tag",
+		Settings: `{"clients":[]}`,
+	}
+	clients := []model.Client{{Email: email, ID: "11111111-1111-1111-1111-111111111111", Reverse: &model.ClientReverse{Tag: "rev-out"}}}
+
+	(&CheckClientIpJob{}).disconnectClientTemporarily(inbound, email, clients)
+
+	var skipped, attempted bool
+	for _, line := range logger.GetLogs(500, "warning") {
+		if strings.Contains(line, "Not disconnecting "+email) {
+			skipped = true
+		}
+		if strings.Contains(line, "Failed to remove user "+email) {
+			attempted = true
+		}
+	}
+	if attempted {
+		t.Fatal("a reverse client must not be removed and re-added")
+	}
+	if !skipped {
+		t.Fatal("the skip must be reported, not silent")
+	}
+}
+
 // The protocol gate must let hysteria through: XrayAPI supports it, and the
 // skip left over-limit Hysteria2 sessions alive until the fail2ban ban caught up.
 func TestDisconnectClientTemporarilyAllowsHysteria(t *testing.T) {

+ 98 - 0
internal/web/job/node_heartbeat_descendants_test.go

@@ -0,0 +1,98 @@
+package job
+
+import (
+	"net/http"
+	"net/http/httptest"
+	"path/filepath"
+	"strconv"
+	"strings"
+	"testing"
+
+	"github.com/op/go-logging"
+
+	"github.com/mhsanaei/3x-ui/v3/internal/database"
+	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
+	xuilogger "github.com/mhsanaei/3x-ui/v3/internal/logger"
+	"github.com/mhsanaei/3x-ui/v3/internal/web/runtime"
+	"github.com/mhsanaei/3x-ui/v3/internal/web/service"
+)
+
+func transitiveGuids(t *testing.T) []string {
+	t.Helper()
+	tree, err := (&service.NodeService{}).GetNodeTree()
+	if err != nil {
+		t.Fatalf("GetNodeTree: %v", err)
+	}
+	var out []string
+	for _, n := range tree {
+		if n.Transitive {
+			out = append(out, n.Guid)
+		}
+	}
+	return out
+}
+
+// The heartbeat skips a disabled node and never sees a deleted one, so the
+// sub-nodes it had learned from them stayed on the Nodes page for good.
+func TestHeartbeatDropsSubNodesOfNodesItNoLongerProbes(t *testing.T) {
+	cases := []struct {
+		name   string
+		retire func(t *testing.T, nodeID int)
+	}{
+		{"disabled", func(t *testing.T, nodeID int) {
+			if err := (&service.NodeService{}).SetEnable(nodeID, false); err != nil {
+				t.Fatalf("SetEnable: %v", err)
+			}
+		}},
+		{"deleted", func(t *testing.T, nodeID int) {
+			if err := (&service.NodeService{}).Delete(nodeID); err != nil {
+				t.Fatalf("Delete: %v", err)
+			}
+		}},
+	}
+	for _, tc := range cases {
+		t.Run(tc.name, func(t *testing.T) {
+			xuilogger.InitLogger(logging.ERROR)
+			if err := database.InitDB(filepath.Join(t.TempDir(), "x-ui.db")); err != nil {
+				t.Fatalf("InitDB: %v", err)
+			}
+			t.Cleanup(func() { _ = database.CloseDB() })
+			runtime.SetManager(runtime.NewManager(runtime.LocalDeps{APIPort: func() int { return 0 }, SetNeedRestart: func() {}}))
+			t.Cleanup(func() { runtime.SetManager(nil) })
+
+			srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+				w.Header().Set("Content-Type", "application/json")
+				switch {
+				case strings.HasSuffix(r.URL.Path, "server/status"):
+					_, _ = w.Write([]byte(`{"success":true,"obj":{"panelGuid":"direct-guid","xray":{"state":"running"}}}`))
+				case strings.HasSuffix(r.URL.Path, "server/descendants"):
+					_, _ = w.Write([]byte(`{"success":true,"obj":[{"guid":"sub-guid","parentGuid":"direct-guid","name":"sub","status":"online"}]}`))
+				default:
+					_, _ = w.Write([]byte(`{"success":true}`))
+				}
+			}))
+			t.Cleanup(srv.Close)
+			host, port, _ := strings.Cut(strings.TrimPrefix(srv.URL, "http://"), ":")
+			portNum, _ := strconv.Atoi(port)
+			node := &model.Node{
+				Name: "direct", Scheme: "http", Address: host, Port: portNum, BasePath: "/", ApiToken: "tok",
+				Enable: true, Status: "unknown", AllowPrivateAddress: true, TlsVerifyMode: "verify",
+			}
+			if err := database.GetDB().Create(node).Error; err != nil {
+				t.Fatalf("create node: %v", err)
+			}
+
+			hb := NewNodeHeartbeatJob()
+			hb.Run()
+			if got := transitiveGuids(t); len(got) != 1 || got[0] != "sub-guid" {
+				t.Fatalf("sub-nodes after first heartbeat = %v, want [sub-guid]", got)
+			}
+
+			tc.retire(t, node.Id)
+			hb.Run()
+			if got := transitiveGuids(t); len(got) != 0 {
+				t.Fatalf("sub-nodes after the node was %s = %v, want none", tc.name, got)
+			}
+		})
+	}
+}

+ 53 - 13
internal/web/job/node_heartbeat_job.go

@@ -2,7 +2,10 @@ package job
 
 import (
 	"context"
+	"fmt"
+	"sort"
 	"strconv"
+	"strings"
 	"sync"
 	"time"
 
@@ -17,6 +20,10 @@ import (
 const (
 	nodeHeartbeatConcurrency    = 32
 	nodeHeartbeatRequestTimeout = 4 * time.Second
+	// Past this many same-direction transitions in one tick, one summary event goes out:
+	// per-node events overflow the notifier queues and every chat's rate limit.
+	nodeTransitionBurst      = 5
+	nodeTransitionBurstNames = 10
 )
 
 type NodeHeartbeatJob struct {
@@ -39,12 +46,15 @@ func (j *NodeHeartbeatJob) Run() {
 		logger.Warning("node heartbeat: load nodes failed:", err)
 		return
 	}
+	j.nodeService.RetainEnabledNodeDescendants(nodes)
 	if len(nodes) == 0 {
 		return
 	}
 
 	sem := make(chan struct{}, nodeHeartbeatConcurrency)
 	var wg sync.WaitGroup
+	var transitionsMu sync.Mutex
+	var transitions []eventbus.Event
 	for _, n := range nodes {
 		if !n.Enable {
 			continue
@@ -55,10 +65,15 @@ func (j *NodeHeartbeatJob) Run() {
 		common.GoRecover("node-heartbeat:"+n.Name, func() {
 			defer wg.Done()
 			defer func() { <-sem }()
-			j.probeOne(n)
+			if event := j.probeOne(n); event != nil {
+				transitionsMu.Lock()
+				transitions = append(transitions, *event)
+				transitionsMu.Unlock()
+			}
 		})
 	}
 	wg.Wait()
+	publishNodeTransitions(transitions)
 
 	if !websocket.HasClients() {
 		return
@@ -71,7 +86,7 @@ func (j *NodeHeartbeatJob) Run() {
 	websocket.BroadcastNodes(updated)
 }
 
-func (j *NodeHeartbeatJob) probeOne(n *model.Node) {
+func (j *NodeHeartbeatJob) probeOne(n *model.Node) *eventbus.Event {
 	ctx, cancel := context.WithTimeout(context.Background(), nodeHeartbeatRequestTimeout)
 	defer cancel()
 	prevStatus := n.Status
@@ -84,7 +99,6 @@ func (j *NodeHeartbeatJob) probeOne(n *model.Node) {
 	if updErr := j.nodeService.UpdateHeartbeat(n.Id, patch); updErr != nil {
 		logger.Warning("node heartbeat: update node", n.Id, "failed:", updErr)
 	}
-	publishNodeTransition(n, prevStatus, patch)
 	// Learn the nodes this node manages so the panel can surface them as
 	// transitive sub-nodes (#4983). Fresh context — the probe budget above may
 	// be spent. Drop them when the node is unreachable.
@@ -95,15 +109,12 @@ func (j *NodeHeartbeatJob) probeOne(n *model.Node) {
 	} else {
 		j.nodeService.ClearDescendants(n.Id)
 	}
+	return nodeTransitionEvent(n, prevStatus, patch)
 }
 
-// publishNodeTransition emits node.down / node.up only on a genuine state change.
-// An "unknown"/empty previous status (fresh start) is treated as not-online, so a
-// node coming up for the first time fires node.up but never a spurious node.down.
-func publishNodeTransition(n *model.Node, prevStatus string, patch service.HeartbeatPatch) {
-	if EventBus == nil {
-		return
-	}
+// nodeTransitionEvent is node.down / node.up on a genuine state change only; an unknown
+// previous status (fresh start) counts as not-online, so it never yields node.down.
+func nodeTransitionEvent(n *model.Node, prevStatus string, patch service.HeartbeatPatch) *eventbus.Event {
 	var eventType eventbus.EventType
 	switch {
 	case prevStatus == "online" && patch.Status == "offline":
@@ -111,13 +122,13 @@ func publishNodeTransition(n *model.Node, prevStatus string, patch service.Heart
 	case prevStatus != "online" && patch.Status == "online":
 		eventType = eventbus.EventNodeUp
 	default:
-		return
+		return nil
 	}
 	source := n.Name
 	if source == "" {
 		source = "node-" + strconv.Itoa(n.Id)
 	}
-	EventBus.Publish(eventbus.Event{
+	return &eventbus.Event{
 		Type:   eventType,
 		Source: source,
 		Data: &eventbus.NodeHealthData{
@@ -128,5 +139,34 @@ func publishNodeTransition(n *model.Node, prevStatus string, patch service.Heart
 			XrayState: patch.XrayState,
 			XrayError: patch.XrayError,
 		},
-	})
+	}
+}
+
+// publishNodeTransitions sends one tick's transitions, folding a same-direction burst
+// (a master-side blip flips every node at once) into one event naming the nodes.
+func publishNodeTransitions(events []eventbus.Event) {
+	if EventBus == nil {
+		return
+	}
+	namesByType := make(map[eventbus.EventType][]string)
+	for _, e := range events {
+		namesByType[e.Type] = append(namesByType[e.Type], e.Source)
+	}
+	for _, e := range events {
+		if len(namesByType[e.Type]) <= nodeTransitionBurst {
+			EventBus.Publish(e)
+		}
+	}
+	for _, eventType := range []eventbus.EventType{eventbus.EventNodeDown, eventbus.EventNodeUp} {
+		names := namesByType[eventType]
+		if len(names) <= nodeTransitionBurst {
+			continue
+		}
+		sort.Strings(names)
+		source := strings.Join(names[:min(len(names), nodeTransitionBurstNames)], ", ")
+		if extra := len(names) - nodeTransitionBurstNames; extra > 0 {
+			source += fmt.Sprintf(" (+%d)", extra)
+		}
+		EventBus.Publish(eventbus.Event{Type: eventType, Source: source})
+	}
 }

+ 88 - 0
internal/web/job/node_traffic_sync_ip_push_test.go

@@ -0,0 +1,88 @@
+package job
+
+import (
+	"encoding/json"
+	"fmt"
+	"net/http"
+	"net/http/httptest"
+	"path/filepath"
+	"slices"
+	"strconv"
+	"strings"
+	"sync"
+	"testing"
+	"time"
+
+	"github.com/op/go-logging"
+
+	"github.com/mhsanaei/3x-ui/v3/internal/database"
+	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
+	xuilogger "github.com/mhsanaei/3x-ui/v3/internal/logger"
+	"github.com/mhsanaei/3x-ui/v3/internal/web/runtime"
+	"github.com/mhsanaei/3x-ui/v3/internal/web/service"
+)
+
+// A node's IP-limit job only reads rows for its own clients, so pushing the
+// whole table made every node store and echo back the entire fleet's IPs.
+func TestNodeTrafficSyncPushesOnlyHostedClientIps(t *testing.T) {
+	xuilogger.InitLogger(logging.ERROR)
+	if err := database.InitDB(filepath.Join(t.TempDir(), "x-ui.db")); err != nil {
+		t.Fatalf("InitDB: %v", err)
+	}
+	t.Cleanup(func() { _ = database.CloseDB() })
+	service.StartTrafficWriter()
+	t.Cleanup(service.StopTrafficWriter)
+	runtime.SetManager(runtime.NewManager(runtime.LocalDeps{APIPort: func() int { return 0 }, SetNeedRestart: func() {}}))
+	t.Cleanup(func() { runtime.SetManager(nil) })
+
+	var mu sync.Mutex
+	pushed := map[string][]string{}
+	now := time.Now().Unix()
+	for i, email := range []string{"a@node", "b@node"} {
+		srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+			w.Header().Set("Content-Type", "application/json")
+			switch {
+			case strings.HasSuffix(r.URL.Path, "inbounds/list"):
+				settings := fmt.Sprintf(`{"clients":[{"email":%q,"id":"0000000%d-0000-4000-8000-000000000000","enable":true}],"decryption":"none"}`, email, i)
+				ib, _ := json.Marshal([]map[string]any{{
+					"id": 1, "tag": fmt.Sprintf("in-%d", 20000+i), "port": 20000 + i, "protocol": "vless", "enable": true,
+					"settings": settings, "streamSettings": `{"network":"tcp"}`, "sniffing": `{}`,
+					"clientStats": []map[string]any{{"email": email, "enable": true}},
+				}})
+				_, _ = w.Write([]byte(`{"success":true,"obj":` + string(ib) + `}`))
+				return
+			case strings.HasSuffix(r.URL.Path, "server/clientIps") && r.Method == http.MethodPost:
+				var rows []model.InboundClientIps
+				_ = json.NewDecoder(r.Body).Decode(&rows)
+				mu.Lock()
+				for _, row := range rows {
+					pushed[email] = append(pushed[email], row.ClientEmail)
+				}
+				mu.Unlock()
+			}
+			_, _ = w.Write([]byte(`{"success":true}`))
+		}))
+		t.Cleanup(srv.Close)
+		host, port, _ := strings.Cut(strings.TrimPrefix(srv.URL, "http://"), ":")
+		portNum, _ := strconv.Atoi(port)
+		if err := database.GetDB().Create(&model.Node{
+			Name: email, Scheme: "http", Address: host, Port: portNum, BasePath: "/", ApiToken: "tok",
+			Enable: true, Status: "online", AllowPrivateAddress: true, TlsVerifyMode: "verify",
+		}).Error; err != nil {
+			t.Fatalf("create node: %v", err)
+		}
+		if err := database.GetDB().Create(&model.InboundClientIps{
+			ClientEmail: email, Ips: fmt.Sprintf(`[{"ip":"10.0.0.%d","timestamp":%d}]`, i+1, now),
+		}).Error; err != nil {
+			t.Fatalf("seed client ips: %v", err)
+		}
+	}
+
+	NewNodeTrafficSyncJob().Run()
+
+	for _, email := range []string{"a@node", "b@node"} {
+		if got := pushed[email]; !slices.Equal(got, []string{email}) {
+			t.Errorf("node hosting %s received IP rows for %v, want only [%s]", email, got, email)
+		}
+	}
+}

+ 5 - 2
internal/web/job/node_traffic_sync_job.go

@@ -16,7 +16,9 @@ import (
 )
 
 const (
-	nodeTrafficSyncConcurrency    = 8
+	// The heartbeat's bound: at 8, 300 nodes 80ms away took 25-30s per 5s tick on SQLite
+	// and 6-9s at 32; neither SQLite nor Postgres raised lock or pool errors.
+	nodeTrafficSyncConcurrency    = 32
 	nodeTrafficSyncRequestTimeout = 4 * time.Second
 	nodeReconcileTimeout          = 30 * time.Second
 	nodeClientIpSyncInterval      = 10 * time.Second
@@ -94,6 +96,7 @@ func (j *NodeTrafficSyncJob) Run() {
 		logger.Warning("node traffic sync: load nodes failed:", err)
 		return
 	}
+	j.inboundService.RetainSyncedNodeOnlineClients(nodes)
 	if len(nodes) == 0 {
 		return
 	}
@@ -447,7 +450,7 @@ func (j *NodeTrafficSyncJob) syncOne(mgr *runtime.Manager, n *model.Node, doIpSy
 		logger.Warningf("node traffic sync: fetch client ips from %s failed: %v", n.Name, err)
 	}
 
-	masterIps, err := j.inboundService.GetAllInboundClientIps()
+	masterIps, err := j.inboundService.GetNodeInboundClientIps(n.Id)
 	if err != nil {
 		logger.Warningf("node traffic sync: load client ips for push to %s failed: %v", n.Name, err)
 		return active

+ 45 - 0
internal/web/job/node_traffic_sync_online_prune_test.go

@@ -0,0 +1,45 @@
+package job
+
+import (
+	"path/filepath"
+	"testing"
+
+	"github.com/op/go-logging"
+
+	"github.com/mhsanaei/3x-ui/v3/internal/database"
+	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
+	xuilogger "github.com/mhsanaei/3x-ui/v3/internal/logger"
+	"github.com/mhsanaei/3x-ui/v3/internal/web/runtime"
+	"github.com/mhsanaei/3x-ui/v3/internal/web/service"
+	"github.com/mhsanaei/3x-ui/v3/internal/xray"
+)
+
+// The sync tick is the only place that sees which nodes it no longer fetches, so it
+// must drop their online sets itself: a disabled node here, a deleted one below.
+func TestNodeTrafficSyncDropsOnlineClientsOfUnsyncedNodes(t *testing.T) {
+	xuilogger.InitLogger(logging.ERROR)
+	if err := database.InitDB(filepath.Join(t.TempDir(), "x-ui.db")); err != nil {
+		t.Fatalf("InitDB: %v", err)
+	}
+	t.Cleanup(func() { _ = database.CloseDB() })
+	runtime.SetManager(runtime.NewManager(runtime.LocalDeps{APIPort: func() int { return 0 }, SetNeedRestart: func() {}}))
+	t.Cleanup(func() { runtime.SetManager(nil) })
+	process := xray.NewTestProcess(nil, "")
+	t.Cleanup(service.SetXrayProcessForTest(process))
+
+	disabled := &model.Node{Name: "disabled", Address: "127.0.0.1", Port: 1, ApiToken: "tok", Enable: true, Status: "online"}
+	if err := database.GetDB().Create(disabled).Error; err != nil {
+		t.Fatalf("create node: %v", err)
+	}
+	if err := database.GetDB().Model(disabled).Update("enable", false).Error; err != nil {
+		t.Fatalf("disable node: %v", err)
+	}
+	process.SetNodeOnlineTree(disabled.Id, map[string][]string{"g-disabled": {"a@x"}})
+	process.SetNodeOnlineTree(disabled.Id+100, map[string][]string{"g-deleted": {"b@x"}})
+
+	NewNodeTrafficSyncJob().Run()
+
+	if got := process.GetMergedNodeTrees(); len(got) != 0 {
+		t.Fatalf("online sets after a sync tick = %v, want none for a disabled or deleted node", got)
+	}
+}

+ 103 - 0
internal/web/job/node_transition_burst_test.go

@@ -0,0 +1,103 @@
+package job
+
+import (
+	"fmt"
+	"net/http/httptest"
+	"path/filepath"
+	"strconv"
+	"strings"
+	"sync"
+	"testing"
+	"time"
+
+	"github.com/op/go-logging"
+
+	"github.com/mhsanaei/3x-ui/v3/internal/database"
+	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
+	"github.com/mhsanaei/3x-ui/v3/internal/eventbus"
+	xuilogger "github.com/mhsanaei/3x-ui/v3/internal/logger"
+	"github.com/mhsanaei/3x-ui/v3/internal/web/runtime"
+)
+
+// goingDownNodes seeds n online nodes whose address refuses connections, so the
+// next heartbeat flips every one of them to offline in the same tick.
+func goingDownNodes(t *testing.T, n int) {
+	t.Helper()
+	xuilogger.InitLogger(logging.ERROR)
+	if err := database.InitDB(filepath.Join(t.TempDir(), "x-ui.db")); err != nil {
+		t.Fatalf("InitDB: %v", err)
+	}
+	t.Cleanup(func() { _ = database.CloseDB() })
+	runtime.SetManager(runtime.NewManager(runtime.LocalDeps{APIPort: func() int { return 0 }, SetNeedRestart: func() {}}))
+	t.Cleanup(func() { runtime.SetManager(nil) })
+	srv := httptest.NewServer(nil)
+	host, port, _ := strings.Cut(strings.TrimPrefix(srv.URL, "http://"), ":")
+	portNum, _ := strconv.Atoi(port)
+	srv.Close()
+	for i := range n {
+		node := &model.Node{
+			Name: fmt.Sprintf("node-%02d", i), Scheme: "http", Address: host, Port: portNum, BasePath: "/",
+			ApiToken: "tok", Enable: true, Status: "online", AllowPrivateAddress: true, TlsVerifyMode: "verify",
+		}
+		if err := database.GetDB().Create(node).Error; err != nil {
+			t.Fatalf("create node: %v", err)
+		}
+	}
+}
+
+func collectNodeEvents(t *testing.T) func() []eventbus.Event {
+	t.Helper()
+	bus := eventbus.New(eventbus.DefaultBufferSize)
+	var mu sync.Mutex
+	var got []eventbus.Event
+	bus.Subscribe("test", func(e eventbus.Event) {
+		mu.Lock()
+		got = append(got, e)
+		mu.Unlock()
+	})
+	prev := EventBus
+	EventBus = bus
+	t.Cleanup(func() {
+		EventBus = prev
+		bus.Stop()
+	})
+	return func() []eventbus.Event {
+		time.Sleep(300 * time.Millisecond)
+		mu.Lock()
+		defer mu.Unlock()
+		return append([]eventbus.Event(nil), got...)
+	}
+}
+
+// A master-side blip flipped every node in one tick and published one event per
+// node, overflowing the notifier queues and every chat's rate limit.
+func TestHeartbeatSummarizesNodeDownBurst(t *testing.T) {
+	goingDownNodes(t, 12)
+	events := collectNodeEvents(t)
+
+	NewNodeHeartbeatJob().Run()
+
+	got := events()
+	if len(got) != 1 {
+		t.Fatalf("heartbeat published %d events for 12 nodes going down, want 1 summary", len(got))
+	}
+	want := "node-00, node-01, node-02, node-03, node-04, node-05, node-06, node-07, node-08, node-09 (+2)"
+	if got[0].Type != eventbus.EventNodeDown || got[0].Source != want {
+		t.Fatalf("summary event = %s %q, want %s %q", got[0].Type, got[0].Source, eventbus.EventNodeDown, want)
+	}
+}
+
+func TestHeartbeatKeepsSingleNodeDownEvent(t *testing.T) {
+	goingDownNodes(t, 1)
+	events := collectNodeEvents(t)
+
+	NewNodeHeartbeatJob().Run()
+
+	got := events()
+	if len(got) != 1 || got[0].Source != "node-00" {
+		t.Fatalf("events = %+v, want the node's own node.down", got)
+	}
+	if _, ok := got[0].Data.(*eventbus.NodeHealthData); !ok {
+		t.Fatalf("single node.down lost its health data: %#v", got[0].Data)
+	}
+}

+ 36 - 9
internal/web/job/periodic_traffic_reset_job.go

@@ -1,12 +1,19 @@
 package job
 
 import (
+	"sync"
+	"sync/atomic"
 	"time"
 
 	"github.com/mhsanaei/3x-ui/v3/internal/logger"
+	"github.com/mhsanaei/3x-ui/v3/internal/util/common"
 	"github.com/mhsanaei/3x-ui/v3/internal/web/service"
 )
 
+// periodicResetConcurrency bounds how many inbounds or clients one run resets at once:
+// each waits on its node, so one at a time a few hanging nodes stretched a run for hours.
+const periodicResetConcurrency = 8
+
 // Period represents the time period for traffic resets.
 type Period string
 
@@ -35,6 +42,21 @@ func monthlyResetDue(resetDay int, now time.Time) bool {
 	return now.Day() == min(resetDay, lastDay)
 }
 
+func forEachResetBounded(n int, reset func(i int)) {
+	sem := make(chan struct{}, periodicResetConcurrency)
+	var wg sync.WaitGroup
+	for i := range n {
+		wg.Add(1)
+		sem <- struct{}{}
+		common.GoRecover("periodic-traffic-reset", func() {
+			defer wg.Done()
+			defer func() { <-sem }()
+			reset(i)
+		})
+	}
+	wg.Wait()
+}
+
 // Run resets traffic statistics for all inbounds that match the configured reset
 // period, then for the clients carrying that period on their own (#5497).
 func (j *PeriodicTrafficResetJob) Run() {
@@ -64,8 +86,9 @@ func (j *PeriodicTrafficResetJob) resetInboundsOnSchedule() {
 	}
 	logger.Infof("Running periodic traffic reset job for period: %s (%d matching inbounds)", j.period, len(inbounds))
 
-	resetCount := 0
-	for _, inbound := range inbounds {
+	var resetCount atomic.Int32
+	forEachResetBounded(len(inbounds), func(i int) {
+		inbound := inbounds[i]
 		resetInboundErr := j.inboundService.ResetInboundTraffic(inbound.Id)
 		if resetInboundErr != nil {
 			logger.Warning("Failed to reset traffic for inbound", inbound.Id, ":", resetInboundErr)
@@ -77,12 +100,12 @@ func (j *PeriodicTrafficResetJob) resetInboundsOnSchedule() {
 		}
 
 		if resetInboundErr == nil && resetClientErr == nil {
-			resetCount++
+			resetCount.Add(1)
 		}
-	}
+	})
 
-	if resetCount > 0 {
-		logger.Infof("Periodic traffic reset completed: %d inbounds reset", resetCount)
+	if count := resetCount.Load(); count > 0 {
+		logger.Infof("Periodic traffic reset completed: %d inbounds reset", count)
 	}
 }
 
@@ -115,19 +138,23 @@ func (j *PeriodicTrafficResetJob) resetClientsOnTheirOwnCycle() {
 	}
 	logger.Infof("Running periodic traffic reset job for period: %s (%d matching clients)", j.period, len(due))
 
+	var mu sync.Mutex
 	resetCount := 0
 	needRestart := false
-	for _, c := range due {
+	forEachResetBounded(len(due), func(i int) {
+		c := due[i]
 		// ResetTrafficByEmail rather than a bulk UPDATE: it is the path that also
 		// propagates to the client's node and clears the MTProto sidecar quota.
 		nr, resetErr := j.clientService.ResetTrafficByEmail(&j.inboundService, c.Email)
 		if resetErr != nil {
 			logger.Warning("Failed to reset traffic for client", c.Email, ":", resetErr)
-			continue
+			return
 		}
+		mu.Lock()
 		needRestart = needRestart || nr
 		resetCount++
-	}
+		mu.Unlock()
+	})
 	// Dropping this leaves a re-enabled client absent from the running core until
 	// something unrelated restarts it.
 	if needRestart {

+ 134 - 0
internal/web/job/periodic_traffic_reset_nodes_test.go

@@ -0,0 +1,134 @@
+package job
+
+import (
+	"encoding/json"
+	"fmt"
+	"io"
+	"net/http"
+	"net/http/httptest"
+	"strconv"
+	"strings"
+	"sync"
+	"sync/atomic"
+	"testing"
+	"time"
+
+	"github.com/mhsanaei/3x-ui/v3/internal/database"
+	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
+	"github.com/mhsanaei/3x-ui/v3/internal/web/runtime"
+	"github.com/mhsanaei/3x-ui/v3/internal/xray"
+)
+
+// resetGate holds every node reset open until released, counting how many nodes
+// the job reaches at once.
+type resetGate struct {
+	entered atomic.Int32
+	release chan struct{}
+	once    sync.Once
+}
+
+func (g *resetGate) open() { g.once.Do(func() { close(g.release) }) }
+
+func (g *resetGate) waitAll(t *testing.T, want int32) {
+	t.Helper()
+	deadline := time.Now().Add(3 * time.Second)
+	for g.entered.Load() < want {
+		if time.Now().After(deadline) {
+			t.Fatalf("periodic reset reached %d of %d hanging nodes, want all of them at once", g.entered.Load(), want)
+		}
+		time.Sleep(10 * time.Millisecond)
+	}
+}
+
+// resetNode is a node whose every traffic reset hangs until the gate opens.
+func resetNode(t *testing.T, gate *resetGate, name string) int {
+	t.Helper()
+	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		_, _ = io.Copy(io.Discard, r.Body)
+		if strings.Contains(r.URL.Path, "resetTraffic") {
+			gate.entered.Add(1)
+			select {
+			case <-r.Context().Done():
+			case <-gate.release:
+			}
+		}
+		w.Header().Set("Content-Type", "application/json")
+		_, _ = w.Write([]byte(`{"success":true}`))
+	}))
+	t.Cleanup(srv.Close)
+	host, port, _ := strings.Cut(strings.TrimPrefix(srv.URL, "http://"), ":")
+	portNum, _ := strconv.Atoi(port)
+	node := &model.Node{
+		Name: name, Scheme: "http", Address: host, Port: portNum, BasePath: "/", ApiToken: "tok",
+		Enable: true, Status: "online", AllowPrivateAddress: true, TlsVerifyMode: "verify",
+	}
+	if err := database.GetDB().Create(node).Error; err != nil {
+		t.Fatalf("create node: %v", err)
+	}
+	return node.Id
+}
+
+func runResetJobAgainstGate(t *testing.T, gate *resetGate, want int32) {
+	t.Helper()
+	done := make(chan struct{})
+	go func() {
+		defer close(done)
+		NewPeriodicTrafficResetJob("daily", time.UTC).Run()
+	}()
+	t.Cleanup(func() { gate.open(); <-done })
+	gate.waitAll(t, want)
+}
+
+func newResetFleet(t *testing.T) *resetGate {
+	t.Helper()
+	initResetJobDB(t)
+	runtime.SetManager(runtime.NewManager(runtime.LocalDeps{APIPort: func() int { return 0 }, SetNeedRestart: func() {}}))
+	t.Cleanup(func() { runtime.SetManager(nil) })
+	return &resetGate{release: make(chan struct{})}
+}
+
+// The job reset due clients and inbounds one by one, each waiting on its node,
+// so a few hanging nodes stretched one run across hours.
+func TestPeriodicResetReachesClientNodesConcurrently(t *testing.T) {
+	gate := newResetFleet(t)
+	db := database.GetDB()
+	for i := range 3 {
+		nodeID := resetNode(t, gate, fmt.Sprintf("client-node-%d", i))
+		email := fmt.Sprintf("cycle-%d@node", i)
+		client := model.Client{Email: email, ID: fmt.Sprintf("00000000-0000-4000-8000-00000000000%d", i), Enable: true, TrafficReset: "daily"}
+		settings, _ := json.Marshal(map[string]any{"clients": []model.Client{client}})
+		ib := model.Inbound{
+			UserId: 1, Enable: true, Port: 47000 + i, Protocol: model.VLESS, NodeID: &nodeID,
+			Tag: "reset-client-" + strconv.Itoa(i), TrafficReset: "never", Settings: string(settings),
+		}
+		if err := db.Create(&ib).Error; err != nil {
+			t.Fatalf("create inbound: %v", err)
+		}
+		rec := model.ClientRecord{Email: email, UUID: client.ID, Enable: true, TrafficReset: "daily"}
+		if err := db.Create(&rec).Error; err != nil {
+			t.Fatalf("create client record: %v", err)
+		}
+		if err := db.Create(&model.ClientInbound{ClientId: rec.Id, InboundId: ib.Id}).Error; err != nil {
+			t.Fatalf("link client: %v", err)
+		}
+		if err := db.Create(&xray.ClientTraffic{InboundId: ib.Id, Email: email, Enable: true, Up: 500, Down: 700}).Error; err != nil {
+			t.Fatalf("create traffic: %v", err)
+		}
+	}
+	runResetJobAgainstGate(t, gate, 3)
+}
+
+func TestPeriodicResetReachesInboundNodesConcurrently(t *testing.T) {
+	gate := newResetFleet(t)
+	for i := range 3 {
+		nodeID := resetNode(t, gate, fmt.Sprintf("inbound-node-%d", i))
+		ib := model.Inbound{
+			UserId: 1, Enable: true, Port: 47100 + i, Protocol: model.VLESS, NodeID: &nodeID,
+			Tag: "reset-inbound-" + strconv.Itoa(i), TrafficReset: "daily", Settings: `{"clients":[]}`,
+		}
+		if err := database.GetDB().Create(&ib).Error; err != nil {
+			t.Fatalf("create inbound: %v", err)
+		}
+	}
+	runResetJobAgainstGate(t, gate, 3)
+}

+ 6 - 1
internal/web/runtime/manager.go

@@ -168,10 +168,15 @@ func sameRemoteIdentity(a, b *model.Node) bool {
 		a.OutboundTag == b.OutboundTag
 }
 
+// InvalidateNode forgets everything cached for a node, its pooled HTTP client too:
+// only a later call for that node would prune it, and a deleted node never makes one.
 func (m *Manager) InvalidateNode(nodeID int) {
 	m.mu.Lock()
-	defer m.mu.Unlock()
 	delete(m.remotes, nodeID)
+	m.mu.Unlock()
+	nodeClientsMu.Lock()
+	dropNodeClients(nodeID, "")
+	nodeClientsMu.Unlock()
 }
 
 func loadNode(id int) (*model.Node, error) {

+ 92 - 26
internal/web/runtime/tls_client.go

@@ -6,6 +6,7 @@ import (
 	"crypto/tls"
 	"encoding/base64"
 	"encoding/hex"
+	"fmt"
 	"net/http"
 	"strings"
 	"sync"
@@ -132,22 +133,104 @@ func (t *credentialRotatingTransport) CloseIdleConnections() {
 	current.CloseIdleConnections()
 }
 
-// defaultNodeHTTPClient reaches nodes trusting the system CA store ("verify"
-// mode or plain http); shared so connections pool across nodes.
-var defaultNodeHTTPClient = &http.Client{
-	Transport: &http.Transport{
-		MaxIdleConns:        64,
-		MaxIdleConnsPerHost: 4,
+// The global cap must exceed the fleet size: below it Go closes a node's
+// connection before its next heartbeat, costing a handshake every tick.
+const (
+	maxIdleNodeConns        = 512
+	maxIdleNodeConnsPerHost = 8
+)
+
+func newNodeTransport(tlsCfg *tls.Config) *http.Transport {
+	return &http.Transport{
+		MaxIdleConns:        maxIdleNodeConns,
+		MaxIdleConnsPerHost: maxIdleNodeConnsPerHost,
 		IdleConnTimeout:     60 * time.Second,
 		DialContext:         netsafe.SSRFGuardedDialContext,
-	},
+		TLSClientConfig:     tlsCfg,
+	}
+}
+
+// defaultNodeHTTPClient reaches nodes trusting the system CA store ("verify"
+// mode or plain http); shared so connections pool across nodes.
+var defaultNodeHTTPClient = &http.Client{Transport: newNodeTransport(nil)}
+
+// nodeClients caches one client per node: heartbeat and traffic sync reach it
+// every few seconds, and a rebuilt client would open its own empty pool.
+type nodeClientEntry struct {
+	nodeID int
+	client *http.Client
 }
 
+var (
+	nodeClientsMu    sync.Mutex
+	nodeClientsCache = map[string]nodeClientEntry{}
+)
+
+// nodeClientIdentity covers everything that decides how the node is trusted; the
+// proxy URL is a variant of it, so it stays out of the identity itself.
+func nodeClientIdentity(n *model.Node, mode string) string {
+	return fmt.Sprintf("%d|%s|%s|%s|%d|%s", n.Id, mode, n.Scheme, n.Address, n.Port, n.PinnedCertSha256)
+}
+
+// dropNodeClients discards every cached client of one node except keep, so a
+// node never holds more than the variant it is using now. Callers hold the lock.
+func dropNodeClients(nodeID int, keep string) {
+	for key, entry := range nodeClientsCache {
+		if entry.nodeID != nodeID || key == keep {
+			continue
+		}
+		entry.client.CloseIdleConnections()
+		delete(nodeClientsCache, key)
+	}
+}
+
+// HTTPClientForNode returns the pooled client for n, building it on first use
+// and whenever the node's identity or TLS material changes.
 func HTTPClientForNode(n *model.Node, proxyURL string) (*http.Client, error) {
 	mode := n.TlsVerifyMode
 	if mode == "" {
 		mode = "verify"
 	}
+	if mode == "verify" || n.Scheme == "http" {
+		// Shared across nodes and not node-specific: nothing to key on.
+		if proxyURL == "" {
+			nodeClientsMu.Lock()
+			dropNodeClients(n.Id, "")
+			nodeClientsMu.Unlock()
+			return defaultNodeHTTPClient, nil
+		}
+	}
+
+	identity := nodeClientIdentity(n, mode)
+	key := identity + "|" + proxyURL
+	nodeClientsMu.Lock()
+	if entry, ok := nodeClientsCache[key]; ok {
+		nodeClientsMu.Unlock()
+		return entry.client, nil
+	}
+	nodeClientsMu.Unlock()
+
+	client, err := buildNodeHTTPClient(n, mode, proxyURL)
+	if err != nil {
+		return nil, err
+	}
+
+	nodeClientsMu.Lock()
+	if entry, ok := nodeClientsCache[key]; ok {
+		// A concurrent caller won the race; keep its client and drop ours.
+		nodeClientsMu.Unlock()
+		client.CloseIdleConnections()
+		return entry.client, nil
+	}
+	// Any other variant is dead weight: a stale identity's pool fits no trust
+	// decision now, and an ephemeral proxy URL is never asked for twice.
+	dropNodeClients(n.Id, key)
+	nodeClientsCache[key] = nodeClientEntry{nodeID: n.Id, client: client}
+	nodeClientsMu.Unlock()
+	return client, nil
+}
+
+func buildNodeHTTPClient(n *model.Node, mode, proxyURL string) (*http.Client, error) {
 	if proxyURL != "" {
 		if mode == "mtls" && n.Scheme != "http" {
 			timeout := remoteHTTPTimeout
@@ -191,22 +274,13 @@ func HTTPClientForNode(n *model.Node, proxyURL string) (*http.Client, error) {
 		transport.TLSClientConfig = tlsCfg
 		return client, nil
 	}
-	if mode == "verify" || n.Scheme == "http" {
-		return defaultNodeHTTPClient, nil
-	}
 	if mode == "mtls" {
 		build := func() (idleClosingRoundTripper, error) {
 			tlsCfg, err := tlsConfigForNode(n)
 			if err != nil {
 				return nil, err
 			}
-			return &http.Transport{
-				MaxIdleConns:        64,
-				MaxIdleConnsPerHost: 4,
-				IdleConnTimeout:     60 * time.Second,
-				DialContext:         netsafe.SSRFGuardedDialContext,
-				TLSClientConfig:     tlsCfg,
-			}, nil
+			return newNodeTransport(tlsCfg), nil
 		}
 		transport, err := newCredentialRotatingTransport(build)
 		if err != nil {
@@ -218,15 +292,7 @@ func HTTPClientForNode(n *model.Node, proxyURL string) (*http.Client, error) {
 	if err != nil {
 		return nil, err
 	}
-	return &http.Client{
-		Transport: &http.Transport{
-			MaxIdleConns:        64,
-			MaxIdleConnsPerHost: 4,
-			IdleConnTimeout:     60 * time.Second,
-			DialContext:         netsafe.SSRFGuardedDialContext,
-			TLSClientConfig:     tlsCfg,
-		},
-	}, nil
+	return &http.Client{Transport: newNodeTransport(tlsCfg)}, nil
 }
 
 func tlsConfigForNode(n *model.Node) (*tls.Config, error) {

+ 25 - 0
internal/web/runtime/tls_client_invalidate_test.go

@@ -0,0 +1,25 @@
+package runtime
+
+import (
+	"testing"
+
+	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
+)
+
+// Only a later call for the same node pruned its pooled client, so a deleted
+// node kept its client and transport cached for the life of the process.
+func TestInvalidateNodeDropsItsCachedHTTPClient(t *testing.T) {
+	node := &model.Node{Id: 8801, Address: "node.example.test", Port: 443, Scheme: "https", TlsVerifyMode: "skip"}
+	if _, err := HTTPClientForNode(node, ""); err != nil {
+		t.Fatalf("HTTPClientForNode: %v", err)
+	}
+	if got := nodeClientEntries(node.Id); got != 1 {
+		t.Fatalf("cached clients before invalidation = %d, want 1", got)
+	}
+
+	NewManager(LocalDeps{}).InvalidateNode(node.Id)
+
+	if got := nodeClientEntries(node.Id); got != 0 {
+		t.Fatalf("cached clients after InvalidateNode = %d, want 0", got)
+	}
+}

+ 127 - 0
internal/web/runtime/tls_client_test.go

@@ -6,6 +6,8 @@ import (
 	"crypto/tls"
 	"encoding/base64"
 	"encoding/hex"
+	"io"
+	"net"
 	"net/http"
 	"net/http/httptest"
 	"net/url"
@@ -17,6 +19,7 @@ import (
 
 	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
 	"github.com/mhsanaei/3x-ui/v3/internal/util/crypto"
+	"github.com/mhsanaei/3x-ui/v3/internal/util/netsafe"
 )
 
 type generationProbeTransport struct {
@@ -77,6 +80,130 @@ func TestCredentialRotatingTransportDropsOldPoolBeforeNextRequest(t *testing.T)
 	}
 }
 
+// Heartbeat and traffic sync ask for a client every few seconds; a rebuilt one
+// owns an empty pool, so each tick paid a fresh TCP+TLS handshake per node.
+func TestHTTPClientForNodeReusesOneConnectionAcrossCalls(t *testing.T) {
+	var handshakes atomic.Int32
+	server := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
+		w.WriteHeader(http.StatusOK)
+	}))
+	server.Config.ConnState = func(_ net.Conn, state http.ConnState) {
+		if state == http.StateNew {
+			handshakes.Add(1)
+		}
+	}
+	server.StartTLS()
+	defer server.Close()
+
+	u, err := url.Parse(server.URL)
+	if err != nil {
+		t.Fatalf("parse test server url: %v", err)
+	}
+	port, err := strconv.Atoi(u.Port())
+	if err != nil {
+		t.Fatalf("test server port: %v", err)
+	}
+	node := &model.Node{
+		Id: 31, Address: u.Hostname(), Port: port, Scheme: "https",
+		TlsVerifyMode: "skip", AllowPrivateAddress: true,
+	}
+
+	for tick := range 2 {
+		client, err := HTTPClientForNode(node, "")
+		if err != nil {
+			t.Fatalf("tick %d: HTTPClientForNode: %v", tick, err)
+		}
+		req, err := http.NewRequestWithContext(
+			netsafe.ContextWithAllowPrivate(context.Background(), true), http.MethodGet, server.URL, nil)
+		if err != nil {
+			t.Fatalf("tick %d: new request: %v", tick, err)
+		}
+		resp, err := client.Do(req)
+		if err != nil {
+			t.Fatalf("tick %d: request: %v", tick, err)
+		}
+		_, _ = io.Copy(io.Discard, resp.Body)
+		_ = resp.Body.Close()
+	}
+
+	if got := handshakes.Load(); got != 1 {
+		t.Fatalf("TLS handshakes = %d, want 1: a rebuilt client re-handshakes on every tick", got)
+	}
+}
+
+// A node that switches to pinning (or gains a proxy) must not be served by the
+// client built for its previous trust decision.
+func TestHTTPClientForNodeRebuildsWhenNodeIdentityChanges(t *testing.T) {
+	pin := base64.StdEncoding.EncodeToString(make([]byte, sha256.Size))
+	node := &model.Node{Id: 32, Address: "node.example.test", Port: 443, Scheme: "https", TlsVerifyMode: "skip"}
+	skipped, err := HTTPClientForNode(node, "")
+	if err != nil {
+		t.Fatalf("skip client: %v", err)
+	}
+
+	pinned := *node
+	pinned.TlsVerifyMode = "pin"
+	pinned.PinnedCertSha256 = pin
+	pinnedClient, err := HTTPClientForNode(&pinned, "")
+	if err != nil {
+		t.Fatalf("pin client: %v", err)
+	}
+	if skipped == pinnedClient {
+		t.Fatal("a pinned node must not reuse the client built to skip verification")
+	}
+
+	proxied, err := HTTPClientForNode(node, "socks5://127.0.0.1:1080")
+	if err != nil {
+		t.Fatalf("proxied client: %v", err)
+	}
+	if skipped == proxied {
+		t.Fatal("a proxied node must not reuse the direct client")
+	}
+
+	if again, err := HTTPClientForNode(node, ""); err != nil || again == pinnedClient {
+		t.Fatalf("a skip request must never be served the pinned client; again=%p err=%v", again, err)
+	}
+}
+
+func nodeClientEntries(id int) int {
+	nodeClientsMu.Lock()
+	defer nodeClientsMu.Unlock()
+	count := 0
+	for _, entry := range nodeClientsCache {
+		if entry.nodeID == id {
+			count++
+		}
+	}
+	return count
+}
+
+// withOutboundBridge mints a fresh loopback port per call, so the variant it
+// asks for can never be hit again; only the variant in use may stay cached.
+func TestHTTPClientForNodeKeepsOneClientPerNode(t *testing.T) {
+	node := &model.Node{Id: 77, Address: "node.example.test", Port: 443, Scheme: "https", TlsVerifyMode: "skip"}
+	variants := []string{"socks5://127.0.0.1:41001", "socks5://127.0.0.1:41002", ""}
+	for _, variant := range variants {
+		if _, err := HTTPClientForNode(node, variant); err != nil {
+			t.Fatalf("HTTPClientForNode(%q): %v", variant, err)
+		}
+		if got := nodeClientEntries(node.Id); got != 1 {
+			t.Fatalf("cached clients for the node after %q = %d, want 1", variant, got)
+		}
+		if client, err := HTTPClientForNode(node, variant); err != nil || client == nil {
+			t.Fatalf("repeat HTTPClientForNode(%q): client=%p err=%v", variant, client, err)
+		}
+	}
+
+	verify := *node
+	verify.TlsVerifyMode = "verify"
+	if client, err := HTTPClientForNode(&verify, ""); err != nil || client != defaultNodeHTTPClient {
+		t.Fatalf("verify client = %p, want the shared one (%p); err=%v", client, defaultNodeHTTPClient, err)
+	}
+	if got := nodeClientEntries(node.Id); got != 0 {
+		t.Fatalf("cached clients for a node now on verify = %d, want 0", got)
+	}
+}
+
 func TestReloadMasterClientConnectionsValidatesProviderBeforeInvalidation(t *testing.T) {
 	before := masterCertEpoch.Load()
 	SetMasterClientCertProvider(func() (tls.Certificate, error) {

+ 4 - 0
internal/web/service/client_bulk.go

@@ -1802,6 +1802,7 @@ func (s *ClientService) bulkSetEnableInboundClients(inboundSvc *InboundService,
 						"auth":     ch.client.Auth,
 						"password": ch.client.Password,
 						"cipher":   cipher,
+						"reverse":  ch.client.Reverse,
 					})
 					if err1 != nil {
 						logger.Debug("Error in adding client on", rt.Name(), ":", err1)
@@ -1812,6 +1813,9 @@ func (s *ClientService) bulkSetEnableInboundClients(inboundSvc *InboundService,
 					if err1 != nil && !strings.Contains(err1.Error(), fmt.Sprintf("User %s not found.", ch.email)) {
 						logger.Debug("Error in removing client on", rt.Name(), ":", err1)
 						res.needRestart = true
+					} else if err1 == nil && droppedClientNeedsRestart() {
+						// A removed credential does not end the session it was serving.
+						res.needRestart = true
 					}
 				}
 			}

+ 64 - 0
internal/web/service/client_disable_restart_test.go

@@ -0,0 +1,64 @@
+package service
+
+import (
+	"testing"
+
+	"github.com/mhsanaei/3x-ui/v3/internal/database"
+	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
+	"github.com/mhsanaei/3x-ui/v3/internal/web/runtime"
+	"github.com/mhsanaei/3x-ui/v3/internal/xray"
+)
+
+// The manual switch applies through the runtime, and the core's API removal
+// drops the credential only: whether the live session ends is what the setting
+// asks for, exactly as on the auto-disable path #6533 reports from.
+func TestManualClientDisableHonoursRestartSetting(t *testing.T) {
+	const email = "[email protected]"
+
+	for _, tc := range []struct {
+		name    string
+		setting bool
+		want    bool
+	}{
+		{"setting on", true, true},
+		{"setting off", false, false},
+	} {
+		t.Run(tc.name, func(t *testing.T) {
+			setupConflictDB(t)
+			setRestartOnClientDisable(t, tc.setting)
+
+			mgr := runtime.NewManager(runtime.LocalDeps{APIPort: func() int { return 0 }})
+			mgr.SetLocalRuntimeOverride(&fakeNodeRuntime{})
+			runtime.SetManager(mgr)
+			t.Cleanup(func() { runtime.SetManager(nil) })
+
+			seedInboundConflict(t, "manual-disable", "0.0.0.0", 50055, model.VLESS, `{"network":"tcp"}`,
+				`{"clients":[{"email":"`+email+`","id":"5f2eb9d6-3a2f-4a55-9812-6ea1e2f7a333","enable":true}]}`)
+			inbound := loadInboundByTag(t, "manual-disable")
+
+			inboundSvc := InboundService{}
+			clientSvc := ClientService{}
+			clients, err := inboundSvc.GetClients(inbound)
+			if err != nil {
+				t.Fatalf("GetClients: %v", err)
+			}
+			if err := clientSvc.SyncInbound(nil, inbound.Id, clients); err != nil {
+				t.Fatalf("SyncInbound: %v", err)
+			}
+			if err := database.GetDB().Create(&xray.ClientTraffic{InboundId: inbound.Id, Email: email, Enable: true}).Error; err != nil {
+				t.Fatalf("seed traffic: %v", err)
+			}
+
+			changed, needRestart, err := clientSvc.SetClientEnableByEmail(&inboundSvc, email, false)
+			if err != nil {
+				t.Fatalf("SetClientEnableByEmail: %v", err)
+			}
+			if !changed {
+				t.Fatal("the disable must be recorded")
+			}
+			if needRestart != tc.want {
+				t.Fatalf("needRestart = %v, want %v", needRestart, tc.want)
+			}
+		})
+	}
+}

+ 18 - 1
internal/web/service/client_inbound_apply.go

@@ -19,6 +19,16 @@ import (
 	"gorm.io/gorm"
 )
 
+// droppedClientNeedsRestart is what restartXrayOnClientDisable asks for: the core
+// API drops the credential only, so a live session needs the process replaced.
+func droppedClientNeedsRestart() bool {
+	on, err := (&SettingService{}).GetRestartXrayOnClientDisable()
+	if err != nil {
+		logger.Warning("get RestartXrayOnClientDisable failed:", err)
+	}
+	return on
+}
+
 func sameClientConfigExceptUpdatedAt(a, b map[string]any) bool {
 	aa := maps.Clone(a)
 	bb := maps.Clone(b)
@@ -600,6 +610,7 @@ func (s *ClientService) AddInboundClient(inboundSvc *InboundService, data *model
 					"allowedIPs":   client.AllowedIPs,
 					"preSharedKey": client.PreSharedKey,
 					"keepAlive":    keepAliveStr(client.KeepAliveSeconds()),
+					"reverse":      client.Reverse,
 				})
 				if err1 == nil {
 					logger.Debug("Client added on", rt.Name(), ":", client.Email)
@@ -1011,6 +1022,11 @@ func (s *ClientService) UpdateInboundClient(inboundSvc *InboundService, data *mo
 					err1 := rt.RemoveUser(context.Background(), oldInbound, oldEmail)
 					if err1 == nil {
 						logger.Debug("Old client deleted on", rt.Name(), ":", oldEmail)
+						// The API removal is enough only while the client is re-added; a
+						// dropped one ends its session only through a restart.
+						if !clients[0].Enable && droppedClientNeedsRestart() {
+							needRestart = true
+						}
 					} else if strings.Contains(err1.Error(), fmt.Sprintf("User %s not found.", oldEmail)) {
 						logger.Debug("User is already deleted. Nothing to do more...")
 					} else {
@@ -1035,6 +1051,7 @@ func (s *ClientService) UpdateInboundClient(inboundSvc *InboundService, data *mo
 						"allowedIPs":   clients[0].AllowedIPs,
 						"preSharedKey": clients[0].PreSharedKey,
 						"keepAlive":    keepAliveStr(clients[0].KeepAliveSeconds()),
+						"reverse":      clients[0].Reverse,
 					})
 					if err1 == nil {
 						logger.Debug("Client edited on", rt.Name(), ":", clients[0].Email)
@@ -1199,7 +1216,7 @@ func (s *ClientService) DelInboundClientByEmail(inboundSvc *InboundService, inbo
 					needRestart = true
 				} else if err1 := rt.RemoveUser(context.Background(), oldInbound, email); err1 == nil {
 					logger.Debug("Client deleted on", rt.Name(), ":", email)
-					needRestart = false
+					needRestart = droppedClientNeedsRestart()
 				} else if strings.Contains(err1.Error(), fmt.Sprintf("User %s not found.", email)) {
 					logger.Debug("User is already deleted. Nothing to do more...")
 				} else {

+ 7 - 8
internal/web/service/client_paging.go

@@ -195,10 +195,9 @@ func (q clientQuery) activeExpr() string {
 	return "(" + sqlClientEnabled + " AND NOT " + q.depletedExpr() + " AND NOT " + q.nearDepletionExpr() + ")"
 }
 
-// summaryDeactiveExpr is narrower than the "deactive" bucket filter: a disabled
-// client that also ran out counts once, under depleted, so the stat cards add
-// up to the client total.
-func (q clientQuery) summaryDeactiveExpr() string {
+// deactiveExpr leaves a disabled client that also ran out to depleted, so the
+// stat cards add up to the total and each card's filter lists what it counts.
+func (q clientQuery) deactiveExpr() string {
 	return "(NOT " + sqlClientEnabled + " AND NOT " + q.depletedExpr() + ")"
 }
 
@@ -275,9 +274,9 @@ func (q clientQuery) bucketCond(buckets, onlines []string) (string, []any) {
 	for _, b := range buckets {
 		switch b {
 		case "active":
-			conds = append(conds, "("+sqlClientEnabled+" AND NOT "+q.depletedExpr()+")")
+			conds = append(conds, q.activeExpr())
 		case "deactive":
-			conds = append(conds, "(NOT "+sqlClientEnabled+")")
+			conds = append(conds, q.deactiveExpr())
 		case "depleted":
 			conds = append(conds, q.depletedExpr())
 		case "expiring":
@@ -490,7 +489,7 @@ func (q clientQuery) summary(onlines []string, total int) (ClientsSummary, error
 		"COALESCE(SUM(CASE WHEN " + q.activeExpr() + " THEN 1 ELSE 0 END), 0) AS active," +
 			" COALESCE(SUM(CASE WHEN " + q.depletedExpr() + " THEN 1 ELSE 0 END), 0) AS depleted," +
 			" COALESCE(SUM(CASE WHEN " + q.expiringExpr() + " THEN 1 ELSE 0 END), 0) AS expiring," +
-			" COALESCE(SUM(CASE WHEN " + q.summaryDeactiveExpr() + " THEN 1 ELSE 0 END), 0) AS deactive",
+			" COALESCE(SUM(CASE WHEN " + q.deactiveExpr() + " THEN 1 ELSE 0 END), 0) AS deactive",
 	).Scan(&counts).Error; err != nil {
 		return s, err
 	}
@@ -506,7 +505,7 @@ func (q clientQuery) summary(onlines []string, total int) (ClientsSummary, error
 	}{
 		{q.depletedExpr(), s.DepletedCount, &s.Depleted},
 		{q.expiringExpr(), s.ExpiringCount, &s.Expiring},
-		{q.summaryDeactiveExpr(), s.DeactiveCount, &s.Deactive},
+		{q.deactiveExpr(), s.DeactiveCount, &s.Deactive},
 	}
 	for _, b := range buckets {
 		// The counter already says the bucket is empty, so skip the scan that

+ 17 - 4
internal/web/service/client_paging_test.go

@@ -157,9 +157,9 @@ func TestListPagedFilters(t *testing.T) {
 			want:   []string{"charlie@x", "delta@x", "foxtrot@x"},
 		},
 		{
-			name:   "deactive bucket is every disabled client",
+			name:   "deactive bucket leaves a disabled client that ran out to depleted",
 			params: ClientPageParams{PageSize: 50, Filter: "deactive"},
-			want:   []string{"echo@x", "foxtrot@x"},
+			want:   []string{"echo@x"},
 		},
 		{
 			name:   "expiring bucket covers near expiry and near quota",
@@ -167,9 +167,9 @@ func TestListPagedFilters(t *testing.T) {
 			want:   []string{"golf@x", "hotel@x"},
 		},
 		{
-			name:   "active bucket keeps enabled clients that still have room",
+			name:   "active bucket leaves clients near depletion to expiring",
 			params: ClientPageParams{PageSize: 50, Filter: "active"},
-			want:   []string{"alpha@x", "bravo@x", "golf@x", "hotel@x", "india@x", "juliet@x", "kilo_1@x", "kilo1@x"},
+			want:   []string{"alpha@x", "bravo@x", "india@x", "juliet@x", "kilo_1@x", "kilo1@x"},
 		},
 		{
 			name:   "buckets are ORed",
@@ -477,6 +477,19 @@ func TestListPagedSummary(t *testing.T) {
 		}
 	})
 
+	t.Run("clicking a stat card filters to exactly the clients it counts", func(t *testing.T) {
+		cards := map[string]int{"active": s.Active, "depleted": s.DepletedCount, "expiring": s.ExpiringCount, "deactive": s.DeactiveCount}
+		for bucket, count := range cards {
+			page, err := svc.ListPaged(inboundSvc, settingSvc, ClientPageParams{PageSize: 50, Filter: bucket})
+			if err != nil {
+				t.Fatalf("ListPaged(%s): %v", bucket, err)
+			}
+			if page.Filtered != count {
+				t.Fatalf("filter %q matched %d clients, card counts %d", bucket, page.Filtered, count)
+			}
+		}
+	})
+
 	t.Run("bucket lists carry the matching emails", func(t *testing.T) {
 		if want := []string{"charlie@x", "delta@x", "foxtrot@x"}; !slices.Equal(s.Depleted, want) {
 			t.Fatalf("depleted = %v, want %v", s.Depleted, want)

+ 149 - 0
internal/web/service/client_reverse_readd_test.go

@@ -0,0 +1,149 @@
+package service
+
+import (
+	"context"
+	"sync"
+	"testing"
+
+	"github.com/mhsanaei/3x-ui/v3/internal/database"
+	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
+	"github.com/mhsanaei/3x-ui/v3/internal/xray"
+)
+
+// reverseUserProbe records the account maps the panel pushes to the core: the
+// last place a stored reverse tag can be dropped before it reaches a listener.
+type reverseUserProbe struct {
+	fakeNodeRuntime
+	mu    sync.Mutex
+	users []map[string]any
+}
+
+func (p *reverseUserProbe) AddUser(_ context.Context, _ *model.Inbound, user map[string]any) error {
+	p.mu.Lock()
+	defer p.mu.Unlock()
+	p.users = append(p.users, user)
+	return nil
+}
+
+func (p *reverseUserProbe) recorded() []map[string]any {
+	p.mu.Lock()
+	defer p.mu.Unlock()
+	return append([]map[string]any(nil), p.users...)
+}
+
+const reverseProbeID = "5f2eb9d6-3a2f-4a55-9812-6ea1e2f7a333"
+
+func reverseProbeClient(email string, enable bool) model.Client {
+	return model.Client{Email: email, ID: reverseProbeID, Enable: enable, Reverse: &model.ClientReverse{Tag: "portal"}}
+}
+
+// seedReverseProbeInbound seeds one local vless inbound holding a single reverse
+// client, and the recording runtime every local apply of it lands on.
+func seedReverseProbeInbound(t *testing.T, tag string, port int, enable bool) (*model.Inbound, string, *reverseUserProbe) {
+	t.Helper()
+	setupConflictDB(t)
+	mgr := useTestRuntimeManager(t)
+	probe := &reverseUserProbe{}
+	mgr.SetLocalRuntimeOverride(probe)
+
+	email := tag + "@example.test"
+	client := reverseProbeClient(email, enable)
+	seedInboundConflict(t, tag, "0.0.0.0", port, model.VLESS, `{"network":"tcp"}`, clientsSettings(t, []model.Client{client}))
+	inbound := loadInboundByTag(t, tag)
+	if err := (&ClientService{}).SyncInbound(nil, inbound.Id, []model.Client{client}); err != nil {
+		t.Fatalf("SyncInbound: %v", err)
+	}
+	return inbound, email, probe
+}
+
+// assertReverseReAdd fails unless the core was handed the client's own tag: the
+// handler is gone the moment RemoveUser runs, and only the tag rebuilds it.
+func assertReverseReAdd(t *testing.T, probe *reverseUserProbe, email string) {
+	t.Helper()
+	users := probe.recorded()
+	if len(users) == 0 {
+		t.Fatalf("%s was never re-added to the core, so its reverse tag was never checked", email)
+	}
+	found := false
+	for _, user := range users {
+		if got, _ := user["email"].(string); got != email {
+			continue
+		}
+		found = true
+		tag, _ := user["reverse"].(*model.ClientReverse)
+		if tag == nil || tag.Tag != "portal" {
+			t.Fatalf("the re-add of %s carries reverse %#v, want its stored tag portal", email, user["reverse"])
+		}
+	}
+	if !found {
+		t.Fatalf("no re-add of %s reached the core: %v", email, users)
+	}
+}
+
+// The panel's most ordinary action on a reverse client: editing it removes the
+// account and adds it back, and the core rebuilds nothing without the tag.
+func TestClientEditKeepsTheReverseTag(t *testing.T) {
+	_, email, probe := seedReverseProbeInbound(t, "rev-edit", 50071, true)
+	rec := lookupClientRecord(t, email)
+
+	edited := reverseProbeClient(email, true)
+	edited.Comment = "edited after the tunnel was up"
+	if _, err := (&ClientService{}).Update(&InboundService{}, rec.Id, edited, 0); err != nil {
+		t.Fatalf("Update: %v", err)
+	}
+	assertReverseReAdd(t, probe, email)
+}
+
+func TestBulkReEnableKeepsTheReverseTag(t *testing.T) {
+	_, email, probe := seedReverseProbeInbound(t, "rev-bulk", 50072, false)
+
+	if _, _, err := (&ClientService{}).BulkSetEnable(&InboundService{}, []string{email}, true); err != nil {
+		t.Fatalf("BulkSetEnable: %v", err)
+	}
+	assertReverseReAdd(t, probe, email)
+}
+
+// The route an operator hits most often: a client that exhausted its quota is
+// removed, then re-added by the reset that renews it.
+func TestTrafficResetKeepsTheReverseTag(t *testing.T) {
+	inbound, email, probe := seedReverseProbeInbound(t, "rev-quota", 50073, true)
+	depleteClientTraffic(t, inbound.Id, email)
+
+	if _, err := (&InboundService{}).ResetClientTraffic(inbound.Id, email); err != nil {
+		t.Fatalf("ResetClientTraffic: %v", err)
+	}
+	assertReverseReAdd(t, probe, email)
+}
+
+func TestAddingClientsKeepsTheReverseTag(t *testing.T) {
+	inbound, _, probe := seedReverseProbeInbound(t, "rev-add", 50074, true)
+	const added = "[email protected]"
+	second := reverseProbeClient(added, true)
+	second.ID = "7c3fad07-4b1c-4d66-9f83-7db2f3c8b444"
+
+	if _, err := (&ClientService{}).AddInboundClient(&InboundService{}, &model.Inbound{
+		Id:       inbound.Id,
+		Protocol: model.VLESS,
+		Settings: clientsSettings(t, []model.Client{second}),
+	}); err != nil {
+		t.Fatalf("AddInboundClient: %v", err)
+	}
+	assertReverseReAdd(t, probe, added)
+}
+
+// depleteClientTraffic leaves the client enabled in settings but out of quota,
+// the state a traffic reset re-adds it from.
+func depleteClientTraffic(t *testing.T, inboundId int, email string) {
+	t.Helper()
+	db := database.GetDB()
+	res := db.Model(&xray.ClientTraffic{}).Where("email = ?", email).
+		Updates(map[string]any{"enable": false, "up": 1, "down": 1})
+	if res.Error != nil {
+		t.Fatalf("deplete traffic: %v", res.Error)
+	}
+	if res.RowsAffected == 0 {
+		if err := db.Create(&xray.ClientTraffic{InboundId: inboundId, Email: email, Enable: false, Up: 1, Down: 1}).Error; err != nil {
+			t.Fatalf("create depleted traffic: %v", err)
+		}
+	}
+}

+ 39 - 7
internal/web/service/inbound.go

@@ -1363,10 +1363,20 @@ func (s *InboundService) AddInbound(inbound *model.Inbound) (*model.Inbound, boo
 }
 
 func (s *InboundService) DelInbound(id int) (bool, error) {
+	needRestart, nodePush, err := s.delInbound(id)
+	if nodePush != nil {
+		nodePush()
+	}
+	return needRestart, err
+}
+
+// delInbound deletes the central row and returns the node push instead of running
+// it, so a bulk delete can fan the pushes out once every row is gone.
+func (s *InboundService) delInbound(id int) (bool, func(), error) {
 	db := database.GetDB()
 
 	needRestart := false
-	var postCommitApply func()
+	var postCommitApply, nodePush func()
 	var ib model.Inbound
 	loadErr := db.Model(model.Inbound{}).Where("id = ?", id).First(&ib).Error
 	if loadErr == nil {
@@ -1377,7 +1387,7 @@ func (s *InboundService) DelInbound(id int) (bool, error) {
 				if perr != nil {
 					logger.Warning("DelInbound: node runtime lookup failed, deleting central row anyway:", perr)
 				} else if push {
-					postCommitApply = func() {
+					nodePush = func() {
 						if err1 := rt.DelInbound(context.Background(), &ib); err1 == nil {
 							logger.Debug("Inbound deleted on", rt.Name(), ":", ib.Tag)
 						} else {
@@ -1440,7 +1450,7 @@ func (s *InboundService) DelInbound(id int) (bool, error) {
 		}
 		return nil
 	}); err != nil {
-		return needRestart, err
+		return needRestart, nil, err
 	}
 	if postCommitApply != nil {
 		postCommitApply()
@@ -1455,11 +1465,11 @@ func (s *InboundService) DelInbound(id int) (bool, error) {
 	if !database.IsPostgres() {
 		var count int64
 		if err := db.Model(&model.Inbound{}).Count(&count).Error; err != nil {
-			return needRestart, err
+			return needRestart, nodePush, err
 		}
 		if count == 0 {
 			if err := db.Exec("DELETE FROM sqlite_sequence WHERE name = ?", "inbounds").Error; err != nil {
-				return needRestart, err
+				return needRestart, nodePush, err
 			}
 		}
 	}
@@ -1467,7 +1477,7 @@ func (s *InboundService) DelInbound(id int) (bool, error) {
 	if mtprotoRoutesThroughXray(&ib) {
 		needRestart = true
 	}
-	return needRestart, nil
+	return needRestart, nodePush, nil
 }
 
 type BulkDelInboundResult struct {
@@ -1487,8 +1497,14 @@ type BulkDelInboundReport struct {
 func (s *InboundService) DelInbounds(ids []int) (BulkDelInboundResult, bool, error) {
 	result := BulkDelInboundResult{}
 	needRestart := false
+	var pushIDs []int
+	var nodePushes []func()
 	for _, id := range ids {
-		r, err := s.DelInbound(id)
+		r, nodePush, err := s.delInbound(id)
+		if nodePush != nil {
+			pushIDs = append(pushIDs, id)
+			nodePushes = append(nodePushes, nodePush)
+		}
 		if err != nil {
 			result.Skipped = append(result.Skipped, BulkDelInboundReport{Id: id, Reason: err.Error()})
 			continue
@@ -1498,6 +1514,11 @@ func (s *InboundService) DelInbounds(ids []int) (BulkDelInboundResult, bool, err
 			needRestart = true
 		}
 	}
+	// Rows go one at a time for the shared routing rewrite; only node pushes fan out.
+	fanoutInboundResults(pushIDs, nodeFanoutConcurrency, func(i int) struct{} {
+		nodePushes[i]()
+		return struct{}{}
+	})
 	return result, needRestart, nil
 }
 
@@ -1581,6 +1602,17 @@ func (s *InboundService) SetInboundEnable(id int, enable bool) (bool, error) {
 	}
 
 	db := database.GetDB()
+	// Enabling puts this row's ports into the running config, and the guards ran
+	// only if it was saved: a restored or hand-edited row reaches it unchecked.
+	if enable && inbound.NodeID == nil {
+		conflict, err := checkPortConflictTx(db, inbound, inbound.Id)
+		if err != nil {
+			return false, err
+		}
+		if conflict != nil {
+			return false, common.NewError(conflict.String())
+		}
+	}
 	if err := db.Transaction(func(tx *gorm.DB) error {
 		if err := tx.Model(model.Inbound{}).Where("id = ?", id).
 			Update("enable", enable).Error; err != nil {

+ 27 - 0
internal/web/service/inbound_client_ips.go

@@ -19,6 +19,33 @@ func (s *InboundService) GetAllInboundClientIps() ([]model.InboundClientIps, err
 	return ips, err
 }
 
+// nodeHostedEmails is every client one node serves, its descendants' included.
+// Per-node pushes are scoped to it so their cost tracks the node, not the fleet.
+func nodeHostedEmails(db *gorm.DB, nodeID int) ([]string, error) {
+	var emails []string
+	err := db.Model(&model.NodeClientTraffic{}).Where("node_id = ?", nodeID).Pluck("email", &emails).Error
+	return emails, err
+}
+
+// GetNodeInboundClientIps returns the IP rows of the clients nodeID hosts: a node's
+// IP-limit job reads no other row, so pushing the rest only made it echo them back.
+func (s *InboundService) GetNodeInboundClientIps(nodeID int) ([]model.InboundClientIps, error) {
+	db := database.GetDB()
+	emails, err := nodeHostedEmails(db, nodeID)
+	if err != nil || len(emails) == 0 {
+		return nil, err
+	}
+	var ips []model.InboundClientIps
+	for _, batch := range chunkStrings(emails, sqlInChunk) {
+		var page []model.InboundClientIps
+		if err := db.Where("client_email IN ?", batch).Find(&page).Error; err != nil {
+			return nil, err
+		}
+		ips = append(ips, page...)
+	}
+	return ips, nil
+}
+
 // clientIpStaleAfterSeconds mirrors job.ipStaleAfterSeconds: client IPs older than
 // 30 minutes are evicted. Applying the same cutoff inside the cross-node merge keeps
 // the synced blob bounded and stops the master's push-back from resurrecting IPs that

+ 83 - 0
internal/web/service/inbound_enable_port_test.go

@@ -0,0 +1,83 @@
+package service
+
+import (
+	"strings"
+	"testing"
+
+	"github.com/mhsanaei/3x-ui/v3/internal/database"
+	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
+	"github.com/mhsanaei/3x-ui/v3/internal/web/runtime"
+)
+
+func setupEnablePortTest(t *testing.T) {
+	t.Helper()
+	setupConflictDB(t)
+	mgr := runtime.NewManager(runtime.LocalDeps{APIPort: func() int { return 0 }})
+	mgr.SetLocalRuntimeOverride(&fakeNodeRuntime{})
+	runtime.SetManager(mgr)
+	t.Cleanup(func() { runtime.SetManager(nil) })
+}
+
+func disableInboundRow(t *testing.T, id int) {
+	t.Helper()
+	if err := database.GetDB().Model(&model.Inbound{}).Where("id = ?", id).Update("enable", false).Error; err != nil {
+		t.Fatalf("disable row %d: %v", id, err)
+	}
+}
+
+// Saving a row while it is disabled skips every port guard, so enabling it later
+// was the one path that could still put two inbounds on one socket.
+func TestSetInboundEnableRefusesAPortAnotherEnabledInboundServes(t *testing.T) {
+	setupEnablePortTest(t)
+	seedInboundConflict(t, "holder", "0.0.0.0", 44431, model.VLESS, `{"network":"tcp"}`, `{}`)
+	seedInboundConflict(t, "sleeper", "0.0.0.0", 44431, model.VLESS, `{"network":"tcp"}`, `{}`)
+	sleeper := loadInboundByTag(t, "sleeper")
+	disableInboundRow(t, sleeper.Id)
+
+	_, err := (&InboundService{}).SetInboundEnable(sleeper.Id, true)
+	if err == nil {
+		t.Fatal("enabling a row onto a port another enabled inbound serves must be refused")
+	}
+	if !strings.Contains(err.Error(), "holder") {
+		t.Fatalf("the refusal must name the row that owns the port; got %q", err)
+	}
+	if after := loadInboundByTag(t, "sleeper"); after.Enable {
+		t.Fatal("a refused enable must not write the flag")
+	}
+}
+
+// The enable path must keep the tcp/udp coexistence the rest of the guards
+// allow, or it would refuse half of the working setups out there.
+func TestSetInboundEnableAllowsTCPUDPCoexistence(t *testing.T) {
+	setupEnablePortTest(t)
+	seedInboundConflict(t, "udp-holder", "0.0.0.0", 44432, model.Hysteria, ``, `{}`)
+	seedInboundConflict(t, "tcp-sleeper", "0.0.0.0", 44432, model.VLESS, `{"network":"tcp"}`, `{}`)
+	sleeper := loadInboundByTag(t, "tcp-sleeper")
+	disableInboundRow(t, sleeper.Id)
+
+	if _, err := (&InboundService{}).SetInboundEnable(sleeper.Id, true); err != nil {
+		t.Fatalf("a tcp inbound must be able to enable onto a udp-only row's port: %v", err)
+	}
+	if after := loadInboundByTag(t, "tcp-sleeper"); !after.Enable {
+		t.Fatal("the row must be enabled")
+	}
+}
+
+// Nodes run their own Xray, so a node row sharing a local port is legal and must
+// stay enableable.
+func TestSetInboundEnableAllowsANodeRowOnALocalPort(t *testing.T) {
+	setupEnablePortTest(t)
+	seedInboundConflict(t, "local-holder", "0.0.0.0", 44433, model.VLESS, `{"network":"tcp"}`, `{}`)
+
+	node := &model.Node{Name: "n1", Address: "127.0.0.1", Port: 2096, Scheme: "https", Enable: true, Status: "online"}
+	if err := database.GetDB().Create(node).Error; err != nil {
+		t.Fatalf("seed node: %v", err)
+	}
+	seedInboundConflictNode(t, "node-row", "0.0.0.0", 44433, model.VLESS, `{"network":"tcp"}`, `{}`, &node.Id)
+	row := loadInboundByTag(t, "node-row")
+	disableInboundRow(t, row.Id)
+
+	if _, err := (&InboundService{}).SetInboundEnable(row.Id, true); err != nil {
+		t.Fatalf("a node row must be enableable regardless of a local row's port: %v", err)
+	}
+}

+ 36 - 10
internal/web/service/inbound_node.go

@@ -12,6 +12,7 @@ import (
 	"github.com/mhsanaei/3x-ui/v3/internal/database"
 	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
 	"github.com/mhsanaei/3x-ui/v3/internal/logger"
+	"github.com/mhsanaei/3x-ui/v3/internal/util/common"
 	"github.com/mhsanaei/3x-ui/v3/internal/web/runtime"
 	"github.com/mhsanaei/3x-ui/v3/internal/xray"
 
@@ -33,6 +34,10 @@ const nodeBulkPushThreshold = 32
 // committed and the node flagged dirty, so a slow node defers to the reconcile.
 const nodeClientPushTimeout = 4 * time.Second
 
+// nodeFanoutConcurrency bounds an operation that calls every node, as the heartbeat
+// does: one at a time, a few hanging nodes outlast the request's write timeout.
+const nodeFanoutConcurrency = 32
+
 func nodePushContext() (context.Context, context.CancelFunc) {
 	return context.WithTimeout(context.Background(), nodeClientPushTimeout)
 }
@@ -354,6 +359,10 @@ func (s *InboundService) SetRemoteTraffic(nodeID int, snap *runtime.TrafficSnaps
 		structuralChange, inner = s.setRemoteTrafficLocked(nodeID, snap, dirty, justPushed)
 		return inner
 	})
+	if err != nil {
+		// As on a failed fetch: a node whose snapshot did not merge keeps no online set.
+		s.ClearNodeOnlineClients(nodeID)
+	}
 	return structuralChange, err
 }
 
@@ -1376,17 +1385,20 @@ func (s *InboundService) restartRemoteNodesOnDisable(nodeIDs []int) {
 	if !restartOnDisable {
 		return
 	}
-	for _, nodeID := range nodeIDs {
-		nodeIDCopy := nodeID
-		rt, rtErr := runtime.GetManager().RuntimeFor(&nodeIDCopy)
-		if rtErr != nil {
-			logger.Warning("disableInvalidClients: get runtime for node", nodeID, "failed:", rtErr)
-			continue
-		}
-		if rtErr = rt.RestartXray(context.Background()); rtErr != nil {
-			logger.Warning("disableInvalidClients: restart xray on node", nodeID, "failed:", rtErr)
+	// Best-effort and never replayed: a hanging node must not hold the traffic poll.
+	common.GoRecover("restart-nodes-on-client-disable", func() {
+		for _, nodeID := range nodeIDs {
+			nodeIDCopy := nodeID
+			rt, rtErr := runtime.GetManager().RuntimeFor(&nodeIDCopy)
+			if rtErr != nil {
+				logger.Warning("disableInvalidClients: get runtime for node", nodeID, "failed:", rtErr)
+				continue
+			}
+			if rtErr = rt.RestartXray(context.Background()); rtErr != nil {
+				logger.Warning("disableInvalidClients: restart xray on node", nodeID, "failed:", rtErr)
+			}
 		}
-	}
+	})
 }
 
 func (s *InboundService) GetOnlineClients() []string {
@@ -1450,6 +1462,20 @@ func (s *InboundService) ClearNodeOnlineClients(nodeID int) {
 	}
 }
 
+// RetainSyncedNodeOnlineClients keeps online clients only for nodes the traffic
+// sync still fetches; a node missing from nodes was deleted.
+func (s *InboundService) RetainSyncedNodeOnlineClients(nodes []*model.Node) {
+	process := currentXrayProcess()
+	if process == nil {
+		return
+	}
+	synced := make(map[int]bool, len(nodes))
+	for _, n := range nodes {
+		synced[n.Id] = n.Enable && n.Status == "online"
+	}
+	process.RetainNodeOnlineClients(func(nodeID int) bool { return synced[nodeID] })
+}
+
 // panelGuid returns this panel's stable self-identifier, used to key the local
 // panel's own clients in the per-node online maps (#4983).
 func (s *InboundService) panelGuid() string {

+ 21 - 9
internal/web/service/inbound_traffic.go

@@ -27,18 +27,24 @@ const depletedClientsClause = "reset = 0 and reset_day = 0 and ((total > 0 and u
 
 func (s *InboundService) AddTraffic(inboundTraffics []*xray.Traffic, clientTraffics []*xray.ClientTraffic) (needRestart bool, clientsDisabled bool, err error) {
 	var disabledNodeIDs []int
+	var remotePlans []trafficInboundUpdatePlan
 	err = submitTrafficWrite(func() error {
 		var inner error
-		needRestart, clientsDisabled, disabledNodeIDs, inner = s.addTrafficLocked(inboundTraffics, clientTraffics)
+		needRestart, clientsDisabled, disabledNodeIDs, remotePlans, inner = s.addTrafficLocked(inboundTraffics, clientTraffics)
 		return inner
 	})
-	if err == nil && len(disabledNodeIDs) > 0 {
+	if err != nil {
+		return
+	}
+	// Off the serial writer: a hanging node must not stall traffic accounting.
+	needRestart = s.applyTrafficRemotePlans(remotePlans) || needRestart
+	if len(disabledNodeIDs) > 0 {
 		s.restartRemoteNodesOnDisable(disabledNodeIDs)
 	}
 	return
 }
 
-func (s *InboundService) addTrafficLocked(inboundTraffics []*xray.Traffic, clientTraffics []*xray.ClientTraffic) (bool, bool, []int, error) {
+func (s *InboundService) addTrafficLocked(inboundTraffics []*xray.Traffic, clientTraffics []*xray.ClientTraffic) (bool, bool, []int, []trafficInboundUpdatePlan, error) {
 	db := database.GetDB()
 	// Commit durable traffic before best-effort lifecycle maintenance so helper
 	// failures cannot discard usage already reported by Xray.
@@ -48,7 +54,7 @@ func (s *InboundService) addTrafficLocked(inboundTraffics []*xray.Traffic, clien
 		}
 		return s.addClientTraffic(tx, clientTraffics)
 	}); err != nil {
-		return false, false, nil, err
+		return false, false, nil, nil, err
 	}
 
 	var (
@@ -93,10 +99,10 @@ func (s *InboundService) addTrafficLocked(inboundTraffics []*xray.Traffic, clien
 	})
 	if err != nil {
 		logger.Warning("traffic lifecycle maintenance failed after traffic commit:", err)
-		return false, false, nil, nil
+		return false, false, nil, nil, nil
 	}
 	needRestart = needRestart || s.applyTrafficMutationBatch(batch)
-	return needRestart, clientsDisabled, disabledNodeIDs, nil
+	return needRestart, clientsDisabled, disabledNodeIDs, batch.remotePlans, nil
 }
 
 func (s *InboundService) addInboundTraffic(tx *gorm.DB, traffics []*xray.Traffic) error {
@@ -728,6 +734,7 @@ func (s *InboundService) resetClientTrafficLocked(id int, clientEmail string) (b
 					"flow":     client.Flow,
 					"password": client.Password,
 					"cipher":   cipher,
+					"reverse":  client.Reverse,
 				}
 				if inbound.NodeID != nil {
 					reenableNodeID = inbound.NodeID
@@ -826,13 +833,18 @@ func (s *InboundService) propagateResetAllTrafficsToNodes() {
 	if err != nil {
 		return
 	}
-	for _, node := range nodes {
-		if rt, err := runtime.GetManager().RuntimeFor(&node.Id); err == nil {
+	ids := make([]int, len(nodes))
+	for i, node := range nodes {
+		ids[i] = node.Id
+	}
+	fanoutInboundResults(ids, nodeFanoutConcurrency, func(i int) struct{} {
+		if rt, err := runtime.GetManager().RuntimeFor(&ids[i]); err == nil {
 			if e := rt.ResetAllTraffics(context.Background()); e != nil {
 				logger.Warning("ResetAllTraffics: remote propagation to", rt.Name(), "failed:", e)
 			}
 		}
-	}
+		return struct{}{}
+	})
 }
 
 func (s *InboundService) ResetInboundTraffic(id int) error {

+ 25 - 10
internal/web/service/inbound_traffic_apply.go

@@ -57,22 +57,37 @@ func (b *trafficMutationBatch) markNodesTx(tx *gorm.DB) error {
 	return nil
 }
 
-func (s *InboundService) applyTrafficMutationBatch(b *trafficMutationBatch) bool {
-	if b == nil {
-		return false
+// applyTrafficRemotePlans is bounded like every per-client node push: the nodes are
+// already dirty, so an offline or slow one defers to the reconcile.
+func (s *InboundService) applyTrafficRemotePlans(plans []trafficInboundUpdatePlan) bool {
+	ids := make([]int, len(plans))
+	for i := range plans {
+		ids[i] = plans[i].newInbound.Id
 	}
-	needRestart := false
-	for i := range b.remotePlans {
-		plan := &b.remotePlans[i]
-		rt, err := s.runtimeFor(&plan.newInbound)
-		if err == nil {
-			err = rt.UpdateInbound(context.Background(), &plan.oldInbound, &plan.newInbound)
+	failed, panics := fanoutInboundResults(ids, inboundFanoutConcurrency, func(i int) bool {
+		rt, push, _, err := s.nodePushPlan(&plans[i].newInbound)
+		if err == nil && push {
+			ctx, cancel := nodePushContext()
+			err = rt.UpdateInbound(ctx, &plans[i].oldInbound, &plans[i].newInbound)
+			cancel()
 		}
 		if err != nil {
 			logger.Debug("traffic post-commit remote apply failed:", err)
-			needRestart = true
 		}
+		return err != nil
+	})
+	needRestart := false
+	for i := range failed {
+		needRestart = needRestart || failed[i] || panics[i] != nil
 	}
+	return needRestart
+}
+
+func (s *InboundService) applyTrafficMutationBatch(b *trafficMutationBatch) bool {
+	if b == nil {
+		return false
+	}
+	needRestart := false
 	for i := range b.localPlans {
 		plan := &b.localPlans[i]
 		if plan.inbound.Protocol == model.MTProto {

+ 2 - 4
internal/web/service/inbound_traffic_global.go

@@ -168,10 +168,8 @@ func overlayGlobalTrafficValues(db *gorm.DB, rows []xray.ClientTraffic) {
 // its own aggregate.
 func (s *InboundService) GetNodeClientTraffics(nodeID int) ([]*xray.ClientTraffic, error) {
 	db := database.GetDB()
-	var emails []string
-	if err := db.Model(&model.NodeClientTraffic{}).
-		Where("node_id = ?", nodeID).
-		Pluck("email", &emails).Error; err != nil {
+	emails, err := nodeHostedEmails(db, nodeID)
+	if err != nil {
 		return nil, err
 	}
 	if len(emails) == 0 {

+ 12 - 7
internal/web/service/node.go

@@ -878,8 +878,9 @@ func (s *NodeService) Delete(id int) error {
 	if mgr := runtime.GetManager(); mgr != nil {
 		mgr.InvalidateNode(id)
 	}
-	nodeMetrics.drop(nodeMetricKey(id, "cpu"))
-	nodeMetrics.drop(nodeMetricKey(id, "mem"))
+	for _, metric := range NodeMetricKeys {
+		nodeMetrics.drop(nodeMetricKey(id, metric))
+	}
 	return nil
 }
 
@@ -935,12 +936,11 @@ func (s *NodeService) UpdatePanels(ids []int, dev bool) ([]NodeUpdateResult, err
 	if mgr == nil {
 		return nil, fmt.Errorf("runtime manager unavailable")
 	}
-	results := make([]NodeUpdateResult, 0, len(ids))
-	for _, id := range ids {
+	results, panics := fanoutInboundResults(ids, nodeFanoutConcurrency, func(i int) NodeUpdateResult {
+		id := ids[i]
 		n, err := s.GetById(id)
 		if err != nil || n == nil {
-			results = append(results, NodeUpdateResult{Id: id, OK: false, Error: "node not found"})
-			continue
+			return NodeUpdateResult{Id: id, OK: false, Error: "node not found"}
 		}
 		res := NodeUpdateResult{Id: id, Name: n.Name}
 		switch {
@@ -963,7 +963,12 @@ func (s *NodeService) UpdatePanels(ids []int, dev bool) ([]NodeUpdateResult, err
 				res.OK = true
 			}
 		}
-		results = append(results, res)
+		return res
+	})
+	for i, panicErr := range panics {
+		if panicErr != nil {
+			results[i] = NodeUpdateResult{Id: ids[i], Error: panicErr.Error()}
+		}
 	}
 	return results, nil
 }

+ 161 - 0
internal/web/service/node_admin_fanout_test.go

@@ -0,0 +1,161 @@
+package service
+
+import (
+	"context"
+	"fmt"
+	"io"
+	"net/http"
+	"net/http/httptest"
+	"strconv"
+	"strings"
+	"sync"
+	"sync/atomic"
+	"testing"
+	"time"
+
+	"github.com/mhsanaei/3x-ui/v3/internal/database"
+	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
+)
+
+// fanoutGate holds every node call open until released, so a test sees how many
+// nodes an operation reaches at once.
+type fanoutGate struct {
+	entered atomic.Int32
+	release chan struct{}
+	once    sync.Once
+}
+
+func newFanoutGate() *fanoutGate { return &fanoutGate{release: make(chan struct{})} }
+
+func (g *fanoutGate) hold(ctx context.Context) error {
+	g.entered.Add(1)
+	select {
+	case <-ctx.Done():
+		return ctx.Err()
+	case <-g.release:
+		return nil
+	}
+}
+
+func (g *fanoutGate) open() { g.once.Do(func() { close(g.release) }) }
+
+func (g *fanoutGate) waitAll(t *testing.T, want int32, op string) {
+	t.Helper()
+	deadline := time.Now().Add(3 * time.Second)
+	for g.entered.Load() < want {
+		if time.Now().After(deadline) {
+			t.Fatalf("%s reached %d of %d hanging nodes, want all of them at once", op, g.entered.Load(), want)
+		}
+		time.Sleep(10 * time.Millisecond)
+	}
+}
+
+type gatedNodeRuntime struct {
+	fakeNodeRuntime
+	gate *fanoutGate
+}
+
+func (r *gatedNodeRuntime) ResetAllTraffics(ctx context.Context) error { return r.gate.hold(ctx) }
+
+func (r *gatedNodeRuntime) DelInbound(ctx context.Context, _ *model.Inbound) error {
+	return r.gate.hold(ctx)
+}
+
+func gatedNodes(t *testing.T, gate *fanoutGate, n int) []int {
+	t.Helper()
+	mgr := useTestRuntimeManager(t)
+	ids := make([]int, 0, n)
+	for i := range n {
+		node := &model.Node{Name: fmt.Sprintf("fanout-%d", i), Address: "127.0.0.1", Port: 2100 + i, ApiToken: "tok", Enable: true, Status: "online"}
+		if err := database.GetDB().Create(node).Error; err != nil {
+			t.Fatalf("create node: %v", err)
+		}
+		mgr.SetRuntimeOverride(node.Id, &gatedNodeRuntime{gate: gate})
+		ids = append(ids, node.Id)
+	}
+	return ids
+}
+
+// Operations that touch every node walked them one at a time, so a few hanging
+// nodes kept the request running for minutes past the panel's write timeout.
+func TestResetAllTrafficsReachesNodesConcurrently(t *testing.T) {
+	setupConflictDB(t)
+	gate := newFanoutGate()
+	gatedNodes(t, gate, 3)
+	done := make(chan struct{})
+	go func() {
+		defer close(done)
+		_ = (&InboundService{}).ResetAllTraffics()
+	}()
+	t.Cleanup(func() { gate.open(); <-done })
+	gate.waitAll(t, 3, "ResetAllTraffics")
+}
+
+func TestDelInboundsPushesNodeDeletesConcurrently(t *testing.T) {
+	setupConflictDB(t)
+	gate := newFanoutGate()
+	var inboundIDs []int
+	for i, nodeID := range gatedNodes(t, gate, 3) {
+		inboundIDs = append(inboundIDs, nodeInbound(t, nodeID, 46400+i, nil).Id)
+	}
+	done := make(chan struct{})
+	var result BulkDelInboundResult
+	var err error
+	go func() {
+		defer close(done)
+		result, _, err = (&InboundService{}).DelInbounds(inboundIDs)
+	}()
+	t.Cleanup(func() { gate.open(); <-done })
+	gate.waitAll(t, 3, "DelInbounds")
+	gate.open()
+	<-done
+	if err != nil || result.Deleted != 3 || len(result.Skipped) != 0 {
+		t.Fatalf("DelInbounds = %+v, %v; want 3 deleted", result, err)
+	}
+}
+
+func TestUpdatePanelsReachesNodesConcurrently(t *testing.T) {
+	setupConflictDB(t)
+	useTestRuntimeManager(t)
+	gate := newFanoutGate()
+	var ids []int
+	for i := range 3 {
+		srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+			_, _ = io.Copy(io.Discard, r.Body)
+			if strings.HasSuffix(r.URL.Path, "server/updatePanel") {
+				_ = gate.hold(r.Context())
+			}
+			w.Header().Set("Content-Type", "application/json")
+			_, _ = w.Write([]byte(`{"success":true}`))
+		}))
+		t.Cleanup(srv.Close)
+		host, port, _ := strings.Cut(strings.TrimPrefix(srv.URL, "http://"), ":")
+		portNum, _ := strconv.Atoi(port)
+		node := &model.Node{
+			Name: fmt.Sprintf("panel-%d", i), Scheme: "http", Address: host, Port: portNum, BasePath: "/",
+			ApiToken: "tok", Enable: true, Status: "online", AllowPrivateAddress: true, TlsVerifyMode: "verify",
+		}
+		if err := database.GetDB().Create(node).Error; err != nil {
+			t.Fatalf("create node: %v", err)
+		}
+		ids = append(ids, node.Id)
+	}
+	done := make(chan struct{})
+	var results []NodeUpdateResult
+	go func() {
+		defer close(done)
+		results, _ = (&NodeService{}).UpdatePanels(ids, false)
+	}()
+	t.Cleanup(func() { gate.open(); <-done })
+	gate.waitAll(t, 3, "UpdatePanels")
+	gate.open()
+	<-done
+	if len(results) != len(ids) {
+		t.Fatalf("UpdatePanels returned %d results for %d nodes", len(results), len(ids))
+	}
+	for i, res := range results {
+		if res.Id != ids[i] || !res.OK {
+			t.Fatalf("UpdatePanels result %d = %+v, want node %d updated, in request order", i, res, ids[i])
+		}
+	}
+}

+ 43 - 0
internal/web/service/node_delete_cleanup_test.go

@@ -0,0 +1,43 @@
+package service
+
+import (
+	"strings"
+	"testing"
+	"time"
+
+	"github.com/mhsanaei/3x-ui/v3/internal/database"
+	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
+)
+
+// Deleting a node dropped only its cpu and mem series, while the heartbeat also
+// records netUp and netDown, so each deleted node leaked two histories for good.
+func TestDeleteNodeDropsEveryMetricSeries(t *testing.T) {
+	setupConflictDB(t)
+	node := &model.Node{Id: 9101, Name: "gone", Address: "127.0.0.1", Port: 2096, ApiToken: "tok", Enable: true, Status: "online"}
+	if err := database.GetDB().Create(node).Error; err != nil {
+		t.Fatalf("create node: %v", err)
+	}
+	ns := NodeService{}
+	if err := ns.UpdateHeartbeat(node.Id, HeartbeatPatch{
+		Status: "online", LastHeartbeat: time.Now().Unix(), CpuPct: 1, MemPct: 2, NetUp: 3, NetDown: 4,
+	}); err != nil {
+		t.Fatalf("UpdateHeartbeat: %v", err)
+	}
+
+	if err := ns.Delete(node.Id); err != nil {
+		t.Fatalf("Delete: %v", err)
+	}
+
+	prefix := nodeMetricKey(node.Id, "")
+	var left []string
+	nodeMetrics.mu.Lock()
+	for key := range nodeMetrics.series {
+		if strings.HasPrefix(key, prefix) {
+			left = append(left, key)
+		}
+	}
+	nodeMetrics.mu.Unlock()
+	if len(left) != 0 {
+		t.Fatalf("metric series left after deleting the node: %v", left)
+	}
+}

+ 16 - 0
internal/web/service/node_tree.go

@@ -86,6 +86,22 @@ func (s *NodeService) ClearDescendants(nodeID int) {
 	nodeDescendantsMu.Unlock()
 }
 
+// RetainEnabledNodeDescendants drops sub-nodes learned from nodes the heartbeat no
+// longer probes: disabled ones it skips, deleted ones missing from nodes.
+func (s *NodeService) RetainEnabledNodeDescendants(nodes []*model.Node) {
+	enabled := make(map[int]bool, len(nodes))
+	for _, n := range nodes {
+		enabled[n.Id] = n.Enable
+	}
+	nodeDescendantsMu.Lock()
+	for nodeID := range nodeDescendantsCache {
+		if !enabled[nodeID] {
+			delete(nodeDescendantsCache, nodeID)
+		}
+	}
+	nodeDescendantsMu.Unlock()
+}
+
 func cachedDescendants() []model.NodeSummary {
 	nodeDescendantsMu.RLock()
 	defer nodeDescendantsMu.RUnlock()

+ 69 - 0
internal/web/service/node_unsynced_online_test.go

@@ -0,0 +1,69 @@
+package service
+
+import (
+	"fmt"
+	"reflect"
+	"testing"
+
+	"github.com/mhsanaei/3x-ui/v3/internal/database"
+	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
+	"github.com/mhsanaei/3x-ui/v3/internal/web/runtime"
+	"github.com/mhsanaei/3x-ui/v3/internal/xray"
+)
+
+func useOnlineTestProcess(t *testing.T) *xray.Process {
+	t.Helper()
+	previousProcess, previousResult := xrayState.snapshot()
+	process := xray.NewTestProcess(nil, "")
+	xrayState.replace(process)
+	t.Cleanup(func() {
+		xrayState.mu.Lock()
+		xrayState.process = previousProcess
+		xrayState.result = previousResult
+		xrayState.mu.Unlock()
+	})
+	return process
+}
+
+// Only a failed snapshot fetch used to clear a node's online set, so a node the
+// sync stopped reaching (disabled, marked offline, deleted) kept its clients online.
+func TestRetainSyncedNodeOnlineClientsDropsUnsyncedNodes(t *testing.T) {
+	setupConflictDB(t)
+	process := useOnlineTestProcess(t)
+	svc := InboundService{}
+	for id := 1; id <= 4; id++ {
+		guid := fmt.Sprintf("g%d", id)
+		svc.SetNodeOnlineTree(id, map[string][]string{guid: {guid + "@x"}})
+		process.SetNodeActiveInboundTree(id, map[string][]string{guid: {"in-" + guid}})
+	}
+
+	svc.RetainSyncedNodeOnlineClients([]*model.Node{
+		{Id: 1, Enable: true, Status: "online"},
+		{Id: 2, Enable: false, Status: "online"},
+		{Id: 3, Enable: true, Status: "offline"},
+	})
+
+	if got, want := svc.GetOnlineClientsByGuid(), map[string][]string{"g1": {"g1@x"}}; !reflect.DeepEqual(got, want) {
+		t.Errorf("online by guid = %v, want %v", got, want)
+	}
+	if got, want := svc.GetActiveInboundsByGuid(), map[string][]string{"g1": {"in-g1"}}; !reflect.DeepEqual(got, want) {
+		t.Errorf("active inbounds by guid = %v, want %v", got, want)
+	}
+}
+
+func TestSetRemoteTrafficFailureClearsNodeOnlineClients(t *testing.T) {
+	setupConflictDB(t)
+	useOnlineTestProcess(t)
+	svc := InboundService{}
+	svc.SetNodeOnlineTree(7, map[string][]string{"g7": {"a@x"}})
+	if err := database.GetDB().Exec("DROP TABLE inbounds").Error; err != nil {
+		t.Fatalf("drop inbounds: %v", err)
+	}
+
+	if _, err := svc.SetRemoteTraffic(7, &runtime.TrafficSnapshot{}, false, false); err == nil {
+		t.Fatal("SetRemoteTraffic succeeded without an inbounds table")
+	}
+	if got := svc.GetOnlineClientsByGuid(); len(got) != 0 {
+		t.Errorf("online by guid after a failed merge = %v, want none", got)
+	}
+}

+ 3 - 2
internal/web/service/panel/user.go

@@ -2,8 +2,8 @@ package panel
 
 import (
 	"errors"
+	"time"
 
-	"github.com/xlzd/gotp"
 	"gorm.io/gorm"
 
 	"github.com/mhsanaei/3x-ui/v3/internal/database"
@@ -11,6 +11,7 @@ import (
 	"github.com/mhsanaei/3x-ui/v3/internal/logger"
 	"github.com/mhsanaei/3x-ui/v3/internal/util/crypto"
 	ldaputil "github.com/mhsanaei/3x-ui/v3/internal/util/ldap"
+	"github.com/mhsanaei/3x-ui/v3/internal/util/totp"
 	"github.com/mhsanaei/3x-ui/v3/internal/web/service"
 )
 
@@ -97,7 +98,7 @@ func (s *UserService) CheckUser(username string, password string, twoFactorCode
 			return nil, err
 		}
 
-		if gotp.NewDefaultTOTP(twoFactorToken).Now() != twoFactorCode {
+		if !totp.VerifyWithSkew(twoFactorToken, twoFactorCode, time.Now()) {
 			return nil, errors.New("invalid 2fa code")
 		}
 	}

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

@@ -5,6 +5,7 @@ import (
 	"fmt"
 	"strings"
 
+	"github.com/mhsanaei/3x-ui/v3/internal/amneziawg"
 	"github.com/mhsanaei/3x-ui/v3/internal/amneziawgnet"
 	"github.com/mhsanaei/3x-ui/v3/internal/database"
 	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
@@ -108,8 +109,11 @@ type portConflictDetail struct {
 	Listen    string
 	Port      int
 	// Relay marks Port as an automatic loopback relay port, not a configured one.
-	Relay      bool
-	Transports transportBits
+	Relay bool
+	// ForwardedBy is the peer whose port forward holds Port, when that is the
+	// reason for the conflict.
+	ForwardedBy string
+	Transports  transportBits
 }
 
 // String renders the detail as a single-line, user-facing summary.
@@ -134,6 +138,10 @@ func (d *portConflictDetail) String() string {
 	if d.Relay {
 		port = fmt.Sprintf("relay port %d", d.Port)
 	}
+	if d.ForwardedBy != "" {
+		return fmt.Sprintf("%s (%s) already forwarded on inbound %s on %s by its client %s",
+			port, transportTagSuffix(d.Transports), name, listen, d.ForwardedBy)
+	}
 	return fmt.Sprintf("%s (%s) already used by inbound %s on %s",
 		port, transportTagSuffix(d.Transports), name, listen)
 }
@@ -242,6 +250,17 @@ func checkPortConflictTx(db *gorm.DB, inbound *model.Inbound, ignoreId int) (*po
 		}
 	}
 
+	// A forwarded port is not a column and not a relay slot, so the query below
+	// cannot see it either: the port is bound by the peer's forward listener.
+	forwardedBy, err := amneziawgForwardedPortOwner(db, inbound, ignoreId)
+	if err != nil {
+		return nil, err
+	}
+	if forwardedBy != nil {
+		forwardedBy.Transports = newBits
+		return forwardedBy, nil
+	}
+
 	var candidates []*model.Inbound
 	q := db.Model(model.Inbound{}).Where("port = ?", inbound.Port)
 	if ignoreId > 0 {
@@ -275,6 +294,43 @@ func checkPortConflictTx(db *gorm.DB, inbound *model.Inbound, ignoreId int) (*po
 	return nil, nil
 }
 
+// amneziawgForwardedPortOwner names the AmneziaWG row on the same host whose peer
+// forwards inbound's port -- a bind on every interface that only the AWG side checked.
+func amneziawgForwardedPortOwner(db *gorm.DB, inbound *model.Inbound, ignoreId int) (*portConflictDetail, error) {
+	var rows []*model.Inbound
+	q := db.Model(model.Inbound{}).Where("protocol = ?", model.AmneziaWG)
+	if ignoreId > 0 {
+		q = q.Where("id != ?", ignoreId)
+	}
+	if err := q.Find(&rows).Error; err != nil {
+		return nil, err
+	}
+	for _, row := range rows {
+		if !sameNode(row.NodeID, inbound.NodeID) {
+			continue
+		}
+		instance, ok := amneziawg.InstanceFromInbound(row)
+		if !ok {
+			continue
+		}
+		email, forwards := amneziawgnet.ForwardedPortOwner(instance, inbound.Port)
+		if !forwards {
+			continue
+		}
+		return &portConflictDetail{
+			InboundID: row.Id,
+			Remark:    row.Remark,
+			Tag:       row.Tag,
+			// the forward binds :port on every interface, wherever the
+			// candidate asked to listen.
+			Listen:      "",
+			Port:        inbound.Port,
+			ForwardedBy: email,
+		}, nil
+	}
+	return nil, nil
+}
+
 // checkAmneziawgnetSocksConflict: inbound's port vs the relay port every matching
 // local row reserves, emitted or not; db keeps it in the caller's transaction (#6225).
 func checkAmneziawgnetSocksConflict(db *gorm.DB, inbound *model.Inbound, ignoreId int, newBits transportBits) (*portConflictDetail, error) {

+ 135 - 0
internal/web/service/port_conflict_forwarded_test.go

@@ -0,0 +1,135 @@
+package service
+
+import (
+	"strings"
+	"testing"
+
+	"github.com/mhsanaei/3x-ui/v3/internal/database"
+	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
+)
+
+// checkForwardedPortsConflict only ran from the AmneziaWG save path, so an
+// ordinary inbound could take a port a peer forwards on every interface.
+func TestAddInboundRefusesAPortAnAmneziaWGPeerForwards(t *testing.T) {
+	const forwarded = 8443
+	cases := []struct {
+		name    string
+		port    int
+		wantErr bool
+	}{
+		{"the forwarded port", forwarded, true},
+		{"a free port", forwarded + 1, false},
+	}
+
+	for _, tc := range cases {
+		t.Run(tc.name, func(t *testing.T) {
+			setupConflictDB(t)
+			seedInboundConflict(t, "awg-forward", "0.0.0.0", 51820, model.AmneziaWG, ``,
+				awgRelayWindowSettingsWithForward(t, "awg-forward", "8443"))
+
+			_, _, err := (&InboundService{}).AddInbound(&model.Inbound{
+				Tag: "user-inbound", Enable: true, Listen: "0.0.0.0", Port: tc.port,
+				Protocol: model.VLESS, StreamSettings: `{"network":"tcp"}`, Settings: `{"clients":[]}`,
+			})
+			if !tc.wantErr {
+				if err != nil {
+					t.Fatalf("port %d is free; the create must be allowed: %v", tc.port, err)
+				}
+				return
+			}
+			if err == nil {
+				t.Fatalf("port %d is forwarded by a peer of another inbound; the create must be refused", tc.port)
+			}
+			if !strings.Contains(err.Error(), "awg-forward@relay-window") {
+				t.Fatalf("the refusal must name the peer holding the port, got %v", err)
+			}
+		})
+	}
+}
+
+// A peer the forward supervisor opens no listener for holds no port: it has no
+// email, or no address the tunnel can route to, and Reconcile skips it either way.
+func TestAddInboundAllowsAPortNoPeerCanActuallyForward(t *testing.T) {
+	cases := []struct {
+		name     string
+		settings func(t *testing.T) string
+	}{
+		{
+			name: "a peer with no email",
+			settings: func(t *testing.T) string {
+				t.Helper()
+				return replaceFirst(t, awgRelayWindowSettingsWithForward(t, "awg-forward", "8443"),
+					`"email":"awg-forward@relay-window"`, `"email":""`)
+			},
+		},
+		{
+			name: "an IPv6-only peer on a row without IPv6",
+			settings: func(t *testing.T) string {
+				t.Helper()
+				return replaceFirst(t, awgRelayWindowSettingsWithForward(t, "awg-forward", "8443"),
+					`"allowedIPs":["10.8.1.2/32"]`, `"allowedIPs":["fd00::2/128"]`)
+			},
+		},
+	}
+
+	for _, tc := range cases {
+		t.Run(tc.name, func(t *testing.T) {
+			setupConflictDB(t)
+			seedInboundConflict(t, "awg-forward", "0.0.0.0", 51820, model.AmneziaWG, ``, tc.settings(t))
+
+			if _, _, err := (&InboundService{}).AddInbound(&model.Inbound{
+				Tag: "user-inbound", Enable: true, Listen: "0.0.0.0", Port: 8443,
+				Protocol: model.VLESS, StreamSettings: `{"network":"tcp"}`, Settings: `{"clients":[]}`,
+			}); err != nil {
+				t.Fatalf("nothing binds 8443 for this peer; the create must be allowed: %v", err)
+			}
+		})
+	}
+}
+
+// The refusal has to point at where the socket really is: the forward listens on
+// every interface, so repeating the candidate's requested address asserts a lie.
+func TestForwardedPortRefusalNamesTheWildcardBind(t *testing.T) {
+	setupConflictDB(t)
+	seedInboundConflict(t, "awg-forward", "0.0.0.0", 51820, model.AmneziaWG, ``,
+		awgRelayWindowSettingsWithForward(t, "awg-forward", "8443"))
+
+	_, _, err := (&InboundService{}).AddInbound(&model.Inbound{
+		Tag: "user-inbound", Enable: true, Listen: "10.0.0.5", Port: 8443,
+		Protocol: model.VLESS, StreamSettings: `{"network":"tcp"}`, Settings: `{"clients":[]}`,
+	})
+	if err == nil {
+		t.Fatal("the port is forwarded on every interface, including 10.0.0.5; the create must be refused")
+	}
+	if !strings.Contains(err.Error(), " on * by its client ") {
+		t.Fatalf("the refusal must place the forward on every interface, got %v", err)
+	}
+}
+
+func replaceFirst(t *testing.T, s, old, new string) string {
+	t.Helper()
+	if !strings.Contains(s, old) {
+		t.Fatalf("fixture no longer contains %s", old)
+	}
+	return strings.Replace(s, old, new, 1)
+}
+
+// The forward listener runs where the AmneziaWG row runs, so a node row sharing
+// a local peer's port stays legal -- the scoping every other guard here uses.
+func TestAddInboundAllowsANodeRowOnALocallyForwardedPort(t *testing.T) {
+	setupConflictDB(t)
+	seedInboundConflict(t, "awg-forward", "0.0.0.0", 51820, model.AmneziaWG, ``,
+		awgRelayWindowSettingsWithForward(t, "awg-forward", "8443"))
+
+	node := &model.Node{Name: "n1", Address: "127.0.0.1", Port: 2096, Scheme: "https", Enable: true, Status: "online"}
+	if err := database.GetDB().Create(node).Error; err != nil {
+		t.Fatalf("seed node: %v", err)
+	}
+	if _, _, err := (&InboundService{}).AddInbound(&model.Inbound{
+		Tag: "node-inbound", Enable: true, Listen: "0.0.0.0", Port: 8443,
+		Protocol: model.VLESS, StreamSettings: `{"network":"tcp"}`, Settings: `{"clients":[]}`,
+		NodeID: &node.Id,
+	}); err != nil {
+		t.Fatalf("a node row does not bind here; the create must be allowed: %v", err)
+	}
+}

+ 3 - 1
internal/web/service/server.go

@@ -627,7 +627,9 @@ func (s *ServerService) GetStatus(lastStatus *Status) *Status {
 	// Xray status
 	if s.xrayService.IsXrayRunning() {
 		status.Xray.State = Running
-		status.Xray.ErrorMsg = ""
+		// A core that runs but was refused the new config is a fault the
+		// operator only ever sees here and in the node list.
+		status.Xray.ErrorMsg = s.xrayService.GetHeldBackConfig()
 	} else {
 		err := s.xrayService.GetXrayErr()
 		if err != nil {

+ 49 - 2
internal/web/service/setting.go

@@ -15,7 +15,6 @@ import (
 	"time"
 
 	"github.com/google/uuid"
-	"github.com/xlzd/gotp"
 	"gorm.io/gorm"
 
 	"github.com/mhsanaei/3x-ui/v3/internal/config"
@@ -26,6 +25,7 @@ import (
 	"github.com/mhsanaei/3x-ui/v3/internal/util/netproxy"
 	"github.com/mhsanaei/3x-ui/v3/internal/util/random"
 	"github.com/mhsanaei/3x-ui/v3/internal/util/reflect_util"
+	"github.com/mhsanaei/3x-ui/v3/internal/util/totp"
 	"github.com/mhsanaei/3x-ui/v3/internal/web/entity"
 	"github.com/mhsanaei/3x-ui/v3/internal/xray"
 	"github.com/mhsanaei/3x-ui/v3/internal/xray/dnsconf"
@@ -44,6 +44,13 @@ const (
 	maxRegexLength                    = 2048
 )
 
+// Built-in profile links expose the subscription URL and require an explicit opt-in.
+const (
+	SubProfileModeNone    = "none"
+	SubProfileModeBuiltin = "builtin"
+	SubProfileModeCustom  = "custom"
+)
+
 var defaultValueMap = map[string]string{
 	"xrayTemplateConfig": xrayTemplateConfig,
 	"webListen":          "",
@@ -101,6 +108,7 @@ var defaultValueMap = map[string]string{
 	"subClashUserAgentRegex":      "",
 	"subTitle":                    "",
 	"subSupportUrl":               "",
+	"subProfileMode":              SubProfileModeNone,
 	"subProfileUrl":               "",
 	"subAnnounce":                 "",
 	"subEnableRouting":            "false",
@@ -298,6 +306,11 @@ func (s *SettingService) GetAllSetting() (*entity.AllSetting, error) {
 		}
 	}
 
+	// A missing mode must still preserve URLs configured before modes existed.
+	if !keyMap["subProfileMode"] {
+		allSetting.SubProfileMode = ""
+	}
+	allSetting.SubProfileMode = effectiveSubProfileMode(allSetting.SubProfileMode, allSetting.SubProfileUrl)
 	return allSetting, nil
 }
 
@@ -655,7 +668,7 @@ func (s *SettingService) VerifyTwoFactorCode(code string) error {
 	if err != nil {
 		return err
 	}
-	if strings.TrimSpace(token) == "" || !gotp.NewDefaultTOTP(token).Verify(strings.TrimSpace(code), time.Now().Unix()) {
+	if strings.TrimSpace(token) == "" || !totp.VerifyWithSkew(token, strings.TrimSpace(code), time.Now()) {
 		return common.NewError("invalid two factor code")
 	}
 	return nil
@@ -850,6 +863,34 @@ func (s *SettingService) GetSubProfileUrl() (string, error) {
 	return common.EnsureURLScheme(value), err
 }
 
+func (s *SettingService) GetSubProfileMode() (string, error) {
+	setting, err := s.getSetting("subProfileMode")
+	if err != nil && !database.IsNotFound(err) {
+		return SubProfileModeNone, err
+	}
+	if err == nil && setting.Value != "" {
+		return effectiveSubProfileMode(setting.Value, ""), nil
+	}
+	profileURL, err := s.getString("subProfileUrl")
+	if err != nil {
+		return SubProfileModeNone, err
+	}
+	return effectiveSubProfileMode("", profileURL), nil
+}
+
+func effectiveSubProfileMode(mode, profileURL string) string {
+	switch mode {
+	case SubProfileModeNone, SubProfileModeBuiltin, SubProfileModeCustom:
+		return mode
+	case "":
+		// Older settings have no mode; only an existing custom URL opts them in.
+		if strings.TrimSpace(profileURL) != "" {
+			return SubProfileModeCustom
+		}
+	}
+	return SubProfileModeNone
+}
+
 func (s *SettingService) GetSubAnnounce() (string, error) {
 	return s.getString("subAnnounce")
 }
@@ -1448,6 +1489,12 @@ type SecretClears struct {
 }
 
 func (s *SettingService) UpdateAllSetting(allSetting *entity.AllSetting, clears SecretClears) error {
+	switch allSetting.SubProfileMode {
+	case "", SubProfileModeNone, SubProfileModeBuiltin, SubProfileModeCustom:
+		allSetting.SubProfileMode = effectiveSubProfileMode(allSetting.SubProfileMode, allSetting.SubProfileUrl)
+	default:
+		return errors.New("subscription profile mode must be none, builtin, or custom")
+	}
 	if err := s.preserveRedactedSecrets(allSetting, clears); err != nil {
 		return err
 	}

+ 4 - 0
internal/web/service/setting_security_test.go

@@ -4,6 +4,7 @@ import (
 	"path/filepath"
 	"regexp"
 	"testing"
+	"time"
 
 	"github.com/xlzd/gotp"
 
@@ -230,6 +231,9 @@ func TestVerifyTwoFactorCode(t *testing.T) {
 	if err := s.VerifyTwoFactorCode(gotp.NewDefaultTOTP(token).Now()); err != nil {
 		t.Fatalf("valid code rejected: %v", err)
 	}
+	if err := s.VerifyTwoFactorCode(gotp.NewDefaultTOTP(token).AtTime(time.Now().Add(-30 * time.Second))); err != nil {
+		t.Fatalf("previous window code rejected: %v", err)
+	}
 	if err := s.VerifyTwoFactorCode("000000"); err == nil {
 		t.Fatal("invalid code accepted")
 	}

+ 148 - 0
internal/web/service/setting_sub_profile_test.go

@@ -0,0 +1,148 @@
+package service
+
+import (
+	"encoding/json"
+	"reflect"
+	"testing"
+
+	"github.com/mhsanaei/3x-ui/v3/internal/database"
+	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
+)
+
+func TestSubProfileModeReadsLegacyAndExplicitSettings(t *testing.T) {
+	tests := []struct {
+		name       string
+		storedMode string
+		storedURL  string
+		modeExists bool
+		want       string
+	}{
+		{name: "fresh installation", want: "none"},
+		{name: "legacy URL", storedURL: " https://profile.example/account ", want: "custom"},
+		{name: "legacy blank URL", storedURL: " \t ", want: "none"},
+		{name: "legacy empty mode with URL", modeExists: true, storedURL: "https://profile.example/account", want: "custom"},
+		{name: "legacy empty mode without URL", modeExists: true, want: "none"},
+		{name: "explicit none preserves saved URL", modeExists: true, storedMode: "none", storedURL: "https://profile.example/account", want: "none"},
+		{name: "explicit builtin", modeExists: true, storedMode: "builtin", storedURL: "https://profile.example/account", want: "builtin"},
+		{name: "explicit custom", modeExists: true, storedMode: "custom", storedURL: "https://profile.example/account", want: "custom"},
+		{name: "custom with empty URL", modeExists: true, storedMode: "custom", want: "custom"},
+		{name: "invalid stored mode fails closed", modeExists: true, storedMode: "automatic", storedURL: "https://profile.example/account", want: "none"},
+	}
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			setupSettingTestDB(t)
+			s := &SettingService{}
+			if tt.modeExists {
+				if err := s.saveSetting("subProfileMode", tt.storedMode); err != nil {
+					t.Fatal(err)
+				}
+			}
+			if err := s.saveSetting("subProfileUrl", tt.storedURL); err != nil {
+				t.Fatal(err)
+			}
+			assertSubProfileSettings(t, s, tt.want, tt.storedURL)
+		})
+	}
+}
+
+func TestSubProfileModeUpdatesPreserveURLAndLegacyPayloads(t *testing.T) {
+	setupSettingTestDB(t)
+	s := &SettingService{}
+	if got := s.GetFactoryDefaults()["subProfileMode"]; got != "none" {
+		t.Errorf("factory profile mode = %q, want none", got)
+	}
+	tests := []struct {
+		name string
+		mode string
+		url  string
+		want string
+	}{
+		{name: "custom", mode: "custom", url: "https://profile.example/account", want: "custom"},
+		{name: "none retains custom URL", mode: "none", url: "https://profile.example/account", want: "none"},
+		{name: "builtin retains custom URL", mode: "builtin", url: "https://profile.example/account", want: "builtin"},
+		{name: "custom restores saved URL", mode: "custom", url: "https://profile.example/account", want: "custom"},
+		{name: "legacy URL submission", url: "https://legacy.example/account", want: "custom"},
+		{name: "legacy empty URL submission", want: "none"},
+	}
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			settings, err := s.GetAllSetting()
+			if err != nil {
+				t.Fatal(err)
+			}
+			payload, err := json.Marshal(map[string]string{"subProfileMode": tt.mode, "subProfileUrl": tt.url})
+			if err != nil {
+				t.Fatal(err)
+			}
+			if err := json.Unmarshal(payload, settings); err != nil {
+				t.Fatal(err)
+			}
+			if err := s.UpdateAllSetting(settings, SecretClears{}); err != nil {
+				t.Fatal(err)
+			}
+			assertSubProfileSettings(t, s, tt.want, tt.url)
+			stored, err := s.getSetting("subProfileMode")
+			if err != nil {
+				t.Fatal(err)
+			}
+			if stored.Value != tt.want {
+				t.Fatalf("persisted mode = %q, want %q", stored.Value, tt.want)
+			}
+		})
+	}
+}
+
+func TestSubProfileModeRejectsInvalidUpdateBeforeWrites(t *testing.T) {
+	setupSettingTestDB(t)
+	s := &SettingService{}
+	if err := s.saveSetting("subTitle", "Original title"); err != nil {
+		t.Fatal(err)
+	}
+	var before []model.Setting
+	if err := database.GetDB().Order("id").Find(&before).Error; err != nil {
+		t.Fatal(err)
+	}
+	settings, err := s.GetAllSetting()
+	if err != nil {
+		t.Fatal(err)
+	}
+	if err := json.Unmarshal([]byte(`{"subProfileMode":"automatic","subTitle":"Changed title","subProfileUrl":"https://profile.example/account"}`), settings); err != nil {
+		t.Fatal(err)
+	}
+	err = s.UpdateAllSetting(settings, SecretClears{})
+	if err == nil || err.Error() != "subscription profile mode must be none, builtin, or custom" {
+		t.Errorf("UpdateAllSetting error = %v, want invalid profile mode error", err)
+	}
+	var after []model.Setting
+	if err := database.GetDB().Order("id").Find(&after).Error; err != nil {
+		t.Fatal(err)
+	}
+	if !reflect.DeepEqual(before, after) {
+		t.Fatal("invalid profile mode update modified stored settings")
+	}
+}
+
+func assertSubProfileSettings(t *testing.T, s *SettingService, wantMode, wantURL string) {
+	t.Helper()
+	if mode, err := s.GetSubProfileMode(); err != nil || mode != wantMode {
+		t.Fatalf("GetSubProfileMode = %q, %v; want %q, nil", mode, err, wantMode)
+	}
+	settings, err := s.GetAllSetting()
+	if err != nil {
+		t.Fatal(err)
+	}
+	payload, err := json.Marshal(settings)
+	if err != nil {
+		t.Fatal(err)
+	}
+	var profile struct {
+		Mode string `json:"subProfileMode"`
+		URL  string `json:"subProfileUrl"`
+	}
+	if err := json.Unmarshal(payload, &profile); err != nil {
+		t.Fatal(err)
+	}
+	if profile.Mode != wantMode || profile.URL != wantURL {
+		t.Fatalf("profile settings = (%q, %q), want (%q, %q)", profile.Mode, profile.URL, wantMode, wantURL)
+	}
+}

+ 159 - 0
internal/web/service/traffic_depletion_node_push_test.go

@@ -0,0 +1,159 @@
+package service
+
+import (
+	"context"
+	"fmt"
+	"testing"
+	"time"
+
+	"github.com/mhsanaei/3x-ui/v3/internal/database"
+	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
+	"github.com/mhsanaei/3x-ui/v3/internal/web/runtime"
+	"github.com/mhsanaei/3x-ui/v3/internal/xray"
+)
+
+type hangingUpdateRuntime struct {
+	fakeNodeRuntime
+	entered chan struct{}
+	release chan struct{}
+}
+
+func (h *hangingUpdateRuntime) UpdateInbound(ctx context.Context, _, _ *model.Inbound) error {
+	h.updateInbound.Add(1)
+	select {
+	case h.entered <- struct{}{}:
+	default:
+	}
+	select {
+	case <-ctx.Done():
+		return ctx.Err()
+	case <-h.release:
+		return nil
+	}
+}
+
+func seedDepletedNodeClient(t *testing.T, nodeID, port int) {
+	t.Helper()
+	client := model.Client{Email: fmt.Sprintf("spent-%d", port), Enable: true}
+	ib := nodeInbound(t, nodeID, port, []model.Client{client})
+	if err := database.GetDB().Create(&xray.ClientTraffic{
+		InboundId: ib.Id, Email: client.Email, Enable: true, Up: 100, Total: 100,
+	}).Error; err != nil {
+		t.Fatalf("seed traffic: %v", err)
+	}
+}
+
+// A depletion wave used to push every node inbound on the serial writer, one by
+// one with no deadline, so a hanging node froze traffic accounting and client edits.
+func TestTrafficDisableNodePushLeavesWriterFreeAndGivesUp(t *testing.T) {
+	setupConflictDB(t)
+	StartTrafficWriter()
+	t.Cleanup(StopTrafficWriter)
+	nodeID, _ := setupNodeRuntime(t)
+	hanging := &hangingUpdateRuntime{entered: make(chan struct{}, 1), release: make(chan struct{})}
+	runtime.GetManager().SetRuntimeOverride(nodeID, hanging)
+	t.Cleanup(func() { close(hanging.release) })
+	seedDepletedNodeClient(t, nodeID, 46311)
+	seedDepletedNodeClient(t, nodeID, 46313)
+
+	returned := make(chan error, 1)
+	go func() {
+		_, _, err := (&InboundService{}).AddTraffic(nil, nil)
+		returned <- err
+	}()
+	select {
+	case <-hanging.entered:
+	case <-time.After(5 * time.Second):
+		t.Fatal("depleted node client was never pushed to its node")
+	}
+
+	writerFree := make(chan error, 1)
+	go func() { writerFree <- submitTrafficWrite(func() error { return nil }) }()
+	select {
+	case err := <-writerFree:
+		if err != nil {
+			t.Fatalf("traffic write while node push hangs: %v", err)
+		}
+	case <-time.After(time.Second):
+		t.Fatal("traffic writer stayed held while a node push hung")
+	}
+
+	// Two hanging pushes: one at a time they would take twice the push timeout.
+	select {
+	case err := <-returned:
+		if err != nil {
+			t.Fatalf("AddTraffic: %v", err)
+		}
+	case <-time.After(nodeClientPushTimeout + 2*time.Second):
+		t.Fatal("AddTraffic kept waiting on hanging node pushes past one push timeout")
+	}
+}
+
+func TestTrafficDisableSkipsOfflineNodePushButMarksDirty(t *testing.T) {
+	setupConflictDB(t)
+	nodeID, fake := setupNodeRuntime(t)
+	if err := database.GetDB().Model(&model.Node{}).Where("id = ?", nodeID).Update("status", "offline").Error; err != nil {
+		t.Fatalf("mark node offline: %v", err)
+	}
+	seedDepletedNodeClient(t, nodeID, 46312)
+
+	if _, _, err := (&InboundService{}).AddTraffic(nil, nil); err != nil {
+		t.Fatalf("AddTraffic: %v", err)
+	}
+	if got := fake.updateInbound.Load(); got != 0 {
+		t.Fatalf("UpdateInbound calls to an offline node = %d, want 0", got)
+	}
+	if _, _, dirty, _, err := (&NodeService{}).NodeSyncState(nodeID); err != nil || !dirty {
+		t.Fatalf("node dirty = %v (err %v), want true so reconcile applies the disable", dirty, err)
+	}
+}
+
+type hangingRestartRuntime struct {
+	fakeNodeRuntime
+	entered chan struct{}
+	release chan struct{}
+}
+
+func (h *hangingRestartRuntime) RestartXray(ctx context.Context) error {
+	select {
+	case h.entered <- struct{}{}:
+	default:
+	}
+	select {
+	case <-ctx.Done():
+		return ctx.Err()
+	case <-h.release:
+		return nil
+	}
+}
+
+// The opt-in restart is best-effort and never replayed, so a hanging node must
+// not hold the traffic poll that disabled its client.
+func TestTrafficDisableNodeRestartDoesNotBlockTrafficPoll(t *testing.T) {
+	setupConflictDB(t)
+	setRestartOnClientDisable(t, true)
+	nodeID, _ := setupNodeRuntime(t)
+	hanging := &hangingRestartRuntime{entered: make(chan struct{}, 1), release: make(chan struct{})}
+	runtime.GetManager().SetRuntimeOverride(nodeID, hanging)
+	t.Cleanup(func() { close(hanging.release) })
+	seedDepletedNodeClient(t, nodeID, 46314)
+
+	returned := make(chan error, 1)
+	go func() {
+		_, _, err := (&InboundService{}).AddTraffic(nil, nil)
+		returned <- err
+	}()
+	select {
+	case <-hanging.entered:
+	case <-time.After(5 * time.Second):
+		t.Fatal("node Xray was never restarted after its client was disabled")
+	}
+	select {
+	case err := <-returned:
+		if err != nil {
+			t.Fatalf("AddTraffic: %v", err)
+		}
+	case <-time.After(time.Second):
+		t.Fatal("AddTraffic waited on a hanging node restart")
+	}
+}

+ 4 - 4
internal/web/service/traffic_runtime_apply_test.go

@@ -20,8 +20,8 @@ func TestTrafficDisableImmediatelyUpdatesNodeRuntime(t *testing.T) {
 		t.Fatalf("seed traffic: %v", err)
 	}
 
-	if _, _, _, err := (&InboundService{}).addTrafficLocked(nil, nil); err != nil {
-		t.Fatalf("addTrafficLocked: %v", err)
+	if _, _, err := (&InboundService{}).AddTraffic(nil, nil); err != nil {
+		t.Fatalf("AddTraffic: %v", err)
 	}
 	if got := fake.updateInbound.Load(); got != 1 {
 		t.Fatalf("remote UpdateInbound calls = %d, want 1 after commit", got)
@@ -52,8 +52,8 @@ func TestTrafficDisableRefreshesLocalMTProtoSidecar(t *testing.T) {
 		t.Fatalf("deplete traffic: %v", err)
 	}
 
-	if _, _, _, err := (&InboundService{}).addTrafficLocked(nil, nil); err != nil {
-		t.Fatalf("addTrafficLocked: %v", err)
+	if _, _, err := (&InboundService{}).AddTraffic(nil, nil); err != nil {
+		t.Fatalf("AddTraffic: %v", err)
 	}
 	if got := fake.updateInbound.Load(); got != 1 {
 		t.Fatalf("MTProto sidecar UpdateInbound calls = %d, want 1 after commit", got)

Some files were not shown because too many files changed in this diff