4 커밋 28b360f2af ... 2f156c8eb0

작성자 SHA1 메시지 날짜
  MHSanaei 2f156c8eb0 fix(ci): publish dev-latest edit-first instead of probing for existence 14 시간 전
  MHSanaei 455d1cd0f7 fix(ci): resolve the mtg-multi tag from the release-page redirect 14 시간 전
  MHSanaei ab1a922806 fix(ci): survive transient GitHub 5xx outages in the release workflow 14 시간 전
  Sanaei 5e1cb7693b Repo-wide self-correcting audit: 54 verified bug fixes (#5970) 15 시간 전
100개의 변경된 파일2716개의 추가작업 그리고 420개의 파일을 삭제
  1. 58 40
      .github/workflows/release.yml
  2. 5 2
      frontend/src/api/http-init.ts
  3. 9 0
      frontend/src/api/websocket.ts
  4. 3 17
      frontend/src/api/websocketBridge.ts
  5. 1 0
      frontend/src/components/clients/ConfigBlock.css
  6. 2 17
      frontend/src/hooks/useWebSocket.ts
  7. 11 3
      frontend/src/lib/xray/forms/fields/SniffingField.tsx
  8. 10 2
      frontend/src/pages/clients/ClientFormModal.tsx
  9. 1 1
      frontend/src/pages/nodes/NodeList.tsx
  10. 2 1
      frontend/src/pages/xray/basics/BasicsTab.tsx
  11. 9 9
      frontend/src/pages/xray/outbounds/OutboundCardList.tsx
  12. 21 10
      frontend/src/pages/xray/outbounds/OutboundsTab.tsx
  13. 13 0
      frontend/src/pages/xray/outbounds/outbounds-tab-helpers.ts
  14. 11 11
      frontend/src/pages/xray/outbounds/useOutboundColumns.tsx
  15. 24 12
      frontend/src/pages/xray/routing/RoutingTab.tsx
  16. 6 0
      frontend/src/pages/xray/routing/RuleFormModal.tsx
  17. 12 0
      frontend/src/pages/xray/routing/helpers.ts
  18. 36 0
      frontend/src/test/basics-happy-eyeballs.test.tsx
  19. 20 0
      frontend/src/test/client-total-bytes.test.tsx
  20. 29 0
      frontend/src/test/http-init-msw.test.ts
  21. 2 2
      frontend/src/test/httpUtil.test.ts
  22. 46 0
      frontend/src/test/node-list-rowkey.test.tsx
  23. 39 2
      frontend/src/test/outbound-form-modal.test.tsx
  24. 11 0
      frontend/src/test/outbound-link-parser.test.ts
  25. 91 0
      frontend/src/test/outbounds-loopback-index.test.tsx
  26. 55 0
      frontend/src/test/routing-loopback-index.test.tsx
  27. 33 0
      frontend/src/test/rule-form-preserve-fields.test.tsx
  28. 21 0
      frontend/src/test/sniffing-field-resync.test.tsx
  29. 51 0
      frontend/src/test/websocket-bridge-outbounds.test.tsx
  30. 49 0
      frontend/src/test/websocket-shared-client.test.tsx
  31. 9 5
      frontend/src/utils/index.ts
  32. 1 1
      internal/database/db.go
  33. 36 0
      internal/database/seeder_fastpath_test.go
  34. 75 23
      internal/eventbus/bus.go
  35. 76 0
      internal/eventbus/bus_test.go
  36. 13 4
      internal/sub/clash_service.go
  37. 43 25
      internal/sub/controller.go
  38. 48 0
      internal/sub/controller_test.go
  39. 13 7
      internal/sub/json_service.go
  40. 9 9
      internal/sub/remark_vars.go
  41. 13 0
      internal/sub/remark_vars_test.go
  42. 40 27
      internal/sub/service.go
  43. 132 0
      internal/sub/sub_panic_test.go
  44. 14 5
      internal/util/link/outbound.go
  45. 45 0
      internal/util/link/outbound_helpers_test.go
  46. 41 1
      internal/web/controller/login_limiter.go
  47. 46 0
      internal/web/controller/login_limiter_test.go
  48. 12 0
      internal/web/entity/check_valid_test.go
  49. 22 1
      internal/web/entity/entity.go
  50. 17 0
      internal/web/runtime/manager.go
  51. 45 0
      internal/web/service/access_log_parse_test.go
  52. 56 0
      internal/web/service/bulk_traffic_test.go
  53. 63 0
      internal/web/service/client_apply_field_test.go
  54. 16 5
      internal/web/service/client_bulk.go
  55. 46 0
      internal/web/service/client_bulk_reenable_test.go
  56. 2 2
      internal/web/service/client_crud.go
  57. 10 52
      internal/web/service/client_inbound_apply.go
  58. 1 1
      internal/web/service/client_locks.go
  59. 26 0
      internal/web/service/client_locks_test.go
  60. 18 9
      internal/web/service/client_traffic.go
  61. 79 2
      internal/web/service/email/email.go
  62. 46 0
      internal/web/service/email/email_deadline_test.go
  63. 143 0
      internal/web/service/email/email_test.go
  64. 18 6
      internal/web/service/inbound.go
  65. 58 0
      internal/web/service/inbound_finalmask_reality_test.go
  66. 72 0
      internal/web/service/inbound_mtproto_txfail_test.go
  67. 45 41
      internal/web/service/inbound_traffic.go
  68. 1 1
      internal/web/service/integration/warp.go
  69. 39 0
      internal/web/service/integration/warp_response_test.go
  70. 33 0
      internal/web/service/key_gen_parse_test.go
  71. 9 1
      internal/web/service/node.go
  72. 23 0
      internal/web/service/node_test.go
  73. 21 2
      internal/web/service/outbound_subscription.go
  74. 30 0
      internal/web/service/outbound_subscription_ssrf_test.go
  75. 68 0
      internal/web/service/reset_writer_test.go
  76. 65 46
      internal/web/service/server.go
  77. 40 0
      internal/web/service/tgbot/tgbot_callback_auth_test.go
  78. 18 0
      internal/web/service/tgbot/tgbot_delete_after_test.go
  79. 18 0
      internal/web/service/tgbot/tgbot_router.go
  80. 15 6
      internal/web/service/tgbot/tgbot_send.go
  81. 94 0
      internal/web/service/traffic_commit_test.go
  82. 17 0
      internal/web/service/xray.go
  83. 40 0
      internal/web/service/xray_restart_test.go
  84. 2 2
      internal/web/service/xray_setting_routing_sync.go
  85. 33 0
      internal/web/service/xray_setting_routing_sync_test.go
  86. 1 0
      internal/web/translation/ar-EG.json
  87. 1 0
      internal/web/translation/en-US.json
  88. 1 0
      internal/web/translation/es-ES.json
  89. 1 0
      internal/web/translation/fa-IR.json
  90. 1 0
      internal/web/translation/id-ID.json
  91. 1 0
      internal/web/translation/ja-JP.json
  92. 1 0
      internal/web/translation/pt-BR.json
  93. 1 0
      internal/web/translation/ru-RU.json
  94. 1 0
      internal/web/translation/tr-TR.json
  95. 1 0
      internal/web/translation/uk-UA.json
  96. 1 0
      internal/web/translation/vi-VN.json
  97. 1 0
      internal/web/translation/zh-CN.json
  98. 1 0
      internal/web/translation/zh-TW.json
  99. 1 6
      internal/web/web.go
  100. 6 1
      internal/xray/api.go

+ 58 - 40
.github/workflows/release.yml

@@ -16,6 +16,7 @@ on:
       - "x-ui.service.debian"
       - "x-ui.service.arch"
       - "x-ui.service.rhel"
+      - ".github/workflows/release.yml"
   pull_request:
     paths:
       - "**.go"
@@ -26,12 +27,15 @@ on:
       - "x-ui.service.debian"
       - "x-ui.service.arch"
       - "x-ui.service.rhel"
+      - ".github/workflows/release.yml"
 
 jobs:
   build:
     permissions:
       contents: write
     strategy:
+      # One platform hitting a transient outage must not cancel the other six.
+      fail-fast: false
       matrix:
         platform:
           - amd64
@@ -71,6 +75,8 @@ jobs:
 
       - name: Build 3X-UI
         run: |
+          CURL_RETRY="--retry 5 --retry-all-errors --retry-delay 3"
+          fetch() { wget -q --tries=5 --waitretry=10 --retry-on-http-error=429,500,502,503 "$@"; }
           export CGO_ENABLED=1
           export GOOS=linux
           export GOARCH=${{ matrix.platform }}
@@ -86,11 +92,11 @@ jobs:
           esac
           echo "Resolving Bootlin musl toolchain for arch=$BOOTLIN_ARCH (platform=${{ matrix.platform }})"
           TARBALL_BASE="https://toolchains.bootlin.com/downloads/releases/toolchains/$BOOTLIN_ARCH/tarballs/"
-          TARBALL_URL=$(curl -fsSL "$TARBALL_BASE" | grep -oE "${BOOTLIN_ARCH}--musl--stable-[^\"]+\\.tar\\.xz" | sort -r | head -n1)
+          TARBALL_URL=$(curl -fsSL $CURL_RETRY "$TARBALL_BASE" | grep -oE "${BOOTLIN_ARCH}--musl--stable-[^\"]+\\.tar\\.xz" | sort -r | head -n1)
           [ -z "$TARBALL_URL" ] && { echo "Failed to locate Bootlin musl toolchain for arch=$BOOTLIN_ARCH" >&2; exit 1; }
           echo "Downloading: $TARBALL_URL"
           cd /tmp
-          curl -fL -sS -o "$(basename "$TARBALL_URL")" "$TARBALL_BASE/$TARBALL_URL"
+          curl -fL -sS $CURL_RETRY -o "$(basename "$TARBALL_URL")" "$TARBALL_BASE/$TARBALL_URL"
           tar -xf "$(basename "$TARBALL_URL")"
           TOOLCHAIN_DIR=$(find . -maxdepth 1 -type d -name "${BOOTLIN_ARCH}--musl--stable-*" | head -n1)
           export PATH="$(realpath "$TOOLCHAIN_DIR")/bin:$PATH"
@@ -120,53 +126,55 @@ jobs:
           # Download dependencies
           Xray_URL="https://github.com/XTLS/Xray-core/releases/download/v26.7.11/"
           if [ "${{ matrix.platform }}" == "amd64" ]; then
-            wget -q ${Xray_URL}Xray-linux-64.zip
+            fetch ${Xray_URL}Xray-linux-64.zip
             unzip Xray-linux-64.zip
             rm -f Xray-linux-64.zip
           elif [ "${{ matrix.platform }}" == "arm64" ]; then
-            wget -q ${Xray_URL}Xray-linux-arm64-v8a.zip
+            fetch ${Xray_URL}Xray-linux-arm64-v8a.zip
             unzip Xray-linux-arm64-v8a.zip
             rm -f Xray-linux-arm64-v8a.zip
           elif [ "${{ matrix.platform }}" == "armv7" ]; then
-            wget -q ${Xray_URL}Xray-linux-arm32-v7a.zip
+            fetch ${Xray_URL}Xray-linux-arm32-v7a.zip
             unzip Xray-linux-arm32-v7a.zip
             rm -f Xray-linux-arm32-v7a.zip
           elif [ "${{ matrix.platform }}" == "armv6" ]; then
-            wget -q ${Xray_URL}Xray-linux-arm32-v6.zip
+            fetch ${Xray_URL}Xray-linux-arm32-v6.zip
             unzip Xray-linux-arm32-v6.zip
             rm -f Xray-linux-arm32-v6.zip
           elif [ "${{ matrix.platform }}" == "386" ]; then
-            wget -q ${Xray_URL}Xray-linux-32.zip
+            fetch ${Xray_URL}Xray-linux-32.zip
             unzip Xray-linux-32.zip
             rm -f Xray-linux-32.zip
           elif [ "${{ matrix.platform }}" == "armv5" ]; then
-            wget -q ${Xray_URL}Xray-linux-arm32-v5.zip
+            fetch ${Xray_URL}Xray-linux-arm32-v5.zip
             unzip Xray-linux-arm32-v5.zip
             rm -f Xray-linux-arm32-v5.zip
           elif [ "${{ matrix.platform }}" == "s390x" ]; then
-            wget -q ${Xray_URL}Xray-linux-s390x.zip
+            fetch ${Xray_URL}Xray-linux-s390x.zip
             unzip Xray-linux-s390x.zip
             rm -f Xray-linux-s390x.zip
           fi
           rm -f geoip.dat geosite.dat
-          wget -q https://github.com/Loyalsoldier/v2ray-rules-dat/releases/latest/download/geoip.dat
-          wget -q https://github.com/Loyalsoldier/v2ray-rules-dat/releases/latest/download/geosite.dat
-          wget -q -O geoip_IR.dat https://github.com/chocolate4u/Iran-v2ray-rules/releases/latest/download/geoip.dat
-          wget -q -O geosite_IR.dat https://github.com/chocolate4u/Iran-v2ray-rules/releases/latest/download/geosite.dat
-          wget -q -O geoip_RU.dat https://github.com/runetfreedom/russia-v2ray-rules-dat/releases/latest/download/geoip.dat
-          wget -q -O geosite_RU.dat https://github.com/runetfreedom/russia-v2ray-rules-dat/releases/latest/download/geosite.dat
+          fetch https://github.com/Loyalsoldier/v2ray-rules-dat/releases/latest/download/geoip.dat
+          fetch https://github.com/Loyalsoldier/v2ray-rules-dat/releases/latest/download/geosite.dat
+          fetch -O geoip_IR.dat https://github.com/chocolate4u/Iran-v2ray-rules/releases/latest/download/geoip.dat
+          fetch -O geosite_IR.dat https://github.com/chocolate4u/Iran-v2ray-rules/releases/latest/download/geosite.dat
+          fetch -O geoip_RU.dat https://github.com/runetfreedom/russia-v2ray-rules-dat/releases/latest/download/geoip.dat
+          fetch -O geosite_RU.dat https://github.com/runetfreedom/russia-v2ray-rules-dat/releases/latest/download/geosite.dat
           mv xray xray-linux-${{ matrix.platform }}
           # mtg-multi (MTProto sidecar) ships prebuilt release binaries whose
           # platform labels match our matrix, so download and unpack the matching
-          # archive. Only the platforms the fork publishes are packaged. The tag
-          # is resolved from the fork's latest release so it never needs bumping
-          # here; the token only lifts the API rate limit for a public read.
-          MTG_MULTI_VER=$(curl -sfL -H "Authorization: Bearer ${{ secrets.GITHUB_TOKEN }}" "https://api.github.com/repos/mhsanaei/mtg-multi/releases/latest" | sed -n 's/.*"tag_name": *"\([^"]*\)".*/\1/p' | head -n 1)
-          if [ -z "$MTG_MULTI_VER" ]; then echo "could not resolve the latest mtg-multi release tag"; exit 1; fi
+          # archive. Only the platforms the fork publishes are packaged — the tag
+          # lookup lives inside that branch so unpackaged platforms (s390x) never
+          # depend on it. The tag comes from the release-page redirect on
+          # github.com — the host the downloads need anyway — because api.github.com
+          # has 503'd whole release runs while asset downloads kept working.
           case "${{ matrix.platform }}" in
             amd64|arm64|armv7|armv6|386)
+              MTG_MULTI_VER=$(curl -sf $CURL_RETRY -o /dev/null -w '%{redirect_url}' "https://github.com/mhsanaei/mtg-multi/releases/latest" | sed -n 's#.*/releases/tag/##p')
+              if [ -z "$MTG_MULTI_VER" ]; then echo "could not resolve the latest mtg-multi release tag"; exit 1; fi
               MTG_PKG="mtg-multi-${MTG_MULTI_VER#v}-linux-${{ matrix.platform }}"
-              curl -sfLRO "https://github.com/mhsanaei/mtg-multi/releases/download/${MTG_MULTI_VER}/${MTG_PKG}.tar.gz"
+              curl -sfLRO $CURL_RETRY "https://github.com/mhsanaei/mtg-multi/releases/download/${MTG_MULTI_VER}/${MTG_PKG}.tar.gz"
               tar -xzf "${MTG_PKG}.tar.gz"
               mv "${MTG_PKG}/mtg-multi" "mtg-linux-${{ matrix.platform }}"
               rm -rf "${MTG_PKG}" "${MTG_PKG}.tar.gz"
@@ -267,6 +275,7 @@ jobs:
       - name: Copy and download resources
         shell: pwsh
         run: |
+          $retry = @{ MaximumRetryCount = 5; RetryIntervalSec = 10 }
           mkdir x-ui
           Copy-Item xui-release.exe x-ui\x-ui.exe
           mkdir x-ui\bin
@@ -274,25 +283,26 @@ jobs:
 
           # Download Xray for Windows
           $Xray_URL = "https://github.com/XTLS/Xray-core/releases/download/v26.7.11/"
-          Invoke-WebRequest -Uri "${Xray_URL}Xray-windows-64.zip" -OutFile "Xray-windows-64.zip"
+          Invoke-WebRequest @retry -Uri "${Xray_URL}Xray-windows-64.zip" -OutFile "Xray-windows-64.zip"
           Expand-Archive -Path "Xray-windows-64.zip" -DestinationPath .
           Remove-Item "Xray-windows-64.zip"
           Remove-Item geoip.dat, geosite.dat -ErrorAction SilentlyContinue
-          Invoke-WebRequest -Uri "https://github.com/Loyalsoldier/v2ray-rules-dat/releases/latest/download/geoip.dat" -OutFile "geoip.dat"
-          Invoke-WebRequest -Uri "https://github.com/Loyalsoldier/v2ray-rules-dat/releases/latest/download/geosite.dat" -OutFile "geosite.dat"
-          Invoke-WebRequest -Uri "https://github.com/chocolate4u/Iran-v2ray-rules/releases/latest/download/geoip.dat" -OutFile "geoip_IR.dat"
-          Invoke-WebRequest -Uri "https://github.com/chocolate4u/Iran-v2ray-rules/releases/latest/download/geosite.dat" -OutFile "geosite_IR.dat"
-          Invoke-WebRequest -Uri "https://github.com/runetfreedom/russia-v2ray-rules-dat/releases/latest/download/geoip.dat" -OutFile "geoip_RU.dat"
-          Invoke-WebRequest -Uri "https://github.com/runetfreedom/russia-v2ray-rules-dat/releases/latest/download/geosite.dat" -OutFile "geosite_RU.dat"
+          Invoke-WebRequest @retry -Uri "https://github.com/Loyalsoldier/v2ray-rules-dat/releases/latest/download/geoip.dat" -OutFile "geoip.dat"
+          Invoke-WebRequest @retry -Uri "https://github.com/Loyalsoldier/v2ray-rules-dat/releases/latest/download/geosite.dat" -OutFile "geosite.dat"
+          Invoke-WebRequest @retry -Uri "https://github.com/chocolate4u/Iran-v2ray-rules/releases/latest/download/geoip.dat" -OutFile "geoip_IR.dat"
+          Invoke-WebRequest @retry -Uri "https://github.com/chocolate4u/Iran-v2ray-rules/releases/latest/download/geosite.dat" -OutFile "geosite_IR.dat"
+          Invoke-WebRequest @retry -Uri "https://github.com/runetfreedom/russia-v2ray-rules-dat/releases/latest/download/geoip.dat" -OutFile "geoip_RU.dat"
+          Invoke-WebRequest @retry -Uri "https://github.com/runetfreedom/russia-v2ray-rules-dat/releases/latest/download/geosite.dat" -OutFile "geosite_RU.dat"
           Rename-Item xray.exe xray-windows-amd64.exe
 
           # mtg-multi (MTProto sidecar) publishes a prebuilt Windows binary, so
-          # download and unpack it instead of compiling. The tag tracks the
-          # fork's latest release so it never needs bumping here.
-          $MTG_MULTI_VER = (Invoke-RestMethod -Uri "https://api.github.com/repos/mhsanaei/mtg-multi/releases/latest" -Headers @{ Authorization = "Bearer ${{ secrets.GITHUB_TOKEN }}"; "User-Agent" = "x-ui-release" }).tag_name
-          if (-not $MTG_MULTI_VER) { throw "could not resolve the latest mtg-multi release tag" }
+          # download and unpack it instead of compiling. The tag comes from the
+          # release-page redirect on github.com — not api.github.com, whose
+          # outages have failed release runs while asset downloads kept working.
+          $MTG_MULTI_VER = (curl.exe -sf --retry 5 --retry-all-errors --retry-delay 3 -o NUL -w '%{redirect_url}' "https://github.com/mhsanaei/mtg-multi/releases/latest") -replace '^.*/releases/tag/', ''
+          if (-not $MTG_MULTI_VER -or $MTG_MULTI_VER -notmatch '^v[\d.]+$') { throw "could not resolve the latest mtg-multi release tag" }
           $MTG_PKG = "mtg-multi-$($MTG_MULTI_VER.TrimStart('v'))-windows-amd64"
-          curl.exe -sfLRO "https://github.com/mhsanaei/mtg-multi/releases/download/$MTG_MULTI_VER/$MTG_PKG.zip"
+          curl.exe -sfLRO --retry 5 --retry-all-errors --retry-delay 3 "https://github.com/mhsanaei/mtg-multi/releases/download/$MTG_MULTI_VER/$MTG_PKG.zip"
           Expand-Archive -Path "$MTG_PKG.zip" -DestinationPath "mtg-tmp" -Force
           Move-Item "mtg-tmp/$MTG_PKG/mtg-multi.exe" "mtg-windows-amd64.exe"
           Remove-Item -Recurse -Force "mtg-tmp", "$MTG_PKG.zip"
@@ -359,6 +369,14 @@ jobs:
           COMMIT: ${{ github.sha }}
         run: |
           set -e
+          retry() {
+            for i in 1 2 3 4 5; do
+              "$@" && return 0
+              echo "attempt $i failed: ${*:1:3}" >&2
+              sleep $((i * 5))
+            done
+            return 1
+          }
           short="${COMMIT::8}"
           notes="Rolling development build — installs via the panel's Dev update channel.
 
@@ -369,14 +387,14 @@ jobs:
 
           # Force-move the dev-latest tag to this commit so the release tracks it.
           git tag -f dev-latest "${COMMIT}"
-          git push -f origin refs/tags/dev-latest
+          retry git push -f origin refs/tags/dev-latest
 
-          if gh release view dev-latest >/dev/null 2>&1; then
-            gh release edit dev-latest --prerelease --latest=false \
-              --title "Dev build ${short}" --notes "${notes}"
-          else
-            gh release create dev-latest --prerelease --latest=false \
+          # The release exists on every run but the first; edit-first avoids an
+          # existence probe that can 503 and mis-route into create (422).
+          if ! retry gh release edit dev-latest --prerelease --latest=false \
+              --title "Dev build ${short}" --notes "${notes}"; then
+            retry gh release create dev-latest --prerelease --latest=false \
               --target "${COMMIT}" --title "Dev build ${short}" --notes "${notes}"
           fi
 
-          gh release upload dev-latest dev-artifacts/*.tar.gz dev-artifacts/*.zip --clobber
+          retry gh release upload dev-latest dev-artifacts/*.tar.gz dev-artifacts/*.zip --clobber

+ 5 - 2
frontend/src/api/http-init.ts

@@ -152,8 +152,11 @@ export async function httpRequest(
 
   if (res.status === 403 && !SAFE_METHODS.has(method.toUpperCase())) {
     csrfToken = null;
-    const fresh = await ensureCsrfToken();
-    if (fresh) res = await performFetch(method, url, data, options, fresh);
+    const fresh = await fetchCsrfToken();
+    if (fresh) {
+      csrfToken = fresh;
+      res = await performFetch(method, url, data, options, fresh);
+    }
   }
 
   if (res.status === 401) {

+ 9 - 0
frontend/src/api/websocket.ts

@@ -190,3 +190,12 @@ export class WebSocketClient {
     }
   }
 }
+
+let sharedClient: WebSocketClient | null = null;
+
+export function getSharedWebSocketClient(): WebSocketClient {
+  if (sharedClient) return sharedClient;
+  const basePath = (typeof window !== 'undefined' && window.X_UI_BASE_PATH) || '';
+  sharedClient = new WebSocketClient(basePath);
+  return sharedClient;
+}

+ 3 - 17
frontend/src/api/websocketBridge.ts

@@ -1,34 +1,19 @@
 import { useEffect } from 'react';
 import { useQueryClient } from '@tanstack/react-query';
 
-import { WebSocketClient } from '@/api/websocket';
+import { getSharedWebSocketClient } from '@/api/websocket';
 import { keys } from '@/api/queryKeys';
 import { isRecentLocalInvalidate } from '@/api/invalidationTracker';
 
 type Handler = (payload: unknown) => void;
 
-interface SharedClient {
-  connect(): void;
-  on(event: string, fn: Handler): void;
-  off(event: string, fn: Handler): void;
-}
-
-let sharedClient: SharedClient | null = null;
-
-function getSharedClient(): SharedClient {
-  if (sharedClient) return sharedClient;
-  const basePath = (typeof window !== 'undefined' && window.X_UI_BASE_PATH) || '';
-  sharedClient = new WebSocketClient(basePath) as SharedClient;
-  return sharedClient;
-}
-
 let invalidateTimer: number | null = null;
 
 export function useWebSocketBridge() {
   const queryClient = useQueryClient();
 
   useEffect(() => {
-    const client = getSharedClient();
+    const client = getSharedWebSocketClient();
 
     const onInvalidate: Handler = (payload) => {
       const p = payload as { type?: string } | undefined;
@@ -46,6 +31,7 @@ export function useWebSocketBridge() {
     };
 
     const onOutbounds: Handler = (payload) => {
+      if (!Array.isArray(payload)) return;
       queryClient.setQueryData(keys.xray.outboundsTraffic(), payload);
     };
 

+ 1 - 0
frontend/src/components/clients/ConfigBlock.css

@@ -38,6 +38,7 @@ body.light .config-block .ant-tag.ant-tag-filled.ant-tag-gold {
   word-break: break-all;
   white-space: pre-wrap;
   padding: 6px 8px;
+  color: var(--ant-color-text);
   background: var(--ant-color-fill-tertiary);
   border-radius: 4px;
   user-select: all;

+ 2 - 17
frontend/src/hooks/useWebSocket.ts

@@ -1,26 +1,11 @@
 import { useEffect } from 'react';
-import { WebSocketClient } from '@/api/websocket';
+import { getSharedWebSocketClient } from '@/api/websocket';
 
 type Handler = (payload: unknown) => void;
 
-interface SharedClient {
-  connect(): void;
-  on(event: string, fn: Handler): void;
-  off(event: string, fn: Handler): void;
-}
-
-let sharedClient: SharedClient | null = null;
-
-function getSharedClient(): SharedClient {
-  if (sharedClient) return sharedClient;
-  const basePath = (typeof window !== 'undefined' && window.X_UI_BASE_PATH) || '';
-  sharedClient = new WebSocketClient(basePath) as SharedClient;
-  return sharedClient;
-}
-
 export function useWebSocket(handlers: Record<string, Handler>) {
   useEffect(() => {
-    const client = getSharedClient();
+    const client = getSharedWebSocketClient();
     const entries = Object.entries(handlers);
     for (const [event, fn] of entries) client.on(event, fn);
     client.connect();

+ 11 - 3
frontend/src/lib/xray/forms/fields/SniffingField.tsx

@@ -2,7 +2,7 @@ import { useEffect, useRef, useState } from 'react';
 import { Form } from 'antd';
 
 import SniffingFields from '@/lib/xray/forms/SniffingFields';
-import type { Sniffing } from '@/schemas/primitives/sniffing';
+import { SniffingSchema, type Sniffing } from '@/schemas/primitives/sniffing';
 
 interface SniffingFieldProps {
   value?: Sniffing;
@@ -12,12 +12,12 @@ interface SniffingFieldProps {
 
 export default function SniffingField({ value, onChange, enableLabel }: SniffingFieldProps) {
   const [form] = Form.useForm();
-  const [initial] = useState(() => value ?? ({} as Sniffing));
+  const [initial] = useState(() => value ?? SniffingSchema.parse({}));
   const onChangeRef = useRef(onChange);
   onChangeRef.current = onChange;
   const lastEmitted = useRef(JSON.stringify(initial));
 
-  const sniffing = Form.useWatch('sniffing', form) as Sniffing | undefined;
+  const sniffing = Form.useWatch('sniffing', { form, preserve: true }) as Sniffing | undefined;
 
   useEffect(() => {
     if (sniffing === undefined) return;
@@ -27,6 +27,14 @@ export default function SniffingField({ value, onChange, enableLabel }: Sniffing
     onChangeRef.current?.(sniffing);
   }, [sniffing]);
 
+  useEffect(() => {
+    if (value === undefined) return;
+    const serialized = JSON.stringify(value);
+    if (serialized === lastEmitted.current) return;
+    lastEmitted.current = serialized;
+    form.setFieldsValue({ sniffing: value });
+  }, [value, form]);
+
   return (
     <Form
       form={form}

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

@@ -146,11 +146,18 @@ function bytesToGB(bytes: number): number {
   return Math.round((bytes / (1024 * 1024 * 1024)) * 100) / 100;
 }
 
-function gbToBytes(gb: number): number {
+export function gbToBytes(gb: number): number {
   if (!gb || gb <= 0) return 0;
   return Math.round(gb * 1024 * 1024 * 1024);
 }
 
+export function resolveTotalBytes(originalBytes: number | null | undefined, displayedGB: number): number {
+  if (originalBytes != null && displayedGB === bytesToGB(originalBytes)) {
+    return originalBytes;
+  }
+  return gbToBytes(displayedGB);
+}
+
 export default function ClientFormModal({
   open,
   mode,
@@ -491,6 +498,7 @@ export default function ClientFormModal({
     const expiryTime = values.delayedStart
       ? -86400000 * (Number(values.delayedDays) || 0)
       : (values.expiryDate || 0);
+    const totalBytes = resolveTotalBytes(client ? (client.totalGB ?? 0) : null, values.totalGB);
     const clientPayload: Record<string, unknown> = {
       email: values.email.trim(),
       subId: values.subId,
@@ -499,7 +507,7 @@ export default function ClientFormModal({
       auth: values.auth,
       flow: showFlow ? (values.flow || '') : '',
       security: showSecurity ? (values.security || 'auto') : 'auto',
-      totalGB: gbToBytes(values.totalGB),
+      totalGB: totalBytes,
       expiryTime,
       reset: Number(values.reset) || 0,
       limitIp: Number(values.limitIp) || 0,

+ 1 - 1
frontend/src/pages/nodes/NodeList.tsx

@@ -650,7 +650,7 @@ export default function NodeList({
           loading={loading}
           scroll={{ x: 'max-content' }}
           size="middle"
-          rowKey="id"
+          rowKey="key"
           rowSelection={dataSource.length > 1 ? {
             selectedRowKeys: selectedIds,
             onChange: (keys) => onSelectionChange(keys.filter((k) => typeof k === 'number') as number[]),

+ 2 - 1
frontend/src/pages/xray/basics/BasicsTab.tsx

@@ -115,7 +115,8 @@ export default function BasicsTab({
       ?.sockopt;
     const raw = sockopt?.happyEyeballs;
     if (raw == null || typeof raw !== 'object') return null;
-    return HappyEyeballsSchema.parse(raw);
+    const parsed = HappyEyeballsSchema.safeParse(raw);
+    return parsed.success ? parsed.data : null;
   })();
 
   const setDirectHappyEyeballs = useCallback(

+ 9 - 9
frontend/src/pages/xray/outbounds/OutboundCardList.tsx

@@ -63,8 +63,8 @@ export default function OutboundCardList({
     setShowEgressIp((prev) => ({ ...prev, [key]: visible }));
   };
 
-  const renderEgress = (index: number, rowKey: string) => {
-    const result = testResult(outboundTestStates, index);
+  const renderEgress = (testKey: number, rowKey: string) => {
+    const result = testResult(outboundTestStates, testKey);
     const egress = result?.egress;
     const isEgressVisible = !!showEgressIp[rowKey];
     const flag = countryFlag(egress?.country);
@@ -156,26 +156,26 @@ export default function OutboundCardList({
               ))}
             </div>
           )}
-          {renderEgress(index, String(record.key))}
+          {renderEgress(record.key, String(record.key))}
           <div className="card-foot">
             <span className="traffic-up">↑ {SizeFormatter.sizeFormat(trafficFor(outboundsTraffic, record).up)}</span>
             <span className="traffic-sep" />
             <span className="traffic-down">↓ {SizeFormatter.sizeFormat(trafficFor(outboundsTraffic, record).down)}</span>
             <span className="card-test">
-              {testResult(outboundTestStates, index) ? (
-                <TestResultPopover result={testResult(outboundTestStates, index)!} />
-              ) : isTesting(outboundTestStates, index) ? (
+              {testResult(outboundTestStates, record.key) ? (
+                <TestResultPopover result={testResult(outboundTestStates, record.key)!} />
+              ) : isTesting(outboundTestStates, record.key) ? (
                 <LoadingOutlined />
               ) : null}
               <Button
                 type="primary"
                 shape="circle"
                 size="small"
-                loading={isTesting(outboundTestStates, index)}
-                disabled={isUntestable(record) || isTesting(outboundTestStates, index)}
+                loading={isTesting(outboundTestStates, record.key)}
+                disabled={isUntestable(record) || isTesting(outboundTestStates, record.key)}
                 icon={<ThunderboltOutlined />}
                 aria-label={t('check')}
-                onClick={() => onTest(index, testMode)}
+                onClick={() => onTest(record.key, testMode)}
               />
             </span>
           </div>

+ 21 - 10
frontend/src/pages/xray/outbounds/OutboundsTab.tsx

@@ -1,4 +1,4 @@
-import { useCallback, useMemo, useState } from 'react';
+import { useCallback, useMemo, useRef, useState } from 'react';
 import { useTranslation } from 'react-i18next';
 import {
   Button,
@@ -50,6 +50,7 @@ import type { XraySettingsValue, SetTemplate, OutboundTestMode, OutboundTestStat
 import './OutboundsTab.css';
 
 import type { OutboundRow } from './outbounds-tab-types';
+import { originalOutboundIndex } from './outbounds-tab-helpers';
 import { useOutboundColumns } from './useOutboundColumns';
 import OutboundCardList from './OutboundCardList';
 import SubscriptionOutbounds from './SubscriptionOutbounds';
@@ -150,6 +151,8 @@ export default function OutboundsTab({
         .filter((o) => !isBalancerLoopbackTag(o.tag || '')),
     [outbounds],
   );
+  const rowsRef = useRef<OutboundRow[]>([]);
+  rowsRef.current = rows;
 
   const dialerProxyTags = useMemo(() => {
     const tags = new Set<string>();
@@ -188,11 +191,12 @@ export default function OutboundsTab({
     loadSubs();
   }
   function openEdit(idx: number) {
-    setEditingOutbound((templateSettings?.outbounds || [])[idx] as Record<string, unknown>);
-    setEditingIndex(idx);
+    const target = originalOutboundIndex(rowsRef.current, idx);
+    setEditingOutbound((templateSettings?.outbounds || [])[target] as Record<string, unknown>);
+    setEditingIndex(target);
     setExistingTags(
       (templateSettings?.outbounds || [])
-        .filter((_, i) => i !== idx)
+        .filter((_, i) => i !== target)
         .map((o) => o?.tag)
         .filter((tg): tg is string => !!tg),
     );
@@ -217,8 +221,9 @@ export default function OutboundsTab({
   }
 
   function confirmDelete(idx: number) {
+    const target = originalOutboundIndex(rowsRef.current, idx);
     const impact = templateSettings
-      ? planOutboundDeletion(templateSettings, idx)
+      ? planOutboundDeletion(templateSettings, target)
       : { rules: [], balancers: [], observatory: false, burst: false };
     modal.confirm({
       title: `${t('delete')} ${t('pages.xray.Outbounds')} #${idx + 1}?`,
@@ -226,27 +231,33 @@ export default function OutboundsTab({
       okText: t('delete'),
       okType: 'danger',
       cancelText: t('cancel'),
-      onOk: () => mutate((tt) => applyOutboundDeletion(tt, idx)),
+      onOk: () => mutate((tt) => applyOutboundDeletion(tt, target)),
     });
   }
   function setFirst(idx: number) {
+    const target = originalOutboundIndex(rowsRef.current, idx);
     mutate((tt) => {
       if (!tt.outbounds) return;
-      const [moved] = tt.outbounds.splice(idx, 1);
+      const [moved] = tt.outbounds.splice(target, 1);
       tt.outbounds.unshift(moved);
     });
   }
   function moveUp(idx: number) {
     if (idx <= 0) return;
+    const target = originalOutboundIndex(rowsRef.current, idx);
+    const prev = originalOutboundIndex(rowsRef.current, idx - 1);
     mutate((tt) => {
       if (!tt.outbounds) return;
-      [tt.outbounds[idx - 1], tt.outbounds[idx]] = [tt.outbounds[idx], tt.outbounds[idx - 1]];
+      [tt.outbounds[prev], tt.outbounds[target]] = [tt.outbounds[target], tt.outbounds[prev]];
     });
   }
   function moveDown(idx: number) {
+    if (idx >= rowsRef.current.length - 1) return;
+    const target = originalOutboundIndex(rowsRef.current, idx);
+    const next = originalOutboundIndex(rowsRef.current, idx + 1);
     mutate((tt) => {
-      if (!tt.outbounds || idx >= tt.outbounds.length - 1) return;
-      [tt.outbounds[idx + 1], tt.outbounds[idx]] = [tt.outbounds[idx], tt.outbounds[idx + 1]];
+      if (!tt.outbounds) return;
+      [tt.outbounds[next], tt.outbounds[target]] = [tt.outbounds[target], tt.outbounds[next]];
     });
   }
 

+ 13 - 0
frontend/src/pages/xray/outbounds/outbounds-tab-helpers.ts

@@ -6,6 +6,19 @@ import type { OutboundTestMode, OutboundTestState, OutboundTrafficRow } from '@/
 
 import type { OutboundRow } from './outbounds-tab-types';
 
+/**
+ * Translate a table row's positional index into that outbound's index in the
+ * full, unfiltered outbounds array. The table hides balancer-loopback outbounds
+ * but keeps each visible row's original index in `key`, so any handler that
+ * mutates the outbounds array (or probes an outbound by index) must map the
+ * positional index back through `key` or it operates on the wrong outbound once
+ * a hidden loopback precedes it.
+ */
+export function originalOutboundIndex(rows: OutboundRow[], positionalIndex: number): number {
+  const row = rows[positionalIndex];
+  return row ? row.key : positionalIndex;
+}
+
 export function outboundAddresses(o: OutboundRow): string[] {
   const settings = o.settings as Record<string, unknown> | undefined;
   switch (o.protocol) {

+ 11 - 11
frontend/src/pages/xray/outbounds/useOutboundColumns.tsx

@@ -158,8 +158,8 @@ export function useOutboundColumns({
         key: 'egress',
         align: 'left',
         width: 210,
-        render: (_v, _record, index) => {
-          const egress = testResult(outboundTestStates, index)?.egress;
+        render: (_v, record) => {
+          const egress = testResult(outboundTestStates, record.key)?.egress;
           const addresses = [
             egress?.ipv4 ? { label: 'v4', value: egress.ipv4 } : null,
             egress?.ipv6 ? { label: 'v6', value: egress.ipv6 } : null,
@@ -190,8 +190,8 @@ export function useOutboundColumns({
         key: 'egressCountry',
         align: 'left',
         width: 160,
-        render: (_v, _record, index) => {
-          const egress = testResult(outboundTestStates, index)?.egress;
+        render: (_v, record) => {
+          const egress = testResult(outboundTestStates, record.key)?.egress;
           if (!egress?.country) {
             return (
               <Tooltip title={t('pages.xray.outbound.egressHint')}>
@@ -229,9 +229,9 @@ export function useOutboundColumns({
         key: 'testResult',
         align: 'left',
         width: 140,
-        render: (_v, _record, index) => {
-          const r = testResult(outboundTestStates, index);
-          if (!r) return isTesting(outboundTestStates, index) ? <LoadingOutlined /> : <span className="empty">—</span>;
+        render: (_v, record) => {
+          const r = testResult(outboundTestStates, record.key);
+          if (!r) return isTesting(outboundTestStates, record.key) ? <LoadingOutlined /> : <span className="empty">—</span>;
           return <TestResultPopover result={r} />;
         },
       },
@@ -240,16 +240,16 @@ export function useOutboundColumns({
         key: 'test',
         align: 'center',
         width: 80,
-        render: (_v, record, index) => (
+        render: (_v, record) => (
           <Tooltip title={`${t('check')} (${testModeLabel(effectiveTestMode(record, testMode), t)})`}>
             <Button
               type="primary"
               shape="circle"
-              loading={isTesting(outboundTestStates, index)}
-              disabled={isUntestable(record) || isTesting(outboundTestStates, index)}
+              loading={isTesting(outboundTestStates, record.key)}
+              disabled={isUntestable(record) || isTesting(outboundTestStates, record.key)}
               icon={<ThunderboltOutlined />}
               aria-label={t('check')}
-              onClick={() => onTest(index, testMode)}
+              onClick={() => onTest(record.key, testMode)}
             />
           </Tooltip>
         ),

+ 24 - 12
frontend/src/pages/xray/routing/RoutingTab.tsx

@@ -21,7 +21,7 @@ import RuleFormModal from './RuleFormModal';
 import type { RoutingRule } from './RuleFormModal';
 import RuleCardList from './RuleCardList';
 import { useRoutingColumns } from './useRoutingColumns';
-import { arrJoin } from './helpers';
+import { arrJoin, originalRuleIndex } from './helpers';
 import type { RuleRow } from './types';
 import type { XraySettingsValue, SetTemplate } from '@/hooks/useXraySetting';
 import type { RuleObject } from '@/schemas/routing';
@@ -64,6 +64,7 @@ export default function RoutingTab({
   );
   const rulesRef = useRef(rules);
   rulesRef.current = rules;
+  const rowsRef = useRef<RuleRow[]>([]);
 
   const rows: RuleRow[] = useMemo(
     () =>
@@ -94,6 +95,7 @@ export default function RoutingTab({
         }),
     [rules],
   );
+  rowsRef.current = rows;
 
   const mutate = useCallback(
     (mutator: (next: XraySettingsValue) => void) => {
@@ -193,8 +195,9 @@ export default function RoutingTab({
     setRuleModalOpen(true);
   }
   function openEdit(idx: number) {
-    setEditingRule(rulesRef.current[idx]);
-    setEditingIndex(idx);
+    const target = originalRuleIndex(rowsRef.current, idx);
+    setEditingRule(rulesRef.current[target]);
+    setEditingIndex(target);
     setRuleModalOpen(true);
   }
   function onRuleConfirm(rule: Record<string, unknown>) {
@@ -213,37 +216,44 @@ export default function RoutingTab({
   }
 
   function confirmDelete(idx: number) {
+    const target = originalRuleIndex(rowsRef.current, idx);
     modal.confirm({
       title: `${t('delete')} ${t('pages.xray.Routings')} #${idx + 1}?`,
       okText: t('delete'),
       okType: 'danger',
       cancelText: t('cancel'),
       onOk: () => mutate((tt) => {
-        tt.routing?.rules?.splice(idx, 1);
+        tt.routing?.rules?.splice(target, 1);
       }),
     });
   }
 
   function moveUp(idx: number) {
     if (idx <= 0) return;
+    const target = originalRuleIndex(rowsRef.current, idx);
+    const prev = originalRuleIndex(rowsRef.current, idx - 1);
     mutate((tt) => {
       const list = tt.routing?.rules;
-      if (!list) return;
-      [list[idx - 1], list[idx]] = [list[idx], list[idx - 1]];
+      if (!list || !list[target] || !list[prev]) return;
+      [list[prev], list[target]] = [list[target], list[prev]];
     });
   }
   function moveDown(idx: number) {
+    if (idx >= rowsRef.current.length - 1) return;
+    const target = originalRuleIndex(rowsRef.current, idx);
+    const next = originalRuleIndex(rowsRef.current, idx + 1);
     mutate((tt) => {
       const list = tt.routing?.rules;
-      if (!list || idx >= list.length - 1) return;
-      [list[idx + 1], list[idx]] = [list[idx], list[idx + 1]];
+      if (!list || !list[target] || !list[next]) return;
+      [list[next], list[target]] = [list[target], list[next]];
     });
   }
   function toggleRule(idx: number, enabled: boolean) {
+    const target = originalRuleIndex(rowsRef.current, idx);
     mutate((tt) => {
       const list = tt.routing?.rules;
-      if (!list || !list[idx]) return;
-      list[idx].enabled = enabled;
+      if (!list || !list[target]) return;
+      list[target].enabled = enabled;
     });
   }
 
@@ -282,11 +292,13 @@ export default function RoutingTab({
       setDraggedIndex(null);
       setDropTargetIndex(null);
       if (!moved || from == null || to == null || from === to) return;
+      const fromOrig = originalRuleIndex(rowsRef.current, from);
+      const toOrig = originalRuleIndex(rowsRef.current, to);
       mutate((tt) => {
         const list = tt.routing?.rules;
         if (!list) return;
-        const [movedItem] = list.splice(from, 1);
-        list.splice(to, 0, movedItem);
+        const [movedItem] = list.splice(fromOrig, 1);
+        list.splice(toOrig, 0, movedItem);
       });
     };
 

+ 6 - 0
frontend/src/pages/xray/routing/RuleFormModal.tsx

@@ -126,7 +126,13 @@ export default function RuleFormModal({
       outboundTag: v.outboundTag === '' ? undefined : v.outboundTag,
       balancerTag: v.balancerTag === '' ? undefined : v.balancerTag,
     };
+    const managedKeys = new Set(Object.keys(built));
     const out: Record<string, unknown> = {};
+    if (rule) {
+      for (const [key, value] of Object.entries(rule)) {
+        if (!managedKeys.has(key) && value !== undefined) out[key] = value;
+      }
+    }
     for (const [k, v] of Object.entries(built)) {
       if (v == null) continue;
       if (Array.isArray(v) && v.length === 0) continue;

+ 12 - 0
frontend/src/pages/xray/routing/helpers.ts

@@ -6,6 +6,18 @@ export function arrJoin(v: unknown): string | undefined {
   return String(v);
 }
 
+/**
+ * Translate a table row's positional index into that rule's index in the full,
+ * unfiltered routing.rules array. The table hides balancer-loopback rules but
+ * keeps each visible row's original index in `key`, so any handler that mutates
+ * routing.rules must map the positional index back through `key` or it operates
+ * on the wrong rule once a hidden loopback precedes it.
+ */
+export function originalRuleIndex(rows: RuleRow[], positionalIndex: number): number {
+  const row = rows[positionalIndex];
+  return row ? row.key : positionalIndex;
+}
+
 export function csv(value?: string): string[] {
   if (!value) return [];
   return String(value).split(',').map((s) => s.trim()).filter(Boolean);

+ 36 - 0
frontend/src/test/basics-happy-eyeballs.test.tsx

@@ -0,0 +1,36 @@
+import { describe, it, expect, vi } from 'vitest';
+
+import BasicsTab from '@/pages/xray/basics/BasicsTab';
+import type { XraySettingsValue } from '@/hooks/useXraySetting';
+
+import { renderWithProviders } from './test-utils';
+
+function settingsWithMalformedHappyEyeballs(): XraySettingsValue {
+  return {
+    outbounds: [
+      {
+        protocol: 'freedom',
+        tag: 'direct',
+        streamSettings: { sockopt: { happyEyeballs: { tryDelayMs: 'fast' } } },
+      },
+    ],
+  } as unknown as XraySettingsValue;
+}
+
+describe('BasicsTab malformed happyEyeballs', () => {
+  it('renders instead of white-screening on a wrong-typed happyEyeballs value', () => {
+    const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
+    expect(() =>
+      renderWithProviders(
+        <BasicsTab
+          templateSettings={settingsWithMalformedHappyEyeballs()}
+          setTemplateSettings={vi.fn()}
+          outboundTestUrl=""
+          onChangeOutboundTestUrl={vi.fn()}
+          onResetDefault={vi.fn()}
+        />,
+      ),
+    ).not.toThrow();
+    errorSpy.mockRestore();
+  });
+});

+ 20 - 0
frontend/src/test/client-total-bytes.test.tsx

@@ -0,0 +1,20 @@
+import { describe, it, expect } from 'vitest';
+
+import { resolveTotalBytes, gbToBytes } from '@/pages/clients/ClientFormModal';
+
+describe('resolveTotalBytes', () => {
+  it('preserves the original byte total on a no-op save of a non-GB-aligned quota', () => {
+    const original = 11_505_016_832;
+    const displayedGB = Math.round((original / 1024 ** 3) * 100) / 100;
+    expect(resolveTotalBytes(original, displayedGB)).toBe(original);
+  });
+
+  it('re-derives bytes from GB when the user changed the quota', () => {
+    const original = 11_505_016_832;
+    expect(resolveTotalBytes(original, 20)).toBe(gbToBytes(20));
+  });
+
+  it('uses the GB value directly when there is no original (add mode)', () => {
+    expect(resolveTotalBytes(null, 5)).toBe(gbToBytes(5));
+  });
+});

+ 29 - 0
frontend/src/test/http-init-msw.test.ts

@@ -41,6 +41,35 @@ describe('httpRequest against the MSW-mocked network', () => {
     expect(res.data).toEqual({ success: true, obj: 'saved' });
   });
 
+  it('on a 403 refetches a fresh token from the server even when a stale meta tag is present', async () => {
+    const STALE = 'stale-meta-token';
+    const FRESH = 'fresh-server-token';
+    vi.stubGlobal('document', {
+      querySelector: (selector: string) =>
+        selector === 'meta[name="csrf-token"]' ? { getAttribute: () => STALE } : null,
+    });
+    setupHttp();
+
+    let posts = 0;
+    const sentTokens: (string | null)[] = [];
+    server.use(
+      http.get(`${ORIGIN}/csrf-token`, () => HttpResponse.json({ success: true, obj: FRESH })),
+      http.post(`${ORIGIN}/panel/api/test`, ({ request }) => {
+        posts += 1;
+        const token = request.headers.get('X-CSRF-Token');
+        sentTokens.push(token);
+        if (token !== FRESH) return new HttpResponse(null, { status: 403 });
+        return HttpResponse.json({ success: true, obj: 'saved' });
+      }),
+    );
+
+    const res = await httpRequest('POST', '/panel/api/test', { hello: 'world' });
+
+    expect(sentTokens).toEqual([STALE, FRESH]);
+    expect(posts).toBe(2);
+    expect(res.status).toBe(200);
+  });
+
   it('resolves a safe GET without requesting a CSRF token', async () => {
     let csrfHits = 0;
     server.use(

+ 2 - 2
frontend/src/test/httpUtil.test.ts

@@ -73,8 +73,8 @@ describe('HttpUtil', () => {
     expect(toast.error).not.toHaveBeenCalled();
   });
 
-  it('maps a thrown HttpError to a failure Msg via response.data.message', async () => {
-    mockRequest.mockRejectedValue(new HttpError(400, 'Bad Request', { message: 'bad input' }));
+  it('surfaces the backend error text from a thrown HttpError body (msg field)', async () => {
+    mockRequest.mockRejectedValue(new HttpError(400, 'Bad Request', { success: false, msg: 'bad input' }));
 
     const msg = await HttpUtil.post('/x', undefined, { silent: true });
 

+ 46 - 0
frontend/src/test/node-list-rowkey.test.tsx

@@ -0,0 +1,46 @@
+import { describe, it, expect, vi, afterEach } from 'vitest';
+
+import NodeList from '@/pages/nodes/NodeList';
+import type { NodeRecord } from '@/schemas/node';
+
+import { renderWithProviders } from './test-utils';
+
+const noop = () => {};
+
+function sampleNodes(): NodeRecord[] {
+  return [
+    { id: 1, name: 'parent', guid: 'p1', transitive: false, enable: true, status: 'online' },
+    { id: 0, name: 'child-a', guid: 'ca', parentGuid: 'p1', transitive: true },
+    { id: 0, name: 'child-b', guid: 'cb', parentGuid: 'p1', transitive: true },
+  ];
+}
+
+describe('NodeList desktop table row keys', () => {
+  afterEach(() => vi.restoreAllMocks());
+
+  it('gives transitive sub-node rows distinct keys instead of colliding on id 0', () => {
+    const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
+
+    renderWithProviders(
+      <NodeList
+        nodes={sampleNodes()}
+        isMobile={false}
+        selectedIds={[]}
+        onSelectionChange={noop}
+        onAdd={noop}
+        onMtls={noop}
+        onEdit={noop}
+        onDelete={noop}
+        onProbe={noop}
+        onToggleEnable={noop}
+        onUpdateNode={noop}
+        onUpdateSelected={noop}
+      />,
+    );
+
+    const duplicateKeyWarning = errorSpy.mock.calls.some((call) =>
+      call.some((arg) => typeof arg === 'string' && arg.includes('same key')),
+    );
+    expect(duplicateKeyWarning).toBe(false);
+  });
+});

+ 39 - 2
frontend/src/test/outbound-form-modal.test.tsx

@@ -1,5 +1,5 @@
-import { describe, it, expect } from 'vitest';
-import { act } from '@testing-library/react';
+import { describe, it, expect, vi } from 'vitest';
+import { act, fireEvent } from '@testing-library/react';
 
 import OutboundFormModal from '@/pages/xray/outbounds/OutboundFormModal';
 import {
@@ -54,4 +54,41 @@ describe('OutboundFormModal', () => {
       expect(labelsByProto.wireguard).not.toContain('Encryption');
     }
   }, 30000); // iterates every protocol, re-rendering a heavy modal each time — slow on CI runners
+
+  it('saves a vless reverse outbound while reverse sniffing stays disabled', async () => {
+    const onConfirm = vi.fn();
+    renderWithProviders(
+      <OutboundFormModal
+        open
+        outbound={{
+          protocol: 'vless',
+          tag: 'reverse-out',
+          settings: {
+            vnext: [{
+              address: 'example.com',
+              port: 443,
+              users: [{ id: 'c9f0c2d0-0000-4000-8000-000000000000', encryption: 'none' }],
+            }],
+            reverse: { tag: 'r1', sniffing: {} },
+          },
+          streamSettings: { network: 'tcp', security: 'none' },
+        }}
+        existingTags={[]}
+        onClose={() => {}}
+        onConfirm={onConfirm}
+      />,
+    );
+    await act(async () => { await new Promise((r) => setTimeout(r, 0)); });
+
+    const ok = document.querySelector('.ant-modal-footer .ant-btn-primary') as HTMLElement;
+    expect(ok).toBeTruthy();
+    await act(async () => { fireEvent.click(ok); });
+    await act(async () => { await new Promise((r) => setTimeout(r, 0)); });
+
+    expect(onConfirm).toHaveBeenCalledTimes(1);
+    const payload = onConfirm.mock.calls[0][0] as {
+      settings: { reverse?: { tag?: string } };
+    };
+    expect(payload.settings.reverse?.tag).toBe('r1');
+  });
 });

+ 11 - 0
frontend/src/test/outbound-link-parser.test.ts

@@ -250,6 +250,17 @@ describe('parseShadowsocksLink', () => {
     expect(settings.servers[0].method).toBe('aes-256-gcm');
     expect(settings.servers[0].password).toBe('legacypw');
   });
+
+  it('decodes URL-safe base64 userinfo (as the emitter writes it)', () => {
+    const method = 'aes-256-gcm';
+    const password = '>>>';
+    const userinfo = Base64.encode(`${method}:${password}`, true);
+    expect(userinfo).toMatch(/[-_]/);
+    const out = parseShadowsocksLink(`ss://${userinfo}@1.2.3.4:8388#urlsafe`);
+    const settings = out?.settings as { servers: Array<{ method: string; password: string }> };
+    expect(settings.servers[0].method).toBe(method);
+    expect(settings.servers[0].password).toBe(password);
+  });
 });
 
 describe('parseHysteria2Link', () => {

+ 91 - 0
frontend/src/test/outbounds-loopback-index.test.tsx

@@ -0,0 +1,91 @@
+import { describe, it, expect, vi } from 'vitest';
+import { fireEvent } from '@testing-library/react';
+import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
+
+import OutboundsTab from '@/pages/xray/outbounds/OutboundsTab';
+import type { XraySettingsValue } from '@/hooks/useXraySetting';
+
+import { renderWithProviders } from './test-utils';
+
+function settingsWithHiddenLoopback(): XraySettingsValue {
+  return {
+    outbounds: [
+      { tag: 'proxy-a', protocol: 'vmess' },
+      { tag: '_bl_bal1', protocol: 'loopback' },
+      { tag: 'proxy-b', protocol: 'vmess' },
+    ],
+  } as unknown as XraySettingsValue;
+}
+
+describe('OutboundsTab hidden-loopback index mapping', () => {
+  it('probes the outbound at its real array index, not the positional row index', () => {
+    const onTest = vi.fn();
+    const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
+
+    renderWithProviders(
+      <QueryClientProvider client={queryClient}>
+        <OutboundsTab
+          templateSettings={settingsWithHiddenLoopback()}
+          setTemplateSettings={vi.fn()}
+          outboundsTraffic={[]}
+          outboundTestStates={{}}
+          subscriptionTestStates={{}}
+          testingAll={false}
+          inboundTags={[]}
+          isMobile={false}
+          onResetTraffic={vi.fn()}
+          onTest={onTest}
+          onTestSubscription={vi.fn()}
+          onTestAll={vi.fn()}
+          onShowWarp={vi.fn()}
+          onShowNord={vi.fn()}
+        />
+      </QueryClientProvider>,
+    );
+
+    const tbody = document.querySelector('.ant-table-tbody');
+    const tableRows = tbody?.querySelectorAll('tr.ant-table-row') ?? [];
+    expect(tableRows.length).toBe(2);
+
+    const checkButton = tableRows[1].querySelector('button[aria-label="Check"]') as HTMLButtonElement;
+    fireEvent.click(checkButton);
+
+    expect(onTest).toHaveBeenCalledTimes(1);
+    expect(onTest.mock.calls[0][0]).toBe(2);
+  });
+
+  it('probes the real array index from the mobile card list as well', () => {
+    const onTest = vi.fn();
+    const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
+
+    renderWithProviders(
+      <QueryClientProvider client={queryClient}>
+        <OutboundsTab
+          templateSettings={settingsWithHiddenLoopback()}
+          setTemplateSettings={vi.fn()}
+          outboundsTraffic={[]}
+          outboundTestStates={{}}
+          subscriptionTestStates={{}}
+          testingAll={false}
+          inboundTags={[]}
+          isMobile
+          onResetTraffic={vi.fn()}
+          onTest={onTest}
+          onTestSubscription={vi.fn()}
+          onTestAll={vi.fn()}
+          onShowWarp={vi.fn()}
+          onShowNord={vi.fn()}
+        />
+      </QueryClientProvider>,
+    );
+
+    const cards = document.querySelectorAll('.outbound-card');
+    expect(cards.length).toBe(2);
+
+    const checkButton = cards[1].querySelector('button[aria-label="Check"]') as HTMLButtonElement;
+    fireEvent.click(checkButton);
+
+    expect(onTest).toHaveBeenCalledTimes(1);
+    expect(onTest.mock.calls[0][0]).toBe(2);
+  });
+});

+ 55 - 0
frontend/src/test/routing-loopback-index.test.tsx

@@ -0,0 +1,55 @@
+import { describe, it, expect, vi } from 'vitest';
+import { fireEvent, screen } from '@testing-library/react';
+import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
+
+import RoutingTab from '@/pages/xray/routing/RoutingTab';
+import type { XraySettingsValue } from '@/hooks/useXraySetting';
+
+import { renderWithProviders } from './test-utils';
+
+function settingsWithHiddenLoopback(): XraySettingsValue {
+  return {
+    routing: {
+      rules: [
+        { type: 'field', outboundTag: 'a', enabled: true },
+        { type: 'field', inboundTag: ['_bl_bal1'], outboundTag: 'b', enabled: true },
+        { type: 'field', outboundTag: 'c', enabled: true },
+      ],
+    },
+  } as unknown as XraySettingsValue;
+}
+
+describe('RoutingTab hidden-loopback index mapping', () => {
+  it('toggles the visible rule, not the hidden loopback rule that precedes it', () => {
+    const setTemplateSettings = vi.fn();
+    const initial = settingsWithHiddenLoopback();
+
+    const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
+    renderWithProviders(
+      <QueryClientProvider client={queryClient}>
+        <RoutingTab
+          templateSettings={initial}
+          setTemplateSettings={setTemplateSettings}
+          inboundTags={[]}
+          clientReverseTags={[]}
+          isMobile={false}
+        />
+      </QueryClientProvider>,
+    );
+
+    fireEvent.click(screen.getByRole('tab', { name: /Routing Rules/ }));
+
+    const switches = document.querySelectorAll('.routing-table .ant-switch');
+    expect(switches.length).toBe(2);
+
+    fireEvent.click(switches[1]);
+
+    expect(setTemplateSettings).toHaveBeenCalledTimes(1);
+    const updater = setTemplateSettings.mock.calls[0][0] as (prev: XraySettingsValue) => XraySettingsValue;
+    const next = updater(initial);
+    const rules = (next.routing as { rules: Array<{ enabled?: boolean }> }).rules;
+
+    expect(rules[2].enabled).toBe(false);
+    expect(rules[1].enabled).toBe(true);
+  });
+});

+ 33 - 0
frontend/src/test/rule-form-preserve-fields.test.tsx

@@ -0,0 +1,33 @@
+import { describe, it, expect, vi } from 'vitest';
+import { fireEvent, screen } from '@testing-library/react';
+import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
+
+import RuleFormModal from '@/pages/xray/routing/RuleFormModal';
+
+import { renderWithProviders } from './test-utils';
+
+describe('RuleFormModal edit preserves unsurfaced fields', () => {
+  it('keeps a field the form does not surface (ruleTag) when saving an edit', () => {
+    const onConfirm = vi.fn();
+    const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
+
+    renderWithProviders(
+      <QueryClientProvider client={queryClient}>
+        <RuleFormModal
+          open
+          rule={{ type: 'field', outboundTag: 'block', ruleTag: 'my-tag', enabled: true }}
+          inboundTags={[]}
+          outboundTags={['block']}
+          balancerTags={[]}
+          onClose={vi.fn()}
+          onConfirm={onConfirm}
+        />
+      </QueryClientProvider>,
+    );
+
+    fireEvent.click(screen.getByRole('button', { name: 'Save Changes' }));
+
+    expect(onConfirm).toHaveBeenCalledTimes(1);
+    expect(onConfirm.mock.calls[0][0]).toMatchObject({ ruleTag: 'my-tag' });
+  });
+});

+ 21 - 0
frontend/src/test/sniffing-field-resync.test.tsx

@@ -0,0 +1,21 @@
+import { describe, it, expect, vi } from 'vitest';
+import { render } from '@testing-library/react';
+
+import SniffingField from '@/lib/xray/forms/fields/SniffingField';
+import { SniffingSchema } from '@/schemas/primitives/sniffing';
+
+describe('SniffingField external re-sync', () => {
+  it('reflects an external value change (e.g. an advanced JSON edit) in the friendly form', () => {
+    const disabled = SniffingSchema.parse({ enabled: false });
+    const enabled = SniffingSchema.parse({ enabled: true });
+    const onChange = vi.fn();
+
+    const { rerender, getByRole } = render(
+      <SniffingField value={disabled} onChange={onChange} enableLabel="Enable" />,
+    );
+    expect(getByRole('switch').getAttribute('aria-checked')).toBe('false');
+
+    rerender(<SniffingField value={enabled} onChange={onChange} enableLabel="Enable" />);
+    expect(getByRole('switch').getAttribute('aria-checked')).toBe('true');
+  });
+});

+ 51 - 0
frontend/src/test/websocket-bridge-outbounds.test.tsx

@@ -0,0 +1,51 @@
+import { describe, it, expect, beforeEach, afterEach } from 'vitest';
+import { renderHook } from '@testing-library/react';
+import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
+import type { ReactNode } from 'react';
+
+import { getSharedWebSocketClient } from '@/api/websocket';
+import { useWebSocketBridge } from '@/api/websocketBridge';
+import { keys } from '@/api/queryKeys';
+
+type ListenerMap = { listeners: Map<string, Set<(payload: unknown) => void>> };
+
+describe('websocket bridge outbounds handler', () => {
+  let originalWS: typeof WebSocket;
+
+  beforeEach(() => {
+    originalWS = globalThis.WebSocket;
+    class FakeWebSocket {
+      static readonly CONNECTING = 0;
+      static readonly OPEN = 1;
+      static readonly CLOSING = 2;
+      static readonly CLOSED = 3;
+      readyState = FakeWebSocket.CONNECTING;
+      addEventListener() {}
+      removeEventListener() {}
+      close() {}
+      send() {}
+    }
+    globalThis.WebSocket = FakeWebSocket as unknown as typeof WebSocket;
+  });
+
+  afterEach(() => {
+    globalThis.WebSocket = originalWS;
+  });
+
+  it('ignores a non-array outbounds push instead of poisoning the cache', () => {
+    const queryClient = new QueryClient();
+    const wrapper = ({ children }: { children: ReactNode }) => (
+      <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
+    );
+
+    renderHook(() => useWebSocketBridge(), { wrapper });
+
+    const client = getSharedWebSocketClient() as unknown as ListenerMap;
+    const handlers = client.listeners.get('outbounds');
+    expect(handlers && handlers.size).toBeGreaterThan(0);
+
+    for (const handler of handlers ?? []) handler({ not: 'an array' });
+
+    expect(queryClient.getQueryData(keys.xray.outboundsTraffic())).toBeUndefined();
+  });
+});

+ 49 - 0
frontend/src/test/websocket-shared-client.test.tsx

@@ -0,0 +1,49 @@
+import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
+import { renderHook } from '@testing-library/react';
+import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
+import type { ReactNode } from 'react';
+
+import { useWebSocket } from '@/hooks/useWebSocket';
+import { useWebSocketBridge } from '@/api/websocketBridge';
+
+describe('shared WebSocket connection', () => {
+  let socketCount = 0;
+  let originalWS: typeof WebSocket;
+
+  beforeEach(() => {
+    socketCount = 0;
+    originalWS = globalThis.WebSocket;
+    class FakeWebSocket {
+      static readonly CONNECTING = 0;
+      static readonly OPEN = 1;
+      static readonly CLOSING = 2;
+      static readonly CLOSED = 3;
+      readyState = FakeWebSocket.CONNECTING;
+      constructor() {
+        socketCount += 1;
+      }
+      addEventListener() {}
+      removeEventListener() {}
+      close() {}
+      send() {}
+    }
+    globalThis.WebSocket = FakeWebSocket as unknown as typeof WebSocket;
+  });
+
+  afterEach(() => {
+    globalThis.WebSocket = originalWS;
+    vi.restoreAllMocks();
+  });
+
+  it('opens a single socket when the bridge and a page hook are both mounted', () => {
+    const queryClient = new QueryClient();
+    const wrapper = ({ children }: { children: ReactNode }) => (
+      <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
+    );
+
+    renderHook(() => useWebSocketBridge(), { wrapper });
+    renderHook(() => useWebSocket({ traffic: () => {} }));
+
+    expect(socketCount).toBe(1);
+  });
+});

+ 9 - 5
frontend/src/utils/index.ts

@@ -76,8 +76,9 @@ export class HttpUtil {
       return msg;
     } catch (error) {
       console.error('GET request failed:', error);
-      const err = error as { response?: { data?: { message?: string } }; message?: string };
-      const errorMsg = new Msg<T>(false, err.response?.data?.message || err.message || 'Request failed');
+      const err = error as { response?: { data?: { msg?: string; message?: string } }; message?: string };
+      const data = err.response?.data;
+      const errorMsg = new Msg<T>(false, data?.msg || data?.message || err.message || 'Request failed');
       if (!silent) this._handleMsg(errorMsg);
       return errorMsg;
     }
@@ -92,8 +93,9 @@ export class HttpUtil {
       return msg;
     } catch (error) {
       console.error('POST request failed:', error);
-      const err = error as { response?: { data?: { message?: string } }; message?: string };
-      const errorMsg = new Msg<T>(false, err.response?.data?.message || err.message || 'Request failed');
+      const err = error as { response?: { data?: { msg?: string; message?: string } }; message?: string };
+      const data = err.response?.data;
+      const errorMsg = new Msg<T>(false, data?.msg || data?.message || err.message || 'Request failed');
       if (!silent) this._handleMsg(errorMsg);
       return errorMsg;
     }
@@ -641,8 +643,10 @@ export class Base64 {
   }
 
   static decode(content: string = ''): string {
+    const normalized = content.replace(/-/g, '+').replace(/_/g, '/');
+    const padded = normalized.padEnd(Math.ceil(normalized.length / 4) * 4, '=');
     return new TextDecoder().decode(
-      Uint8Array.from(window.atob(content), (c) => c.charCodeAt(0)),
+      Uint8Array.from(window.atob(padded), (c) => c.charCodeAt(0)),
     );
   }
 }

+ 1 - 1
internal/database/db.go

@@ -1057,7 +1057,7 @@ func runSeeders(isUsersEmpty bool) error {
 	}
 
 	if empty && isUsersEmpty {
-		seeders := []string{"UserPasswordHash", "ClientsTable", "InboundClientsArrayFix", "InboundClientTgIdFix", "InboundClientSubIdFix", "FreedomFinalRulesReverseFix", "ApiTokensHash", "LegacyProxySettingsCleanup", "WireguardPeersToClients", "MtprotoSecretsToClients", "NodeInboundsAdopted"}
+		seeders := []string{"UserPasswordHash", "ClientsTable", "InboundClientsArrayFix", "InboundClientTgIdFix", "InboundClientSubIdFix", "FreedomFinalRulesReverseFix", "ApiTokensHash", "LegacyProxySettingsCleanup", "WireguardPeersToClients", "MtprotoSecretsToClients", "NodeInboundsAdopted", "ResetIpLimitNoFail2ban"}
 		for _, name := range seeders {
 			if err := db.Create(&model.HistoryOfSeeders{SeederName: name}).Error; err != nil {
 				return err

+ 36 - 0
internal/database/seeder_fastpath_test.go

@@ -0,0 +1,36 @@
+package database
+
+import (
+	"path/filepath"
+	"testing"
+
+	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
+)
+
+func TestFreshInstallFastPathMarksResetIpLimitSeeder(t *testing.T) {
+	if err := InitDB(filepath.Join(t.TempDir(), "x-ui.db")); err != nil {
+		t.Fatalf("InitDB: %v", err)
+	}
+	t.Cleanup(func() { _ = CloseDB() })
+
+	if err := db.Where("1 = 1").Delete(&model.HistoryOfSeeders{}).Error; err != nil {
+		t.Fatalf("reset seeder history: %v", err)
+	}
+	if err := db.Where("1 = 1").Delete(&model.User{}).Error; err != nil {
+		t.Fatalf("reset users: %v", err)
+	}
+
+	if err := runSeeders(true); err != nil {
+		t.Fatalf("runSeeders: %v", err)
+	}
+
+	var cnt int64
+	if err := db.Model(&model.HistoryOfSeeders{}).
+		Where("seeder_name = ?", "ResetIpLimitNoFail2ban").
+		Count(&cnt).Error; err != nil {
+		t.Fatalf("count seeder history: %v", err)
+	}
+	if cnt != 1 {
+		t.Fatal("fresh-install fast path must mark ResetIpLimitNoFail2ban done so it cannot wipe admin-set IP limits on the next boot")
+	}
+}

+ 75 - 23
internal/eventbus/bus.go

@@ -10,21 +10,31 @@ import (
 // DefaultBufferSize is the number of events the bus can hold before Publish starts dropping.
 const DefaultBufferSize = 256
 
-// subscriber pairs an ID with its event handler.
+// subscriberQueueSize bounds how many undelivered events a single subscriber may
+// hold before the newest are dropped. Each subscriber drains its own queue on a
+// dedicated worker goroutine, so a slow subscriber can neither stall delivery to
+// the others nor make the bus spawn an unbounded number of goroutines.
+const subscriberQueueSize = 64
+
+// subscriber pairs an ID with its event handler and the per-subscriber worker
+// state used to deliver events to it serially, without blocking the dispatch loop.
 type subscriber struct {
 	id      string
 	handler func(Event)
+	queue   chan Event
+	quit    chan struct{}
 }
 
 // Bus is a minimal in-process pub/sub event bus backed by a buffered channel.
 // Producers call Publish (non-blocking) and every event is fanned out to all
 // subscribers; per-event filtering is the subscriber's responsibility.
 type Bus struct {
-	ch   chan Event
-	subs []subscriber
-	mu   sync.RWMutex
-	done chan struct{}
-	wg   sync.WaitGroup
+	ch      chan Event
+	subs    []*subscriber
+	mu      sync.RWMutex
+	done    chan struct{}
+	wg      sync.WaitGroup
+	stopped bool
 }
 
 // New creates a Bus with the given buffer size. Use 0 for DefaultBufferSize.
@@ -41,27 +51,41 @@ func New(bufSize int) *Bus {
 	return b
 }
 
-// Subscribe registers a handler that receives every published event.
-// The id is used for Unsubscribe; it must be unique across active subscribers.
-// Subscribing with an already-registered id replaces the previous handler.
+// Subscribe registers a handler that receives every published event on its own
+// worker goroutine. The id is used for Unsubscribe; it must be unique across
+// active subscribers. Subscribing with an already-registered id replaces the
+// previous subscriber, stopping its worker.
 func (b *Bus) Subscribe(id string, handler func(Event)) {
 	b.mu.Lock()
 	defer b.mu.Unlock()
+	if b.stopped {
+		return
+	}
 	for i, s := range b.subs {
 		if s.id == id {
-			b.subs[i].handler = handler
-			return
+			close(s.quit)
+			b.subs = append(b.subs[:i], b.subs[i+1:]...)
+			break
 		}
 	}
-	b.subs = append(b.subs, subscriber{id: id, handler: handler})
+	s := &subscriber{
+		id:      id,
+		handler: handler,
+		queue:   make(chan Event, subscriberQueueSize),
+		quit:    make(chan struct{}),
+	}
+	b.subs = append(b.subs, s)
+	b.wg.Add(1)
+	go b.runWorker(s)
 }
 
-// Unsubscribe removes a subscriber by id. Safe to call with unknown id.
+// Unsubscribe removes a subscriber by id and stops its worker. Safe to call with an unknown id.
 func (b *Bus) Unsubscribe(id string) {
 	b.mu.Lock()
 	defer b.mu.Unlock()
 	for i, s := range b.subs {
 		if s.id == id {
+			close(s.quit)
 			b.subs = append(b.subs[:i], b.subs[i+1:]...)
 			return
 		}
@@ -81,9 +105,13 @@ func (b *Bus) Publish(e Event) {
 	}
 }
 
-// dispatch is the fan-out loop. It reads events from the channel and calls
-// every subscriber's handler sequentially. Handlers run on the dispatch
-// goroutine — they must not block.
+// dispatch is the fan-out loop. It reads events from the channel and hands each
+// one to every subscriber's queue with a non-blocking send, so a subscriber
+// whose handler blocks on network I/O (the email and Telegram notifiers can
+// block for tens of seconds) can neither stall delivery of unrelated, higher-
+// value events such as xray.crash or node.down, nor force the bus to spawn an
+// unbounded number of goroutines under load. A subscriber whose queue is full
+// drops the event, keeping the bus non-blocking and its memory bounded.
 func (b *Bus) dispatch() {
 	defer b.wg.Done()
 	for {
@@ -93,12 +121,30 @@ func (b *Bus) dispatch() {
 				return
 			}
 			b.mu.RLock()
-			subs := make([]subscriber, len(b.subs))
-			copy(subs, b.subs)
-			b.mu.RUnlock()
-			for _, s := range subs {
-				safeCall(s.handler, e)
+			for _, s := range b.subs {
+				select {
+				case s.queue <- e:
+				default:
+					logger.Warning("eventbus: subscriber ", s.id, " queue full, dropping ", e.Type)
+				}
 			}
+			b.mu.RUnlock()
+		case <-b.done:
+			return
+		}
+	}
+}
+
+// runWorker delivers queued events to one subscriber serially, so a subscriber
+// never runs concurrently with itself and observes events in publication order.
+func (b *Bus) runWorker(s *subscriber) {
+	defer b.wg.Done()
+	for {
+		select {
+		case e := <-s.queue:
+			safeCall(s.handler, e)
+		case <-s.quit:
+			return
 		case <-b.done:
 			return
 		}
@@ -115,9 +161,15 @@ func safeCall(fn func(Event), e Event) {
 	fn(e)
 }
 
-// Stop shuts down the bus: the dispatch goroutine exits, in-flight handlers
-// finish, and any events still buffered may be dropped. Safe to call once.
+// Stop shuts down the bus: the dispatch loop and every subscriber worker exit
+// after finishing any handler already in progress, and any events still buffered
+// or queued may be dropped. Safe to call once. After Stop returns, Subscribe is
+// a no-op — this also keeps Subscribe's wg.Add from ever racing with Wait below,
+// since both are serialized through mu.
 func (b *Bus) Stop() {
+	b.mu.Lock()
+	b.stopped = true
+	b.mu.Unlock()
 	close(b.done)
 	b.wg.Wait()
 }

+ 76 - 0
internal/eventbus/bus_test.go

@@ -149,6 +149,68 @@ func TestBusPanicRecovery(t *testing.T) {
 	}
 }
 
+func TestBusBlockingSubscriberDoesNotStallOthers(t *testing.T) {
+	b := New(16)
+	defer b.Stop()
+
+	release := make(chan struct{})
+	b.Subscribe("blocking", func(e Event) {
+		<-release
+	})
+
+	fast := make(chan struct{}, 1)
+	b.Subscribe("fast", func(e Event) {
+		fast <- struct{}{}
+	})
+
+	b.Publish(Event{Type: EventXrayCrash})
+
+	select {
+	case <-fast:
+	case <-time.After(time.Second):
+		close(release)
+		t.Fatal("a blocking subscriber stalled event delivery to another subscriber")
+	}
+	close(release)
+}
+
+func TestBusSubscriberRunsSerially(t *testing.T) {
+	b := New(16)
+	defer b.Stop()
+
+	var inFlight atomic.Int32
+	var maxSeen atomic.Int32
+	var wg sync.WaitGroup
+	const n = 8
+	wg.Add(n)
+
+	b.Subscribe("serial", func(Event) {
+		cur := inFlight.Add(1)
+		for {
+			m := maxSeen.Load()
+			if cur <= m || maxSeen.CompareAndSwap(m, cur) {
+				break
+			}
+		}
+		time.Sleep(5 * time.Millisecond)
+		inFlight.Add(-1)
+		wg.Done()
+	})
+
+	for i := 0; i < n; i++ {
+		b.Publish(Event{Type: EventXrayCrash})
+	}
+
+	select {
+	case <-waitDone(&wg):
+	case <-time.After(2 * time.Second):
+		t.Fatal("subscriber did not process all events")
+	}
+	if got := maxSeen.Load(); got != 1 {
+		t.Fatalf("subscriber ran concurrently with itself: max in-flight = %d, want 1", got)
+	}
+}
+
 func TestBusBufferFull(t *testing.T) {
 	b := New(2)
 	defer b.Stop()
@@ -198,3 +260,17 @@ func waitDone(wg *sync.WaitGroup) <-chan struct{} {
 	}()
 	return ch
 }
+
+func TestBusSubscribeAfterStopIsNoop(t *testing.T) {
+	b := New(4)
+	b.Stop()
+
+	b.Subscribe("late", func(Event) {})
+
+	b.mu.RLock()
+	n := len(b.subs)
+	b.mu.RUnlock()
+	if n != 0 {
+		t.Fatalf("Subscribe after Stop registered %d subscriber(s), want 0 (a stopped bus must not accept new subscribers, and must not call wg.Add after wg.Wait has been entered)", n)
+	}
+}

+ 13 - 4
internal/sub/clash_service.go

@@ -168,16 +168,22 @@ func (s *SubClashService) getProxies(subReq *SubService, inbound *model.Inbound,
 
 	proxies := make([]map[string]any, 0, len(externalProxies))
 	for _, ep := range externalProxies {
-		extPrxy := ep.(map[string]any)
+		extPrxy, ok := ep.(map[string]any)
+		if !ok {
+			continue
+		}
 		// Expand the host's {{VAR}} remark template for this client (no-op for
 		// the synthetic/legacy entry) before it becomes the proxy name.
 		subReq.renderHostRemark(inbound, client, extPrxy, network)
 		workingInbound := *inbound
-		workingInbound.Listen = extPrxy["dest"].(string)
-		workingInbound.Port = int(extPrxy["port"].(float64))
+		workingInbound.Listen, _ = extPrxy["dest"].(string)
+		if port, ok := extPrxy["port"].(float64); ok {
+			workingInbound.Port = int(port)
+		}
 		workingStream := cloneStreamForExternalProxy(stream)
 
-		switch extPrxy["forceTls"].(string) {
+		forceTls, _ := extPrxy["forceTls"].(string)
+		switch forceTls {
 		case "tls":
 			if workingStream["security"] != "tls" {
 				workingStream["security"] = "tls"
@@ -684,6 +690,9 @@ func (s *SubClashService) applySecurity(proxy map[string]any, security string, s
 					proxy["skip-cert-verify"] = true
 				}
 			}
+			if pins, ok := tlsSettings["pin-sha256"].([]any); ok && len(pins) > 0 {
+				proxy["pin-sha256"] = pins
+			}
 		}
 		return true
 	case "reality":

+ 43 - 25
internal/sub/controller.go

@@ -340,14 +340,12 @@ func (a *SUBController) subs(c *gin.Context) {
 		logSubscriptionRoute(userAgent, "html")
 		return
 	}
-	if shouldAutoServeClash(a.subClashAutoDetect, a.clashEnabled, false, userAgent, a.clashUserAgent) {
+	if shouldAutoServeClash(a.subClashAutoDetect, a.clashEnabled, false, userAgent, a.clashUserAgent) && a.serveClashBody(c) {
 		logSubscriptionRoute(userAgent, "clash")
-		a.subClashs(c)
 		return
 	}
-	if shouldAutoServeJson(a.jsonAutoDetect, a.jsonEnabled, false, userAgent, a.jsonUserAgent) {
+	if shouldAutoServeJson(a.jsonAutoDetect, a.jsonEnabled, false, userAgent, a.jsonUserAgent) && a.serveJsonBody(c, true, "application/json; charset=utf-8") {
 		logSubscriptionRoute(userAgent, "json")
-		a.serveJson(c, true, "application/json; charset=utf-8")
 		return
 	}
 	logSubscriptionRoute(userAgent, "raw")
@@ -605,43 +603,63 @@ func (a *SUBController) subJsons(c *gin.Context) {
 }
 
 func (a *SUBController) serveJson(c *gin.Context, alwaysReturnArray bool, contentType string) {
+	if !a.serveJsonBody(c, alwaysReturnArray, contentType) {
+		writeSubError(c, nil)
+	}
+}
+
+func (a *SUBController) serveJsonBody(c *gin.Context, alwaysReturnArray bool, contentType string) bool {
 	subId := c.Param("subid")
 	scheme, host, hostWithPort, _ := a.subService.ResolveRequest(c)
 	jsonSub, header, err := a.subJsonService.GetJson(subId, host, alwaysReturnArray)
-	if err != nil || len(jsonSub) == 0 {
+	if err != nil {
 		writeSubError(c, err)
-	} else {
-		profileUrl := a.subProfileUrl
-		if profileUrl == "" {
-			profileUrl = fmt.Sprintf("%s://%s%s", scheme, hostWithPort, c.Request.RequestURI)
-		}
-		a.ApplyCommonHeaders(c, header, a.updateInterval, a.subTitle, a.subSupportUrl, profileUrl, a.subAnnounce, a.subEnableRouting, a.subRoutingRules, a.subHideSettings)
-
-		c.Data(200, contentType, []byte(jsonSub))
+		return true
+	}
+	if len(jsonSub) == 0 {
+		return false
+	}
+	profileUrl := a.subProfileUrl
+	if profileUrl == "" {
+		profileUrl = fmt.Sprintf("%s://%s%s", scheme, hostWithPort, c.Request.RequestURI)
 	}
+	a.ApplyCommonHeaders(c, header, a.updateInterval, a.subTitle, a.subSupportUrl, profileUrl, a.subAnnounce, a.subEnableRouting, a.subRoutingRules, a.subHideSettings)
+
+	c.Data(200, contentType, []byte(jsonSub))
+	return true
 }
 
 func (a *SUBController) subClashs(c *gin.Context) {
 	if a.maybeServeSubPage(c) {
 		return
 	}
+	if !a.serveClashBody(c) {
+		writeSubError(c, nil)
+	}
+}
+
+func (a *SUBController) serveClashBody(c *gin.Context) bool {
 	subId := c.Param("subid")
 	scheme, host, hostWithPort, _ := a.subService.ResolveRequest(c)
 	clashSub, header, err := a.subClashService.GetClash(subId, host)
-	if err != nil || len(clashSub) == 0 {
+	if err != nil {
 		writeSubError(c, err)
-	} else {
-		profileUrl := a.subProfileUrl
-		if profileUrl == "" {
-			profileUrl = fmt.Sprintf("%s://%s%s", scheme, hostWithPort, c.Request.RequestURI)
-		}
-		a.ApplyCommonHeaders(c, header, a.updateInterval, a.subTitle, a.subSupportUrl, profileUrl, a.subAnnounce, a.subEnableRouting, a.subRoutingRules, a.subHideSettings)
-		if a.subTitle != "" {
-			// Clash clients commonly use Content-Disposition to choose the imported profile name.
-			c.Writer.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename*=UTF-8''%s`, url.PathEscape(a.subTitle)))
-		}
-		c.Data(200, "application/yaml; charset=utf-8", []byte(clashSub))
+		return true
+	}
+	if len(clashSub) == 0 {
+		return false
+	}
+	profileUrl := a.subProfileUrl
+	if profileUrl == "" {
+		profileUrl = fmt.Sprintf("%s://%s%s", scheme, hostWithPort, c.Request.RequestURI)
 	}
+	a.ApplyCommonHeaders(c, header, a.updateInterval, a.subTitle, a.subSupportUrl, profileUrl, a.subAnnounce, a.subEnableRouting, a.subRoutingRules, a.subHideSettings)
+	if a.subTitle != "" {
+		// Clash clients commonly use Content-Disposition to choose the imported profile name.
+		c.Writer.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename*=UTF-8''%s`, url.PathEscape(a.subTitle)))
+	}
+	c.Data(200, "application/yaml; charset=utf-8", []byte(clashSub))
+	return true
 }
 
 // ApplyCommonHeaders sets common HTTP headers for subscription responses including user info, update interval, and profile title.

+ 48 - 0
internal/sub/controller_test.go

@@ -3,6 +3,7 @@ package sub
 import (
 	"bytes"
 	"encoding/base64"
+	"fmt"
 	"net/http"
 	"net/http/httptest"
 	"os"
@@ -13,6 +14,8 @@ import (
 
 	"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"
 )
 
@@ -185,6 +188,51 @@ func TestSanitizeUserAgentForLog(t *testing.T) {
 	}
 }
 
+func seedSubMtprotoInbound(t *testing.T, subId, tag string, port int) {
+	t.Helper()
+	db := database.GetDB()
+	secret := "ee1234567890abcdef1234567890abcd7777772e636c6f7564666c6172652e636f6d"
+	email := tag + "@e"
+	settings := fmt.Sprintf(`{"clients":[{"email":%q,"subId":%q,"enable":true,"secret":%q}]}`, email, subId, secret)
+	ib := &model.Inbound{
+		UserId: 1, Tag: tag, Enable: true, Listen: "203.0.113.5", Port: port,
+		Protocol: model.MTProto, Remark: tag, Settings: settings, StreamSettings: "{}",
+	}
+	if err := db.Create(ib).Error; err != nil {
+		t.Fatalf("seed mtproto inbound %s: %v", tag, err)
+	}
+	client := &model.ClientRecord{Email: email, SubID: subId, Secret: secret, Enable: true}
+	if err := db.Create(client).Error; err != nil {
+		t.Fatalf("seed client %s: %v", email, err)
+	}
+	if err := db.Create(&model.ClientInbound{ClientId: client.Id, InboundId: ib.Id}).Error; err != nil {
+		t.Fatalf("seed client_inbound %s: %v", email, err)
+	}
+}
+
+func TestAutoDetectFallsBackToRawWhenFormatHasNoContent(t *testing.T) {
+	seedSubDB(t)
+	seedSubMtprotoInbound(t, "s1", "tg", 4490)
+	gin.SetMode(gin.TestMode)
+
+	req := httptest.NewRequest(http.MethodGet, "http://sub.example.com/sub/s1", nil)
+	req.Header.Set("User-Agent", "Clash-Verge/v2.4.2")
+	resp := httptest.NewRecorder()
+
+	newSubscriptionTestRouter(subscriptionTestRouterConfig{clashAutoDetect: true, jsonAutoDetect: true}).ServeHTTP(resp, req)
+
+	if resp.Code != http.StatusOK {
+		t.Fatalf("status = %d, want 200; body=%s", resp.Code, resp.Body.String())
+	}
+	decoded, err := base64.StdEncoding.DecodeString(resp.Body.String())
+	if err != nil {
+		t.Fatalf("fallback response is not base64: %v", err)
+	}
+	if !strings.Contains(string(decoded), "tg://proxy") {
+		t.Fatalf("decoded fallback lacks the Telegram proxy link: %s", decoded)
+	}
+}
+
 func TestStandardSubscriptionAutoDetectsFormats(t *testing.T) {
 	seedSubDB(t)
 	seedSubInbound(t, "s1", "auto", 4480, 1, `{"network":"tcp","security":"none"}`)

+ 13 - 7
internal/sub/json_service.go

@@ -177,14 +177,20 @@ func (s *SubJsonService) getConfig(subReq *SubService, inbound *model.Inbound, c
 	network, _ := stream["network"].(string)
 
 	for _, ep := range externalProxies {
-		extPrxy := ep.(map[string]any)
+		extPrxy, ok := ep.(map[string]any)
+		if !ok {
+			continue
+		}
 		// Expand the host's {{VAR}} remark template for this client (no-op for
 		// the synthetic/legacy entry) before it's used as the config remark.
 		subReq.renderHostRemark(inbound, client, extPrxy, network)
-		inbound.Listen = extPrxy["dest"].(string)
-		inbound.Port = int(extPrxy["port"].(float64))
+		inbound.Listen, _ = extPrxy["dest"].(string)
+		if port, ok := extPrxy["port"].(float64); ok {
+			inbound.Port = int(port)
+		}
 		newStream := cloneStreamForExternalProxy(stream)
-		switch extPrxy["forceTls"].(string) {
+		forceTls, _ := extPrxy["forceTls"].(string)
+		switch forceTls {
 		case "tls":
 			if newStream["security"] != "tls" {
 				newStream["security"] = "tls"
@@ -351,13 +357,13 @@ func (s *SubJsonService) realityData(rData map[string]any, clientKey string) map
 	rltyData["spiderX"] = deriveSpiderX(seed, clientKey)
 	shortIds, ok := rData["shortIds"].([]any)
 	if ok && len(shortIds) > 0 {
-		rltyData["shortId"] = shortIds[random.Num(len(shortIds))].(string)
+		rltyData["shortId"], _ = shortIds[random.Num(len(shortIds))].(string)
 	} else {
 		rltyData["shortId"] = ""
 	}
 	serverNames, ok := rData["serverNames"].([]any)
 	if ok && len(serverNames) > 0 {
-		rltyData["serverName"] = serverNames[random.Num(len(serverNames))].(string)
+		rltyData["serverName"], _ = serverNames[random.Num(len(serverNames))].(string)
 	} else {
 		rltyData["serverName"] = ""
 	}
@@ -496,7 +502,7 @@ func (s *SubJsonService) genHy(inbound *model.Inbound, newStream map[string]any,
 	}
 
 	_ = json.Unmarshal([]byte(inbound.StreamSettings), &stream)
-	hyStream := stream["hysteriaSettings"].(map[string]any)
+	hyStream, _ := stream["hysteriaSettings"].(map[string]any)
 	outHyStream := map[string]any{
 		"version": int(version),
 		"auth":    client.Auth,

+ 9 - 9
internal/sub/remark_vars.go

@@ -124,23 +124,23 @@ func expandRemarkVars(template string, ctx remarkContext) string {
 }
 
 // expandSegment expands one "|" segment and reports whether it should be dropped.
-// It drops only when the segment carries an unlimited (∞) quota/expiry token and
-// no other token in it resolves to a non-empty value — so a segment mixing, say,
-// {{EMAIL}} with {{TRAFFIC_LEFT}} is always kept.
+// A segment that contains tokens is dropped when none of them resolve to a real
+// value — whether because they render the unlimited (∞) mark or the empty string
+// — so it leaves no stray "|" separator or dangling decoration. A segment mixing,
+// say, {{EMAIL}} with {{TRAFFIC_LEFT}} is kept, and a pure-literal segment (no
+// tokens) is always kept.
 func expandSegment(seg string, ctx remarkContext) (string, bool) {
-	hasUnlimited, hasOtherValue := false, false
+	hasToken, hasOtherValue := false, false
 	out := remarkVarRe.ReplaceAllStringFunc(seg, func(m string) string {
+		hasToken = true
 		token := m[2 : len(m)-2]
 		val := remarkVarValue(token, ctx)
-		switch {
-		case unlimitedDropTokens[token] && val == unlimitedMark:
-			hasUnlimited = true
-		case val != "":
+		if val != "" && (!unlimitedDropTokens[token] || val != unlimitedMark) {
 			hasOtherValue = true
 		}
 		return val
 	})
-	return out, hasUnlimited && !hasOtherValue
+	return out, hasToken && !hasOtherValue
 }
 
 func remarkVarValue(token string, ctx remarkContext) string {

+ 13 - 0
internal/sub/remark_vars_test.go

@@ -128,6 +128,19 @@ func TestExpandRemarkVars_DropUnlimitedSegments(t *testing.T) {
 	}
 }
 
+func TestExpandRemarkVars_DropEmptySegments(t *testing.T) {
+	inbound := &model.Inbound{Remark: "host"}
+
+	noComment := expandCtx(model.Client{}, xray.ClientTraffic{Enable: true}, inbound)
+	if got := expandRemarkVars("{{INBOUND}}|{{COMMENT}}", noComment); got != "host" {
+		t.Errorf("empty comment segment = %q, want %q (no trailing pipe)", got, "host")
+	}
+
+	if got := expandRemarkVars("{{INBOUND}}|📅{{EXPIRE_DATE}}", noComment); got != "host" {
+		t.Errorf("decorated empty segment = %q, want %q", got, "host")
+	}
+}
+
 func TestClientStatus(t *testing.T) {
 	cases := []struct {
 		name string

+ 40 - 27
internal/sub/service.go

@@ -781,7 +781,7 @@ func (s *SubService) genVlessLink(inbound *model.Inbound, email string) string {
 	}
 	uuid := client.ID
 	port := inbound.Port
-	streamNetwork := stream["network"].(string)
+	streamNetwork, _ := stream["network"].(string)
 	params := make(map[string]string)
 	params["type"] = streamNetwork
 
@@ -840,7 +840,7 @@ func (s *SubService) genTrojanLink(inbound *model.Inbound, email string) string
 	}
 	password := encodeUserinfo(client.Password)
 	port := inbound.Port
-	streamNetwork := stream["network"].(string)
+	streamNetwork, _ := stream["network"].(string)
 	params := make(map[string]string)
 	params["type"] = streamNetwork
 
@@ -913,9 +913,9 @@ func (s *SubService) genShadowsocksLink(inbound *model.Inbound, email string) st
 	}
 
 	settings := s.linkSettings(inbound)
-	inboundPassword := settings["password"].(string)
-	method := settings["method"].(string)
-	streamNetwork := stream["network"].(string)
+	inboundPassword, _ := settings["password"].(string)
+	method, _ := settings["method"].(string)
+	streamNetwork, _ := stream["network"].(string)
 	params := make(map[string]string)
 	params["type"] = streamNetwork
 
@@ -993,7 +993,9 @@ func (s *SubService) genHysteriaLink(inbound *model.Inbound, email string) strin
 	alpns, _ := tlsSetting["alpn"].([]any)
 	var alpn []string
 	for _, a := range alpns {
-		alpn = append(alpn, a.(string))
+		if s, ok := a.(string); ok {
+			alpn = append(alpn, s)
+		}
 	}
 	if len(alpn) > 0 {
 		params["alpn"] = strings.Join(alpn, ",")
@@ -1180,7 +1182,7 @@ func unmarshalStreamSettings(streamSettings string) map[string]any {
 }
 
 func applyPathAndHostParams(settings map[string]any, params map[string]string) {
-	params["path"] = settings["path"].(string)
+	params["path"], _ = settings["path"].(string)
 	if host, ok := settings["host"].(string); ok && len(host) > 0 {
 		params["host"] = host
 	} else {
@@ -1190,7 +1192,7 @@ func applyPathAndHostParams(settings map[string]any, params map[string]string) {
 }
 
 func applyPathAndHostObj(settings map[string]any, obj map[string]any) {
-	obj["path"] = settings["path"].(string)
+	obj["path"], _ = settings["path"].(string)
 	if host, ok := settings["host"].(string); ok && len(host) > 0 {
 		obj["host"] = host
 	} else {
@@ -1206,9 +1208,11 @@ func applyShareNetworkParams(stream map[string]any, streamNetwork string, params
 		header, _ := tcp["header"].(map[string]any)
 		typeStr, _ := header["type"].(string)
 		if typeStr == "http" {
-			request := header["request"].(map[string]any)
+			request, _ := header["request"].(map[string]any)
 			requestPath, _ := request["path"].([]any)
-			params["path"] = requestPath[0].(string)
+			if len(requestPath) > 0 {
+				params["path"], _ = requestPath[0].(string)
+			}
 			host := ""
 			if response, ok := header["response"].(map[string]any); ok {
 				if respHeaders, ok := response["headers"].(map[string]any); ok {
@@ -1229,9 +1233,9 @@ func applyShareNetworkParams(stream map[string]any, streamNetwork string, params
 		applyPathAndHostParams(ws, params)
 	case "grpc":
 		grpc, _ := stream["grpcSettings"].(map[string]any)
-		params["serviceName"] = grpc["serviceName"].(string)
+		params["serviceName"], _ = grpc["serviceName"].(string)
 		params["authority"], _ = grpc["authority"].(string)
-		if grpc["multiMode"].(bool) {
+		if mm, _ := grpc["multiMode"].(bool); mm {
 			params["mode"] = "multi"
 		}
 	case "httpupgrade":
@@ -1262,9 +1266,11 @@ func applyVmessNetworkParams(stream map[string]any, network string, obj map[stri
 		typeStr, _ := header["type"].(string)
 		obj["type"] = typeStr
 		if typeStr == "http" {
-			request := header["request"].(map[string]any)
+			request, _ := header["request"].(map[string]any)
 			requestPath, _ := request["path"].([]any)
-			obj["path"] = requestPath[0].(string)
+			if len(requestPath) > 0 {
+				obj["path"], _ = requestPath[0].(string)
+			}
 			host := ""
 			if response, ok := header["response"].(map[string]any); ok {
 				if respHeaders, ok := response["headers"].(map[string]any); ok {
@@ -1284,9 +1290,9 @@ func applyVmessNetworkParams(stream map[string]any, network string, obj map[stri
 		applyPathAndHostObj(ws, obj)
 	case "grpc":
 		grpc, _ := stream["grpcSettings"].(map[string]any)
-		obj["path"] = grpc["serviceName"].(string)
-		obj["authority"] = grpc["authority"].(string)
-		if grpc["multiMode"].(bool) {
+		obj["path"], _ = grpc["serviceName"].(string)
+		obj["authority"], _ = grpc["authority"].(string)
+		if mm, _ := grpc["multiMode"].(bool); mm {
 			obj["type"] = "multi"
 		}
 	case "httpupgrade":
@@ -1308,7 +1314,9 @@ func applyShareTLSParams(stream map[string]any, params map[string]string) {
 	alpns, _ := tlsSetting["alpn"].([]any)
 	var alpn []string
 	for _, a := range alpns {
-		alpn = append(alpn, a.(string))
+		if s, ok := a.(string); ok {
+			alpn = append(alpn, s)
+		}
 	}
 	if len(alpn) > 0 {
 		params["alpn"] = strings.Join(alpn, ",")
@@ -1342,7 +1350,9 @@ func applyVmessTLSParams(stream map[string]any, obj map[string]any) {
 	if len(alpns) > 0 {
 		var alpn []string
 		for _, a := range alpns {
-			alpn = append(alpn, a.(string))
+			if s, ok := a.(string); ok {
+				alpn = append(alpn, s)
+			}
 		}
 		obj["alpn"] = strings.Join(alpn, ",")
 	}
@@ -1451,15 +1461,17 @@ func applyShareRealityParams(stream map[string]any, params map[string]string, cl
 	realitySettings, _ := searchKey(realitySetting, "settings")
 	if realitySetting != nil {
 		if sniValue, ok := searchKey(realitySetting, "serverNames"); ok {
-			sNames, _ := sniValue.([]any)
-			params["sni"] = sNames[random.Num(len(sNames))].(string)
+			if sNames, _ := sniValue.([]any); len(sNames) > 0 {
+				params["sni"], _ = sNames[random.Num(len(sNames))].(string)
+			}
 		}
 		if pbkValue, ok := searchKey(realitySettings, "publicKey"); ok {
 			params["pbk"], _ = pbkValue.(string)
 		}
 		if sidValue, ok := searchKey(realitySetting, "shortIds"); ok {
-			shortIds, _ := sidValue.([]any)
-			params["sid"] = shortIds[random.Num(len(shortIds))].(string)
+			if shortIds, _ := sidValue.([]any); len(shortIds) > 0 {
+				params["sid"], _ = shortIds[random.Num(len(shortIds))].(string)
+			}
 		}
 		if fpValue, ok := searchKey(realitySettings, "fingerprint"); ok {
 			if fp, ok := fpValue.(string); ok && len(fp) > 0 {
@@ -2422,12 +2434,13 @@ func searchHost(headers any) string {
 			case []any:
 				hosts, _ := v.([]any)
 				if len(hosts) > 0 {
-					return hosts[0].(string)
-				} else {
-					return ""
+					h, _ := hosts[0].(string)
+					return h
 				}
+				return ""
 			case any:
-				return v.(string)
+				h, _ := v.(string)
+				return h
 			}
 		}
 	}

+ 132 - 0
internal/sub/sub_panic_test.go

@@ -0,0 +1,132 @@
+package sub
+
+import (
+	"fmt"
+	"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 TestGetSubsToleratesUnusualStreamSettings(t *testing.T) {
+	gin.SetMode(gin.TestMode)
+	cases := []struct {
+		name   string
+		stream string
+	}{
+		{"reality empty arrays", `{"network":"tcp","security":"reality","realitySettings":{"serverNames":[],"shortIds":[],"settings":{"publicKey":"pk"}}}`},
+		{"tcp http missing request", `{"network":"tcp","security":"none","tcpSettings":{"header":{"type":"http","response":{"headers":{}}}}}`},
+		{"tcp http empty path", `{"network":"tcp","security":"none","tcpSettings":{"header":{"type":"http","request":{"path":[]}}}}`},
+		{"grpc missing keys", `{"network":"grpc","security":"none","grpcSettings":{}}`},
+		{"empty stream settings", `{}`},
+		{"ws missing wsSettings", `{"network":"ws","security":"none"}`},
+		{"httpupgrade missing settings", `{"network":"httpupgrade","security":"none"}`},
+		{"tls alpn non-string element", `{"network":"tcp","security":"tls","tlsSettings":{"alpn":[123]}}`},
+	}
+
+	for i, tc := range cases {
+		t.Run(tc.name, func(t *testing.T) {
+			seedSubDB(t)
+			subId := fmt.Sprintf("s%d", i)
+			seedSubInbound(t, subId, fmt.Sprintf("t%d", i), 46000+i, 1, tc.stream)
+
+			links, _, _, _, err := NewSubService("").GetSubs(subId, "req.example.com")
+			if err != nil {
+				t.Fatalf("GetSubs errored: %v", err)
+			}
+			if len(links) != 1 {
+				t.Fatalf("expected 1 share link, got %d", len(links))
+			}
+		})
+	}
+}
+
+func TestGetJsonToleratesHysteriaWithoutHysteriaSettings(t *testing.T) {
+	seedSubDB(t)
+	db := database.GetDB()
+
+	const subId = "hy1"
+	const email = "hy@e"
+	ib := &model.Inbound{
+		UserId: 1, Tag: "hy", Enable: true, Listen: "203.0.113.5", Port: 46200,
+		Protocol:       model.Hysteria,
+		Remark:         "hy",
+		Settings:       fmt.Sprintf(`{"version":2,"clients":[{"auth":"hyauth","email":%q,"subId":%q,"enable":true}]}`, email, subId),
+		StreamSettings: `{"security":"tls","tlsSettings":{"serverName":"hy.sni"}}`,
+	}
+	if err := db.Create(ib).Error; err != nil {
+		t.Fatalf("seed inbound: %v", err)
+	}
+	client := &model.ClientRecord{Email: email, SubID: subId, Enable: true}
+	if err := db.Create(client).Error; err != nil {
+		t.Fatalf("seed client: %v", err)
+	}
+	if err := db.Create(&model.ClientInbound{ClientId: client.Id, InboundId: ib.Id}).Error; err != nil {
+		t.Fatalf("seed client_inbound: %v", err)
+	}
+
+	jsonService := NewSubJsonService("", "", "", NewSubService(""))
+	out, _, err := jsonService.GetJson(subId, "sub.example.com", true)
+	if err != nil {
+		t.Fatalf("GetJson: %v", err)
+	}
+	if out == "" {
+		t.Fatal("GetJson returned empty for a hysteria inbound without hysteriaSettings")
+	}
+}
+
+func TestGetJsonToleratesNonStringRealityShortId(t *testing.T) {
+	seedSubDB(t)
+	stream := `{"network":"tcp","security":"reality","realitySettings":{"serverNames":["sni.example.com"],"shortIds":[42],"settings":{"publicKey":"pk"}}}`
+	seedSubInbound(t, "rlty1", "rlty", 46400, 1, stream)
+
+	jsonService := NewSubJsonService("", "", "", NewSubService(""))
+	out, _, err := jsonService.GetJson("rlty1", "sub.example.com", true)
+	if err != nil {
+		t.Fatalf("GetJson: %v", err)
+	}
+	if out == "" {
+		t.Fatal("GetJson returned empty for a reality inbound with a non-string shortId element")
+	}
+}
+
+func TestGetClashEmitsPinnedCertSha256(t *testing.T) {
+	seedSubDB(t)
+	const pin = "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789"
+	stream := `{"network":"tcp","security":"tls","tlsSettings":{"serverName":"pin.sni","settings":{"pinnedPeerCertSha256":["` + pin + `"]}}}`
+	seedSubInbound(t, "pin1", "pin", 46300, 1, stream)
+
+	out, _, err := NewSubClashService(false, "", NewSubService("")).GetClash("pin1", "sub.example.com")
+	if err != nil {
+		t.Fatalf("GetClash: %v", err)
+	}
+	if !strings.Contains(out, "pin-sha256") {
+		t.Fatalf("Clash proxy dropped the pinned cert sha256:\n%s", out)
+	}
+}
+
+func TestJsonAndClashTolerateExternalProxyMissingPort(t *testing.T) {
+	seedSubDB(t)
+	stream := `{"network":"tcp","security":"none","externalProxy":[{"forceTls":"same","dest":"cdn.example.com"}]}`
+	seedSubInbound(t, "extp1", "extp", 46500, 1, stream)
+
+	jsonService := NewSubJsonService("", "", "", NewSubService(""))
+	jsonOut, _, err := jsonService.GetJson("extp1", "sub.example.com", true)
+	if err != nil {
+		t.Fatalf("GetJson: %v", err)
+	}
+	if jsonOut == "" {
+		t.Fatal("GetJson returned empty for an externalProxy entry missing port")
+	}
+
+	clashOut, _, err := NewSubClashService(false, "", NewSubService("")).GetClash("extp1", "sub.example.com")
+	if err != nil {
+		t.Fatalf("GetClash: %v", err)
+	}
+	if clashOut == "" {
+		t.Fatal("GetClash returned empty for an externalProxy entry missing port")
+	}
+}

+ 14 - 5
internal/util/link/outbound.go

@@ -157,9 +157,8 @@ func parseVmess(link string) (*ParseResult, error) {
 	// Map known fields (best effort, matching frontend parser coverage)
 	switch network {
 	case "ws":
-		if host, ok := j["host"].(string); ok {
-			setWS(stream, host, getString(j, "path", "/"))
-		}
+		host, _ := j["host"].(string)
+		setWS(stream, host, getString(j, "path", "/"))
 	case "grpc":
 		svc := getString(j, "path", "")
 		if auth, ok := j["authority"].(string); ok && auth != "" {
@@ -452,7 +451,7 @@ func parseHysteria2(link string) (*ParseResult, error) {
 			"alpn":                 splitCommaOrDefault(params.Get("alpn"), []string{"h3"}),
 			"fingerprint":          params.Get("fp"),
 			"echConfigList":        params.Get("ech"),
-			"verifyPeerCertByName": "",
+			"verifyPeerCertByName": params.Get("vcn"),
 			"pinnedPeerCertSha256": params.Get("pinSHA256"),
 		},
 	}
@@ -622,7 +621,17 @@ func applyTransport(stream map[string]any, p url.Values) {
 		if m := p.Get("mode"); m != "" {
 			xh["mode"] = m
 		}
-		// A few advanced xhttp fields that are commonly carried
+		if v := p.Get("x_padding_bytes"); v != "" {
+			xh["xPaddingBytes"] = v
+		}
+		if extra := p.Get("extra"); extra != "" {
+			var parsed map[string]any
+			if err := json.Unmarshal([]byte(extra), &parsed); err == nil {
+				for k, v := range parsed {
+					xh[k] = v
+				}
+			}
+		}
 		for _, k := range []string{"xPaddingBytes", "scMaxEachPostBytes", "scMinPostsIntervalMs", "uplinkChunkSize"} {
 			if v := p.Get(k); v != "" {
 				xh[k] = v

+ 45 - 0
internal/util/link/outbound_helpers_test.go

@@ -156,6 +156,51 @@ func TestParse_WSAndGRPCTransport(t *testing.T) {
 	}
 }
 
+func TestParse_XhttpExtraAndSnakeCaseFields(t *testing.T) {
+	q := url.Values{}
+	q.Set("type", "xhttp")
+	q.Set("encryption", "none")
+	q.Set("security", "none")
+	q.Set("mode", "auto")
+	q.Set("x_padding_bytes", "1-50")
+	q.Set("extra", `{"mode":"auto","xPaddingBytes":"1-50","scMaxEachPostBytes":"1000000"}`)
+	res, err := ParseLink("vless://[email protected]:443?" + q.Encode() + "#r")
+	if err != nil {
+		t.Fatalf("parse: %v", err)
+	}
+	xh := streamSub(t, res, "xhttpSettings")
+	if xh["xPaddingBytes"] != "1-50" {
+		t.Errorf("xPaddingBytes = %v, want 1-50 (dropped from the snake_case/extra payload the emitter writes)", xh["xPaddingBytes"])
+	}
+	if xh["scMaxEachPostBytes"] != "1000000" {
+		t.Errorf("scMaxEachPostBytes = %v, want 1000000 (dropped from the extra blob)", xh["scMaxEachPostBytes"])
+	}
+}
+
+func TestParse_VmessWSPathWithoutHostKey(t *testing.T) {
+	inner := `{"v":"2","add":"h","port":443,"id":"11111111-2222-4333-8444-555555555555","net":"ws","path":"/api","tls":"tls"}`
+	link := "vmess://" + base64.StdEncoding.EncodeToString([]byte(inner))
+	res, err := ParseLink(link)
+	if err != nil {
+		t.Fatalf("parse: %v", err)
+	}
+	wss := streamSub(t, res, "wsSettings")
+	if wss["path"] != "/api" {
+		t.Errorf("wsSettings path = %v, want /api (dropped when host key absent)", wss["path"])
+	}
+}
+
+func TestParse_Hysteria2VerifyPeerCertByName(t *testing.T) {
+	res, err := ParseLink("hysteria2://[email protected]:443?security=tls&sni=decoy.com&vcn=real-cert.com#r")
+	if err != nil {
+		t.Fatalf("parse: %v", err)
+	}
+	tls := streamSub(t, res, "tlsSettings")
+	if tls["verifyPeerCertByName"] != "real-cert.com" {
+		t.Errorf("verifyPeerCertByName = %v, want real-cert.com (vcn param ignored)", tls["verifyPeerCertByName"])
+	}
+}
+
 func TestParse_TCPHTTPHeader(t *testing.T) {
 	res, err := ParseLink("vless://[email protected]:443?type=tcp&headerType=http&host=ex.com&path=%2F")
 	if err != nil {

+ 41 - 1
internal/web/controller/login_limiter.go

@@ -10,6 +10,10 @@ const (
 	loginLimitMaxFailures = 5
 	loginLimitWindow      = 5 * time.Minute
 	loginLimitCooldown    = 15 * time.Minute
+	// Hard ceiling on tracked (ip, username) records. The key includes the
+	// caller-supplied username, so an unauthenticated attacker rotating
+	// usernames would otherwise grow the map without bound.
+	loginLimitMaxRecords = 10000
 )
 
 var defaultLoginLimiter = newLoginLimiter(loginLimitMaxFailures, loginLimitWindow, loginLimitCooldown)
@@ -63,13 +67,14 @@ func (l *loginLimiter) registerFailure(ip, username string) (time.Time, bool) {
 	l.mu.Lock()
 	defer l.mu.Unlock()
 
+	now := l.now()
 	key := loginLimitKey(ip, username)
 	record := l.attempts[key]
 	if record == nil {
+		l.evictForRoom(now)
 		record = &loginLimitRecord{}
 		l.attempts[key] = record
 	}
-	now := l.now()
 	record.failures = pruneLoginFailures(record.failures, now.Add(-l.window))
 	record.failures = append(record.failures, now)
 	if len(record.failures) >= l.maxFailures {
@@ -86,6 +91,41 @@ func (l *loginLimiter) registerSuccess(ip, username string) {
 	delete(l.attempts, loginLimitKey(ip, username))
 }
 
+// evictForRoom keeps the attempts map bounded before inserting a new record.
+// It first reclaims records that are no longer blocked and whose failures have
+// aged out of the window; if the map is still at the ceiling (a genuine
+// broad flood), it drops one arbitrary record so memory can never grow past the
+// cap. Callers hold l.mu.
+func (l *loginLimiter) evictForRoom(now time.Time) {
+	if len(l.attempts) < loginLimitMaxRecords {
+		return
+	}
+	cutoff := now.Add(-l.window)
+	for key, record := range l.attempts {
+		if now.Before(record.blockedUntil) {
+			continue
+		}
+		record.failures = pruneLoginFailures(record.failures, cutoff)
+		if len(record.failures) == 0 {
+			delete(l.attempts, key)
+		}
+	}
+	if len(l.attempts) < loginLimitMaxRecords {
+		return
+	}
+	for key, record := range l.attempts {
+		if now.Before(record.blockedUntil) {
+			continue
+		}
+		delete(l.attempts, key)
+		return
+	}
+	for key := range l.attempts {
+		delete(l.attempts, key)
+		return
+	}
+}
+
 func loginLimitKey(ip, username string) string {
 	return strings.TrimSpace(ip) + "\x00" + strings.ToLower(strings.TrimSpace(username))
 }

+ 46 - 0
internal/web/controller/login_limiter_test.go

@@ -1,10 +1,56 @@
 package controller
 
 import (
+	"strconv"
+	"strings"
 	"testing"
 	"time"
 )
 
+func TestLoginLimiterBoundsMemoryUnderUsernameFlood(t *testing.T) {
+	limiter := newLoginLimiter(5, 5*time.Minute, 15*time.Minute)
+	for i := 0; i < loginLimitMaxRecords+100; i++ {
+		limiter.registerFailure("1.2.3.4", "user-"+strconv.Itoa(i))
+	}
+
+	limiter.mu.Lock()
+	n := len(limiter.attempts)
+	limiter.mu.Unlock()
+
+	if n > loginLimitMaxRecords {
+		t.Fatalf("attempts map grew to %d, exceeding the %d ceiling under a username flood", n, loginLimitMaxRecords)
+	}
+}
+
+func TestLoginLimiterEvictionSparesActiveBlocks(t *testing.T) {
+	now := time.Date(2026, 5, 6, 12, 0, 0, 0, time.UTC)
+	limiter := newLoginLimiter(5, 5*time.Minute, 15*time.Minute)
+	limiter.now = func() time.Time { return now }
+
+	limiter.mu.Lock()
+	for i := 0; i < loginLimitMaxRecords-1; i++ {
+		limiter.attempts["victim-"+strconv.Itoa(i)] = &loginLimitRecord{blockedUntil: now.Add(10 * time.Minute)}
+	}
+	limiter.attempts["filler"] = &loginLimitRecord{failures: []time.Time{now}}
+	limiter.mu.Unlock()
+
+	if _, blocked := limiter.registerFailure("9.9.9.9", "newcomer"); blocked {
+		t.Fatal("the eviction-triggering failure itself should not be blocked yet")
+	}
+
+	limiter.mu.Lock()
+	defer limiter.mu.Unlock()
+	survivors := 0
+	for key, record := range limiter.attempts {
+		if strings.HasPrefix(key, "victim-") && now.Before(record.blockedUntil) {
+			survivors++
+		}
+	}
+	if survivors != loginLimitMaxRecords-1 {
+		t.Fatalf("eviction under a full map dropped an actively-blocked record: %d/%d victims survived", survivors, loginLimitMaxRecords-1)
+	}
+}
+
 func TestLoginLimiterBlocksAfterConfiguredFailures(t *testing.T) {
 	now := time.Date(2026, 5, 6, 12, 0, 0, 0, time.UTC)
 	limiter := newLoginLimiter(5, 5*time.Minute, 15*time.Minute)

+ 12 - 0
internal/web/entity/check_valid_test.go

@@ -27,3 +27,15 @@ func TestCheckValidSmtpFrom(t *testing.T) {
 		}
 	}
 }
+
+func TestCheckValidWildcardListenPortConflict(t *testing.T) {
+	s := &AllSetting{WebPort: 2053, SubPort: 2053, WebListen: "0.0.0.0", SubListen: ""}
+	if err := s.CheckValid(); err == nil {
+		t.Error("CheckValid must reject the same port bound on 0.0.0.0 and \"\" (both wildcard)")
+	}
+
+	ok := &AllSetting{WebPort: 2053, SubPort: 2053, WebListen: "127.0.0.1", SubListen: "192.168.1.1"}
+	if err := ok.CheckValid(); err != nil {
+		t.Errorf("distinct specific listens on the same port should be allowed: %v", err)
+	}
+}

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

@@ -172,7 +172,7 @@ func (s *AllSetting) CheckValid() error {
 		return common.NewError("Sub port is not a valid port:", s.SubPort)
 	}
 
-	if (s.SubPort == s.WebPort) && (s.WebListen == s.SubListen) {
+	if (s.SubPort == s.WebPort) && listenAddressesConflict(s.WebListen, s.SubListen) {
 		return common.NewError("Sub and Web could not use same ip:port, ", s.SubListen, ":", s.SubPort, " & ", s.WebListen, ":", s.WebPort)
 	}
 
@@ -258,6 +258,27 @@ func (s *AllSetting) CheckValid() error {
 	return nil
 }
 
+// listenAddressesConflict reports whether two listen addresses on the same port
+// would collide at bind time. A wildcard listen ("", "0.0.0.0", "::") overlaps
+// every address, so it conflicts with anything on that port; two specific
+// addresses conflict only when identical.
+func listenAddressesConflict(a, b string) bool {
+	if a == b {
+		return true
+	}
+	return isWildcardListen(a) || isWildcardListen(b)
+}
+
+func isWildcardListen(listen string) bool {
+	if listen == "" {
+		return true
+	}
+	if ip := net.ParseIP(listen); ip != nil {
+		return ip.IsUnspecified()
+	}
+	return false
+}
+
 type HostGroup struct {
 	GroupId    string   `json:"groupId"`
 	InboundIds []int    `json:"inboundIds" validate:"required,min=1"`

+ 17 - 0
internal/web/runtime/manager.go

@@ -18,6 +18,7 @@ type Manager struct {
 	mu             sync.RWMutex
 	remotes        map[int]*Remote
 	overrides      map[int]Runtime // test-only: forces RuntimeFor to return a stub
+	localOverride  Runtime         // test-only: forces RuntimeFor(nil) to return a stub
 	egressResolver NodeEgressResolver
 }
 
@@ -44,6 +45,15 @@ func (m *Manager) SetRuntimeOverride(nodeID int, rt Runtime) {
 	m.overrides[nodeID] = rt
 }
 
+// SetLocalRuntimeOverride makes RuntimeFor(nil) return rt instead of the real
+// local runtime. Test seam for exercising the local dispatch path (MTProto
+// sidecar, local Xray) without a running child process; pass nil rt to clear.
+func (m *Manager) SetLocalRuntimeOverride(rt Runtime) {
+	m.mu.Lock()
+	defer m.mu.Unlock()
+	m.localOverride = rt
+}
+
 func (m *Manager) SetNodeEgressResolver(r NodeEgressResolver) {
 	m.mu.Lock()
 	defer m.mu.Unlock()
@@ -61,6 +71,13 @@ func (m *Manager) NodeEgressProxyURL(nodeID int) string {
 
 func (m *Manager) RuntimeFor(nodeID *int) (Runtime, error) {
 	if nodeID == nil {
+		m.mu.RLock()
+		if m.localOverride != nil {
+			rt := m.localOverride
+			m.mu.RUnlock()
+			return rt, nil
+		}
+		m.mu.RUnlock()
 		return m.local, nil
 	}
 	m.mu.RLock()

+ 45 - 0
internal/web/service/access_log_parse_test.go

@@ -0,0 +1,45 @@
+package service
+
+import "testing"
+
+func TestParseAccessLogFields(t *testing.T) {
+	malformed := []string{
+		"",
+		"singletoken",
+		"2024/01/02",
+		"2024/01/02 15:04:05.000000 from",
+		"2024/01/02 15:04:05.000000 accepted",
+		"2024/01/02 15:04:05.000000 email:",
+	}
+	for _, line := range malformed {
+		func() {
+			defer func() {
+				if r := recover(); r != nil {
+					t.Errorf("parseAccessLogFields panicked on %q: %v", line, r)
+				}
+			}()
+			_ = parseAccessLogFields(line)
+		}()
+	}
+
+	line := "2024/01/02 15:04:05.123456 from 1.2.3.4:555 accepted tcp:example.com:443 [inbound-tag >> outbound-tag] email: [email protected]"
+	entry := parseAccessLogFields(line)
+	if entry.FromAddress != "1.2.3.4:555" {
+		t.Errorf("FromAddress = %q, want %q", entry.FromAddress, "1.2.3.4:555")
+	}
+	if entry.ToAddress != "tcp:example.com:443" {
+		t.Errorf("ToAddress = %q, want %q", entry.ToAddress, "tcp:example.com:443")
+	}
+	if entry.Inbound != "inbound-tag" {
+		t.Errorf("Inbound = %q, want %q", entry.Inbound, "inbound-tag")
+	}
+	if entry.Outbound != "outbound-tag" {
+		t.Errorf("Outbound = %q, want %q", entry.Outbound, "outbound-tag")
+	}
+	if entry.Email != "[email protected]" {
+		t.Errorf("Email = %q, want %q", entry.Email, "[email protected]")
+	}
+	if entry.DateTime.IsZero() {
+		t.Error("DateTime was not parsed from a well-formed line")
+	}
+}

+ 56 - 0
internal/web/service/bulk_traffic_test.go

@@ -119,6 +119,62 @@ func TestDelDepletedRemovesOnlyDepleted(t *testing.T) {
 	}
 }
 
+func TestClientDeleteKeepTrafficPreservesRowForAttachedClient(t *testing.T) {
+	setupBulkDB(t)
+	svc := &ClientService{}
+	inboundSvc := &InboundService{}
+
+	email := "keepme@x"
+	c := model.Client{Email: email, ID: "11111111-1111-1111-1111-111111111111", SubID: email, Enable: true}
+	ib := mkInbound(t, 52030, model.VLESS, clientsSettings(t, []model.Client{c}))
+	if err := svc.SyncInbound(nil, ib.Id, []model.Client{c}); err != nil {
+		t.Fatalf("seed linkage: %v", err)
+	}
+	mkTraffic(t, ib.Id, email, 111, 222, 0, 0, true)
+
+	rec := lookupClientRecord(t, email)
+	if _, err := svc.Delete(inboundSvc, rec.Id, true); err != nil {
+		t.Fatalf("Delete(keepTraffic): %v", err)
+	}
+
+	var cnt int64
+	if err := database.GetDB().Model(&xray.ClientTraffic{}).Where("email = ?", email).Count(&cnt).Error; err != nil {
+		t.Fatalf("count traffic: %v", err)
+	}
+	if cnt != 1 {
+		t.Fatalf("keepTraffic delete of an inbound-attached client must preserve its client_traffics row, found %d", cnt)
+	}
+}
+
+func TestBulkDeleteRemovesClientExternalLinks(t *testing.T) {
+	setupBulkDB(t)
+	svc := &ClientService{}
+	inboundSvc := &InboundService{}
+
+	email := "extlink@x"
+	c := model.Client{Email: email, ID: "11111111-1111-1111-1111-111111111111", SubID: email, Enable: true}
+	ib := mkInbound(t, 52040, model.VLESS, clientsSettings(t, []model.Client{c}))
+	if err := svc.SyncInbound(nil, ib.Id, []model.Client{c}); err != nil {
+		t.Fatalf("seed linkage: %v", err)
+	}
+	rec := lookupClientRecord(t, email)
+	if err := database.GetDB().Create(&model.ClientExternalLink{ClientId: rec.Id, Kind: "sub", Value: "https://example.com/x"}).Error; err != nil {
+		t.Fatalf("seed external link: %v", err)
+	}
+
+	if _, _, err := svc.BulkDelete(inboundSvc, []string{email}, false); err != nil {
+		t.Fatalf("BulkDelete: %v", err)
+	}
+
+	var cnt int64
+	if err := database.GetDB().Model(&model.ClientExternalLink{}).Where("client_id = ?", rec.Id).Count(&cnt).Error; err != nil {
+		t.Fatalf("count external links: %v", err)
+	}
+	if cnt != 0 {
+		t.Fatalf("BulkDelete left %d orphan external-link row(s) behind", cnt)
+	}
+}
+
 func TestGetClientTrafficByEmailReadsClientsTable(t *testing.T) {
 	setupBulkDB(t)
 	svc := &ClientService{}

+ 63 - 0
internal/web/service/client_apply_field_test.go

@@ -7,6 +7,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/xray"
 )
 
 // TestResetClientExpiryTimeByEmail_MultiInbound reproduces #5039: a client
@@ -86,3 +87,65 @@ func TestResetClientExpiryTimeByEmail_MultiInbound(t *testing.T) {
 		t.Errorf("client record expiry = %d, want %d", rec.ExpiryTime, newExpiry)
 	}
 }
+
+func TestSetClientEnableByEmail_MultiInbound(t *testing.T) {
+	dbDir := t.TempDir()
+	t.Setenv("XUI_DB_FOLDER", dbDir)
+	if err := database.InitDB(filepath.Join(dbDir, "x-ui.db")); err != nil {
+		t.Fatalf("InitDB: %v", err)
+	}
+	t.Cleanup(func() { _ = database.CloseDB() })
+
+	db := database.GetDB()
+
+	const email = "[email protected]"
+	const uid = "ce8d33df-3a64-4f10-8f9b-91c3a8e0c222"
+	clientJSON := `{"clients":[{"email":"` + email + `","id":"` + uid + `","enable":true,"subId":"sub-en-1"}]}`
+
+	first := &model.Inbound{
+		Tag: "vless-en-a", Enable: true, Port: 50011, Protocol: model.VLESS,
+		StreamSettings: `{"network":"tcp","security":"reality"}`, Settings: clientJSON,
+	}
+	second := &model.Inbound{
+		Tag: "vless-en-b", Enable: true, Port: 50012, Protocol: model.VLESS,
+		StreamSettings: `{"network":"ws","security":"tls"}`, Settings: clientJSON,
+	}
+	for _, ib := range []*model.Inbound{first, second} {
+		if err := db.Create(ib).Error; err != nil {
+			t.Fatalf("create inbound %s: %v", ib.Tag, err)
+		}
+	}
+
+	clientSvc := ClientService{}
+	inboundSvc := InboundService{}
+	for _, ib := range []*model.Inbound{first, second} {
+		clients, err := inboundSvc.GetClients(ib)
+		if err != nil {
+			t.Fatalf("GetClients(%s): %v", ib.Tag, err)
+		}
+		if err := clientSvc.SyncInbound(nil, ib.Id, clients); err != nil {
+			t.Fatalf("SyncInbound(%s): %v", ib.Tag, err)
+		}
+	}
+	if err := db.Create(&xray.ClientTraffic{InboundId: first.Id, Email: email, Enable: true}).Error; err != nil {
+		t.Fatalf("seed traffic: %v", err)
+	}
+
+	if _, _, err := clientSvc.SetClientEnableByEmail(&inboundSvc, email, false); err != nil {
+		t.Fatalf("SetClientEnableByEmail: %v", err)
+	}
+
+	for _, ib := range []*model.Inbound{first, second} {
+		fresh, err := inboundSvc.GetInbound(ib.Id)
+		if err != nil {
+			t.Fatalf("GetInbound(%s): %v", ib.Tag, err)
+		}
+		clients, err := inboundSvc.GetClients(fresh)
+		if err != nil {
+			t.Fatalf("GetClients(%s): %v", ib.Tag, err)
+		}
+		if len(clients) != 1 || clients[0].Enable {
+			t.Errorf("inbound %s: client still enabled after disable-by-email; a sibling inbound kept access", ib.Tag)
+		}
+	}
+}

+ 16 - 5
internal/web/service/client_bulk.go

@@ -359,9 +359,15 @@ func (s *ClientService) BulkAdjust(inboundSvc *InboundService, emails []string,
 					skippedReasons[email] = "unlimited traffic"
 				}
 			} else {
-				next := max(rec.TotalGB+addBytes, 0)
-				entry.applyTotal = true
-				entry.newTotal = next
+				next := rec.TotalGB + addBytes
+				if next <= 0 {
+					if _, exists := skippedReasons[email]; !exists {
+						skippedReasons[email] = "reduction exceeds remaining quota"
+					}
+				} else {
+					entry.applyTotal = true
+					entry.newTotal = next
+				}
 			}
 		}
 		if entry.applyExpiry || entry.applyTotal || adjustFlow {
@@ -403,6 +409,7 @@ func (s *ClientService) BulkAdjust(inboundSvc *InboundService, emails []string,
 	needRestart := false
 	flowHonored := map[string]bool{}
 	flowIneligible := map[string]bool{}
+	execFailed := map[string]bool{}
 	for inboundId, ibEmails := range emailsByInbound {
 		ibRes := s.bulkAdjustInboundClients(inboundSvc, inboundId, ibEmails, plan, flow)
 		if ibRes.needRestart {
@@ -415,6 +422,7 @@ func (s *ClientService) BulkAdjust(inboundSvc *InboundService, emails []string,
 			flowIneligible[email] = true
 		}
 		for email, reason := range ibRes.perEmailSkipped {
+			execFailed[email] = true
 			if _, already := skippedReasons[email]; !already {
 				skippedReasons[email] = reason
 			}
@@ -444,7 +452,7 @@ func (s *ClientService) BulkAdjust(inboundSvc *InboundService, emails []string,
 
 	adjusted := map[string]struct{}{}
 	for email, entry := range plan {
-		if _, skipped := skippedReasons[email]; skipped {
+		if execFailed[email] {
 			continue
 		}
 		updates := map[string]any{}
@@ -815,7 +823,7 @@ func (s *ClientService) BulkDelete(inboundSvc *InboundService, emails []string,
 
 	needRestart := false
 	for inboundId, ibEmails := range emailsByInbound {
-		ibResult := s.bulkDelInboundClients(inboundSvc, inboundId, ibEmails, recordsByEmail, false)
+		ibResult := s.bulkDelInboundClients(inboundSvc, inboundId, ibEmails, recordsByEmail, keepTraffic)
 		if ibResult.needRestart {
 			needRestart = true
 		}
@@ -847,6 +855,9 @@ func (s *ClientService) BulkDelete(inboundSvc *InboundService, emails []string,
 				if e := tx.Where("client_id IN ?", batch).Delete(&model.ClientInbound{}).Error; e != nil {
 					return e
 				}
+				if e := tx.Where("client_id IN ?", batch).Delete(&model.ClientExternalLink{}).Error; e != nil {
+					return e
+				}
 			}
 			if !keepTraffic && len(successEmails) > 0 {
 				for _, batch := range chunkStrings(successEmails, sqlInChunk) {

+ 46 - 0
internal/web/service/client_bulk_reenable_test.go

@@ -164,6 +164,52 @@ func TestBulkAdjust_ReenablesOverQuota_WhenAddBytesClearsQuota(t *testing.T) {
 	}
 }
 
+func TestBulkAdjust_QuotaReductionBelowZeroSkipsInsteadOfUnlimited(t *testing.T) {
+	setupBulkDB(t)
+	svc := &ClientService{}
+	inboundSvc := &InboundService{}
+
+	email := "qr@x"
+	c := model.Client{Email: email, ID: "11111111-1111-1111-1111-111111111111", SubID: email, Enable: true, TotalGB: 10}
+	ib := mkInbound(t, 52020, model.VLESS, clientsSettings(t, []model.Client{c}))
+	if err := svc.SyncInbound(nil, ib.Id, []model.Client{c}); err != nil {
+		t.Fatalf("seed linkage: %v", err)
+	}
+	mkTraffic(t, ib.Id, email, 0, 0, 10, 0, true)
+
+	res, _, err := svc.BulkAdjust(inboundSvc, []string{email}, 0, -20, "")
+	if err != nil {
+		t.Fatalf("BulkAdjust: %v", err)
+	}
+	if res.Adjusted != 0 {
+		t.Fatalf("over-reduction should not adjust, got Adjusted=%d skipped=%v", res.Adjusted, res.Skipped)
+	}
+	if got := trafficOf(t, email).Total; got != 10 {
+		t.Fatalf("quota reduced past zero was written as %d (0 means unlimited); want it left at 10", got)
+	}
+}
+
+func TestBulkAdjust_AppliedFieldReachesTrafficRowDespiteOtherFieldSkip(t *testing.T) {
+	setupBulkDB(t)
+	svc := &ClientService{}
+	inboundSvc := &InboundService{}
+
+	email := "mix2@x"
+	c := model.Client{Email: email, ID: "11111111-1111-1111-1111-111111111111", SubID: email, Enable: true, TotalGB: 100, ExpiryTime: 0}
+	ib := mkInbound(t, 52021, model.VLESS, clientsSettings(t, []model.Client{c}))
+	if err := svc.SyncInbound(nil, ib.Id, []model.Client{c}); err != nil {
+		t.Fatalf("seed linkage: %v", err)
+	}
+	mkTraffic(t, ib.Id, email, 0, 0, 100, 0, true)
+
+	if _, _, err := svc.BulkAdjust(inboundSvc, []string{email}, 30, 50, ""); err != nil {
+		t.Fatalf("BulkAdjust: %v", err)
+	}
+	if got := trafficOf(t, email).Total; got != 150 {
+		t.Fatalf("client_traffics.total = %d, want 150: the applied quota adjustment must reach the enforcement row even though the unlimited-expiry field was skipped", got)
+	}
+}
+
 func TestBulkAdjust_OverQuota_DaysOnly_StaysDisabled(t *testing.T) {
 	setupBulkDB(t)
 	svc := &ClientService{}

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

@@ -510,7 +510,7 @@ func (s *ClientService) Delete(inboundSvc *InboundService, id int, keepTraffic b
 		if existing.Email == "" {
 			continue
 		}
-		nr, delErr := s.DelInboundClientByEmail(inboundSvc, ibId, existing.Email, false, true)
+		nr, delErr := s.DelInboundClientByEmail(inboundSvc, ibId, existing.Email, keepTraffic, true)
 		if delErr != nil {
 			// The client is already absent from this inbound (data drift or a
 			// retried delete). Skip it — deletion stays idempotent.
@@ -678,7 +678,7 @@ func (s *ClientService) DeleteByEmail(inboundSvc *InboundService, email string,
 	needRestart := false
 	var delErrs []error
 	for _, ibId := range inboundIds {
-		nr, delErr := s.DelInboundClientByEmail(inboundSvc, ibId, email, false, true)
+		nr, delErr := s.DelInboundClientByEmail(inboundSvc, ibId, email, keepTraffic, true)
 		if delErr != nil {
 			if errors.Is(delErr, ErrClientNotInInbound) {
 				continue

+ 10 - 52
internal/web/service/client_inbound_apply.go

@@ -1142,62 +1142,18 @@ func (s *ClientService) CheckIsEnabledByEmail(inboundSvc *InboundService, client
 }
 
 func (s *ClientService) ToggleClientEnableByEmail(inboundSvc *InboundService, clientEmail string) (bool, bool, error) {
-	_, inbound, err := inboundSvc.GetClientInboundByEmail(clientEmail)
-	if err != nil {
-		return false, false, err
-	}
-	if inbound == nil {
-		return false, false, common.NewError("Inbound Not Found For Email:", clientEmail)
-	}
-
-	oldClients, err := inboundSvc.GetClients(inbound)
-	if err != nil {
-		return false, false, err
-	}
-
-	found := false
-	clientOldEnabled := false
-
-	for _, oldClient := range oldClients {
-		if oldClient.Email == clientEmail {
-			found = true
-			clientOldEnabled = oldClient.Enable
-			break
-		}
-	}
-
-	if !found {
-		return false, false, common.NewError("Client Not Found For Email:", clientEmail)
-	}
-
-	var settings map[string]any
-	err = json.Unmarshal([]byte(inbound.Settings), &settings)
-	if err != nil {
-		return false, false, err
-	}
-	clients := settings["clients"].([]any)
-	var newClients []any
-	for client_index := range clients {
-		c := clients[client_index].(map[string]any)
-		if c["email"] == clientEmail {
-			c["enable"] = !clientOldEnabled
-			c["updated_at"] = time.Now().Unix() * 1000
-			newClients = append(newClients, any(c))
-		}
-	}
-	settings["clients"] = newClients
-	modifiedSettings, err := json.MarshalIndent(settings, "", "  ")
+	current, err := s.CheckIsEnabledByEmail(inboundSvc, clientEmail)
 	if err != nil {
 		return false, false, err
 	}
-	inbound.Settings = string(modifiedSettings)
-
-	needRestart, err := s.UpdateInboundClient(inboundSvc, inbound, clientEmail)
+	target := !current
+	needRestart, err := s.applyClientFieldByEmail(inboundSvc, clientEmail, func(c map[string]any) {
+		c["enable"] = target
+	})
 	if err != nil {
 		return false, needRestart, err
 	}
-
-	return !clientOldEnabled, needRestart, nil
+	return target, needRestart, nil
 }
 
 func (s *ClientService) SetClientEnableByEmail(inboundSvc *InboundService, clientEmail string, enable bool) (bool, bool, error) {
@@ -1208,11 +1164,13 @@ func (s *ClientService) SetClientEnableByEmail(inboundSvc *InboundService, clien
 	if current == enable {
 		return false, false, nil
 	}
-	newEnabled, needRestart, err := s.ToggleClientEnableByEmail(inboundSvc, clientEmail)
+	needRestart, err := s.applyClientFieldByEmail(inboundSvc, clientEmail, func(c map[string]any) {
+		c["enable"] = enable
+	})
 	if err != nil {
 		return false, needRestart, err
 	}
-	return newEnabled == enable, needRestart, nil
+	return true, needRestart, nil
 }
 
 // applyClientFieldByEmail loads the inbound currently hosting clientEmail,

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

@@ -28,12 +28,12 @@ var (
 
 func lockInbound(inboundId int) *sync.Mutex {
 	inboundMutationLocksMu.Lock()
-	defer inboundMutationLocksMu.Unlock()
 	m, ok := inboundMutationLocks[inboundId]
 	if !ok {
 		m = &sync.Mutex{}
 		inboundMutationLocks[inboundId] = m
 	}
+	inboundMutationLocksMu.Unlock()
 	m.Lock()
 	return m
 }

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

@@ -0,0 +1,26 @@
+package service
+
+import (
+	"testing"
+	"time"
+)
+
+func TestLockInboundReleasesRegistryMutexWhileWaiting(t *testing.T) {
+	const id = 990006
+	held := lockInbound(id)
+
+	parked := make(chan struct{})
+	go func() {
+		close(parked)
+		lockInbound(id).Unlock()
+	}()
+	<-parked
+	time.Sleep(50 * time.Millisecond)
+
+	if !inboundMutationLocksMu.TryLock() {
+		held.Unlock()
+		t.Fatal("registry mutex is held while a lockInbound caller waits on a busy inbound")
+	}
+	inboundMutationLocksMu.Unlock()
+	held.Unlock()
+}

+ 18 - 9
internal/web/service/client_traffic.go

@@ -200,15 +200,24 @@ func (s *ClientService) resetAllClientTrafficsLocked(id int) error {
 }
 
 func (s *ClientService) ResetAllTraffics() (bool, error) {
-	db := database.GetDB()
-	res := db.Model(&xray.ClientTraffic{}).
-		Where("1 = 1").
-		Updates(map[string]any{"up": 0, "down": 0})
-	if res.Error != nil {
-		return false, res.Error
-	}
-	if err := db.Where("1 = 1").Delete(&model.ClientGlobalTraffic{}).Error; err != nil {
+	var affected int64
+	err := submitTrafficWrite(func() error {
+		return database.GetDB().Transaction(func(tx *gorm.DB) error {
+			res := tx.Model(&xray.ClientTraffic{}).
+				Where("1 = 1").
+				Updates(map[string]any{"enable": true, "up": 0, "down": 0})
+			if res.Error != nil {
+				return res.Error
+			}
+			affected = res.RowsAffected
+			if err := tx.Where("1 = 1").Delete(&model.ClientGlobalTraffic{}).Error; err != nil {
+				return err
+			}
+			return tx.Where("1 = 1").Delete(&model.NodeClientTraffic{}).Error
+		})
+	})
+	if err != nil {
 		return false, err
 	}
-	return res.RowsAffected > 0, nil
+	return affected > 0, nil
 }

+ 79 - 2
internal/web/service/email/email.go

@@ -33,6 +33,15 @@ func NewEmailService(settingService service.SettingService) *EmailService {
 	return &EmailService{settingService: settingService}
 }
 
+// smtpConnectTimeout bounds the TCP dial. smtpDeadline bounds every SMTP
+// protocol step after the connection is up, so a server that accepts the socket
+// but then stalls cannot block the sender goroutine (and leak its socket) long
+// after the caller's own timeout has already fired. smtpDeadline is a var only
+// so tests can shorten it.
+const smtpConnectTimeout = 10 * time.Second
+
+var smtpDeadline = 30 * time.Second
+
 // Send sends an HTML email to all configured recipients.
 func (s *EmailService) Send(subject, body string) error {
 	host, err := s.settingService.GetSmtpHost()
@@ -57,6 +66,7 @@ func (s *EmailService) Send(subject, body string) error {
 	if from == "" {
 		return fmt.Errorf("smtp from not configured")
 	}
+	from, fromName = resolveFrom(from, fromName)
 
 	recipients := parseRecipients(toStr)
 	if len(recipients) == 0 {
@@ -81,7 +91,7 @@ func (s *EmailService) Send(subject, body string) error {
 		case "tls":
 			ch <- result{s.sendWithTLS(addr, auth, from, recipients, msg, host)}
 		case "starttls", "none":
-			ch <- result{smtp.SendMail(addr, auth, from, recipients, msg)}
+			ch <- result{s.sendPlain(addr, auth, from, recipients, msg, host)}
 		default:
 			ch <- result{fmt.Errorf("unknown SMTP encryption type: %s", encryptionType)}
 		}
@@ -116,6 +126,10 @@ func (s *EmailService) TestConnection() SMTPTestResult {
 	if from == "" {
 		from = username
 	}
+	if from == "" {
+		return SMTPTestResult{false, "send", "smtpFromNotConfigured"}
+	}
+	from, fromName = resolveFrom(from, fromName)
 
 	recipients := parseRecipients(toStr)
 	if len(recipients) == 0 {
@@ -142,6 +156,7 @@ func (s *EmailService) TestConnection() SMTPTestResult {
 		return SMTPTestResult{false, "connect", classifySMTPError(err)}
 	}
 	defer conn.Close()
+	_ = conn.SetDeadline(time.Now().Add(smtpDeadline))
 
 	// Stage 2: Handshake + Auth
 	client, err := smtp.NewClient(conn, host)
@@ -202,7 +217,7 @@ func (s *EmailService) TestConnection() SMTPTestResult {
 
 func (s *EmailService) sendWithTLS(addr string, auth smtp.Auth, from string, to []string, msg []byte, host string) error {
 	// Dial with explicit timeout
-	dialer := &net.Dialer{Timeout: 10 * time.Second}
+	dialer := &net.Dialer{Timeout: smtpConnectTimeout}
 	conn, err := (&tls.Dialer{NetDialer: dialer, Config: &tls.Config{
 		ServerName:         host,
 		InsecureSkipVerify: false,
@@ -211,6 +226,7 @@ func (s *EmailService) sendWithTLS(addr string, auth smtp.Auth, from string, to
 		return err
 	}
 	defer conn.Close()
+	_ = conn.SetDeadline(time.Now().Add(smtpDeadline))
 
 	client, err := smtp.NewClient(conn, host)
 	if err != nil {
@@ -244,6 +260,56 @@ func (s *EmailService) sendWithTLS(addr string, auth smtp.Auth, from string, to
 	return w.Close()
 }
 
+// sendPlain delivers over a plain TCP connection, opportunistically upgrading
+// via STARTTLS when the server advertises it (the behavior net/smtp.SendMail
+// gives the "starttls" and "none" transports). Unlike SendMail it dials with a
+// timeout and arms a connection deadline, so a server that never speaks or
+// stalls mid-protocol cannot block the sender goroutine past smtpDeadline.
+func (s *EmailService) sendPlain(addr string, auth smtp.Auth, from string, to []string, msg []byte, host string) error {
+	conn, err := (&net.Dialer{Timeout: smtpConnectTimeout}).Dial("tcp", addr)
+	if err != nil {
+		return err
+	}
+	defer conn.Close()
+	_ = conn.SetDeadline(time.Now().Add(smtpDeadline))
+
+	client, err := smtp.NewClient(conn, host)
+	if err != nil {
+		return err
+	}
+	defer client.Close()
+
+	if err = client.Hello("localhost"); err != nil {
+		return err
+	}
+	if ok, _ := client.Extension("STARTTLS"); ok {
+		if err = client.StartTLS(&tls.Config{ServerName: host}); err != nil {
+			return err
+		}
+	}
+	if auth != nil {
+		if err = client.Auth(auth); err != nil {
+			return err
+		}
+	}
+	if err = client.Mail(from); err != nil {
+		return err
+	}
+	for _, r := range to {
+		if err = client.Rcpt(r); err != nil {
+			return err
+		}
+	}
+	w, err := client.Data()
+	if err != nil {
+		return err
+	}
+	if _, err = w.Write(msg); err != nil {
+		return err
+	}
+	return w.Close()
+}
+
 // SendTest sends a test email and returns any error with detail.
 func (s *EmailService) SendTest() error {
 	return s.Send(
@@ -304,6 +370,17 @@ func parseRecipients(toStr string) []string {
 // (entity.AllSetting.CheckValid), this is defense in depth for buildMessage.
 var headerSanitizer = strings.NewReplacer("\r", "", "\n", "")
 
+func resolveFrom(from, fromName string) (string, string) {
+	parsed, err := mail.ParseAddress(from)
+	if err != nil {
+		return from, fromName
+	}
+	if fromName == "" {
+		fromName = parsed.Name
+	}
+	return parsed.Address, fromName
+}
+
 func buildMessage(fromAddr, fromName string, to []string, subject, body string) []byte {
 	fromAddr = headerSanitizer.Replace(fromAddr)
 	fromName = headerSanitizer.Replace(fromName)

+ 46 - 0
internal/web/service/email/email_deadline_test.go

@@ -0,0 +1,46 @@
+package email
+
+import (
+	"net"
+	"testing"
+	"time"
+)
+
+func TestSendPlainReturnsOnStalledServer(t *testing.T) {
+	orig := smtpDeadline
+	smtpDeadline = 300 * time.Millisecond
+	t.Cleanup(func() { smtpDeadline = orig })
+
+	ln, err := net.Listen("tcp", "127.0.0.1:0")
+	if err != nil {
+		t.Fatalf("listen: %v", err)
+	}
+	defer ln.Close()
+
+	stall := make(chan struct{})
+	defer close(stall)
+	go func() {
+		conn, err := ln.Accept()
+		if err != nil {
+			return
+		}
+		defer conn.Close()
+		<-stall
+	}()
+
+	s := &EmailService{}
+	done := make(chan error, 1)
+	go func() {
+		done <- s.sendPlain(ln.Addr().String(), nil, "[email protected]",
+			[]string{"[email protected]"}, []byte("body"), "example.com")
+	}()
+
+	select {
+	case err := <-done:
+		if err == nil {
+			t.Fatal("expected an error from a silent SMTP server, got nil")
+		}
+	case <-time.After(3 * time.Second):
+		t.Fatal("sendPlain did not return on a stalled server within the deadline")
+	}
+}

+ 143 - 0
internal/web/service/email/email_test.go

@@ -1,11 +1,20 @@
 package email
 
 import (
+	"bufio"
+	"fmt"
 	"io"
 	"mime"
+	"net"
 	"net/mail"
+	"path/filepath"
+	"strconv"
 	"strings"
+	"sync"
 	"testing"
+
+	"github.com/mhsanaei/3x-ui/v3/internal/database"
+	"github.com/mhsanaei/3x-ui/v3/internal/web/service"
 )
 
 func TestBuildMessageIsRFC5322(t *testing.T) {
@@ -62,6 +71,140 @@ func TestBuildMessageFromWithoutName(t *testing.T) {
 	}
 }
 
+func startFakeSMTPServer(t *testing.T) (string, func() []string) {
+	t.Helper()
+	ln, err := net.Listen("tcp", "127.0.0.1:0")
+	if err != nil {
+		t.Fatal(err)
+	}
+	t.Cleanup(func() { _ = ln.Close() })
+
+	var mu sync.Mutex
+	var lines []string
+	record := func(line string) {
+		mu.Lock()
+		defer mu.Unlock()
+		lines = append(lines, line)
+	}
+
+	go func() {
+		conn, err := ln.Accept()
+		if err != nil {
+			return
+		}
+		defer conn.Close()
+		reader := bufio.NewReader(conn)
+		fmt.Fprint(conn, "220 fake ready\r\n")
+		inData := false
+		for {
+			line, err := reader.ReadString('\n')
+			if err != nil {
+				return
+			}
+			line = strings.TrimRight(line, "\r\n")
+			record(line)
+			if inData {
+				if line == "." {
+					inData = false
+					fmt.Fprint(conn, "250 ok\r\n")
+				}
+				continue
+			}
+			switch {
+			case strings.HasPrefix(line, "DATA"):
+				inData = true
+				fmt.Fprint(conn, "354 send\r\n")
+			case strings.HasPrefix(line, "QUIT"):
+				fmt.Fprint(conn, "221 bye\r\n")
+				return
+			default:
+				fmt.Fprint(conn, "250 ok\r\n")
+			}
+		}
+	}()
+
+	return ln.Addr().String(), func() []string {
+		mu.Lock()
+		defer mu.Unlock()
+		return append([]string(nil), lines...)
+	}
+}
+
+func TestSendUsesBareAddressFromNameAddrSmtpFrom(t *testing.T) {
+	if err := database.InitDB(filepath.Join(t.TempDir(), "x-ui.db")); err != nil {
+		t.Fatal(err)
+	}
+	t.Cleanup(func() { _ = database.CloseDB() })
+
+	addr, recordedLines := startFakeSMTPServer(t)
+	host, portStr, err := net.SplitHostPort(addr)
+	if err != nil {
+		t.Fatal(err)
+	}
+	port, err := strconv.Atoi(portStr)
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	settingService := service.SettingService{}
+	mustSet := func(name string, err error) {
+		t.Helper()
+		if err != nil {
+			t.Fatalf("set %s: %v", name, err)
+		}
+	}
+	mustSet("host", settingService.SetSmtpHost(host))
+	mustSet("port", settingService.SetSmtpPort(port))
+	mustSet("from", settingService.SetSmtpFrom("3x-ui Panel <[email protected]>"))
+	mustSet("to", settingService.SetSmtpTo("[email protected]"))
+	mustSet("encryption", settingService.SetSmtpEncryptionType("none"))
+
+	if err := NewEmailService(settingService).Send("subject", "<b>hi</b>"); err != nil {
+		t.Fatalf("send: %v", err)
+	}
+
+	var mailFrom, fromHeader string
+	for _, line := range recordedLines() {
+		if strings.HasPrefix(line, "MAIL FROM:") {
+			mailFrom = line
+		}
+		if strings.HasPrefix(line, "From: ") {
+			fromHeader = line
+		}
+	}
+	if want := "MAIL FROM:<[email protected]>"; mailFrom != want {
+		t.Errorf("envelope sender = %q, want %q", mailFrom, want)
+	}
+	if want := `From: "3x-ui Panel" <[email protected]>`; fromHeader != want {
+		t.Errorf("from header = %q, want %q", fromHeader, want)
+	}
+}
+
+func TestConnectionReportsMissingFrom(t *testing.T) {
+	if err := database.InitDB(filepath.Join(t.TempDir(), "x-ui.db")); err != nil {
+		t.Fatal(err)
+	}
+	t.Cleanup(func() { _ = database.CloseDB() })
+
+	settingService := service.SettingService{}
+	mustSet := func(name string, err error) {
+		t.Helper()
+		if err != nil {
+			t.Fatalf("set %s: %v", name, err)
+		}
+	}
+	mustSet("host", settingService.SetSmtpHost("127.0.0.1"))
+	mustSet("port", settingService.SetSmtpPort(1))
+	mustSet("to", settingService.SetSmtpTo("[email protected]"))
+	mustSet("encryption", settingService.SetSmtpEncryptionType("none"))
+
+	got := NewEmailService(settingService).TestConnection()
+	want := SMTPTestResult{Success: false, Stage: "send", Message: "smtpFromNotConfigured"}
+	if got != want {
+		t.Errorf("TestConnection() = %+v, want %+v", got, want)
+	}
+}
+
 func TestBuildMessageStripsHeaderInjection(t *testing.T) {
 	raw := buildMessage(
 		"[email protected]\r\nBcc: [email protected]",

+ 18 - 6
internal/web/service/inbound.go

@@ -719,6 +719,7 @@ func (s *InboundService) normalizeMtprotoXrayPort(inbound *model.Inbound, oldSet
 // then saves the inbound to the database and optionally adds it to the running Xray instance.
 // Returns the created inbound, whether Xray needs restart, and any error.
 func (s *InboundService) AddInbound(inbound *model.Inbound) (*model.Inbound, bool, error) {
+	inbound.Id = 0
 	// Normalize streamSettings based on protocol
 	s.normalizeStreamSettings(inbound)
 	if err := validateFinalMaskRealityCombo(inbound.StreamSettings); err != nil {
@@ -803,6 +804,10 @@ func (s *InboundService) AddInbound(inbound *model.Inbound) (*model.Inbound, boo
 			if client.Auth == "" {
 				return inbound, false, common.NewError("empty client ID")
 			}
+		case "wireguard":
+			if client.PublicKey == "" {
+				return inbound, false, common.NewError("wireguard client requires a key")
+			}
 		case "mtproto":
 			if client.Secret == "" {
 				return inbound, false, common.NewError("mtproto client requires a secret")
@@ -1098,6 +1103,10 @@ func (s *InboundService) SetInboundEnable(id int, enable bool) (bool, error) {
 		return false, nil
 	}
 
+	if mtprotoRoutesThroughXray(inbound) {
+		needRestart = true
+	}
+
 	if !push {
 		return true, nil
 	}
@@ -1298,13 +1307,16 @@ func (s *InboundService) UpdateInbound(inbound *model.Inbound) (*model.Inbound,
 						pushable = false
 					}
 				}
+				newProtocolIsMtproto := oldInbound.Protocol == model.MTProto
 				if pushable {
-					if err2 := rt.UpdateInbound(context.Background(), &oldSnapshot, payload); err2 == nil {
-						logger.Debug("Updated inbound applied on", rt.Name(), ":", oldInbound.Tag)
-					} else {
-						logger.Debug("Unable to update inbound on", rt.Name(), ":", err2)
-						if oldInbound.Protocol != model.MTProto {
-							needRestart = true
+					postCommitApply = func() {
+						if err2 := rt.UpdateInbound(context.Background(), &oldSnapshot, payload); err2 == nil {
+							logger.Debug("Updated inbound applied on", rt.Name(), ":", oldInbound.Tag)
+						} else {
+							logger.Debug("Unable to update inbound on", rt.Name(), ":", err2)
+							if !newProtocolIsMtproto {
+								needRestart = true
+							}
 						}
 					}
 				}

+ 58 - 0
internal/web/service/inbound_finalmask_reality_test.go

@@ -91,6 +91,64 @@ func TestAddInbound_RejectsFinalMaskRealityCombo(t *testing.T) {
 	}
 }
 
+func TestAddInbound_IgnoresBoundIdAndCreatesNewRow(t *testing.T) {
+	setupConflictDB(t)
+	svc := &InboundService{}
+
+	first := &model.Inbound{Tag: "in-45100-tcp", Enable: true, Listen: "0.0.0.0", Port: 45100, Protocol: model.VLESS, Settings: `{"clients":[]}`}
+	created, _, err := svc.AddInbound(first)
+	if err != nil {
+		t.Fatalf("AddInbound first: %v", err)
+	}
+
+	second := &model.Inbound{Id: created.Id, Tag: "in-45101-tcp", Enable: true, Listen: "0.0.0.0", Port: 45101, Protocol: model.VLESS, Settings: `{"clients":[]}`}
+	if _, _, err := svc.AddInbound(second); err != nil {
+		t.Fatalf("AddInbound second: %v", err)
+	}
+
+	var count int64
+	if err := database.GetDB().Model(&model.Inbound{}).Count(&count).Error; err != nil {
+		t.Fatalf("count: %v", err)
+	}
+	if count != 2 {
+		t.Fatalf("expected 2 inbound rows, got %d: a bound id overwrote the first row instead of creating a new one", count)
+	}
+
+	var reloaded model.Inbound
+	if err := database.GetDB().First(&reloaded, created.Id).Error; err != nil {
+		t.Fatalf("reload first: %v", err)
+	}
+	if reloaded.Port != 45100 {
+		t.Fatalf("first inbound port = %d, want 45100 (the second add overwrote it)", reloaded.Port)
+	}
+}
+
+func TestAddInbound_AcceptsWireguardClientWithKey(t *testing.T) {
+	setupConflictDB(t)
+	svc := &InboundService{}
+
+	settings := `{"secretKey":"` + wgTestSecretKey() + `","mtu":1420,"clients":[{"email":"wgimp@x","enable":true,"privateKey":"keep-priv","publicKey":"keep-pub","allowedIPs":["10.0.0.50/32"]}]}`
+	in := &model.Inbound{
+		Tag:      "in-45200-wg",
+		Enable:   true,
+		Listen:   "0.0.0.0",
+		Port:     45200,
+		Protocol: model.WireGuard,
+		Settings: settings,
+	}
+	if _, _, err := svc.AddInbound(in); err != nil {
+		t.Fatalf("AddInbound rejected a keyed WireGuard client: %v", err)
+	}
+
+	var count int64
+	if err := database.GetDB().Model(&model.Inbound{}).Where("tag = ?", "in-45200-wg").Count(&count).Error; err != nil {
+		t.Fatalf("count: %v", err)
+	}
+	if count != 1 {
+		t.Fatalf("WireGuard inbound with a keyed client was not created, row count = %d", count)
+	}
+}
+
 // end-to-end: same guard on the update path, on a row that was valid before
 // the edit — the rejected StreamSettings must not overwrite the stored row.
 func TestUpdateInbound_RejectsFinalMaskRealityCombo(t *testing.T) {

+ 72 - 0
internal/web/service/inbound_mtproto_txfail_test.go

@@ -0,0 +1,72 @@
+package service
+
+import (
+	"errors"
+	"testing"
+
+	"gorm.io/gorm"
+
+	"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 TestUpdateInboundLocalMtprotoDefersPushUntilCommit(t *testing.T) {
+	setupConflictDB(t)
+
+	mgr := runtime.NewManager(runtime.LocalDeps{APIPort: func() int { return 0 }})
+	fake := &fakeNodeRuntime{}
+	mgr.SetLocalRuntimeOverride(fake)
+	runtime.SetManager(mgr)
+	t.Cleanup(func() { runtime.SetManager(nil) })
+
+	seedInboundConflict(t, "mt-txfail", "", 46150, model.MTProto, "",
+		`{"clients":[{"email":"mtx","secret":"`+mtprotoTestSecretA+`","enable":true}]}`)
+	seeded := loadInboundByTag(t, "mt-txfail")
+	seedClientTraffic(t, seeded.Id, "mtx", true)
+
+	db := database.GetDB()
+	const cbName = "b1-05:fail-inbound-update"
+	if err := db.Callback().Update().After("gorm:update").Register(cbName, func(tx *gorm.DB) {
+		if tx.Statement != nil && tx.Statement.Table == "inbounds" {
+			tx.AddError(errors.New("injected transaction failure"))
+		}
+	}); err != nil {
+		t.Fatalf("register callback: %v", err)
+	}
+	t.Cleanup(func() { _ = db.Callback().Update().Remove(cbName) })
+
+	update := *loadInboundByTag(t, "mt-txfail")
+	update.Remark = "edited"
+	if _, _, err := (&InboundService{}).UpdateInbound(&update); err == nil {
+		t.Fatal("UpdateInbound: expected the injected transaction failure")
+	}
+
+	if n := fake.updateInbound.Load(); n != 0 {
+		t.Fatalf("the MTProto sidecar push ran %d time(s) inside the failed transaction; it must be deferred until the commit succeeds", n)
+	}
+}
+
+func TestSetInboundEnableRoutedMtprotoRequestsRestart(t *testing.T) {
+	setupConflictDB(t)
+
+	mgr := runtime.NewManager(runtime.LocalDeps{APIPort: func() int { return 0 }})
+	mgr.SetLocalRuntimeOverride(&fakeNodeRuntime{})
+	runtime.SetManager(mgr)
+	t.Cleanup(func() { runtime.SetManager(nil) })
+
+	seedInboundConflict(t, "mt-route", "", 46160, model.MTProto, "",
+		`{"clients":[{"email":"mtr","secret":"`+mtprotoTestSecretA+`","enable":true}],"routeThroughXray":true,"routeXrayPort":12345}`)
+	seeded := loadInboundByTag(t, "mt-route")
+	if err := database.GetDB().Model(&model.Inbound{}).Where("id = ?", seeded.Id).Update("enable", false).Error; err != nil {
+		t.Fatalf("force disable: %v", err)
+	}
+
+	needRestart, err := (&InboundService{}).SetInboundEnable(seeded.Id, true)
+	if err != nil {
+		t.Fatalf("SetInboundEnable: %v", err)
+	}
+	if !needRestart {
+		t.Fatal("re-enabling a routed MTProto inbound must request an xray restart to re-inject the egress bridge")
+	}
+}

+ 45 - 41
internal/web/service/inbound_traffic.go

@@ -37,9 +37,11 @@ func (s *InboundService) addTrafficLocked(inboundTraffics []*xray.Traffic, clien
 
 	defer func() {
 		if err != nil {
-			tx.Rollback()
-		} else {
-			tx.Commit()
+			if rbErr := tx.Rollback().Error; rbErr != nil {
+				logger.Warning("Error rolling back traffic tx:", rbErr)
+			}
+		} else if cErr := tx.Commit().Error; cErr != nil {
+			logger.Warning("Error committing traffic tx:", cErr)
 		}
 	}()
 	err = s.addInboundTraffic(tx, inboundTraffics)
@@ -51,25 +53,25 @@ func (s *InboundService) addTrafficLocked(inboundTraffics []*xray.Traffic, clien
 		return false, false, err
 	}
 
-	needRestart0, count, err := s.autoRenewClients(tx)
-	if err != nil {
-		logger.Warning("Error in renew clients:", err)
+	needRestart0, count, renewErr := s.autoRenewClients(tx)
+	if renewErr != nil {
+		logger.Warning("Error in renew clients:", renewErr)
 	} else if count > 0 {
 		logger.Debugf("%v clients renewed", count)
 	}
 
 	disabledClientsCount := int64(0)
-	needRestart1, count, err := s.disableInvalidClients(tx)
-	if err != nil {
-		logger.Warning("Error in disabling invalid clients:", err)
+	needRestart1, count, disableClientsErr := s.disableInvalidClients(tx)
+	if disableClientsErr != nil {
+		logger.Warning("Error in disabling invalid clients:", disableClientsErr)
 	} else if count > 0 {
 		logger.Debugf("%v clients disabled", count)
 		disabledClientsCount = count
 	}
 
-	needRestart2, count, err := s.disableInvalidInbounds(tx)
-	if err != nil {
-		logger.Warning("Error in disabling invalid inbounds:", err)
+	needRestart2, count, disableInboundsErr := s.disableInvalidInbounds(tx)
+	if disableInboundsErr != nil {
+		logger.Warning("Error in disabling invalid inbounds:", disableInboundsErr)
 	} else if count > 0 {
 		logger.Debugf("%v inbounds disabled", count)
 	}
@@ -657,6 +659,7 @@ func (s *InboundService) ResetAllTraffics() error {
 		return s.resetAllTrafficsLocked()
 	})
 	if err == nil {
+		s.propagateResetAllTrafficsToNodes()
 		s.resetAllMtprotoQuotas()
 	}
 	return err
@@ -666,52 +669,53 @@ func (s *InboundService) resetAllTrafficsLocked() error {
 	db := database.GetDB()
 	now := time.Now().UnixMilli()
 
-	if err := db.Model(model.Inbound{}).
+	return db.Model(model.Inbound{}).
 		Where("user_id > ?", 0).
 		Updates(map[string]any{
 			"up":                      0,
 			"down":                    0,
 			"last_traffic_reset_time": now,
-		}).Error; err != nil {
-		return err
-	}
+		}).Error
+}
 
+// propagateResetAllTrafficsToNodes tells every node to zero its own counters.
+// Kept OUT of the traffic-writer transaction: each remote call can block up to
+// remoteHTTPTimeout, and holding the single serial writer across N such calls
+// stalls traffic accounting and drops the deltas of every concurrent poll.
+func (s *InboundService) propagateResetAllTrafficsToNodes() {
 	nodes, err := (&NodeService{}).GetAll()
-	if err == nil {
-		for _, node := range nodes {
-			if rt, err := runtime.GetManager().RuntimeFor(&node.Id); err == nil {
-				if e := rt.ResetAllTraffics(context.Background()); e != nil {
-					logger.Warning("ResetAllTraffics: remote propagation to", rt.Name(), "failed:", e)
-				}
+	if err != nil {
+		return
+	}
+	for _, node := range nodes {
+		if rt, err := runtime.GetManager().RuntimeFor(&node.Id); err == nil {
+			if e := rt.ResetAllTraffics(context.Background()); e != nil {
+				logger.Warning("ResetAllTraffics: remote propagation to", rt.Name(), "failed:", e)
 			}
 		}
 	}
-
-	return nil
 }
 
 func (s *InboundService) ResetInboundTraffic(id int) error {
-	return submitTrafficWrite(func() error {
-		db := database.GetDB()
-		if err := db.Model(model.Inbound{}).
+	if err := submitTrafficWrite(func() error {
+		return database.GetDB().Model(model.Inbound{}).
 			Where("id = ?", id).
-			Updates(map[string]any{"up": 0, "down": 0}).Error; err != nil {
-			return err
-		}
+			Updates(map[string]any{"up": 0, "down": 0}).Error
+	}); err != nil {
+		return err
+	}
 
-		inbound, err := s.GetInbound(id)
-		if err == nil && inbound != nil && inbound.NodeID != nil {
-			if rt, rterr := s.runtimeFor(inbound); rterr == nil {
-				if e := rt.ResetInboundTraffic(context.Background(), inbound); e != nil {
-					logger.Warning("ResetInboundTraffic: remote propagation to", rt.Name(), "failed:", e)
-				}
-			} else {
-				logger.Warning("ResetInboundTraffic: runtime lookup failed:", rterr)
+	inbound, err := s.GetInbound(id)
+	if err == nil && inbound != nil && inbound.NodeID != nil {
+		if rt, rterr := s.runtimeFor(inbound); rterr == nil {
+			if e := rt.ResetInboundTraffic(context.Background(), inbound); e != nil {
+				logger.Warning("ResetInboundTraffic: remote propagation to", rt.Name(), "failed:", e)
 			}
+		} else {
+			logger.Warning("ResetInboundTraffic: runtime lookup failed:", rterr)
 		}
-
-		return nil
-	})
+	}
+	return nil
 }
 
 func (s *InboundService) DelDepletedClients(id int) (err error) {

+ 1 - 1
internal/web/service/integration/warp.go

@@ -238,7 +238,7 @@ func (s *WarpService) doWarpRequest(req *http.Request) ([]byte, error) {
 	}
 	defer resp.Body.Close()
 
-	body, err := io.ReadAll(resp.Body)
+	body, err := io.ReadAll(io.LimitReader(resp.Body, maxResponseSize))
 	if err != nil {
 		return nil, err
 	}

+ 39 - 0
internal/web/service/integration/warp_response_test.go

@@ -0,0 +1,39 @@
+package integration
+
+import (
+	"bytes"
+	"net/http"
+	"net/http/httptest"
+	"path/filepath"
+	"testing"
+
+	"github.com/mhsanaei/3x-ui/v3/internal/database"
+)
+
+func TestDoWarpRequestCapsResponseBody(t *testing.T) {
+	if err := database.InitDB(filepath.Join(t.TempDir(), "x-ui.db")); err != nil {
+		t.Fatalf("InitDB: %v", err)
+	}
+	t.Cleanup(func() { _ = database.CloseDB() })
+
+	oversize := maxResponseSize + 4096
+	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		w.WriteHeader(http.StatusOK)
+		_, _ = w.Write(bytes.Repeat([]byte("a"), oversize))
+	}))
+	defer srv.Close()
+
+	s := &WarpService{}
+	req, err := http.NewRequest(http.MethodGet, srv.URL, nil)
+	if err != nil {
+		t.Fatalf("NewRequest: %v", err)
+	}
+
+	body, err := s.doWarpRequest(req)
+	if err != nil {
+		t.Fatalf("doWarpRequest: %v", err)
+	}
+	if len(body) != maxResponseSize {
+		t.Fatalf("response body not capped: got %d bytes, want %d", len(body), maxResponseSize)
+	}
+}

+ 33 - 0
internal/web/service/key_gen_parse_test.go

@@ -0,0 +1,33 @@
+package service
+
+import "testing"
+
+func TestParseXrayKeyPairOutput(t *testing.T) {
+	a, b, err := parseXrayKeyPairOutput("Private key: abc123\nPublic key: def456\n")
+	if err != nil {
+		t.Fatalf("well-formed output errored: %v", err)
+	}
+	if a != "abc123" || b != "def456" {
+		t.Fatalf("got (%q, %q), want (abc123, def456)", a, b)
+	}
+
+	malformed := []string{
+		"",
+		"only one line: value",
+		"Private key: abc\n",
+		"no colon here\nno colon two",
+		"Private key\nPublic key",
+	}
+	for _, out := range malformed {
+		func() {
+			defer func() {
+				if r := recover(); r != nil {
+					t.Errorf("parseXrayKeyPairOutput panicked on %q: %v", out, r)
+				}
+			}()
+			if _, _, err := parseXrayKeyPairOutput(out); err == nil {
+				t.Errorf("expected error for malformed output %q, got nil", out)
+			}
+		}()
+	}
+}

+ 9 - 1
internal/web/service/node.go

@@ -533,9 +533,17 @@ func FilterNodeSnapshot(n *model.Node, snap *runtime.TrafficSnapshot) {
 	if n == nil || snap == nil || n.InboundSyncMode != "selected" {
 		return
 	}
-	allowed := make(map[string]struct{}, len(n.InboundTags))
+	prefix := nodeTagPrefix(&n.Id)
+	allowed := make(map[string]struct{}, len(n.InboundTags)*2)
 	for _, tag := range n.InboundTags {
 		allowed[tag] = struct{}{}
+		if prefix != "" {
+			if stripped, found := strings.CutPrefix(tag, prefix); found {
+				allowed[stripped] = struct{}{}
+			} else {
+				allowed[prefix+tag] = struct{}{}
+			}
+		}
 	}
 	filtered := make([]*model.Inbound, 0, len(snap.Inbounds))
 	for _, inbound := range snap.Inbounds {

+ 23 - 0
internal/web/service/node_test.go

@@ -212,3 +212,26 @@ func TestFilterNodeSnapshot(t *testing.T) {
 		t.Fatalf("empty selection kept %d inbounds, want 0", len(none.Inbounds))
 	}
 }
+
+func TestFilterNodeSnapshotMatchesPrefixedSelectedTag(t *testing.T) {
+	snap := &runtime.TrafficSnapshot{Inbounds: []*model.Inbound{
+		{Tag: "in-100-tcp"},
+		{Tag: "in-443-tcp"},
+	}}
+	FilterNodeSnapshot(&model.Node{
+		Id:              5,
+		InboundSyncMode: "selected",
+		InboundTags:     []string{"in-100-tcp", "n5-in-443-tcp"},
+	}, snap)
+
+	kept := make(map[string]bool, len(snap.Inbounds))
+	for _, ib := range snap.Inbounds {
+		kept[ib.Tag] = true
+	}
+	if !kept["in-443-tcp"] {
+		t.Fatalf("node-side tag in-443-tcp filtered out despite the prefixed central tag being selected; kept=%v", kept)
+	}
+	if !kept["in-100-tcp"] {
+		t.Fatalf("bare selected tag in-100-tcp was dropped; kept=%v", kept)
+	}
+}

+ 21 - 2
internal/web/service/outbound_subscription.go

@@ -17,6 +17,7 @@ import (
 	"github.com/mhsanaei/3x-ui/v3/internal/logger"
 	"github.com/mhsanaei/3x-ui/v3/internal/util/common"
 	"github.com/mhsanaei/3x-ui/v3/internal/util/link"
+	"github.com/mhsanaei/3x-ui/v3/internal/util/netsafe"
 	"github.com/mhsanaei/3x-ui/v3/internal/xray"
 )
 
@@ -276,6 +277,23 @@ func (s *OutboundSubscriptionService) RefreshAllEnabled() (int, error) {
 	return refreshed, nil
 }
 
+// subscriptionFetchClient builds the HTTP client used to fetch a subscription.
+// A configured panel egress proxy dials the loopback SOCKS bridge (xray handles
+// the real egress), so its localhost dial must not be SSRF-blocked. A direct
+// fetch dials the target itself and re-resolves the hostname at dial time, so it
+// goes through the SSRF-guarded dialer, which resolves, checks and dials the same
+// IP atomically — closing the DNS-rebinding gap left by validating the hostname
+// separately from the dial.
+func (s *OutboundSubscriptionService) subscriptionFetchClient(timeout time.Duration) *http.Client {
+	if s.settingService.PanelEgressProxyURL() != "" {
+		return s.settingService.NewProxiedHTTPClient(timeout)
+	}
+	return &http.Client{
+		Timeout:   timeout,
+		Transport: &http.Transport{DialContext: netsafe.SSRFGuardedDialContext},
+	}
+}
+
 // fetchAndStore does the actual network + parse + stability + persist work.
 func (s *OutboundSubscriptionService) fetchAndStore(sub *model.OutboundSubscription) ([]any, error) {
 	// Re-sanitize on every fetch (handles legacy rows + defense in depth against
@@ -291,7 +309,7 @@ func (s *OutboundSubscriptionService) fetchAndStore(sub *model.OutboundSubscript
 	}
 	sub.Url = cleanURL // persist the cleaned version
 
-	client := s.settingService.NewProxiedHTTPClient(30 * time.Second)
+	client := s.subscriptionFetchClient(30 * time.Second)
 	// Re-validate every redirect hop: the initial host is checked above, but a
 	// redirect could still point at a private/internal address (SSRF). Cap the
 	// redirect chain as well.
@@ -307,7 +325,8 @@ func (s *OutboundSubscriptionService) fetchAndStore(sub *model.OutboundSubscript
 		return rejectPrivateHost(ctx, req.URL.Hostname())
 	}
 
-	req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, sub.Url, nil)
+	reqCtx := netsafe.ContextWithAllowPrivate(context.Background(), sub.AllowPrivate)
+	req, err := http.NewRequestWithContext(reqCtx, http.MethodGet, sub.Url, nil)
 	if err != nil {
 		s.recordError(sub, err)
 		return nil, err

+ 30 - 0
internal/web/service/outbound_subscription_ssrf_test.go

@@ -0,0 +1,30 @@
+package service
+
+import (
+	"context"
+	"net/http"
+	"strings"
+	"testing"
+	"time"
+
+	"github.com/mhsanaei/3x-ui/v3/internal/util/netsafe"
+)
+
+func TestSubscriptionFetchClientBlocksPrivateDial(t *testing.T) {
+	setupSettingTestDB(t)
+	client := (&OutboundSubscriptionService{}).subscriptionFetchClient(5 * time.Second)
+
+	ctx := netsafe.ContextWithAllowPrivate(context.Background(), false)
+	req, err := http.NewRequestWithContext(ctx, http.MethodGet, "http://127.0.0.1:1/", nil)
+	if err != nil {
+		t.Fatalf("new request: %v", err)
+	}
+
+	_, err = client.Do(req)
+	if err == nil {
+		t.Fatal("the fetch client dialed a private address instead of blocking it")
+	}
+	if !strings.Contains(err.Error(), "blocked private") {
+		t.Fatalf("expected an SSRF-guard block, got a plain dial error: %v", err)
+	}
+}

+ 68 - 0
internal/web/service/reset_writer_test.go

@@ -0,0 +1,68 @@
+package service
+
+import (
+	"context"
+	"testing"
+	"time"
+
+	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
+	"github.com/mhsanaei/3x-ui/v3/internal/web/runtime"
+)
+
+type blockingResetRuntime struct {
+	fakeNodeRuntime
+	reached chan struct{}
+	release chan struct{}
+}
+
+func (b *blockingResetRuntime) ResetAllTraffics(context.Context) error {
+	close(b.reached)
+	<-b.release
+	return nil
+}
+
+func TestResetAllTrafficsDoesNotBlockWriterOnNodeCall(t *testing.T) {
+	db := initTrafficTestDB(t)
+	resetTrafficWriterForTest(t)
+	StartTrafficWriter()
+
+	mgr := runtime.NewManager(runtime.LocalDeps{APIPort: func() int { return 0 }})
+	runtime.SetManager(mgr)
+	t.Cleanup(func() { runtime.SetManager(nil) })
+
+	node := &model.Node{Name: "n1", Address: "127.0.0.1", Port: 2096, ApiToken: "tok", Enable: true, Status: "online"}
+	if err := db.Create(node).Error; err != nil {
+		t.Fatalf("create node: %v", err)
+	}
+	fake := &blockingResetRuntime{reached: make(chan struct{}), release: make(chan struct{})}
+	mgr.SetRuntimeOverride(node.Id, fake)
+
+	done := make(chan error, 1)
+	go func() { done <- (&InboundService{}).ResetAllTraffics() }()
+
+	select {
+	case <-fake.reached:
+	case <-time.After(3 * time.Second):
+		close(fake.release)
+		t.Fatal("node ResetAllTraffics was never reached")
+	}
+
+	writerFree := make(chan error, 1)
+	go func() { writerFree <- submitTrafficWrite(func() error { return nil }) }()
+	select {
+	case err := <-writerFree:
+		if err != nil {
+			close(fake.release)
+			t.Fatalf("concurrent writer submit failed: %v", err)
+		}
+	case <-time.After(2 * time.Second):
+		close(fake.release)
+		<-done
+		t.Fatal("the serial traffic writer was blocked by a node reset HTTP call")
+	}
+
+	close(fake.release)
+	if err := <-done; err != nil {
+		t.Fatalf("ResetAllTraffics: %v", err)
+	}
+}

+ 65 - 46
internal/web/service/server.go

@@ -1132,6 +1132,40 @@ func (s *ServerService) GetLogs(count string, level string, syslog string) []str
 	return lines
 }
 
+// parseAccessLogFields extracts the structured fields from one Xray access-log
+// line. Lines are attacker-influenced (a client's requested destination lands in
+// the log verbatim) and may be truncated, so every positional lookup is length
+// guarded: a malformed line yields a partial entry rather than panicking.
+func parseAccessLogFields(line string) LogEntry {
+	var entry LogEntry
+	parts := strings.Fields(line)
+
+	for i, part := range parts {
+
+		if i == 0 && len(parts) > 1 {
+			dateTime, err := time.ParseInLocation("2006/01/02 15:04:05.999999", parts[0]+" "+parts[1], time.Local)
+			if err != nil {
+				continue
+			}
+			entry.DateTime = dateTime.UTC()
+		}
+
+		if part == "from" && i+1 < len(parts) {
+			entry.FromAddress = strings.TrimLeft(parts[i+1], "/")
+		} else if part == "accepted" && i+1 < len(parts) {
+			entry.ToAddress = strings.TrimLeft(parts[i+1], "/")
+		} else if strings.HasPrefix(part, "[") {
+			entry.Inbound = part[1:]
+		} else if strings.HasSuffix(part, "]") {
+			entry.Outbound = part[:len(part)-1]
+		} else if part == "email:" && i+1 < len(parts) {
+			entry.Email = parts[i+1]
+		}
+	}
+
+	return entry
+}
+
 func (s *ServerService) GetXrayLogs(
 	count string,
 	filter string,
@@ -1176,31 +1210,7 @@ func (s *ServerService) GetXrayLogs(
 			continue
 		}
 
-		var entry LogEntry
-		parts := strings.Fields(line)
-
-		for i, part := range parts {
-
-			if i == 0 {
-				dateTime, err := time.ParseInLocation("2006/01/02 15:04:05.999999", parts[0]+" "+parts[1], time.Local)
-				if err != nil {
-					continue
-				}
-				entry.DateTime = dateTime.UTC()
-			}
-
-			if part == "from" {
-				entry.FromAddress = strings.TrimLeft(parts[i+1], "/")
-			} else if part == "accepted" {
-				entry.ToAddress = strings.TrimLeft(parts[i+1], "/")
-			} else if strings.HasPrefix(part, "[") {
-				entry.Inbound = part[1:]
-			} else if strings.HasSuffix(part, "]") {
-				entry.Outbound = part[:len(part)-1]
-			} else if part == "email:" {
-				entry.Email = parts[i+1]
-			}
-		}
+		entry := parseAccessLogFields(line)
 
 		if logEntryContains(line, freedoms) {
 			if showDirect == "false" {
@@ -2013,6 +2023,24 @@ func (s *ServerService) UpdateGeofile(fileName string) error {
 	return nil
 }
 
+// parseXrayKeyPairOutput reads the two-line "Label: value" output that xray's
+// key-generation subcommands (x25519, mldsa65, mlkem768) print and returns the
+// two values. Short or label-less output yields an error instead of panicking
+// on an out-of-range slice index, so a future xray version that changes the
+// format degrades to a 500 with a message rather than a crash.
+func parseXrayKeyPairOutput(output string) (string, string, error) {
+	lines := strings.Split(output, "\n")
+	if len(lines) < 2 {
+		return "", "", common.NewError("unexpected key generator output")
+	}
+	first := strings.Split(lines[0], ":")
+	second := strings.Split(lines[1], ":")
+	if len(first) < 2 || len(second) < 2 {
+		return "", "", common.NewError("unexpected key generator output")
+	}
+	return strings.TrimSpace(first[1]), strings.TrimSpace(second[1]), nil
+}
+
 func (s *ServerService) GetNewX25519Cert() (any, error) {
 	// Run the command
 	cmd := exec.CommandContext(context.Background(), xray.GetBinaryPath(), "x25519")
@@ -2023,13 +2051,10 @@ func (s *ServerService) GetNewX25519Cert() (any, error) {
 		return nil, err
 	}
 
-	lines := strings.Split(out.String(), "\n")
-
-	privateKeyLine := strings.Split(lines[0], ":")
-	publicKeyLine := strings.Split(lines[1], ":")
-
-	privateKey := strings.TrimSpace(privateKeyLine[1])
-	publicKey := strings.TrimSpace(publicKeyLine[1])
+	privateKey, publicKey, err := parseXrayKeyPairOutput(out.String())
+	if err != nil {
+		return nil, err
+	}
 
 	keyPair := map[string]any{
 		"privateKey": privateKey,
@@ -2049,13 +2074,10 @@ func (s *ServerService) GetNewmldsa65() (any, error) {
 		return nil, err
 	}
 
-	lines := strings.Split(out.String(), "\n")
-
-	SeedLine := strings.Split(lines[0], ":")
-	VerifyLine := strings.Split(lines[1], ":")
-
-	seed := strings.TrimSpace(SeedLine[1])
-	verify := strings.TrimSpace(VerifyLine[1])
+	seed, verify, err := parseXrayKeyPairOutput(out.String())
+	if err != nil {
+		return nil, err
+	}
 
 	keyPair := map[string]any{
 		"seed":   seed,
@@ -2371,13 +2393,10 @@ func (s *ServerService) GetNewmlkem768() (any, error) {
 		return nil, err
 	}
 
-	lines := strings.Split(out.String(), "\n")
-
-	SeedLine := strings.Split(lines[0], ":")
-	ClientLine := strings.Split(lines[1], ":")
-
-	seed := strings.TrimSpace(SeedLine[1])
-	client := strings.TrimSpace(ClientLine[1])
+	seed, client, err := parseXrayKeyPairOutput(out.String())
+	if err != nil {
+		return nil, err
+	}
 
 	keyPair := map[string]any{
 		"seed":   seed,

+ 40 - 0
internal/web/service/tgbot/tgbot_callback_auth_test.go

@@ -0,0 +1,40 @@
+package tgbot
+
+import (
+	"testing"
+
+	"github.com/mymmrac/telego"
+)
+
+func TestAnswerCallbackDeniesPrivilegedActionToNonAdmin(t *testing.T) {
+	defer func() {
+		if r := recover(); r != nil {
+			t.Fatalf("a non-admin callback reached a privileged handler: %v", r)
+		}
+	}()
+
+	tg := &Tgbot{}
+	for _, data := range []string{"get_backup", "reset_all_traffics_c", "add_client", "onlines", "inbounds"} {
+		q := &telego.CallbackQuery{
+			Data:    data,
+			From:    telego.User{ID: 999999},
+			Message: &telego.Message{Chat: telego.Chat{ID: 1}},
+		}
+		tg.answerCallback(q, false)
+	}
+}
+
+func TestIsClientSelfCallback(t *testing.T) {
+	allowed := []string{"client_traffic", "client_sub_links", "client_qr_links", "client_sub_links alice@x"}
+	for _, d := range allowed {
+		if !isClientSelfCallback(d) {
+			t.Errorf("%q should be a per-user client callback", d)
+		}
+	}
+	denied := []string{"get_backup", "reset_all_traffics_c", "add_client", "onlines", "get_banlogs", "get_usage"}
+	for _, d := range denied {
+		if isClientSelfCallback(d) {
+			t.Errorf("%q is an admin-only callback and must not be treated as per-user", d)
+		}
+	}
+}

+ 18 - 0
internal/web/service/tgbot/tgbot_delete_after_test.go

@@ -0,0 +1,18 @@
+package tgbot
+
+import "testing"
+
+func TestDeleteMessageAfterDelayKeepsUserState(t *testing.T) {
+	userStateMgr.reset()
+	t.Cleanup(userStateMgr.reset)
+
+	const chatID = int64(4242)
+	userStateMgr.set(chatID, "awaiting_comment")
+
+	tg := &Tgbot{}
+	tg.deleteMessageAfterDelay(chatID, 1, 0)
+
+	if st, ok := userStateMgr.get(chatID); !ok || st != "awaiting_comment" {
+		t.Fatalf("delayed message deletion cleared the conversation state: got (%q, %v), want (%q, true)", st, ok, "awaiting_comment")
+	}
+}

+ 18 - 0
internal/web/service/tgbot/tgbot_router.go

@@ -1128,6 +1128,10 @@ func (t *Tgbot) answerCallback(callbackQuery *telego.CallbackQuery, isAdmin bool
 		}
 	}
 
+	if !isAdmin && !isClientSelfCallback(callbackQuery.Data) {
+		return
+	}
+
 	switch callbackQuery.Data {
 	case "get_usage":
 		t.sendCallbackAnswerTgBot(callbackQuery.ID, t.I18nBot("tgbot.buttons.serverUsage"))
@@ -1526,3 +1530,17 @@ func (t *Tgbot) answerCallback(callbackQuery *telego.CallbackQuery, isAdmin bool
 func checkAdmin(tgId int64) bool {
 	return slices.Contains(adminIds, tgId)
 }
+
+// isClientSelfCallback reports whether a callback is one of the per-user client
+// actions that resolve their own data from the caller's Telegram id, and so are
+// safe to run for a non-admin. Every other callback is admin-only (default-deny).
+func isClientSelfCallback(data string) bool {
+	switch data {
+	case "client_traffic", "client_commands", "client_sub_links",
+		"client_individual_links", "client_qr_links":
+		return true
+	}
+	return strings.HasPrefix(data, "client_sub_links ") ||
+		strings.HasPrefix(data, "client_individual_links ") ||
+		strings.HasPrefix(data, "client_qr_links ")
+}

+ 15 - 6
internal/web/service/tgbot/tgbot_send.go

@@ -228,16 +228,25 @@ func (t *Tgbot) SendMsgToTgbotDeleteAfter(chatId int64, msg string, delayInSecon
 		return
 	}
 
-	// Delete the sent message after the specified number of seconds
-	go func() {
-		time.Sleep(time.Duration(delayInSeconds) * time.Second) // Wait for the specified delay
-		t.deleteMessageTgBot(chatId, sentMsg.MessageID)         // Delete the message
-		userStateMgr.clear(chatId)
-	}()
+	// Delete the sent message after the specified number of seconds.
+	go t.deleteMessageAfterDelay(chatId, sentMsg.MessageID, delayInSeconds)
+}
+
+// deleteMessageAfterDelay waits delayInSeconds and then removes the message. It
+// deliberately does not touch the conversation state: every caller that ends a
+// wizard step already clears the state synchronously, and clearing it here — up
+// to several seconds later — would wipe a state the user set for the next step
+// in the meantime, silently dropping their following input.
+func (t *Tgbot) deleteMessageAfterDelay(chatId int64, messageID, delayInSeconds int) {
+	time.Sleep(time.Duration(delayInSeconds) * time.Second)
+	t.deleteMessageTgBot(chatId, messageID)
 }
 
 // deleteMessageTgBot deletes a message from the chat.
 func (t *Tgbot) deleteMessageTgBot(chatId int64, messageID int) {
+	if bot == nil {
+		return
+	}
 	params := telego.DeleteMessageParams{
 		ChatID:    tu.ID(chatId),
 		MessageID: messageID,

+ 94 - 0
internal/web/service/traffic_commit_test.go

@@ -0,0 +1,94 @@
+package service
+
+import (
+	"errors"
+	"strings"
+	"testing"
+
+	"gorm.io/gorm"
+
+	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
+	"github.com/mhsanaei/3x-ui/v3/internal/xray"
+)
+
+func TestAddTrafficCommitsDespiteDisableHelperError(t *testing.T) {
+	db := initTrafficTestDB(t)
+	svc := &InboundService{}
+
+	normal := &model.Inbound{UserId: 1, Tag: "in-normal", Enable: true, Port: 43001, Protocol: model.VLESS, Settings: `{"clients":[]}`}
+	if err := db.Create(normal).Error; err != nil {
+		t.Fatalf("seed normal inbound: %v", err)
+	}
+	expired := &model.Inbound{UserId: 1, Tag: "in-expired", Enable: true, Port: 43002, Protocol: model.VLESS, ExpiryTime: 1, Settings: `{"clients":[]}`}
+	if err := db.Create(expired).Error; err != nil {
+		t.Fatalf("seed expired inbound: %v", err)
+	}
+
+	const cbName = "b2-03:fail-disable"
+	if err := db.Callback().Update().After("gorm:update").Register(cbName, func(tx *gorm.DB) {
+		if tx.Statement != nil && tx.Statement.Table == "inbounds" &&
+			strings.Contains(tx.Statement.SQL.String(), "expiry_time") {
+			tx.AddError(errors.New("injected disableInvalidInbounds failure"))
+		}
+	}); err != nil {
+		t.Fatalf("register callback: %v", err)
+	}
+	t.Cleanup(func() { _ = db.Callback().Update().Remove(cbName) })
+
+	if _, _, err := svc.AddTraffic([]*xray.Traffic{{IsInbound: true, Tag: "in-normal", Up: 500, Down: 700}}, nil); err != nil {
+		t.Fatalf("AddTraffic: %v", err)
+	}
+
+	var reloaded model.Inbound
+	if err := db.Where("tag = ?", "in-normal").First(&reloaded).Error; err != nil {
+		t.Fatalf("reload normal inbound: %v", err)
+	}
+	if reloaded.Up != 500 || reloaded.Down != 700 {
+		t.Fatalf("traffic tick was rolled back by a best-effort disable-helper error: up=%d down=%d, want 500/700", reloaded.Up, reloaded.Down)
+	}
+}
+
+func TestResetAllTrafficsReenablesDepletedClients(t *testing.T) {
+	db := initTrafficTestDB(t)
+	svc := &ClientService{}
+
+	if err := db.Create(&xray.ClientTraffic{InboundId: 1, Email: "spent@x", Enable: false, Up: 60, Down: 60, Total: 100}).Error; err != nil {
+		t.Fatalf("seed: %v", err)
+	}
+
+	if _, err := svc.ResetAllTraffics(); err != nil {
+		t.Fatalf("ResetAllTraffics: %v", err)
+	}
+
+	row := readTraffic(t, db, "spent@x")
+	if row.Up != 0 || row.Down != 0 {
+		t.Fatalf("usage not reset: up=%d down=%d", row.Up, row.Down)
+	}
+	if !row.Enable {
+		t.Fatal("a depleted client must be re-enabled after Reset All Client Traffic, matching every other reset path")
+	}
+}
+
+func TestResetAllTrafficsClearsNodeBaselines(t *testing.T) {
+	db := initTrafficTestDB(t)
+	svc := &ClientService{}
+
+	if err := db.Create(&xray.ClientTraffic{InboundId: 1, Email: "spent@x", Enable: true, Up: 60, Down: 60, Total: 100}).Error; err != nil {
+		t.Fatalf("seed traffic: %v", err)
+	}
+	if err := db.Create(&model.NodeClientTraffic{NodeId: 1, Email: "spent@x", Up: 60, Down: 60}).Error; err != nil {
+		t.Fatalf("seed node baseline: %v", err)
+	}
+
+	if _, err := svc.ResetAllTraffics(); err != nil {
+		t.Fatalf("ResetAllTraffics: %v", err)
+	}
+
+	var cnt int64
+	if err := db.Model(&model.NodeClientTraffic{}).Where("email = ?", "spent@x").Count(&cnt).Error; err != nil {
+		t.Fatalf("count baselines: %v", err)
+	}
+	if cnt != 0 {
+		t.Fatalf("Reset All Client Traffic must clear node baselines like its sibling reset paths, found %d", cnt)
+	}
+}

+ 17 - 0
internal/web/service/xray.go

@@ -986,6 +986,9 @@ func (s *XrayService) RestartXray(isForce bool) error {
 	lock.Lock()
 	defer lock.Unlock()
 	logger.Debug("restart Xray, force:", isForce)
+	if !isForce && isManuallyStopped.Load() {
+		return nil
+	}
 	isManuallyStopped.Store(false)
 
 	xrayConfig, err := s.GetXrayConfig()
@@ -1182,6 +1185,20 @@ func (s *XrayService) IsNeedRestartAndSetFalse() bool {
 	return isNeedXrayRestart.CompareAndSwap(true, false)
 }
 
+// ApplyPendingRestart consumes the need-restart flag and restarts Xray. If the
+// restart fails (for example GetXrayConfig hits a transient DB error and leaves
+// the old process running), it re-arms the flag so the next tick retries instead
+// of silently dropping the pending config change.
+func (s *XrayService) ApplyPendingRestart() {
+	if !s.IsNeedRestartAndSetFalse() {
+		return
+	}
+	if err := s.RestartXray(false); err != nil {
+		logger.Error("restart xray failed:", err)
+		s.SetToNeedRestart()
+	}
+}
+
 // DidXrayCrash checks if Xray crashed by verifying it's not running and wasn't manually stopped.
 func (s *XrayService) DidXrayCrash() bool {
 	return !s.IsXrayRunning() && !isManuallyStopped.Load()

+ 40 - 0
internal/web/service/xray_restart_test.go

@@ -0,0 +1,40 @@
+package service
+
+import (
+	"testing"
+)
+
+func TestRestartXrayRespectsManualStop(t *testing.T) {
+	setupSettingTestDB(t)
+	if err := (&SettingService{}).saveSetting("xrayTemplateConfig", "{ not valid json"); err != nil {
+		t.Fatalf("seed template: %v", err)
+	}
+	t.Cleanup(func() { isManuallyStopped.Store(false) })
+
+	isManuallyStopped.Store(true)
+	_ = (&XrayService{}).RestartXray(false)
+
+	if !isManuallyStopped.Load() {
+		t.Fatal("a non-forced restart cleared a deliberate manual stop and would revive xray")
+	}
+}
+
+func TestApplyPendingRestartReArmsFlagOnFailure(t *testing.T) {
+	setupSettingTestDB(t)
+	if err := (&SettingService{}).saveSetting("xrayTemplateConfig", "{ not valid json"); err != nil {
+		t.Fatalf("seed template: %v", err)
+	}
+	t.Cleanup(func() {
+		isManuallyStopped.Store(false)
+		isNeedXrayRestart.Store(false)
+	})
+	isManuallyStopped.Store(false)
+
+	svc := &XrayService{}
+	svc.SetToNeedRestart()
+	svc.ApplyPendingRestart()
+
+	if !isNeedXrayRestart.Load() {
+		t.Fatal("a failed restart must re-arm the need-restart flag so the pending config change is retried")
+	}
+}

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

@@ -5,8 +5,8 @@ import (
 )
 
 var routingMatcherKeys = []string{
-	"domain", "ip", "port", "sourcePort", "localPort", "network",
-	"sourceIP", "localIP", "user", "vlessRoute", "protocol", "attrs", "process",
+	"domain", "domains", "ip", "port", "sourcePort", "localPort", "network",
+	"source", "sourceIP", "localIP", "user", "vlessRoute", "protocol", "attrs", "process",
 }
 
 func readInboundTags(raw any) []string {

+ 33 - 0
internal/web/service/xray_setting_routing_sync_test.go

@@ -158,6 +158,39 @@ func TestRemoveInboundTagReferences_KeepsRuleWithOtherMatchers(t *testing.T) {
 	}
 }
 
+func TestRemoveInboundTagReferences_KeepsSourceScopedRule(t *testing.T) {
+	setupSettingTestDB(t)
+	seedXrayTemplate(t, `{
+		"routing": {
+			"rules": [
+				{
+					"type":"field",
+					"inboundTag":["in-443-tcp"],
+					"source":["10.0.0.0/8"],
+					"outboundTag":"blocked"
+				}
+			]
+		}
+	}`)
+
+	svc := &XraySettingService{}
+	if _, err := svc.RemoveInboundTagReferences("in-443-tcp"); err != nil {
+		t.Fatalf("RemoveInboundTagReferences: %v", err)
+	}
+
+	got, err := svc.GetXrayConfigTemplate()
+	if err != nil {
+		t.Fatalf("GetXrayConfigTemplate: %v", err)
+	}
+	rule := findRuleByOutbound(t, got, "blocked")
+	if _, ok := rule["inboundTag"]; ok {
+		t.Fatalf("inboundTag should be trimmed, rule = %#v", rule)
+	}
+	if src, _ := rule["source"].([]any); len(src) != 1 {
+		t.Fatalf("source-scoped rule was dropped instead of kept; rule = %#v", rule)
+	}
+}
+
 func TestRemoveInboundTagReferences_RemovesOneTagFromMultiInboundRule(t *testing.T) {
 	setupSettingTestDB(t)
 	seedXrayTemplate(t, `{

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

@@ -1410,6 +1410,7 @@
       "smtpTestSuccess": "تم إرسال البريد التجريبي بنجاح",
       "smtpHostNotConfigured": "خادم SMTP غير مهيأ",
       "smtpNoRecipients": "لا يوجد مستلمون مهيؤون",
+      "smtpFromNotConfigured": "عنوان مرسل SMTP غير مُهيأ",
       "eventLoginAttempt": "محاولة تسجيل دخول",
       "telegramTokenConfigured": "مهيأ؛ اتركه فارغاً للاحتفاظ بالتوكن الحالي.",
       "telegramTokenPlaceholder": "مهيأ — أدخل توكن جديد لاستبداله",

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

@@ -1529,6 +1529,7 @@
       "smtpTestSuccess": "Test email sent successfully",
       "smtpHostNotConfigured": "SMTP host not configured",
       "smtpNoRecipients": "No recipients configured",
+      "smtpFromNotConfigured": "SMTP sender address not configured",
       "eventLoginAttempt": "Login attempt",
       "telegramTokenConfigured": "Configured; leave blank to keep current token.",
       "telegramTokenPlaceholder": "Configured - enter a new token to replace",

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

@@ -1410,6 +1410,7 @@
       "smtpTestSuccess": "Correo de prueba enviado correctamente",
       "smtpHostNotConfigured": "Servidor SMTP no configurado",
       "smtpNoRecipients": "No hay destinatarios configurados",
+      "smtpFromNotConfigured": "La dirección del remitente SMTP no está configurada",
       "eventLoginAttempt": "Intento de inicio de sesión",
       "telegramTokenConfigured": "Configurado; deje en blanco para mantener el token actual.",
       "telegramTokenPlaceholder": "Configurado: introduzca un nuevo token para reemplazarlo",

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

@@ -1412,6 +1412,7 @@
       "smtpTestSuccess": "ایمیل آزمایشی با موفقیت ارسال شد",
       "smtpHostNotConfigured": "میزبان SMTP پیکربندی نشده است",
       "smtpNoRecipients": "هیچ گیرنده‌ای پیکربندی نشده است",
+      "smtpFromNotConfigured": "آدرس فرستنده SMTP پیکربندی نشده است",
       "eventLoginAttempt": "تلاش برای ورود",
       "telegramTokenConfigured": "پیکربندی شده؛ برای حفظ توکن فعلی خالی بگذارید.",
       "telegramTokenPlaceholder": "پیکربندی شده - برای جایگزینی، توکن جدید وارد کنید",

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

@@ -1410,6 +1410,7 @@
       "smtpTestSuccess": "Email uji berhasil dikirim",
       "smtpHostNotConfigured": "Host SMTP belum dikonfigurasi",
       "smtpNoRecipients": "Tidak ada penerima yang dikonfigurasi",
+      "smtpFromNotConfigured": "Alamat pengirim SMTP belum dikonfigurasi",
       "eventLoginAttempt": "Percobaan masuk",
       "telegramTokenConfigured": "Terkonfigurasi; kosongkan untuk mempertahankan token saat ini.",
       "telegramTokenPlaceholder": "Terkonfigurasi - masukkan token baru untuk mengganti",

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

@@ -1410,6 +1410,7 @@
       "smtpTestSuccess": "テストメールを正常に送信しました",
       "smtpHostNotConfigured": "SMTPホストが設定されていません",
       "smtpNoRecipients": "受信者が設定されていません",
+      "smtpFromNotConfigured": "SMTP送信者アドレスが設定されていません",
       "eventLoginAttempt": "ログイン試行",
       "telegramTokenConfigured": "設定済み。現在のトークンを維持する場合は空欄のままにしてください。",
       "telegramTokenPlaceholder": "設定済み - 置き換えるには新しいトークンを入力してください",

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

@@ -1410,6 +1410,7 @@
       "smtpTestSuccess": "E-mail de teste enviado com sucesso",
       "smtpHostNotConfigured": "Servidor SMTP não configurado",
       "smtpNoRecipients": "Nenhum destinatário configurado",
+      "smtpFromNotConfigured": "Endereço do remetente SMTP não configurado",
       "eventLoginAttempt": "Tentativa de login",
       "telegramTokenConfigured": "Configurado; deixe em branco para manter o token atual.",
       "telegramTokenPlaceholder": "Configurado - insira um novo token para substituir",

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

@@ -1410,6 +1410,7 @@
       "smtpTestSuccess": "Тестовое письмо отправлено успешно",
       "smtpHostNotConfigured": "SMTP хост не настроен",
       "smtpNoRecipients": "Получатели не настроены",
+      "smtpFromNotConfigured": "Адрес отправителя SMTP не настроен",
       "eventLoginAttempt": "Попытка входа",
       "telegramTokenConfigured": "Настроен; оставьте пустым для сохранения текущего токена.",
       "telegramTokenPlaceholder": "Настроен - введите новый токен для замены",

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

@@ -1410,6 +1410,7 @@
       "smtpTestSuccess": "Test e-postası başarıyla gönderildi",
       "smtpHostNotConfigured": "SMTP sunucusu yapılandırılmamış",
       "smtpNoRecipients": "Yapılandırılmış alıcı yok",
+      "smtpFromNotConfigured": "SMTP gönderen adresi yapılandırılmamış",
       "eventLoginAttempt": "Oturum açma denemesi",
       "telegramTokenConfigured": "Yapılandırıldı; mevcut belirteci korumak için boş bırakın.",
       "telegramTokenPlaceholder": "Yapılandırıldı - değiştirmek için yeni bir belirteç girin",

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

@@ -1410,6 +1410,7 @@
       "smtpTestSuccess": "Тестовий лист успішно надіслано",
       "smtpHostNotConfigured": "Хост SMTP не налаштовано",
       "smtpNoRecipients": "Отримувачів не налаштовано",
+      "smtpFromNotConfigured": "Адресу відправника SMTP не налаштовано",
       "eventLoginAttempt": "Спроба входу",
       "telegramTokenConfigured": "Налаштовано; залиште порожнім, щоб зберегти поточний токен.",
       "telegramTokenPlaceholder": "Налаштовано — введіть новий токен для заміни",

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

@@ -1410,6 +1410,7 @@
       "smtpTestSuccess": "Đã gửi email thử nghiệm thành công",
       "smtpHostNotConfigured": "Chưa cấu hình máy chủ SMTP",
       "smtpNoRecipients": "Chưa cấu hình người nhận",
+      "smtpFromNotConfigured": "Chưa cấu hình địa chỉ người gửi SMTP",
       "eventLoginAttempt": "Lần thử đăng nhập",
       "telegramTokenConfigured": "Đã cấu hình; để trống để giữ token hiện tại.",
       "telegramTokenPlaceholder": "Đã cấu hình - nhập token mới để thay thế",

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

@@ -1410,6 +1410,7 @@
       "smtpTestSuccess": "测试邮件发送成功",
       "smtpHostNotConfigured": "尚未配置 SMTP 主机",
       "smtpNoRecipients": "尚未配置收件人",
+      "smtpFromNotConfigured": "未配置 SMTP 发件人地址",
       "eventLoginAttempt": "登录尝试",
       "telegramTokenConfigured": "已配置;留空则保留当前令牌。",
       "telegramTokenPlaceholder": "已配置——输入新令牌以替换",

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

@@ -1410,6 +1410,7 @@
       "smtpTestSuccess": "測試郵件已成功傳送",
       "smtpHostNotConfigured": "尚未設定 SMTP 主機",
       "smtpNoRecipients": "尚未設定收件人",
+      "smtpFromNotConfigured": "未設定 SMTP 寄件人地址",
       "eventLoginAttempt": "登入嘗試",
       "telegramTokenConfigured": "已設定;留空以保留目前的權杖。",
       "telegramTokenPlaceholder": "已設定 - 輸入新權杖以取代",

+ 1 - 6
internal/web/web.go

@@ -314,12 +314,7 @@ func (s *Server) startTask(restartXray bool) {
 
 	// Check if xray needs to be restarted every 30 seconds
 	_, _ = s.cron.AddFunc(cadenceXrayRestart, func() {
-		if s.xrayService.IsNeedRestartAndSetFalse() {
-			err := s.xrayService.RestartXray(false)
-			if err != nil {
-				logger.Error("restart xray failed:", err)
-			}
-		}
+		s.xrayService.ApplyPendingRestart()
 	})
 
 	go func() {

+ 6 - 1
internal/xray/api.go

@@ -637,6 +637,11 @@ func (x *XrayAPI) AddUser(Protocol string, inboundTag string, user map[string]an
 
 // RemoveUser removes a user from an inbound in the Xray core by email.
 func (x *XrayAPI) RemoveUser(inboundTag, email string) error {
+	if x.HandlerServiceClient == nil {
+		return common.NewError("xray HandlerServiceClient is not initialized")
+	}
+	client := *x.HandlerServiceClient
+
 	ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
 	defer cancel()
 
@@ -646,7 +651,7 @@ func (x *XrayAPI) RemoveUser(inboundTag, email string) error {
 		Operation: serial.ToTypedMessage(op),
 	}
 
-	_, err := (*x.HandlerServiceClient).AlterInbound(ctx, req)
+	_, err := client.AlterInbound(ctx, req)
 	if err != nil {
 		return fmt.Errorf("failed to remove user: %w", err)
 	}

이 변경점에서 너무 많은 파일들이 변경되어 몇몇 파일들은 표시되지 않았습니다.