10 Commits dabd3f5d2b ... b07fad0e69

Author SHA1 Message Date
  Rouzbeh† b07fad0e69 refactor(wireguard): drop removed workers field (xray v26.6.22) (#5509) 12 hours ago
  MHSanaei fd092444a8 Bump frontend package & deps to new patch versions 12 hours ago
  Rouzbeh† a0f4c13dc5 fix(sockopt): honor trustedXForwardedFor on gRPC inbounds (xray v26.6.22) (#5503) 13 hours ago
  MHSanaei 1c0b76c27a Use efficient APIs and simplify loops 14 hours ago
  MHSanaei 852b53db79 feat(xray): add loopback sniffing and per-segment fragment masks 15 hours ago
  MHSanaei 42cd351e4e refactor(job): drop access log from IP limiting, wipe it daily instead 16 hours ago
  MHSanaei a2961fd046 Update Xray to v26.6.22 17 hours ago
  n0ctal 523a593ca7 fix(xray): write generated config atomically (#5494) 17 hours ago
  n0ctal ecb0b0a9fa fix(subscription): bound outbound response body (#5493) 17 hours ago
  n0ctal 67344cae6f fix(sub): error instead of silently truncating oversized subscription (#5495) 17 hours ago
61 changed files with 1045 additions and 822 deletions
  1. 2 2
      .github/workflows/release.yml
  2. 1 1
      DockerInit.sh
  3. 208 208
      frontend/package-lock.json
  4. 11 11
      frontend/package.json
  5. 63 0
      frontend/src/lib/xray/forms/SniffingFields.tsx
  6. 121 14
      frontend/src/lib/xray/forms/transport/FinalMaskForm.tsx
  7. 0 1
      frontend/src/lib/xray/outbound-defaults.ts
  8. 29 15
      frontend/src/lib/xray/outbound-form-adapter.ts
  9. 1 2
      frontend/src/pages/inbounds/form/InboundFormModal.tsx
  10. 9 60
      frontend/src/pages/inbounds/form/SniffingTab.tsx
  11. 0 3
      frontend/src/pages/inbounds/form/protocols/wireguard.tsx
  12. 6 4
      frontend/src/pages/inbounds/form/transport/sockopt.tsx
  13. 6 65
      frontend/src/pages/xray/outbounds/OutboundFormModal.tsx
  14. 15 3
      frontend/src/pages/xray/outbounds/protocols/loopback.tsx
  15. 0 3
      frontend/src/pages/xray/outbounds/protocols/wireguard.tsx
  16. 0 20
      frontend/src/schemas/forms/inbound-form.ts
  17. 27 56
      frontend/src/schemas/forms/outbound-form.ts
  18. 0 6
      frontend/src/schemas/protocols/inbound/vless.ts
  19. 0 1
      frontend/src/schemas/protocols/inbound/wireguard.ts
  20. 0 4
      frontend/src/schemas/protocols/outbound/index.ts
  21. 3 2
      frontend/src/schemas/protocols/outbound/loopback.ts
  22. 0 3
      frontend/src/schemas/protocols/outbound/trojan.ts
  23. 0 3
      frontend/src/schemas/protocols/outbound/vmess.ts
  24. 0 6
      frontend/src/schemas/protocols/outbound/wireguard.ts
  25. 23 0
      frontend/src/test/__snapshots__/finalmask.test.ts.snap
  26. 13 0
      frontend/src/test/golden/fixtures/finalmask/tcp-fragment-segments.json
  27. 0 1
      frontend/src/test/outbound-defaults.test.ts
  28. 33 1
      frontend/src/test/outbound-form-adapter.test.ts
  29. 2 2
      go.mod
  30. 4 4
      go.sum
  31. 29 29
      internal/sub/clash_service_test.go
  32. 8 2
      internal/sub/external_subscription.go
  33. 43 0
      internal/sub/external_subscription_test.go
  34. 1 1
      internal/sub/service.go
  35. 20 20
      internal/web/entity/entity.go
  36. 48 143
      internal/web/job/check_client_ip_job.go
  37. 79 67
      internal/web/job/check_client_ip_job_integration_test.go
  38. 18 2
      internal/web/job/clear_logs_job.go
  39. 55 0
      internal/web/job/clear_logs_job_test.go
  40. 1 4
      internal/web/job/node_traffic_sync_job.go
  41. 20 1
      internal/web/service/outbound_subscription.go
  42. 26 0
      internal/web/service/outbound_subscription_test.go
  43. 1 1
      internal/web/service/server.go
  44. 1 2
      internal/web/translation/ar-EG.json
  45. 1 2
      internal/web/translation/en-US.json
  46. 1 2
      internal/web/translation/es-ES.json
  47. 1 2
      internal/web/translation/fa-IR.json
  48. 1 2
      internal/web/translation/id-ID.json
  49. 1 2
      internal/web/translation/ja-JP.json
  50. 1 2
      internal/web/translation/pt-BR.json
  51. 1 2
      internal/web/translation/ru-RU.json
  52. 1 2
      internal/web/translation/tr-TR.json
  53. 1 2
      internal/web/translation/uk-UA.json
  54. 1 2
      internal/web/translation/vi-VN.json
  55. 1 2
      internal/web/translation/zh-CN.json
  56. 1 2
      internal/web/translation/zh-TW.json
  57. 1 1
      internal/web/web.go
  58. 4 4
      internal/xray/log_writer_race_test.go
  59. 49 11
      internal/xray/process.go
  60. 5 9
      internal/xray/process_race_test.go
  61. 47 0
      internal/xray/process_test.go

+ 2 - 2
.github/workflows/release.yml

@@ -112,7 +112,7 @@ jobs:
           cd x-ui/bin
 
           # Download dependencies
-          Xray_URL="https://github.com/XTLS/Xray-core/releases/download/v26.6.1/"
+          Xray_URL="https://github.com/XTLS/Xray-core/releases/download/v26.6.22/"
           if [ "${{ matrix.platform }}" == "amd64" ]; then
             wget -q ${Xray_URL}Xray-linux-64.zip
             unzip Xray-linux-64.zip
@@ -256,7 +256,7 @@ jobs:
           cd x-ui\bin
 
           # Download Xray for Windows
-          $Xray_URL = "https://github.com/XTLS/Xray-core/releases/download/v26.6.1/"
+          $Xray_URL = "https://github.com/XTLS/Xray-core/releases/download/v26.6.22/"
           Invoke-WebRequest -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"

+ 1 - 1
DockerInit.sh

@@ -34,7 +34,7 @@ esac
 MTG_VER="2.2.8"
 mkdir -p build/bin
 cd build/bin
-curl -sfLRO "https://github.com/XTLS/Xray-core/releases/download/v26.6.1/Xray-linux-${ARCH}.zip"
+curl -sfLRO "https://github.com/XTLS/Xray-core/releases/download/v26.6.22/Xray-linux-${ARCH}.zip"
 unzip "Xray-linux-${ARCH}.zip"
 rm -f "Xray-linux-${ARCH}.zip" geoip.dat geosite.dat
 mv xray "xray-linux-${FNAME}"

+ 208 - 208
frontend/package-lock.json

@@ -1,20 +1,20 @@
 {
   "name": "3x-ui-frontend",
-  "version": "0.3.1",
+  "version": "0.4.0",
   "lockfileVersion": 3,
   "requires": true,
   "packages": {
     "": {
       "name": "3x-ui-frontend",
-      "version": "0.3.1",
+      "version": "0.4.0",
       "dependencies": {
         "@ant-design/icons": "^6.2.5",
         "@codemirror/lang-json": "^6.0.2",
         "@codemirror/theme-one-dark": "^6.1.3",
-        "@tanstack/react-query": "^5.101.0",
-        "@tanstack/react-query-devtools": "^5.101.0",
-        "antd": "^6.4.4",
-        "axios": "^1.18.0",
+        "@tanstack/react-query": "^5.101.1",
+        "@tanstack/react-query-devtools": "^5.101.1",
+        "antd": "^6.4.5",
+        "axios": "^1.18.1",
         "codemirror": "^6.0.2",
         "dayjs": "^1.11.21",
         "i18next": "^26.3.1",
@@ -25,8 +25,8 @@
         "react-dom": "^19.2.7",
         "react-i18next": "^17.0.8",
         "react-router-dom": "^7.18.0",
-        "recharts": "^3.8.1",
-        "swagger-ui-react": "^5.32.6",
+        "recharts": "^3.9.0",
+        "swagger-ui-react": "^5.32.8",
         "zod": "^4.4.3"
       },
       "devDependencies": {
@@ -36,15 +36,15 @@
         "@types/react": "^19.2.17",
         "@types/react-dom": "^19.2.3",
         "@types/swagger-ui-react": "^5.18.0",
-        "@vitejs/plugin-react": "^6.0.2",
+        "@vitejs/plugin-react": "^6.0.3",
         "@vitest/coverage-v8": "^4.1.9",
         "eslint": "^10.5.0",
         "eslint-plugin-react-hooks": "^7.1.1",
-        "globals": "^17.6.0",
+        "globals": "^17.7.0",
         "jsdom": "^29.1.1",
         "typescript": "^6.0.3",
-        "typescript-eslint": "^8.61.1",
-        "vite": "8.0.16",
+        "typescript-eslint": "^8.62.0",
+        "vite": "8.1.0",
         "vitest": "^4.1.9"
       },
       "engines": {
@@ -724,21 +724,21 @@
       }
     },
     "node_modules/@emnapi/core": {
-      "version": "1.10.0",
-      "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz",
-      "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==",
+      "version": "1.11.1",
+      "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz",
+      "integrity": "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==",
       "dev": true,
       "license": "MIT",
       "optional": true,
       "dependencies": {
-        "@emnapi/wasi-threads": "1.2.1",
+        "@emnapi/wasi-threads": "1.2.2",
         "tslib": "^2.4.0"
       }
     },
     "node_modules/@emnapi/runtime": {
-      "version": "1.10.0",
-      "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz",
-      "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==",
+      "version": "1.11.1",
+      "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz",
+      "integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==",
       "dev": true,
       "license": "MIT",
       "optional": true,
@@ -747,9 +747,9 @@
       }
     },
     "node_modules/@emnapi/wasi-threads": {
-      "version": "1.2.1",
-      "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz",
-      "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==",
+      "version": "1.2.2",
+      "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz",
+      "integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==",
       "dev": true,
       "license": "MIT",
       "optional": true,
@@ -1104,9 +1104,9 @@
       }
     },
     "node_modules/@oxc-project/types": {
-      "version": "0.133.0",
-      "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.133.0.tgz",
-      "integrity": "sha512-KzkdCd6Uxqnf6l3HOw1xfatAlUURA0g14cvBYFyJ5SaNOQbOUvBr9PKArcPcrNIeRsBdgcUzOGrhKveVpvOIGA==",
+      "version": "0.137.0",
+      "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.137.0.tgz",
+      "integrity": "sha512-WT+Gb24i8hmvo85AIv2oEYouEXkRlKAlT9WaCa3TfLgNCN+GhrJOGZuIlMouAh38Qe4QOx26eUOVsq70qXrywA==",
       "dev": true,
       "license": "MIT",
       "funding": {
@@ -1247,9 +1247,9 @@
       }
     },
     "node_modules/@rc-component/form": {
-      "version": "1.8.4",
-      "resolved": "https://registry.npmjs.org/@rc-component/form/-/form-1.8.4.tgz",
-      "integrity": "sha512-I2FHNMWoiGQNjC+hQFhAj/rQeScAIBc+AkZqvu4Zyaxe4I3WOVpQte2E5lyZhruswyT8aULYHu1clPaPwE9L2A==",
+      "version": "1.8.5",
+      "resolved": "https://registry.npmjs.org/@rc-component/form/-/form-1.8.5.tgz",
+      "integrity": "sha512-d24EYtvUOBhxEtSd/EqIu9DaMuqrWF2IRIvAFCTM6NQ/GJIYNr8DvEpUSUlv2uPxEJ0ZPwYQ+wwlGIAaiHvdrw==",
       "license": "MIT",
       "dependencies": {
         "@rc-component/async-validator": "^6.0.0",
@@ -1853,9 +1853,9 @@
       }
     },
     "node_modules/@rolldown/binding-android-arm64": {
-      "version": "1.0.3",
-      "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.3.tgz",
-      "integrity": "sha512-454rs7jHngixp/NMxd5srYD57OnzSlZ/eFTETjORQHLwJG1lRtmNOJcBerZlfu4GjKqeq8aCCIQrMdHyhI51Hw==",
+      "version": "1.1.2",
+      "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.1.2.tgz",
+      "integrity": "sha512-2cZ+7xRS+DBcuJBJKnfzsbleumJhBqSlJVpuzHC0nTqfd3QQ7Vx2/x5YR/D7cBamKSeWplwo82Fn9lqYUDEMfA==",
       "cpu": [
         "arm64"
       ],
@@ -1870,9 +1870,9 @@
       }
     },
     "node_modules/@rolldown/binding-darwin-arm64": {
-      "version": "1.0.3",
-      "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.3.tgz",
-      "integrity": "sha512-PcAhP+ynjURNyy8SKGl5DQP94aGuB/7JrXJb/t7P+hanXvQVMWzUvRRhBAcg/lNRadBhoUPqSoP4xw5tR/KBEA==",
+      "version": "1.1.2",
+      "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.1.2.tgz",
+      "integrity": "sha512-RkPMJnygxsgOYdkfqgpwY0/Fzm8d0VQe6HGU2/B00Xa9eqdLbrII+DOKAodbJAn3ZL1AJxGHkZRPYazgGY6Ljw==",
       "cpu": [
         "arm64"
       ],
@@ -1887,9 +1887,9 @@
       }
     },
     "node_modules/@rolldown/binding-darwin-x64": {
-      "version": "1.0.3",
-      "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.3.tgz",
-      "integrity": "sha512-9YpfeUvSE2RS7wysJ81uOZkXJz7f7Q55H2Gvp3VEw/EsahqDtrphrZ0EwDLK5vvKOzaCrBsjF8JmnMLcUt78Gg==",
+      "version": "1.1.2",
+      "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.1.2.tgz",
+      "integrity": "sha512-Uiczh6vFhwyfd7WNe7Q7mCA4KxAiLdz7jPE/WGizfRpIieoyFuNVMmM8HqZ9HwudTkY6/AeMQwlNJ9NJijguWw==",
       "cpu": [
         "x64"
       ],
@@ -1904,9 +1904,9 @@
       }
     },
     "node_modules/@rolldown/binding-freebsd-x64": {
-      "version": "1.0.3",
-      "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.3.tgz",
-      "integrity": "sha512-yB1IlAsSNHncV6SCTL27/MVGR5htvQsoGxIv5KMGXALp+Ll1wYsn+x98M9MW7qa+NdSbvrrY7ANI4wLJ0n1e6g==",
+      "version": "1.1.2",
+      "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.1.2.tgz",
+      "integrity": "sha512-+TpdtTRgHiJFjCVFbw311SuLk3KfytPOQQn+VlAEv+gBxYPtL7E6JS9e/tk+8CwxhIZvemJKo4rTKgfWNsKkkA==",
       "cpu": [
         "x64"
       ],
@@ -1921,9 +1921,9 @@
       }
     },
     "node_modules/@rolldown/binding-linux-arm-gnueabihf": {
-      "version": "1.0.3",
-      "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.3.tgz",
-      "integrity": "sha512-Yi30IVAAfLUCy2MseFjbB1jAMDl1VMCAas5StnYp8da9+CKvMd2H2cbEjWcw5NPaPqzvYkVIaF1nNUG+b7u/sw==",
+      "version": "1.1.2",
+      "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.1.2.tgz",
+      "integrity": "sha512-4lv1/tkmi7ueIVHnyreaOeUpiZP26BH9rRy6hoYfR9310A2B9nUEVRDvBx69vx64Nr3eTPPRkyciqJJs+j9Jmw==",
       "cpu": [
         "arm"
       ],
@@ -1938,9 +1938,9 @@
       }
     },
     "node_modules/@rolldown/binding-linux-arm64-gnu": {
-      "version": "1.0.3",
-      "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.3.tgz",
-      "integrity": "sha512-jsO7R8To+AdlYgUmN5sHSCZbfhtMBkO0WUx8iORQnPcMMdgr7qM2DQmMwgabs3GhNztdmoKkMKQFHD6DTMCIQw==",
+      "version": "1.1.2",
+      "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.1.2.tgz",
+      "integrity": "sha512-gBSUVO0eaWgw1JMjK3gB8BMlX2Mk148s2lTiVT3e9vjVxbl7UDfMWWY8CfIaaqiXuM9fVTMxIpUz6CAo/B6Vlw==",
       "cpu": [
         "arm64"
       ],
@@ -1958,9 +1958,9 @@
       }
     },
     "node_modules/@rolldown/binding-linux-arm64-musl": {
-      "version": "1.0.3",
-      "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.3.tgz",
-      "integrity": "sha512-VWkUHwWriDciit80wleYwKILoR/KMvxh/IdwS/paX+ZgpuRpCrKLUdadJbc0NpBEiyhpYawsJ73j9aCvOH+f7Q==",
+      "version": "1.1.2",
+      "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.1.2.tgz",
+      "integrity": "sha512-LjQP/iZLBu8o8PjIfk4x3At0/mT6h282pvz8Z5LAyhGbu/kDezyO7ea62rF5uoqmgnIYqbN/MqJ3Si3Aymi7xQ==",
       "cpu": [
         "arm64"
       ],
@@ -1978,9 +1978,9 @@
       }
     },
     "node_modules/@rolldown/binding-linux-ppc64-gnu": {
-      "version": "1.0.3",
-      "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.3.tgz",
-      "integrity": "sha512-5f1laC0SlIR0yDbFCd8acUhvJIag6N3zC5P7oUPN6wX0aOma+uKJ0wBDH5aq7I1PVI2ttTlhJwzwRIBnLiSGEg==",
+      "version": "1.1.2",
+      "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.1.2.tgz",
+      "integrity": "sha512-X/7bVLWelEsbyWDUSXt7zVsTniLLPIY2n1rH58qr78l9i7MNbbxBWD8gI2vRfBWf4NUXJCUuQnfZDsp32LqsfQ==",
       "cpu": [
         "ppc64"
       ],
@@ -1998,9 +1998,9 @@
       }
     },
     "node_modules/@rolldown/binding-linux-s390x-gnu": {
-      "version": "1.0.3",
-      "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.3.tgz",
-      "integrity": "sha512-Iq4ko0r4XsgbrF/LunNgHtAGLRRVE2kXonAXQ/MV0mC6jQpMOhW1SvtZja2EhC/kd05++bP78dsqBeIQyYJ6Yg==",
+      "version": "1.1.2",
+      "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.1.2.tgz",
+      "integrity": "sha512-gb6dYKW/1KDorGXyy48glEBJs/sxVSC5pcVrox/pFGV4mvwSFeg2sK5L2tRkVsVlh7kueqOgg4GEcuipJcGuKg==",
       "cpu": [
         "s390x"
       ],
@@ -2018,9 +2018,9 @@
       }
     },
     "node_modules/@rolldown/binding-linux-x64-gnu": {
-      "version": "1.0.3",
-      "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.3.tgz",
-      "integrity": "sha512-B8m6tD5+/N5FeNQFbKlLA/2yVq9ycQP1SeedyEYYKWBNR3ZQbkvIUcNnDNM03lO1l5F2roiiFJGgvoLLyZXtSg==",
+      "version": "1.1.2",
+      "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.1.2.tgz",
+      "integrity": "sha512-JY4w85pU3iAiJVMh5nuk4/Mh9GjMsupe8MrIN53rwxAZW64GKrWeJBuN6SxQg9QTU5uB1cxyhDzW8jqRn1EABw==",
       "cpu": [
         "x64"
       ],
@@ -2038,9 +2038,9 @@
       }
     },
     "node_modules/@rolldown/binding-linux-x64-musl": {
-      "version": "1.0.3",
-      "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.3.tgz",
-      "integrity": "sha512-pSdpdUJHkuCxun9LE7jvgUB9qsRgaiyNNCX7m/AvHTcq67AiT/Yhoxvw5zPfhrM8k/BfP8ce/hMOpthKDpEUow==",
+      "version": "1.1.2",
+      "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.1.2.tgz",
+      "integrity": "sha512-xvpA7o5KCYLB0Rwscmuylb1/zHHSUx4g4xilm4prC5jP76pEUlzBmMbgpbh7bVDbId4NcfT96gN5i6mE6UDaiw==",
       "cpu": [
         "x64"
       ],
@@ -2058,9 +2058,9 @@
       }
     },
     "node_modules/@rolldown/binding-openharmony-arm64": {
-      "version": "1.0.3",
-      "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.3.tgz",
-      "integrity": "sha512-OXXS3RKJgX2uLwM+gYyuH5omcH8fL1LJs96pZGgtetVCahON57+d4SJHzTgZiOjxgGkSnpXpOsWuPDGAKAigEg==",
+      "version": "1.1.2",
+      "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.1.2.tgz",
+      "integrity": "sha512-p/ts6KBLjuk49Bp21XH77poQGt02iNz7ChgHep7tudPOaLinR/De/RHdxF8w8Yj4r/bF/bqXwH6PZrB2sA+Nvw==",
       "cpu": [
         "arm64"
       ],
@@ -2075,9 +2075,9 @@
       }
     },
     "node_modules/@rolldown/binding-wasm32-wasi": {
-      "version": "1.0.3",
-      "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.3.tgz",
-      "integrity": "sha512-JTtb8BWFynicNSoPrehsCzBtOKjZ6jhMiPFEmOiuXg1Fl8dn2KHQob+GuPSGR0dryQa1PQJbzjF3dqO/whhjLg==",
+      "version": "1.1.2",
+      "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.1.2.tgz",
+      "integrity": "sha512-VMu/wmrZ9hJzYlRhbw7jK5PODlugyKZ5mOdX78+lS8OvuFkWNQdz1pFLrI2p3P0pjXOmUZ7B48o5VnMH9QOGtg==",
       "cpu": [
         "wasm32"
       ],
@@ -2085,18 +2085,18 @@
       "license": "MIT",
       "optional": true,
       "dependencies": {
-        "@emnapi/core": "1.10.0",
-        "@emnapi/runtime": "1.10.0",
-        "@napi-rs/wasm-runtime": "^1.1.4"
+        "@emnapi/core": "1.11.1",
+        "@emnapi/runtime": "1.11.1",
+        "@napi-rs/wasm-runtime": "^1.1.5"
       },
       "engines": {
         "node": "^20.19.0 || >=22.12.0"
       }
     },
     "node_modules/@rolldown/binding-win32-arm64-msvc": {
-      "version": "1.0.3",
-      "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.3.tgz",
-      "integrity": "sha512-gEdFFEN70A/jxb2svrWsN3aDL7OUtmvlOy+6fa2jxG8K0wQ1ZbdeLGnidov6Yu5/733dI5ySfzFlQ/cb0bSz1g==",
+      "version": "1.1.2",
+      "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.1.2.tgz",
+      "integrity": "sha512-xtUJqs8qEkuSviS0n1tsohaPuz3a1SPhZywOji4Oo+sgrJs8daEDMZ0QtqL0OS7dx8PoVpg2J/ZZycPY5I2+Zg==",
       "cpu": [
         "arm64"
       ],
@@ -2111,9 +2111,9 @@
       }
     },
     "node_modules/@rolldown/binding-win32-x64-msvc": {
-      "version": "1.0.3",
-      "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.3.tgz",
-      "integrity": "sha512-eXB7CHuaQdqmJcc3koCNtNPmT/bj2gc999kUFgBxG8Ac0NdgXc4rkCHhqrgrhN3zddvvvrgzj1e90SuSfmyIXA==",
+      "version": "1.1.2",
+      "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.1.2.tgz",
+      "integrity": "sha512-85YiLQqjUKgSO/Zjnf9e0XIn5Ymrh1fLDWBeAkZqpuBR/3R8TpfoHXuyblqyQrftSSgWO9qpcHN8mkyKsLraoA==",
       "cpu": [
         "x64"
       ],
@@ -2813,9 +2813,9 @@
       }
     },
     "node_modules/@tanstack/query-core": {
-      "version": "5.101.0",
-      "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.101.0.tgz",
-      "integrity": "sha512-cQetA74EB+seWySv1TTKr828TnP0u39m6LykwDXIo84SNortpDkp30TMEjkqtYCNP9c40uT/iwl6MLiufEt0Ow==",
+      "version": "5.101.1",
+      "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.101.1.tgz",
+      "integrity": "sha512-Y6Y92dkXtNqx67m2pMSxUsA3zOCwv862JexZRP8/EPwvKXMPu9m8rv43spiXWzOUIggQ3SQApttALStzhA8B4g==",
       "license": "MIT",
       "funding": {
         "type": "github",
@@ -2823,9 +2823,9 @@
       }
     },
     "node_modules/@tanstack/query-devtools": {
-      "version": "5.101.0",
-      "resolved": "https://registry.npmjs.org/@tanstack/query-devtools/-/query-devtools-5.101.0.tgz",
-      "integrity": "sha512-MVqw17k08RQtGGLEL654+dX/btbX9p/8WjkznO//zusLTMaObxi3Q+MoFwGVkC9K3tqjn8qrrNhJevXx4fJTeQ==",
+      "version": "5.101.1",
+      "resolved": "https://registry.npmjs.org/@tanstack/query-devtools/-/query-devtools-5.101.1.tgz",
+      "integrity": "sha512-37RQ9U2PxlXQiv1era2t+uHgVhmiyvxqTMu30+KoVf0rufiucu6rpGRKFJk61Wh5OAZFKqCQd6lxTzFWfLZiuQ==",
       "license": "MIT",
       "funding": {
         "type": "github",
@@ -2833,12 +2833,12 @@
       }
     },
     "node_modules/@tanstack/react-query": {
-      "version": "5.101.0",
-      "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.101.0.tgz",
-      "integrity": "sha512-rLlJXSpkqfizLWgkR5+eLeIk0MvTx/meEIR7LRjxic+qxiQP8zVjq7BqQkiCMNLQBlLfuOLqqr6KO5GtrDlmSg==",
+      "version": "5.101.1",
+      "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.101.1.tgz",
+      "integrity": "sha512-ZnONUuQKJe1bJMStXUL1s5uKN9FcfC28j5cK+iDZcdSHtUv1wtin1cGc/Oewhf2Oc4eKY7lggtpvT/AbMmhHew==",
       "license": "MIT",
       "dependencies": {
-        "@tanstack/query-core": "5.101.0"
+        "@tanstack/query-core": "5.101.1"
       },
       "funding": {
         "type": "github",
@@ -2849,19 +2849,19 @@
       }
     },
     "node_modules/@tanstack/react-query-devtools": {
-      "version": "5.101.0",
-      "resolved": "https://registry.npmjs.org/@tanstack/react-query-devtools/-/react-query-devtools-5.101.0.tgz",
-      "integrity": "sha512-cpZA0+WqKXwrwMfiWZEGGF6QrIWVQFbhBtxqDF5sQsAfrFf47HIE6fiPbQU3wyAUEN2+7UNqLCQe7oG6m3f93w==",
+      "version": "5.101.1",
+      "resolved": "https://registry.npmjs.org/@tanstack/react-query-devtools/-/react-query-devtools-5.101.1.tgz",
+      "integrity": "sha512-OXFR9XKdEslraq3cpl3kCUeNvTIq/xGWEZiFZdn2bLB/q4WxSALMEDKYZ5yYjMQytsfnQxwQYqV4qtVEf0nuog==",
       "license": "MIT",
       "dependencies": {
-        "@tanstack/query-devtools": "5.101.0"
+        "@tanstack/query-devtools": "5.101.1"
       },
       "funding": {
         "type": "github",
         "url": "https://github.com/sponsors/tannerlinsley"
       },
       "peerDependencies": {
-        "@tanstack/react-query": "^5.101.0",
+        "@tanstack/react-query": "^5.101.1",
         "react": "^18 || ^19"
       }
     },
@@ -2914,9 +2914,9 @@
       }
     },
     "node_modules/@tybys/wasm-util": {
-      "version": "0.10.2",
-      "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz",
-      "integrity": "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==",
+      "version": "0.10.3",
+      "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz",
+      "integrity": "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==",
       "dev": true,
       "license": "MIT",
       "optional": true,
@@ -3107,17 +3107,17 @@
       "license": "MIT"
     },
     "node_modules/@typescript-eslint/eslint-plugin": {
-      "version": "8.61.1",
-      "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.61.1.tgz",
-      "integrity": "sha512-ZPlVl3PB3et/59Ne0fv/sci6ZXz4T4Hp4nTJ56i/Y0gR89ARb+KphojTq6j+56E5PIezmOIOOWyY+aWQFd+IkQ==",
+      "version": "8.62.0",
+      "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.62.0.tgz",
+      "integrity": "sha512-o+mpz7EYiMzXoySXiKmzlabIvTVqUuK5yLrAedRPRDA0IpPFMUV1IXt6OqljIxX/kumN6EjUYp41Hqelh6p/Dw==",
       "dev": true,
       "license": "MIT",
       "dependencies": {
         "@eslint-community/regexpp": "^4.12.2",
-        "@typescript-eslint/scope-manager": "8.61.1",
-        "@typescript-eslint/type-utils": "8.61.1",
-        "@typescript-eslint/utils": "8.61.1",
-        "@typescript-eslint/visitor-keys": "8.61.1",
+        "@typescript-eslint/scope-manager": "8.62.0",
+        "@typescript-eslint/type-utils": "8.62.0",
+        "@typescript-eslint/utils": "8.62.0",
+        "@typescript-eslint/visitor-keys": "8.62.0",
         "ignore": "^7.0.5",
         "natural-compare": "^1.4.0",
         "ts-api-utils": "^2.5.0"
@@ -3130,7 +3130,7 @@
         "url": "https://opencollective.com/typescript-eslint"
       },
       "peerDependencies": {
-        "@typescript-eslint/parser": "^8.61.1",
+        "@typescript-eslint/parser": "^8.62.0",
         "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
         "typescript": ">=4.8.4 <6.1.0"
       }
@@ -3146,16 +3146,16 @@
       }
     },
     "node_modules/@typescript-eslint/parser": {
-      "version": "8.61.1",
-      "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.61.1.tgz",
-      "integrity": "sha512-PJ5vePq5/ognBbrIcoC5+SHO5dfpeLPzP9FpLkzWrguoYQEeeSjlJpVwOpo1JRSTEi7dRcwNy4h4dzV70PqHcg==",
+      "version": "8.62.0",
+      "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.62.0.tgz",
+      "integrity": "sha512-dzHeT2gySzZtLDsuqxU9AkYgIsQoHAHtRBpOqM+Ofzx1Bwrd2RcCjQJ+6iQbsHOIR6NS33bF2W1k3blN1zLDrA==",
       "dev": true,
       "license": "MIT",
       "dependencies": {
-        "@typescript-eslint/scope-manager": "8.61.1",
-        "@typescript-eslint/types": "8.61.1",
-        "@typescript-eslint/typescript-estree": "8.61.1",
-        "@typescript-eslint/visitor-keys": "8.61.1",
+        "@typescript-eslint/scope-manager": "8.62.0",
+        "@typescript-eslint/types": "8.62.0",
+        "@typescript-eslint/typescript-estree": "8.62.0",
+        "@typescript-eslint/visitor-keys": "8.62.0",
         "debug": "^4.4.3"
       },
       "engines": {
@@ -3171,14 +3171,14 @@
       }
     },
     "node_modules/@typescript-eslint/project-service": {
-      "version": "8.61.1",
-      "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.61.1.tgz",
-      "integrity": "sha512-PrC4JYGmR241lYnfhmKGTXkFqv8+ymbTFgSAY0fVXpY82/QkMw5TZPl+vGzuDDU2QYJk9fIDOBTntF+yDv9LEA==",
+      "version": "8.62.0",
+      "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.62.0.tgz",
+      "integrity": "sha512-wexnCqiTg7BOGtbLDftYpRWlmLq4xfoMd7BKFR6Y75sZS3QmRKLdN3yWLhmIYgqMmP/OXWpj3H8odkb5nGURCQ==",
       "dev": true,
       "license": "MIT",
       "dependencies": {
-        "@typescript-eslint/tsconfig-utils": "^8.61.1",
-        "@typescript-eslint/types": "^8.61.1",
+        "@typescript-eslint/tsconfig-utils": "^8.62.0",
+        "@typescript-eslint/types": "^8.62.0",
         "debug": "^4.4.3"
       },
       "engines": {
@@ -3193,14 +3193,14 @@
       }
     },
     "node_modules/@typescript-eslint/scope-manager": {
-      "version": "8.61.1",
-      "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.61.1.tgz",
-      "integrity": "sha512-L2bdIeoQS8FlKAvONAr20w6OcLXeB+qiDKbAooS9A0Ben+iSIkBef0FxqwKWYqt5sa0i4KJtxVyVmhMylKzF5w==",
+      "version": "8.62.0",
+      "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.62.0.tgz",
+      "integrity": "sha512-1lX38kNxXIRb8mEc3lbq5mdHq1Pf2+U0nFU65KfT18mtPxxl0fvjuEE92mHuXPuCtElJhOrddOpyMlM3Z0umEA==",
       "dev": true,
       "license": "MIT",
       "dependencies": {
-        "@typescript-eslint/types": "8.61.1",
-        "@typescript-eslint/visitor-keys": "8.61.1"
+        "@typescript-eslint/types": "8.62.0",
+        "@typescript-eslint/visitor-keys": "8.62.0"
       },
       "engines": {
         "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
@@ -3211,9 +3211,9 @@
       }
     },
     "node_modules/@typescript-eslint/tsconfig-utils": {
-      "version": "8.61.1",
-      "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.61.1.tgz",
-      "integrity": "sha512-UN/H4di+OO7EWx2ovME+8t31YO+KVnK0RRKEHR3kOt21/Ay8BOq3M1OMvWs5vNiqcFCYGYoxK3MXPZzmMUE+yg==",
+      "version": "8.62.0",
+      "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.62.0.tgz",
+      "integrity": "sha512-y2GAdB6ykaXUvuspbYnizQc4oDDz0Tz/Yc7iWrXf9mx8vm/L/0vLHCe0tS2boG96Zy+DivnVDQ9ZUEWoHqqx1g==",
       "dev": true,
       "license": "MIT",
       "engines": {
@@ -3228,15 +3228,15 @@
       }
     },
     "node_modules/@typescript-eslint/type-utils": {
-      "version": "8.61.1",
-      "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.61.1.tgz",
-      "integrity": "sha512-GYRicKmVK0C4fsKgaACaknOUAq9Oa2kwsjnpFhFcS/5p4Ht5IP9OVLbgIgcK4SRk92nVHFluurg1lumD9dBcLw==",
+      "version": "8.62.0",
+      "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.62.0.tgz",
+      "integrity": "sha512-+g5O3j0w2ldzC86Pv6fvbO/xhAonbJFIdf/MKQ1d30gndlsVzUOE83ldfSE15Qrl9fhFjK6AovHs5Wpp6vx86w==",
       "dev": true,
       "license": "MIT",
       "dependencies": {
-        "@typescript-eslint/types": "8.61.1",
-        "@typescript-eslint/typescript-estree": "8.61.1",
-        "@typescript-eslint/utils": "8.61.1",
+        "@typescript-eslint/types": "8.62.0",
+        "@typescript-eslint/typescript-estree": "8.62.0",
+        "@typescript-eslint/utils": "8.62.0",
         "debug": "^4.4.3",
         "ts-api-utils": "^2.5.0"
       },
@@ -3253,9 +3253,9 @@
       }
     },
     "node_modules/@typescript-eslint/types": {
-      "version": "8.61.1",
-      "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.61.1.tgz",
-      "integrity": "sha512-G+CRlPqLv7Bz1IZVs03x5K59F1veqL0EJUROAdGhKsEq8qOiRiZbI+HUojPq5l0fEGOKModD9br6lObhB8zkoA==",
+      "version": "8.62.0",
+      "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.62.0.tgz",
+      "integrity": "sha512-KvAclkktORPvM54TgLgA4z9HIV1M8zOgw9ZVNXl9f/8dLYfXYX1wkMXP7qmabpijQRV5bHJLOmoyGQbLMaUYeg==",
       "dev": true,
       "license": "MIT",
       "engines": {
@@ -3267,16 +3267,16 @@
       }
     },
     "node_modules/@typescript-eslint/typescript-estree": {
-      "version": "8.61.1",
-      "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.61.1.tgz",
-      "integrity": "sha512-u+oQD3BqYWPc8YV9Zab4vaJElJuwOLPRc10Jm1o/qS+6Qwen14HCWwx0Seo4LnSn2wxea2Ik8DxPt2/FHmuhrg==",
+      "version": "8.62.0",
+      "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.62.0.tgz",
+      "integrity": "sha512-+hVbNxtW64pIcZWDPGbyaKF7vp2IBTVY5ma1blwwksrjdsbdqqEKvJWMGbBofei4F6Dovx1M0RJgoFeNu2279A==",
       "dev": true,
       "license": "MIT",
       "dependencies": {
-        "@typescript-eslint/project-service": "8.61.1",
-        "@typescript-eslint/tsconfig-utils": "8.61.1",
-        "@typescript-eslint/types": "8.61.1",
-        "@typescript-eslint/visitor-keys": "8.61.1",
+        "@typescript-eslint/project-service": "8.62.0",
+        "@typescript-eslint/tsconfig-utils": "8.62.0",
+        "@typescript-eslint/types": "8.62.0",
+        "@typescript-eslint/visitor-keys": "8.62.0",
         "debug": "^4.4.3",
         "minimatch": "^10.2.2",
         "semver": "^7.7.3",
@@ -3295,9 +3295,9 @@
       }
     },
     "node_modules/@typescript-eslint/typescript-estree/node_modules/semver": {
-      "version": "7.8.4",
-      "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.4.tgz",
-      "integrity": "sha512-rUCObTnP32Q08R2uuIrt7r9PlEonuTmtuXYcW6s5kjdlj3xbnwe+21yXptAUYcMAABLkYYTtnmzb3w3EDZfueA==",
+      "version": "7.8.5",
+      "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz",
+      "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==",
       "dev": true,
       "license": "ISC",
       "bin": {
@@ -3308,16 +3308,16 @@
       }
     },
     "node_modules/@typescript-eslint/utils": {
-      "version": "8.61.1",
-      "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.61.1.tgz",
-      "integrity": "sha512-1+P/3Dj6jvtybE1q0HQ6yBt/gq+oKJyLdEv4HdnqasaEXRSYCAsD59mXEVQnM/ULNdQxbX77tdG4jPRjIS6knA==",
+      "version": "8.62.0",
+      "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.62.0.tgz",
+      "integrity": "sha512-82r66fi9zYwZ+mTq3vKgwjbZ1PVk/DJzrXFLpG6RnBbdvH8TEGVHIs9H4d2drhkOzf0syZuD/OZvvlu6GDbP4g==",
       "dev": true,
       "license": "MIT",
       "dependencies": {
         "@eslint-community/eslint-utils": "^4.9.1",
-        "@typescript-eslint/scope-manager": "8.61.1",
-        "@typescript-eslint/types": "8.61.1",
-        "@typescript-eslint/typescript-estree": "8.61.1"
+        "@typescript-eslint/scope-manager": "8.62.0",
+        "@typescript-eslint/types": "8.62.0",
+        "@typescript-eslint/typescript-estree": "8.62.0"
       },
       "engines": {
         "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
@@ -3332,13 +3332,13 @@
       }
     },
     "node_modules/@typescript-eslint/visitor-keys": {
-      "version": "8.61.1",
-      "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.61.1.tgz",
-      "integrity": "sha512-6fJ9MHWtK14C1DSkiMlHUSOmrVebL7150xZJBlJiL62jjhIA4JmOq6flwBgDxIdBKKdoiZRel+dfPD5MLfny3w==",
+      "version": "8.62.0",
+      "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.62.0.tgz",
+      "integrity": "sha512-CY3uyFSRbcQv3nnSv8S0+lDftMVz6P963PoRlxrV7ew/Md564g9ut60PYzdLM5qW4jFn93GBF+Soi90ISAN+GQ==",
       "dev": true,
       "license": "MIT",
       "dependencies": {
-        "@typescript-eslint/types": "8.61.1",
+        "@typescript-eslint/types": "8.62.0",
         "eslint-visitor-keys": "^5.0.0"
       },
       "engines": {
@@ -3350,13 +3350,13 @@
       }
     },
     "node_modules/@vitejs/plugin-react": {
-      "version": "6.0.2",
-      "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.2.tgz",
-      "integrity": "sha512-DlSMqo4WhThw4vB8Mpn0Woe9J+Jfq1geJ61AKW0QEgLzGMNwtIMdxbDUzLxcun8W7NbJO0e2Jg/Nxm3cCSVzzg==",
+      "version": "6.0.3",
+      "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.3.tgz",
+      "integrity": "sha512-vmFvco5/QuC2f9Oj+wTk0+9XeDFkHxSamwZKYc7MxYwKICfvUvlMhqKI0VuICPltGqh1neqBKDvO4kes1ya8vg==",
       "dev": true,
       "license": "MIT",
       "dependencies": {
-        "@rolldown/pluginutils": "^1.0.0"
+        "@rolldown/pluginutils": "^1.0.1"
       },
       "engines": {
         "node": "^20.19.0 || >=22.12.0"
@@ -3595,9 +3595,9 @@
       }
     },
     "node_modules/antd": {
-      "version": "6.4.4",
-      "resolved": "https://registry.npmjs.org/antd/-/antd-6.4.4.tgz",
-      "integrity": "sha512-lgPz4KhfhiYddV/qPYo0ieqWimCVgV2OQF72mbeGNixE753JWNnmEc7UNGy08wBS/zZ7hxrmX0pc5aX7EUaIIg==",
+      "version": "6.4.5",
+      "resolved": "https://registry.npmjs.org/antd/-/antd-6.4.5.tgz",
+      "integrity": "sha512-xyAgX/sqF/CRS1G95oM4ql0+3TBG+tE58aRJqdUPVv4yMZcQrnnkA4cU7Uc5Rny2yK2TrusDVargHzzXUrlJ1g==",
       "license": "MIT",
       "dependencies": {
         "@ant-design/colors": "^8.0.1",
@@ -3614,7 +3614,7 @@
         "@rc-component/dialog": "~1.9.0",
         "@rc-component/drawer": "~1.4.2",
         "@rc-component/dropdown": "~1.0.2",
-        "@rc-component/form": "~1.8.3",
+        "@rc-component/form": "~1.8.5",
         "@rc-component/image": "~1.9.0",
         "@rc-component/input": "~1.3.1",
         "@rc-component/input-number": "~1.6.2",
@@ -3739,9 +3739,9 @@
       }
     },
     "node_modules/axios": {
-      "version": "1.18.0",
-      "resolved": "https://registry.npmjs.org/axios/-/axios-1.18.0.tgz",
-      "integrity": "sha512-E32NzpYKp++W7XRe52rHiXV2ehxmh3wbdgO7MHeFM+vqxLBYHzt0ElkiImtOBxtOmyp0yoC8C6uESVV84Y2/hw==",
+      "version": "1.18.1",
+      "resolved": "https://registry.npmjs.org/axios/-/axios-1.18.1.tgz",
+      "integrity": "sha512-3nTvFlvpn9Zu/RkHUqtc7/+al4UpRW5az71ap5zccp6e8RAYEzhMTecX8Dz1wWDYrPpUoB1HAQEGEAEvUr7S9g==",
       "license": "MIT",
       "dependencies": {
         "follow-redirects": "^1.16.0",
@@ -4978,9 +4978,9 @@
       }
     },
     "node_modules/globals": {
-      "version": "17.6.0",
-      "resolved": "https://registry.npmjs.org/globals/-/globals-17.6.0.tgz",
-      "integrity": "sha512-sepffkT8stwnIYbsMBpoCHJuJM5l98FUF2AnE07hfvE0m/qp3R586hw4jF4uadbhvg1ooIdzuu7CsfD2jzCaNA==",
+      "version": "17.7.0",
+      "resolved": "https://registry.npmjs.org/globals/-/globals-17.7.0.tgz",
+      "integrity": "sha512-Czmyns5dUsq4seFBR/Kdydhmo8y9kC79hiSkPn0YcGtNnYWnrgt0vjrSjx9tspoDGWm2CMarffRuLjM4xUz8xg==",
       "dev": true,
       "license": "MIT",
       "engines": {
@@ -6747,9 +6747,9 @@
       }
     },
     "node_modules/recharts": {
-      "version": "3.8.1",
-      "resolved": "https://registry.npmjs.org/recharts/-/recharts-3.8.1.tgz",
-      "integrity": "sha512-mwzmO1s9sFL0TduUpwndxCUNoXsBw3u3E/0+A+cLcrSfQitSG62L32N69GhqUrrT5qKcAE3pCGVINC6pqkBBQg==",
+      "version": "3.9.0",
+      "resolved": "https://registry.npmjs.org/recharts/-/recharts-3.9.0.tgz",
+      "integrity": "sha512-dCEcE9y20c8H2tkVeByrAXhhnBJk6/QLbxKmn+dJUptOfc5NMjwRh1jo0vZPRLD+5dMrHrP+hPEsfbGBMfnf5Q==",
       "license": "MIT",
       "workspaces": [
         "www"
@@ -6762,7 +6762,7 @@
         "eventemitter3": "^5.0.1",
         "immer": "^10.1.1",
         "react-redux": "8.x.x || 9.x.x",
-        "reselect": "5.1.1",
+        "reselect": "5.2.0",
         "tiny-invariant": "^1.3.3",
         "use-sync-external-store": "^1.2.2",
         "victory-vendor": "^37.0.2"
@@ -6867,9 +6867,9 @@
       "license": "MIT"
     },
     "node_modules/reselect": {
-      "version": "5.1.1",
-      "resolved": "https://registry.npmjs.org/reselect/-/reselect-5.1.1.tgz",
-      "integrity": "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==",
+      "version": "5.2.0",
+      "resolved": "https://registry.npmjs.org/reselect/-/reselect-5.2.0.tgz",
+      "integrity": "sha512-AgZ3UOZm3YndfrJ4OYjgrT7bmCm/1iqkjvEfH/oYjzh6PD2qw4QuT3jjnXIrpdt4MTpMXclMT3lXbmRY+XRakw==",
       "license": "MIT"
     },
     "node_modules/ret": {
@@ -6882,13 +6882,13 @@
       }
     },
     "node_modules/rolldown": {
-      "version": "1.0.3",
-      "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.3.tgz",
-      "integrity": "sha512-i00lAJ2ks1BYr7rjNjKC7BcqAS7nVfiT3QX1SI5aY+AFHblCmaUf9OE9dbdzDvW6dJxbi2ZCZiy9v3CcwOiX3g==",
+      "version": "1.1.2",
+      "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.1.2.tgz",
+      "integrity": "sha512-x0CrQQqCXWGeI8dTvFfN/Dnv3yMKT9hv5jFjlOreKAx9wqLq9wz7VvLLHyaAXC90/CpggTu9SisSbsJJTPSjNQ==",
       "dev": true,
       "license": "MIT",
       "dependencies": {
-        "@oxc-project/types": "=0.133.0",
+        "@oxc-project/types": "=0.137.0",
         "@rolldown/pluginutils": "^1.0.0"
       },
       "bin": {
@@ -6898,21 +6898,21 @@
         "node": "^20.19.0 || >=22.12.0"
       },
       "optionalDependencies": {
-        "@rolldown/binding-android-arm64": "1.0.3",
-        "@rolldown/binding-darwin-arm64": "1.0.3",
-        "@rolldown/binding-darwin-x64": "1.0.3",
-        "@rolldown/binding-freebsd-x64": "1.0.3",
-        "@rolldown/binding-linux-arm-gnueabihf": "1.0.3",
-        "@rolldown/binding-linux-arm64-gnu": "1.0.3",
-        "@rolldown/binding-linux-arm64-musl": "1.0.3",
-        "@rolldown/binding-linux-ppc64-gnu": "1.0.3",
-        "@rolldown/binding-linux-s390x-gnu": "1.0.3",
-        "@rolldown/binding-linux-x64-gnu": "1.0.3",
-        "@rolldown/binding-linux-x64-musl": "1.0.3",
-        "@rolldown/binding-openharmony-arm64": "1.0.3",
-        "@rolldown/binding-wasm32-wasi": "1.0.3",
-        "@rolldown/binding-win32-arm64-msvc": "1.0.3",
-        "@rolldown/binding-win32-x64-msvc": "1.0.3"
+        "@rolldown/binding-android-arm64": "1.1.2",
+        "@rolldown/binding-darwin-arm64": "1.1.2",
+        "@rolldown/binding-darwin-x64": "1.1.2",
+        "@rolldown/binding-freebsd-x64": "1.1.2",
+        "@rolldown/binding-linux-arm-gnueabihf": "1.1.2",
+        "@rolldown/binding-linux-arm64-gnu": "1.1.2",
+        "@rolldown/binding-linux-arm64-musl": "1.1.2",
+        "@rolldown/binding-linux-ppc64-gnu": "1.1.2",
+        "@rolldown/binding-linux-s390x-gnu": "1.1.2",
+        "@rolldown/binding-linux-x64-gnu": "1.1.2",
+        "@rolldown/binding-linux-x64-musl": "1.1.2",
+        "@rolldown/binding-openharmony-arm64": "1.1.2",
+        "@rolldown/binding-wasm32-wasi": "1.1.2",
+        "@rolldown/binding-win32-arm64-msvc": "1.1.2",
+        "@rolldown/binding-win32-x64-msvc": "1.1.2"
       }
     },
     "node_modules/safe-buffer": {
@@ -7244,9 +7244,9 @@
       }
     },
     "node_modules/swagger-ui-react": {
-      "version": "5.32.6",
-      "resolved": "https://registry.npmjs.org/swagger-ui-react/-/swagger-ui-react-5.32.6.tgz",
-      "integrity": "sha512-2q2kXd6eDR+syyWV5HE2CkWANyr2MHPkNezG4M7fC0FPlBUZEsNgyA/2dcb9dIwgE5xd995dO42h89fNMF5/ng==",
+      "version": "5.32.8",
+      "resolved": "https://registry.npmjs.org/swagger-ui-react/-/swagger-ui-react-5.32.8.tgz",
+      "integrity": "sha512-Cstx4Tq8fT5l2TBxHxts8pG+ks0qKSkuO1pwUwgrQQiZ241Mqs+KUODLVIonsYXL/gqX143rkcipUa4d0Rid7w==",
       "license": "Apache-2.0",
       "dependencies": {
         "@babel/runtime-corejs3": "^7.27.1",
@@ -7256,11 +7256,11 @@
         "classnames": "^2.5.1",
         "css.escape": "1.5.1",
         "deep-extend": "0.6.0",
-        "dompurify": "^3.4.0",
+        "dompurify": "^3.4.11",
         "ieee754": "^1.2.1",
         "immutable": "^3.x.x",
         "js-file-download": "^0.4.12",
-        "js-yaml": "=4.1.1",
+        "js-yaml": "=4.2.0",
         "lodash": "^4.18.1",
         "prop-types": "^15.8.1",
         "randexp": "^0.5.3",
@@ -7547,16 +7547,16 @@
       }
     },
     "node_modules/typescript-eslint": {
-      "version": "8.61.1",
-      "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.61.1.tgz",
-      "integrity": "sha512-V7PayAfJokV3pEHgN7/v03D1SpujhRfQtYLbLIiBfDDncdg4PAiRBfoS4cnCANK4jmAPncczi59QO3afiXUlNw==",
+      "version": "8.62.0",
+      "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.62.0.tgz",
+      "integrity": "sha512-8QxXi+ZACKX0kaqO4gY8kn0RSD9gFfaHDWwjqtEN48aWCBkX4MJaufWN+c3BzlrXLOxfywDL8CaoqUwcRq4j4Q==",
       "dev": true,
       "license": "MIT",
       "dependencies": {
-        "@typescript-eslint/eslint-plugin": "8.61.1",
-        "@typescript-eslint/parser": "8.61.1",
-        "@typescript-eslint/typescript-estree": "8.61.1",
-        "@typescript-eslint/utils": "8.61.1"
+        "@typescript-eslint/eslint-plugin": "8.62.0",
+        "@typescript-eslint/parser": "8.62.0",
+        "@typescript-eslint/typescript-estree": "8.62.0",
+        "@typescript-eslint/utils": "8.62.0"
       },
       "engines": {
         "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
@@ -7669,16 +7669,16 @@
       }
     },
     "node_modules/vite": {
-      "version": "8.0.16",
-      "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.16.tgz",
-      "integrity": "sha512-h9bXPmJichP5fLmVQo3PyaGSDE2n3aPuomeAlVRm0JLmt4rY6zmPKd59HYI4LNW8oTK7tlTsuC7l/m7awx9Jcw==",
+      "version": "8.1.0",
+      "resolved": "https://registry.npmjs.org/vite/-/vite-8.1.0.tgz",
+      "integrity": "sha512-BuJcQK/56NQTWDGn4ABea3q4SSBdNPWwNZKTkkUpcMPnLoquSYH8llRtSUIgoL1KSCpHt5eghLShn50mH36y7Q==",
       "dev": true,
       "license": "MIT",
       "dependencies": {
         "lightningcss": "^1.32.0",
         "picomatch": "^4.0.4",
         "postcss": "^8.5.15",
-        "rolldown": "1.0.3",
+        "rolldown": "~1.1.2",
         "tinyglobby": "^0.2.17"
       },
       "bin": {
@@ -7695,7 +7695,7 @@
       },
       "peerDependencies": {
         "@types/node": "^20.19.0 || >=22.12.0",
-        "@vitejs/devtools": "^0.1.18",
+        "@vitejs/devtools": "^0.3.0",
         "esbuild": "^0.27.0 || ^0.28.0",
         "jiti": ">=1.21.0",
         "less": "^4.0.0",

+ 11 - 11
frontend/package.json

@@ -1,7 +1,7 @@
 {
   "name": "3x-ui-frontend",
   "private": true,
-  "version": "0.3.1",
+  "version": "0.4.0",
   "type": "module",
   "description": "3x-ui panel frontend (React 19 + Ant Design 6 + Vite 8).",
   "engines": {
@@ -24,10 +24,10 @@
     "@ant-design/icons": "^6.2.5",
     "@codemirror/lang-json": "^6.0.2",
     "@codemirror/theme-one-dark": "^6.1.3",
-    "@tanstack/react-query": "^5.101.0",
-    "@tanstack/react-query-devtools": "^5.101.0",
-    "antd": "^6.4.4",
-    "axios": "^1.18.0",
+    "@tanstack/react-query": "^5.101.1",
+    "@tanstack/react-query-devtools": "^5.101.1",
+    "antd": "^6.4.5",
+    "axios": "^1.18.1",
     "codemirror": "^6.0.2",
     "dayjs": "^1.11.21",
     "i18next": "^26.3.1",
@@ -38,8 +38,8 @@
     "react-dom": "^19.2.7",
     "react-i18next": "^17.0.8",
     "react-router-dom": "^7.18.0",
-    "recharts": "^3.8.1",
-    "swagger-ui-react": "^5.32.6",
+    "recharts": "^3.9.0",
+    "swagger-ui-react": "^5.32.8",
     "zod": "^4.4.3"
   },
   "devDependencies": {
@@ -49,15 +49,15 @@
     "@types/react": "^19.2.17",
     "@types/react-dom": "^19.2.3",
     "@types/swagger-ui-react": "^5.18.0",
-    "@vitejs/plugin-react": "^6.0.2",
+    "@vitejs/plugin-react": "^6.0.3",
     "@vitest/coverage-v8": "^4.1.9",
     "eslint": "^10.5.0",
     "eslint-plugin-react-hooks": "^7.1.1",
-    "globals": "^17.6.0",
+    "globals": "^17.7.0",
     "jsdom": "^29.1.1",
     "typescript": "^6.0.3",
-    "typescript-eslint": "^8.61.1",
-    "vite": "8.0.16",
+    "typescript-eslint": "^8.62.0",
+    "vite": "8.1.0",
     "vitest": "^4.1.9"
   },
   "overrides": {

+ 63 - 0
frontend/src/lib/xray/forms/SniffingFields.tsx

@@ -0,0 +1,63 @@
+import { useTranslation } from 'react-i18next';
+import { Form, Select, Switch } from 'antd';
+import type { FormInstance } from 'antd/es/form';
+
+import { SNIFFING_OPTION } from '@/schemas/primitives';
+
+const DEST_OPTIONS = Object.entries(SNIFFING_OPTION).map(([label, value]) => ({ value, label }));
+
+export interface SniffingFieldsProps {
+  // Base path to the sniffing object in the form, e.g. ['sniffing'] (inbound),
+  // ['settings', 'reverseSniffing'] (VLESS reverse), ['settings', 'sniffing']
+  // (loopback). All sub-fields hang off this path.
+  name: (string | number)[];
+  form: FormInstance;
+  // Label for the enable toggle — Enable / Reverse Sniffing / Sniffing differ
+  // per host.
+  enableLabel: string;
+}
+
+// Shared sniffing form fragment used everywhere the panel edits an xray
+// SniffingConfig: the inbound Sniffing tab, VLESS reverse sniffing, and the
+// loopback outbound. Renders the enable toggle plus the destOverride /
+// metadataOnly / routeOnly / excluded fields when enabled.
+export default function SniffingFields({ name, form, enableLabel }: SniffingFieldsProps) {
+  const { t } = useTranslation();
+  const enabled = Form.useWatch([...name, 'enabled'], form) ?? false;
+
+  return (
+    <>
+      <Form.Item label={enableLabel} name={[...name, 'enabled']} valuePropName="checked">
+        <Switch />
+      </Form.Item>
+
+      {enabled && (
+        <>
+          <Form.Item name={[...name, 'destOverride']} wrapperCol={{ md: { span: 14, offset: 8 } }}>
+            <Select mode="multiple" className="sniffing-options" options={DEST_OPTIONS} />
+          </Form.Item>
+          <Form.Item
+            label={t('pages.inbounds.sniffingMetadataOnly')}
+            name={[...name, 'metadataOnly']}
+            valuePropName="checked"
+          >
+            <Switch />
+          </Form.Item>
+          <Form.Item
+            label={t('pages.inbounds.sniffingRouteOnly')}
+            name={[...name, 'routeOnly']}
+            valuePropName="checked"
+          >
+            <Switch />
+          </Form.Item>
+          <Form.Item label={t('pages.inbounds.sniffingIpsExcluded')} name={[...name, 'ipsExcluded']}>
+            <Select mode="tags" tokenSeparators={[',']} placeholder="IP/CIDR/geoip:*/ext:*" style={{ width: '100%' }} />
+          </Form.Item>
+          <Form.Item label={t('pages.inbounds.sniffingDomainsExcluded')} name={[...name, 'domainsExcluded']}>
+            <Select mode="tags" tokenSeparators={[',']} placeholder="domain:*/ext:*" style={{ width: '100%' }} />
+          </Form.Item>
+        </>
+      )}
+    </>
+  );
+}

+ 121 - 14
frontend/src/lib/xray/forms/transport/FinalMaskForm.tsx

@@ -1,3 +1,4 @@
+import { useEffect, useRef } from 'react';
 import { AutoComplete, Button, Divider, Form, Input, InputNumber, Select, Space, Switch } from 'antd';
 import { DeleteOutlined, PlusOutlined, ReloadOutlined } from '@ant-design/icons';
 import type { FormInstance } from 'antd/es/form';
@@ -68,7 +69,9 @@ function asPath(name: NamePath): (string | number)[] {
 function defaultTcpMaskSettings(type: string): Record<string, unknown> {
   switch (type) {
     case 'fragment':
-      return { packets: '1-3', length: '100-200', delay: '', maxSplit: '' };
+      // `lengths`/`delays` are per-segment range arrays (xray-core #6334);
+      // a single length entry reproduces the legacy single-range behavior.
+      return { packets: '1-3', lengths: ['100-200'], delays: [], maxSplit: '' };
     case 'sudoku':
       return {
         password: '', ascii: '', customTable: '', customTables: [],
@@ -81,6 +84,32 @@ function defaultTcpMaskSettings(type: string): Record<string, unknown> {
   }
 }
 
+// xray-core #6334 replaced a fragment mask's single `length`/`delay` ranges
+// with `lengths`/`delays` arrays (the singular keys remain in core only as a
+// fallback). Lift any legacy singular value into a one-element array so the
+// list UI shows it, and drop the singular key so we never emit both.
+function migrateFragmentSettings(settings: Record<string, unknown>): { next: Record<string, unknown>; changed: boolean } {
+  const out: Record<string, unknown> = { ...settings };
+  let changed = false;
+  if (!Array.isArray(out.lengths) && typeof out.length === 'string' && out.length.trim() !== '') {
+    out.lengths = [out.length];
+    changed = true;
+  }
+  if ('length' in out) {
+    delete out.length;
+    changed = true;
+  }
+  if (!Array.isArray(out.delays) && typeof out.delay === 'string' && out.delay.trim() !== '') {
+    out.delays = [out.delay];
+    changed = true;
+  }
+  if ('delay' in out) {
+    delete out.delay;
+    changed = true;
+  }
+  return { next: out, changed };
+}
+
 function defaultUdpMaskSettings(type: string): Record<string, unknown> {
   switch (type) {
     case 'salamander':
@@ -137,6 +166,29 @@ function defaultUdpHop(): Record<string, unknown> {
 
 export default function FinalMaskForm({ name, network, protocol, form, showAll = false }: FinalMaskFormProps) {
   const base = asPath(name);
+
+  // Migrate legacy single-range fragment masks to the per-segment arrays once
+  // on mount so configs saved before #6334 render in the list UI.
+  const migratedRef = useRef(false);
+  useEffect(() => {
+    if (migratedRef.current) return;
+    migratedRef.current = true;
+    const tcp = form.getFieldValue([...base, 'tcp']);
+    if (!Array.isArray(tcp)) return;
+    let anyChanged = false;
+    const next = tcp.map((mask) => {
+      if (!mask || typeof mask !== 'object') return mask;
+      const m = mask as Record<string, unknown>;
+      if (m.type !== 'fragment' || !m.settings || typeof m.settings !== 'object') return mask;
+      const { next: migrated, changed } = migrateFragmentSettings(m.settings as Record<string, unknown>);
+      if (!changed) return mask;
+      anyChanged = true;
+      return { ...m, settings: migrated };
+    });
+    if (anyChanged) form.setFieldValue([...base, 'tcp'], next);
+    // eslint-disable-next-line react-hooks/exhaustive-deps
+  }, []);
+
   const isHysteria = protocol === OutboundProtocols.Hysteria || protocol === 'hysteria';
   // Wireguard carries no user-selectable transport (always a UDP listener/
   // dialer), so only the UDP mask section applies — TCP masks would never
@@ -261,16 +313,19 @@ function TcpMaskItem({
                     placeholder="tlshello or n-m, e.g. 1-3"
                   />
                 </Form.Item>
-                <Form.Item
-                  label="Length"
-                  name={[fieldName, 'settings', 'length']}
-                  rules={[{ validator: validateFragmentLength }]}
-                >
-                  <Input placeholder="e.g. 100-200" />
-                </Form.Item>
-                <Form.Item label="Delay" name={[fieldName, 'settings', 'delay']}>
-                  <Input />
-                </Form.Item>
+                <FragmentRangeList
+                  listName={[fieldName, 'settings', 'lengths']}
+                  label="Lengths"
+                  placeholder="e.g. 100-200"
+                  minItems={1}
+                  validator={validateFragmentLength}
+                />
+                <FragmentRangeList
+                  listName={[fieldName, 'settings', 'delays']}
+                  label="Delays"
+                  placeholder="e.g. 10-20 or 0"
+                  validator={validateFragmentDelayEntry}
+                />
                 <Form.Item label="Max Split" name={[fieldName, 'settings', 'maxSplit']}>
                   <Input />
                 </Form.Item>
@@ -321,9 +376,6 @@ function validateFragmentPackets(_rule: unknown, value: unknown): Promise<void>
   return Promise.reject(new Error('Use "tlshello" or a packet range like 1-3'));
 }
 
-// Walks a deep object path safely. Used inside shouldUpdate which gets
-// the whole form values blob; we need to compare a deep field across
-// prev/curr without crashing on missing intermediates.
 function validateFragmentLength(_rule: unknown, value: unknown): Promise<void> {
   const str = typeof value === 'string' ? value.trim() : String(value ?? '').trim();
   if (str.length === 0) {
@@ -336,6 +388,61 @@ function validateFragmentLength(_rule: unknown, value: unknown): Promise<void> {
   return Promise.resolve();
 }
 
+// A delay segment is a millisecond value or range; 0 is allowed (no delay),
+// but an empty row would serialize as "" and break xray's Int32Range parse,
+// so require a value and let the user remove the row instead.
+function validateFragmentDelayEntry(_rule: unknown, value: unknown): Promise<void> {
+  const str = typeof value === 'string' ? value.trim() : String(value ?? '').trim();
+  if (str.length === 0) {
+    return Promise.reject(new Error("Delay is required — remove the row if you don't want a delay"));
+  }
+  if (!/^\d+(?:-\d+)?$/.test(str)) {
+    return Promise.reject(new Error('Use a delay in ms, e.g. 10 or 10-20'));
+  }
+  return Promise.resolve();
+}
+
+// Per-segment range list for a fragment mask's `lengths`/`delays` (xray-core
+// #6334): an editable list of dash-range strings. xray applies entry N to
+// fragment segment N, clamping to the last entry. `minItems` keeps at least
+// one length row so the config never collapses to an empty (rejected) list.
+function FragmentRangeList({
+  listName, label, placeholder, validator, minItems = 0,
+}: {
+  listName: (string | number)[];
+  label: string;
+  placeholder: string;
+  validator?: (rule: unknown, value: unknown) => Promise<void>;
+  minItems?: number;
+}) {
+  return (
+    <Form.List name={listName}>
+      {(fields, { add, remove }) => (
+        <>
+          <Form.Item label={label}>
+            <Button type="primary" size="small" icon={<PlusOutlined />} onClick={() => add('')} />
+          </Form.Item>
+          {fields.map((field, idx) => (
+            <Form.Item
+              key={field.key}
+              label={`#${idx + 1}`}
+              name={field.name}
+              rules={validator ? [{ validator }] : undefined}
+            >
+              <Input
+                placeholder={placeholder}
+                addonAfter={fields.length > minItems
+                  ? <DeleteOutlined className="danger-icon" onClick={() => remove(field.name)} />
+                  : null}
+              />
+            </Form.Item>
+          ))}
+        </>
+      )}
+    </Form.List>
+  );
+}
+
 // randRange bytes must sit in 0-255 — xray rejects the whole config with
 // "invalid randRange" otherwise (reversed ranges like "200-100" are fine,
 // xray reorders them).

+ 0 - 1
frontend/src/lib/xray/outbound-defaults.ts

@@ -111,7 +111,6 @@ export function createDefaultWireguardOutboundSettings(
     mtu: 1420,
     secretKey,
     address: [],
-    workers: 2,
     peers: [{
       publicKey: '',
       allowedIPs: ['0.0.0.0/0', '::/0'],

+ 29 - 15
frontend/src/lib/xray/outbound-form-adapter.ts

@@ -1,6 +1,7 @@
 import { XHttpXmuxSchema } from '@/schemas/protocols/stream/xhttp';
 import { normalizeStreamSettingsForWire } from '@/lib/xray/stream-wire-normalize';
 import { Wireguard } from '@/utils';
+import type { Sniffing, SniffingDest } from '@/schemas/primitives';
 
 import type {
   DnsOutboundFormSettings,
@@ -13,7 +14,6 @@ import type {
   OutboundFormSettings,
   OutboundFormValues,
   OutboundStreamFormValues,
-  ReverseSniffingForm,
   ShadowsocksOutboundFormSettings,
   TrojanOutboundFormSettings,
   VlessOutboundFormSettings,
@@ -55,21 +55,28 @@ function asPort(value: unknown, fallback: number): number {
   return n;
 }
 
-const REVERSE_SNIFFING_DEFAULT: ReverseSniffingForm = {
+const SNIFFING_DEST_VALUES: readonly SniffingDest[] = ['http', 'tls', 'quic', 'fakedns'];
+
+const SNIFFING_DEFAULT: Sniffing = {
   enabled: false,
-  destOverride: ['http', 'tls', 'quic', 'fakedns'],
+  destOverride: [...SNIFFING_DEST_VALUES],
   metadataOnly: false,
   routeOnly: false,
   ipsExcluded: [],
   domainsExcluded: [],
 };
 
-function reverseSniffingFromWire(raw: unknown): ReverseSniffingForm {
+// Shared by VLESS reverse sniffing and the loopback outbound — both edit the
+// same xray SniffingConfig. Unknown destOverride tokens are dropped so the
+// value satisfies SniffingSchema's enum.
+function sniffingFromWire(raw: unknown): Sniffing {
   const r = asObject(raw);
-  const dest = asArray(r.destOverride).map((x) => asString(x));
+  const dest = asArray(r.destOverride)
+    .map((x) => asString(x))
+    .filter((x): x is SniffingDest => (SNIFFING_DEST_VALUES as readonly string[]).includes(x));
   return {
     enabled: asBool(r.enabled),
-    destOverride: dest.length > 0 ? dest : ['http', 'tls', 'quic', 'fakedns'],
+    destOverride: dest.length > 0 ? dest : [...SNIFFING_DEST_VALUES],
     metadataOnly: asBool(r.metadataOnly),
     routeOnly: asBool(r.routeOnly),
     ipsExcluded: asArray(r.ipsExcluded).map((x) => asString(x)),
@@ -112,8 +119,8 @@ function vlessFromWire(raw: Raw): VlessOutboundFormSettings {
   const reverse = asObject(raw.reverse);
   const reverseTag = asString(reverse.tag);
   const reverseSniffing = reverseTag
-    ? reverseSniffingFromWire(reverse.sniffing)
-    : REVERSE_SNIFFING_DEFAULT;
+    ? sniffingFromWire(reverse.sniffing)
+    : SNIFFING_DEFAULT;
   const savedSeed = asArray(raw.testseed);
   const testseed = savedSeed.length === 4
     && savedSeed.every((n) => Number.isInteger(n) && (n as number) > 0)
@@ -198,7 +205,6 @@ function wireguardFromWire(raw: Raw): WireguardOutboundFormSettings {
     secretKey,
     pubKey,
     address: addressArr.join(','),
-    workers: asNumber(raw.workers, 2),
     domainStrategy: ((): WireguardOutboundFormSettings['domainStrategy'] => {
       const allowed = ['ForceIP', 'ForceIPv4', 'ForceIPv4v6', 'ForceIPv6', 'ForceIPv6v4'];
       const s = asString(raw.domainStrategy);
@@ -324,7 +330,10 @@ function dnsFromWire(raw: Raw): DnsOutboundFormSettings {
 }
 
 function loopbackFromWire(raw: Raw): LoopbackOutboundFormSettings {
-  return { inboundTag: asString(raw.inboundTag) };
+  return {
+    inboundTag: asString(raw.inboundTag),
+    sniffing: sniffingFromWire(raw.sniffing),
+  };
 }
 
 function muxFromWire(raw: unknown): MuxForm {
@@ -417,7 +426,7 @@ function vmessToWire(s: VmessOutboundFormSettings) {
   };
 }
 
-function reverseSniffingToWire(s: ReverseSniffingForm) {
+function sniffingToWire(s: Sniffing) {
   return {
     enabled: s.enabled,
     destOverride: s.destOverride,
@@ -437,8 +446,8 @@ function vlessToWire(s: VlessOutboundFormSettings) {
     encryption: s.encryption || 'none',
   };
   if (s.reverseTag) {
-    const sn = reverseSniffingToWire(s.reverseSniffing);
-    const defaultSn = reverseSniffingToWire(REVERSE_SNIFFING_DEFAULT);
+    const sn = sniffingToWire(s.reverseSniffing);
+    const defaultSn = sniffingToWire(SNIFFING_DEFAULT);
     result.reverse = {
       tag: s.reverseTag,
       sniffing: JSON.stringify(sn) === JSON.stringify(defaultSn) ? {} : sn,
@@ -485,7 +494,6 @@ function wireguardToWire(s: WireguardOutboundFormSettings) {
     mtu: s.mtu || undefined,
     secretKey: s.secretKey,
     address: s.address ? s.address.split(',').map((x) => x.trim()).filter(Boolean) : [],
-    workers: s.workers || undefined,
     domainStrategy: s.domainStrategy || undefined,
     reserved: s.reserved
       ? s.reserved.split(',').map((x) => Number(x.trim())).filter((n) => Number.isFinite(n))
@@ -563,7 +571,13 @@ function dnsToWire(s: DnsOutboundFormSettings) {
 }
 
 function loopbackToWire(s: LoopbackOutboundFormSettings) {
-  return { inboundTag: s.inboundTag || undefined };
+  const result: Raw = { inboundTag: s.inboundTag || undefined };
+  // Sniffing rides only when enabled — a disabled block is a no-op for
+  // xray's BuildSniffingRequest, so omitting it keeps the wire minimal.
+  if (s.sniffing.enabled) {
+    result.sniffing = sniffingToWire(s.sniffing);
+  }
+  return result;
 }
 
 // canEnableMux mirrors the legacy Outbound.canEnableMux().

+ 1 - 2
frontend/src/pages/inbounds/form/InboundFormModal.tsx

@@ -193,7 +193,6 @@ export default function InboundFormModal({
   // actually live on a node — otherwise the node address it would resolve to is
   // always empty. Offer it only then; `listen`/`custom` work for local inbounds.
   const nodeShareOptionAvailable = selectableNodes.length > 0 && isNodeEligible;
-  const sniffingEnabled = Form.useWatch(['sniffing', 'enabled'], form) ?? false;
   const vlessEncryption = Form.useWatch(['settings', 'encryption'], form) ?? '';
   const ssMethod = Form.useWatch(['settings', 'method'], form);
   const isSSWith2022 = isSS2022({
@@ -977,7 +976,7 @@ export default function InboundFormModal({
     </div>
   );
 
-  const sniffingTab = <SniffingTab sniffingEnabled={sniffingEnabled} />;
+  const sniffingTab = <SniffingTab />;
 
   return (
     <>

+ 9 - 60
frontend/src/pages/inbounds/form/SniffingTab.tsx

@@ -1,67 +1,16 @@
 import { useTranslation } from 'react-i18next';
-import { Checkbox, Form, Select, Switch } from 'antd';
+import { Form } from 'antd';
 
-import { SNIFFING_OPTION } from '@/schemas/primitives';
+import SniffingFields from '@/lib/xray/forms/SniffingFields';
 
-export default function SniffingTab({ sniffingEnabled }: { sniffingEnabled: boolean }) {
+export default function SniffingTab() {
   const { t } = useTranslation();
+  const form = Form.useFormInstance();
   return (
-    <>
-      <Form.Item name={['sniffing', 'enabled']} label={t('enable')} valuePropName="checked">
-        <Switch />
-      </Form.Item>
-
-      {sniffingEnabled && (
-        <>
-          <Form.Item name={['sniffing', 'destOverride']} wrapperCol={{ span: 24 }}>
-            <Checkbox.Group>
-              {Object.entries(SNIFFING_OPTION).map(([key, value]) => (
-                <Checkbox key={key} value={value}>{key}</Checkbox>
-              ))}
-            </Checkbox.Group>
-          </Form.Item>
-
-          <Form.Item
-            name={['sniffing', 'metadataOnly']}
-            label={t('pages.inbounds.sniffingMetadataOnly')}
-            valuePropName="checked"
-          >
-            <Switch />
-          </Form.Item>
-
-          <Form.Item
-            name={['sniffing', 'routeOnly']}
-            label={t('pages.inbounds.sniffingRouteOnly')}
-            valuePropName="checked"
-          >
-            <Switch />
-          </Form.Item>
-
-          <Form.Item
-            name={['sniffing', 'ipsExcluded']}
-            label={t('pages.inbounds.sniffingIpsExcluded')}
-          >
-            <Select
-              mode="tags"
-              tokenSeparators={[',']}
-              placeholder="IP/CIDR/geoip:*/ext:*"
-              style={{ width: '100%' }}
-            />
-          </Form.Item>
-
-          <Form.Item
-            name={['sniffing', 'domainsExcluded']}
-            label={t('pages.inbounds.sniffingDomainsExcluded')}
-          >
-            <Select
-              mode="tags"
-              tokenSeparators={[',']}
-              placeholder="domain:*/ext:*"
-              style={{ width: '100%' }}
-            />
-          </Form.Item>
-        </>
-      )}
-    </>
+    <SniffingFields
+      name={['sniffing']}
+      form={form}
+      enableLabel={t('enable')}
+    />
   );
 }

+ 0 - 3
frontend/src/pages/inbounds/form/protocols/wireguard.tsx

@@ -62,9 +62,6 @@ export default function WireguardFields({ wgPubKey, regenInboundWg, regenWgPeerK
       >
         <Switch />
       </Form.Item>
-      <Form.Item name={['settings', 'workers']} label='Workers'>
-        <InputNumber min={1} />
-      </Form.Item>
       <Form.Item name={['settings', 'domainStrategy']} label={t('pages.xray.wireguard.domainStrategy')}>
         <Select
           allowClear

+ 6 - 4
frontend/src/pages/inbounds/form/transport/sockopt.tsx

@@ -11,8 +11,10 @@ const TRANSPORT_PROXY_FIELD: Record<string, string> = {
   ws: 'wsSettings',
   httpupgrade: 'httpupgradeSettings',
 };
-// Transports on which xray-core honors sockopt.trustedXForwardedFor.
-const TRUSTED_HEADER_NETWORKS = ['ws', 'httpupgrade', 'xhttp'];
+// Transports on which xray-core honors sockopt.trustedXForwardedFor. gRPC joined
+// in v26.6.22 (xray-core 711aea4): it now reads X-Forwarded-For via this option
+// instead of the old x-real-ip gRPC metadata.
+const TRUSTED_HEADER_NETWORKS = ['ws', 'httpupgrade', 'xhttp', 'grpc'];
 
 type RealClientIpPreset = 'off' | 'cloudflare' | 'proxy';
 
@@ -27,7 +29,7 @@ export default function SockoptForm({
 
   // Presets write the same sockopt fields the user could set by hand below,
   // picking the mechanism xray-core actually honors for the chosen transport:
-  // CF-Connecting-IP via trustedXForwardedFor (ws/httpupgrade/xhttp) or the
+  // CF-Connecting-IP via trustedXForwardedFor (ws/httpupgrade/xhttp/grpc) or the
   // PROXY-protocol header via acceptProxyProtocol (every transport but mKCP).
   const applyRealClientIpPreset = (
     preset: RealClientIpPreset,
@@ -60,7 +62,7 @@ export default function SockoptForm({
     }
 
     // proxy — clear trustedXForwardedFor so a lingering header can't override the
-    // PROXY-recovered IP (xray reads the header last on ws/httpupgrade/xhttp).
+    // PROXY-recovered IP (xray reads the header last on ws/httpupgrade/xhttp/grpc).
     setFieldValue(['streamSettings', 'sockopt', 'trustedXForwardedFor'], []);
     setFieldValue(['streamSettings', 'sockopt', 'acceptProxyProtocol'], true);
     if (transportField) setFieldValue(['streamSettings', transportField, 'acceptProxyProtocol'], true);

+ 6 - 65
frontend/src/pages/xray/outbounds/OutboundFormModal.tsx

@@ -8,11 +8,11 @@ import {
   Radio,
   Select,
   Space,
-  Switch,
   Tabs,
   message,
 } from 'antd';
 import { FinalMaskForm } from '@/lib/xray/forms/transport';
+import SniffingFields from '@/lib/xray/forms/SniffingFields';
 import { JsonEditor } from '@/components/form';
 import { Wireguard } from '@/utils';
 import {
@@ -25,7 +25,6 @@ import {
   OutboundFormBaseSchema,
   type OutboundFormValues,
 } from '@/schemas/forms/outbound-form';
-import { SNIFFING_OPTION } from '@/schemas/primitives';
 import {
   canEnableReality,
   canEnableStream,
@@ -412,70 +411,12 @@ export default function OutboundFormModal({
                         {() => {
                           const reverseTag = form.getFieldValue(['settings', 'reverseTag']);
                           if (!reverseTag) return null;
-                          const sniff = (form.getFieldValue(['settings', 'reverseSniffing']) ?? {}) as {
-                            enabled?: boolean;
-                          };
                           return (
-                            <>
-                              <Form.Item
-                                label={t('pages.xray.outboundForm.reverseSniffing')}
-                                name={['settings', 'reverseSniffing', 'enabled']}
-                                valuePropName="checked"
-                              >
-                                <Switch />
-                              </Form.Item>
-                              {sniff.enabled && (
-                                <>
-                                  <Form.Item
-                                    wrapperCol={{ md: { span: 14, offset: 8 } }}
-                                    name={['settings', 'reverseSniffing', 'destOverride']}
-                                  >
-                                    <Select
-                                      mode="multiple"
-                                      className="sniffing-options"
-                                      options={Object.entries(SNIFFING_OPTION).map(([k, v]) => ({
-                                        value: v,
-                                        label: k,
-                                      }))}
-                                    />
-                                  </Form.Item>
-                                  <Form.Item
-                                    label={t('pages.inbounds.sniffingMetadataOnly')}
-                                    name={['settings', 'reverseSniffing', 'metadataOnly']}
-                                    valuePropName="checked"
-                                  >
-                                    <Switch />
-                                  </Form.Item>
-                                  <Form.Item
-                                    label={t('pages.inbounds.sniffingRouteOnly')}
-                                    name={['settings', 'reverseSniffing', 'routeOnly']}
-                                    valuePropName="checked"
-                                  >
-                                    <Switch />
-                                  </Form.Item>
-                                  <Form.Item
-                                    label={t('pages.inbounds.sniffingIpsExcluded')}
-                                    name={['settings', 'reverseSniffing', 'ipsExcluded']}
-                                  >
-                                    <Select
-                                      mode="tags"
-                                      tokenSeparators={[',']}
-                                      placeholder="IP/CIDR/geoip:*"
-                                    />
-                                  </Form.Item>
-                                  <Form.Item
-                                    label={t('pages.inbounds.sniffingDomainsExcluded')}
-                                    name={['settings', 'reverseSniffing', 'domainsExcluded']}
-                                  >
-                                    <Select
-                                      mode="tags"
-                                      tokenSeparators={[',']}
-                                      placeholder="domain:*"
-                                    />
-                                  </Form.Item>
-                                </>
-                              )}
-                            </>
+                            <SniffingFields
+                              name={['settings', 'reverseSniffing']}
+                              form={form}
+                              enableLabel={t('pages.xray.outboundForm.reverseSniffing')}
+                            />
                           );
                         }}
                       </Form.Item>

+ 15 - 3
frontend/src/pages/xray/outbounds/protocols/loopback.tsx

@@ -1,11 +1,23 @@
 import { useTranslation } from 'react-i18next';
 import { Form, Input } from 'antd';
 
+import SniffingFields from '@/lib/xray/forms/SniffingFields';
+
 export default function LoopbackFields() {
   const { t } = useTranslation();
+  const form = Form.useFormInstance();
+
   return (
-    <Form.Item label={t('pages.xray.outboundForm.inboundTag')} name={['settings', 'inboundTag']}>
-      <Input placeholder={t('pages.xray.outboundForm.inboundTagPlaceholder')} />
-    </Form.Item>
+    <>
+      <Form.Item label={t('pages.xray.outboundForm.inboundTag')} name={['settings', 'inboundTag']}>
+        <Input placeholder={t('pages.xray.outboundForm.inboundTagPlaceholder')} />
+      </Form.Item>
+
+      <SniffingFields
+        name={['settings', 'sniffing']}
+        form={form}
+        enableLabel={t('pages.inbounds.sniffingTab')}
+      />
+    </>
   );
 }

+ 0 - 3
frontend/src/pages/xray/outbounds/protocols/wireguard.tsx

@@ -43,9 +43,6 @@ export default function WireguardFields({ form }: { form: FormInstance<OutboundF
       <Form.Item label="MTU" name={['settings', 'mtu']}>
         <InputNumber min={0} />
       </Form.Item>
-      <Form.Item label={t('pages.xray.outboundForm.workers')} name={['settings', 'workers']}>
-        <InputNumber min={0} />
-      </Form.Item>
       <Form.Item
         label={t('pages.inbounds.info.noKernelTun')}
         name={['settings', 'noKernelTun']}

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

@@ -5,19 +5,6 @@ import { InboundSettingsSchema } from '@/schemas/protocols/inbound';
 import { SecuritySettingsSchema } from '@/schemas/protocols/security';
 import { NetworkSettingsSchema, StreamExtrasSchema } from '@/schemas/protocols/stream';
 
-// InboundFormValues = the values shape Form.useForm<T>() carries in
-// InboundFormModal. Mirrors the wire shape (so submission can hand
-// values straight to Schema.parse + POST) plus the DB-side fields that
-// the panel's /panel/api/inbounds/add endpoint expects alongside.
-//
-// Differences from schemas/api/inbound.ts InboundSchema:
-//   - settings/streamSettings/sniffing are nested OBJECTS here, not the
-//     JSON strings the endpoint accepts. The form holds typed data; the
-//     submit handler stringifies right before POSTing.
-//   - Adds DB fields not in InboundSchema: up, down, total, trafficReset,
-//     lastTrafficResetTime, nodeId. These flow through the DBInbound row,
-//     not the xray-config slice.
-
 export const InboundStreamFormSchema = NetworkSettingsSchema
   .and(SecuritySettingsSchema)
   .and(StreamExtrasSchema);
@@ -43,9 +30,6 @@ export const InboundDbFieldsSchema = z.object({
 });
 export type InboundDbFields = z.infer<typeof InboundDbFieldsSchema>;
 
-// Base fields that apply to every inbound regardless of protocol or
-// transport. The protocol-specific `settings` and the transport-specific
-// `streamSettings` are layered on via intersection below.
 export const InboundFormBaseSchema = z.object({
   remark: z.string().default(''),
   enable: z.boolean().default(true),
@@ -73,10 +57,6 @@ export const InboundFormSchema = InboundFormBaseSchema
   .and(InboundSettingsSchema);
 export type InboundFormValues = z.infer<typeof InboundFormSchema>;
 
-// Fallback rows ride alongside the inbound submission for VLESS/Trojan
-// hosts. They're saved via a separate endpoint after the main inbound
-// POST returns, so the schema lives here but is not part of the wire
-// inbound payload.
 export const FallbackRowSchema = z.object({
   rowKey: z.string(),
   childId: z.number().int().nullable(),

+ 27 - 56
frontend/src/schemas/forms/outbound-form.ts

@@ -1,6 +1,6 @@
 import { z } from 'zod';
 
-import { PortSchema } from '@/schemas/primitives';
+import { PortSchema, SniffingSchema, type Sniffing } from '@/schemas/primitives';
 import { SSMethodSchema } from '@/schemas/protocols/shared/shadowsocks';
 import { VmessSecuritySchema } from '@/schemas/protocols/shared/vmess';
 import { SecuritySettingsSchema } from '@/schemas/protocols/security';
@@ -15,28 +15,6 @@ import {
   WireguardDomainStrategySchema,
 } from '@/schemas/protocols/outbound';
 
-// OutboundFormValues = the shape Form.useForm<T>() carries inside
-// OutboundFormModal. Differences from schemas/api wire schemas:
-//
-//   - vmess vnext / trojan-ss-socks-http servers are FLATTENED into
-//     {address, port, ...auth} at settings root. The adapter handles
-//     nesting on submit.
-//   - wireguard `address` (string[] wire) and `reserved` (number[] wire)
-//     are comma-joined STRINGS in the form. The adapter splits + coerces.
-//   - wireguard `pubKey` is a UI-only field derived from `secretKey`. Not
-//     emitted on the wire — the adapter strips it.
-//   - VLESS `reverseTag` and `reverseSniffing` are flat at settings root;
-//     the adapter wraps them as { reverse: { tag, sniffing } } on the wire.
-//   - blackhole `type` ('' | 'none' | 'http') is flat; the adapter wraps it
-//     as { response: { type } } on the wire (omitted when empty).
-//   - DNS rules carry `qType` and `domain` as comma-joined strings (matches
-//     the legacy DNSRule UI). The adapter normalizes them on submit.
-//
-// All flat-form settings types are documented inline so the adapter has a
-// single source of truth for the shape it converts between.
-
-// VMess outbound: connect target (address+port) + first user (id+security).
-// Wire: { vnext: [{ address, port, users: [{ id, security }] }] }.
 export const VmessOutboundFormSettingsSchema = z.object({
   address: z.string().default(''),
   port: PortSchema.default(443),
@@ -45,20 +23,18 @@ export const VmessOutboundFormSettingsSchema = z.object({
 });
 export type VmessOutboundFormSettings = z.infer<typeof VmessOutboundFormSettingsSchema>;
 
-// Reverse-sniffing is only emitted when reverseTag is non-empty. Defaults
-// match legacy ReverseSniffing constructor.
-export const ReverseSniffingFormSchema = z.object({
-  enabled: z.boolean().default(false),
-  destOverride: z.array(z.string()).default(['http', 'tls', 'quic', 'fakedns']),
-  metadataOnly: z.boolean().default(false),
-  routeOnly: z.boolean().default(false),
-  ipsExcluded: z.array(z.string()).default([]),
-  domainsExcluded: z.array(z.string()).default([]),
-});
-export type ReverseSniffingForm = z.infer<typeof ReverseSniffingFormSchema>;
+// Reverse sniffing (VLESS) and loopback sniffing share the canonical
+// SniffingSchema — the same definition the inbound Sniffing tab uses — so
+// there is one source of truth for an xray SniffingConfig across the panel.
+const DEFAULT_SNIFFING: Sniffing = {
+  enabled: false,
+  destOverride: ['http', 'tls', 'quic', 'fakedns'],
+  metadataOnly: false,
+  routeOnly: false,
+  ipsExcluded: [],
+  domainsExcluded: [],
+};
 
-// VLESS outbound: flat connect target + auth + Vision-specific knobs +
-// reverse-sniffing slice. testpre/testseed live behind canEnableVisionSeed.
 export const VlessOutboundFormSettingsSchema = z.object({
   address: z.string().default(''),
   port: PortSchema.default(443),
@@ -66,14 +42,7 @@ export const VlessOutboundFormSettingsSchema = z.object({
   flow: z.string().default(''),
   encryption: z.string().min(1).default('none'),
   reverseTag: z.string().default(''),
-  reverseSniffing: ReverseSniffingFormSchema.default({
-    enabled: false,
-    destOverride: ['http', 'tls', 'quic', 'fakedns'],
-    metadataOnly: false,
-    routeOnly: false,
-    ipsExcluded: [],
-    domainsExcluded: [],
-  }),
+  reverseSniffing: SniffingSchema.default(DEFAULT_SNIFFING),
   testpre: z.number().int().min(0).default(0),
   testseed: z.array(z.number().int().positive()).default([]),
 });
@@ -134,7 +103,6 @@ export const WireguardOutboundFormSettingsSchema = z.object({
   secretKey: z.string().default(''),
   pubKey: z.string().default(''),
   address: z.string().default(''),
-  workers: z.number().int().min(0).default(2),
   domainStrategy: z.union([WireguardDomainStrategySchema, z.literal('')]).default(''),
   reserved: z.string().default(''),
   peers: z.array(WireguardOutboundFormPeerSchema).default([]),
@@ -205,26 +173,29 @@ export const DnsOutboundFormSettingsSchema = z.object({
 });
 export type DnsOutboundFormSettings = z.infer<typeof DnsOutboundFormSettingsSchema>;
 
+// Loopback reinjects into a named inbound; `sniffing` (same flat shape as
+// VLESS reverse-sniffing) is only emitted when enabled — see the adapter.
 export const LoopbackOutboundFormSettingsSchema = z.object({
   inboundTag: z.string().default(''),
+  sniffing: SniffingSchema.default(DEFAULT_SNIFFING),
 });
 export type LoopbackOutboundFormSettings = z.infer<typeof LoopbackOutboundFormSettingsSchema>;
 
 // Discriminated union on `protocol`. Same tagged-wrapper pattern as the
 // inbound side: each branch is { protocol: literal, settings: <flat> }.
 export const OutboundFormSettingsSchema = z.discriminatedUnion('protocol', [
-  z.object({ protocol: z.literal('vmess'),       settings: VmessOutboundFormSettingsSchema }),
-  z.object({ protocol: z.literal('vless'),       settings: VlessOutboundFormSettingsSchema }),
-  z.object({ protocol: z.literal('trojan'),      settings: TrojanOutboundFormSettingsSchema }),
+  z.object({ protocol: z.literal('vmess'), settings: VmessOutboundFormSettingsSchema }),
+  z.object({ protocol: z.literal('vless'), settings: VlessOutboundFormSettingsSchema }),
+  z.object({ protocol: z.literal('trojan'), settings: TrojanOutboundFormSettingsSchema }),
   z.object({ protocol: z.literal('shadowsocks'), settings: ShadowsocksOutboundFormSettingsSchema }),
-  z.object({ protocol: z.literal('socks'),       settings: SocksOutboundFormSettingsSchema }),
-  z.object({ protocol: z.literal('http'),        settings: HttpOutboundFormSettingsSchema }),
-  z.object({ protocol: z.literal('wireguard'),   settings: WireguardOutboundFormSettingsSchema }),
-  z.object({ protocol: z.literal('hysteria'),    settings: HysteriaOutboundFormSettingsSchema }),
-  z.object({ protocol: z.literal('freedom'),     settings: FreedomOutboundFormSettingsSchema }),
-  z.object({ protocol: z.literal('blackhole'),   settings: BlackholeOutboundFormSettingsSchema }),
-  z.object({ protocol: z.literal('dns'),         settings: DnsOutboundFormSettingsSchema }),
-  z.object({ protocol: z.literal('loopback'),    settings: LoopbackOutboundFormSettingsSchema }),
+  z.object({ protocol: z.literal('socks'), settings: SocksOutboundFormSettingsSchema }),
+  z.object({ protocol: z.literal('http'), settings: HttpOutboundFormSettingsSchema }),
+  z.object({ protocol: z.literal('wireguard'), settings: WireguardOutboundFormSettingsSchema }),
+  z.object({ protocol: z.literal('hysteria'), settings: HysteriaOutboundFormSettingsSchema }),
+  z.object({ protocol: z.literal('freedom'), settings: FreedomOutboundFormSettingsSchema }),
+  z.object({ protocol: z.literal('blackhole'), settings: BlackholeOutboundFormSettingsSchema }),
+  z.object({ protocol: z.literal('dns'), settings: DnsOutboundFormSettingsSchema }),
+  z.object({ protocol: z.literal('loopback'), settings: LoopbackOutboundFormSettingsSchema }),
 ]);
 export type OutboundFormSettings = z.infer<typeof OutboundFormSettingsSchema>;
 

+ 0 - 6
frontend/src/schemas/protocols/inbound/vless.ts

@@ -23,9 +23,6 @@ export const VlessClientSchema = z.object({
   subId: z.string().default(''),
   comment: z.string().default(''),
   reset: z.number().int().min(0).default(0),
-  // VLESS simple reverse-proxy: which reverse tag this client routes to,
-  // plus an optional sniffing override for that path. Distinct from the
-  // inbound-level `fallbacks` feature.
   reverse: z
     .object({
       tag: z.string(),
@@ -42,9 +39,6 @@ export const VlessInboundSettingsSchema = z.object({
   decryption: z.string().min(1).default('none'),
   encryption: z.string().min(1).default('none'),
   fallbacks: z.array(VlessFallbackSchema).default([]),
-  // TODO: narrow to flow === 'xtls-rprx-vision' once a per-flow discriminator
-  // exists. 4-positive-int padding seed for xtls-rprx-vision; backend uses
-  // safe defaults when omitted.
   testseed: z.array(z.number().int().positive()).length(4).optional(),
 });
 export type VlessInboundSettings = z.infer<typeof VlessInboundSettingsSchema>;

+ 0 - 1
frontend/src/schemas/protocols/inbound/wireguard.ts

@@ -38,7 +38,6 @@ export const WireguardInboundSettingsSchema = z.object({
   secretKey: z.string().min(1),
   peers: z.array(WireguardInboundPeerSchema).default([]),
   noKernelTun: z.boolean().default(false),
-  workers: optionalClearedInt(z.number().int().min(1)),
   domainStrategy: WireguardDomainStrategySchema.optional(),
 });
 export type WireguardInboundSettings = z.infer<typeof WireguardInboundSettingsSchema>;

+ 0 - 4
frontend/src/schemas/protocols/outbound/index.ts

@@ -26,10 +26,6 @@ export * from './vless';
 export * from './vmess';
 export * from './wireguard';
 
-// Outbound discriminated union spans 13 protocols (mixed/tunnel are
-// inbound-only; freedom/blackhole/dns/loopback are outbound-only). The wire
-// shape is `{ protocol, settings }` — same wrapper pattern as the inbound
-// union, even though some leaf schemas (freedom, blackhole) are sparse.
 export const OutboundSettingsSchema = z.discriminatedUnion('protocol', [
   z.object({ protocol: z.literal('vmess'),       settings: VmessOutboundSettingsSchema }),
   z.object({ protocol: z.literal('vless'),       settings: VlessOutboundSettingsSchema }),

+ 3 - 2
frontend/src/schemas/protocols/outbound/loopback.ts

@@ -1,8 +1,9 @@
 import { z } from 'zod';
 
-// Loopback outbound reinjects traffic back into a named inbound for chained
-// routing. The single `inboundTag` field references an inbound tag by name.
+import { SniffingSchema } from '@/schemas/primitives';
+
 export const LoopbackOutboundSettingsSchema = z.object({
   inboundTag: z.string().optional(),
+  sniffing: SniffingSchema.optional(),
 });
 export type LoopbackOutboundSettings = z.infer<typeof LoopbackOutboundSettingsSchema>;

+ 0 - 3
frontend/src/schemas/protocols/outbound/trojan.ts

@@ -2,9 +2,6 @@ import { z } from 'zod';
 
 import { PortSchema } from '@/schemas/primitives';
 
-// Trojan outbound persists as { servers: [{ address, port, password }] }
-// — distinct from VLESS outbound which stores the connect target flat at
-// the settings root. The wrapping mirrors what Xray expects.
 export const TrojanOutboundServerSchema = z.object({
   address: z.string().min(1),
   port: PortSchema,

+ 0 - 3
frontend/src/schemas/protocols/outbound/vmess.ts

@@ -3,9 +3,6 @@ import { z } from 'zod';
 import { PortSchema } from '@/schemas/primitives';
 import { VmessSecuritySchema } from '@/schemas/protocols/shared/vmess';
 
-// Vmess outbound persists in the standard Xray `vnext` shape:
-// { vnext: [{ address, port, users: [{ id, security }] }] }
-// — distinct from VLESS outbound which the panel stores flat.
 export const VmessOutboundUserSchema = z.object({
   id: z.uuid(),
   security: VmessSecuritySchema.default('auto'),

+ 0 - 6
frontend/src/schemas/protocols/outbound/wireguard.ts

@@ -9,8 +9,6 @@ export const WireguardDomainStrategySchema = z.enum([
 ]);
 export type WireguardDomainStrategy = z.infer<typeof WireguardDomainStrategySchema>;
 
-// Outbound peer is the remote server we connect to: no privateKey, but an
-// `endpoint` (host:port) the inbound side does not need.
 export const WireguardOutboundPeerSchema = z.object({
   publicKey: z.string().min(1),
   preSharedKey: z.string().optional(),
@@ -20,14 +18,10 @@ export const WireguardOutboundPeerSchema = z.object({
 });
 export type WireguardOutboundPeer = z.infer<typeof WireguardOutboundPeerSchema>;
 
-// Wire format: address is a string[] (Xray expects an array even though the
-// panel UI stores it comma-joined); reserved is number[] (panel splits the
-// comma string and Number()-coerces each entry).
 export const WireguardOutboundSettingsSchema = z.object({
   mtu: z.number().int().min(1).optional(),
   secretKey: z.string().min(1),
   address: z.array(z.string()).default([]),
-  workers: z.number().int().min(1).optional(),
   domainStrategy: WireguardDomainStrategySchema.optional(),
   reserved: z.array(z.number().int()).optional(),
   peers: z.array(WireguardOutboundPeerSchema).min(1),

+ 23 - 0
frontend/src/test/__snapshots__/finalmask.test.ts.snap

@@ -101,6 +101,29 @@ exports[`FinalMaskStreamSettingsSchema fixtures > parses salamander-gecko byte-s
 }
 `;
 
+exports[`FinalMaskStreamSettingsSchema fixtures > parses tcp-fragment-segments byte-stably 1`] = `
+{
+  "tcp": [
+    {
+      "settings": {
+        "delays": [
+          "5-10",
+          "0",
+        ],
+        "lengths": [
+          "10-20",
+          "50-100",
+        ],
+        "maxSplit": "0",
+        "packets": "tlshello",
+      },
+      "type": "fragment",
+    },
+  ],
+  "udp": [],
+}
+`;
+
 exports[`FinalMaskStreamSettingsSchema fixtures > parses tcp-mask byte-stably 1`] = `
 {
   "tcp": [

+ 13 - 0
frontend/src/test/golden/fixtures/finalmask/tcp-fragment-segments.json

@@ -0,0 +1,13 @@
+{
+  "tcp": [
+    {
+      "type": "fragment",
+      "settings": {
+        "packets": "tlshello",
+        "lengths": ["10-20", "50-100"],
+        "delays": ["5-10", "0"],
+        "maxSplit": "0"
+      }
+    }
+  ]
+}

+ 0 - 1
frontend/src/test/outbound-defaults.test.ts

@@ -111,7 +111,6 @@ describe('outbound default factories: shape snapshots', () => {
     const out = createDefaultWireguardOutboundSettings({ secretKey: SAMPLE_SECRET });
     expect(out.secretKey).toBe(SAMPLE_SECRET);
     expect(out.mtu).toBe(1420);
-    expect(out.workers).toBe(2);
     expect(out.address).toEqual([]);
     expect(out.noKernelTun).toBe(false);
     expect(out.peers).toEqual([{

+ 33 - 1
frontend/src/test/outbound-form-adapter.test.ts

@@ -182,7 +182,6 @@ describe('outbound-form-adapter: round-trip', () => {
         mtu: 1420,
         secretKey: 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=',
         address: ['10.0.0.1', 'fd00::1'],
-        workers: 2,
         peers: [{ publicKey: 'pk', allowedIPs: ['0.0.0.0/0'], endpoint: 'e:51820', preSharedKey: 'psk' }],
         reserved: [1, 2, 3],
         noKernelTun: false,
@@ -359,6 +358,39 @@ describe('outbound-form-adapter: round-trip', () => {
     expect(back.settings).toEqual({ inboundTag: 'tagged-inbound' });
   });
 
+  it('loopback omits sniffing when disabled', () => {
+    const form = rawOutboundToFormValues({
+      protocol: 'loopback',
+      settings: { inboundTag: 'tagged-inbound' },
+    });
+    if (form.protocol === 'loopback') {
+      expect(form.settings.sniffing.enabled).toBe(false);
+    }
+    const back = formValuesToWirePayload(form);
+    expect(back.settings).not.toHaveProperty('sniffing');
+  });
+
+  it('loopback round-trips sniffing when enabled', () => {
+    const wire = {
+      protocol: 'loopback',
+      settings: {
+        inboundTag: 'tagged-inbound',
+        sniffing: { enabled: true, destOverride: ['tls', 'http'], routeOnly: true },
+      },
+    };
+    const form = rawOutboundToFormValues(wire);
+    if (form.protocol === 'loopback') {
+      expect(form.settings.sniffing.enabled).toBe(true);
+      expect(form.settings.sniffing.destOverride).toEqual(['tls', 'http']);
+      expect(form.settings.sniffing.routeOnly).toBe(true);
+    }
+    const back = formValuesToWirePayload(form);
+    const sniffing = (back.settings as Record<string, unknown>).sniffing as Record<string, unknown>;
+    expect(sniffing.enabled).toBe(true);
+    expect(sniffing.destOverride).toEqual(['tls', 'http']);
+    expect(sniffing.routeOnly).toBe(true);
+  });
+
   it('unknown protocol falls back to vless without throwing', () => {
     const form = rawOutboundToFormValues({ protocol: 'mysterious', settings: {} });
     expect(form.protocol).toBe('vless');

+ 2 - 2
go.mod

@@ -21,7 +21,7 @@ require (
 	github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e
 	github.com/valyala/fasthttp v1.71.0
 	github.com/xlzd/gotp v0.1.0
-	github.com/xtls/xray-core v1.260327.1-0.20260619120227-be8009c62509
+	github.com/xtls/xray-core v1.260327.1-0.20260622185510-b99c3e56574f
 	go.uber.org/atomic v1.11.0
 	golang.org/x/crypto v0.53.0
 	golang.org/x/sys v0.46.0
@@ -37,7 +37,7 @@ require (
 require (
 	github.com/pion/dtls/v3 v3.1.4 // indirect
 	github.com/pion/logging v0.2.4 // indirect
-	github.com/pion/stun/v3 v3.1.5 // indirect
+	github.com/pion/stun/v3 v3.1.6 // indirect
 	github.com/pion/transport/v4 v4.0.2 // indirect
 	github.com/wlynxg/anet v0.0.5 // indirect
 	golang.zx2c4.com/wireguard/windows v1.0.1 // indirect

+ 4 - 4
go.sum

@@ -152,8 +152,8 @@ github.com/pion/dtls/v3 v3.1.4 h1:QhvtMflMfu9Kf0RcDC5BJBle4caPskByrKQR6uuYqpY=
 github.com/pion/dtls/v3 v3.1.4/go.mod h1:cr/qotLISUw/9C1m83ZPNZtj9WnXkYLpfCptPqbkInc=
 github.com/pion/logging v0.2.4 h1:tTew+7cmQ+Mc1pTBLKH2puKsOvhm32dROumOZ655zB8=
 github.com/pion/logging v0.2.4/go.mod h1:DffhXTKYdNZU+KtJ5pyQDjvOAh/GsNSyv1lbkFbe3so=
-github.com/pion/stun/v3 v3.1.5 h1:Y1FHlhaI6+4UoC5i/zQf4F7JvdZtB24/05oyy/GF1x8=
-github.com/pion/stun/v3 v3.1.5/go.mod h1:zRUghXSQU32Lx5orJsz3uYMkIihweXb3mu5gIns02fs=
+github.com/pion/stun/v3 v3.1.6 h1:WnhsD0eHCiwCfKNkVx0VJJwr2Y3eV4Ueih3KJ+dfZy8=
+github.com/pion/stun/v3 v3.1.6/go.mod h1:zRUghXSQU32Lx5orJsz3uYMkIihweXb3mu5gIns02fs=
 github.com/pion/transport/v4 v4.0.2 h1:ifYlPqNwsy6aKQ9y8yzxXlHae5431ZrH2avkD/Rn6Tk=
 github.com/pion/transport/v4 v4.0.2/go.mod h1:06hFI+jCFcok2X2MekVufNZ/uzNZXivGBPfviSVcjgM=
 github.com/pires/go-proxyproto v0.12.0 h1:TTCxD66dU898tahivkqc3hoceZp7P44FnorWyo9d5vM=
@@ -218,8 +218,8 @@ github.com/xlzd/gotp v0.1.0 h1:37blvlKCh38s+fkem+fFh7sMnceltoIEBYTVXyoa5Po=
 github.com/xlzd/gotp v0.1.0/go.mod h1:ndLJ3JKzi3xLmUProq4LLxCuECL93dG9WASNLpHz8qg=
 github.com/xtls/reality v0.0.0-20260322125925-9234c772ba8f h1:iy2JRioxmUpoJ3SzbFPyTxHZMbR/rSHP7dOOgYaq1O8=
 github.com/xtls/reality v0.0.0-20260322125925-9234c772ba8f/go.mod h1:DsJblcWDGt76+FVqBVwbwRhxyyNJsGV48gJLch0OOWI=
-github.com/xtls/xray-core v1.260327.1-0.20260619120227-be8009c62509 h1:HPhr73dDjzX3OVUO7MrAFTh7OpSeSl6e7IHOiYExbBc=
-github.com/xtls/xray-core v1.260327.1-0.20260619120227-be8009c62509/go.mod h1:kxL2uOFeoKMqrsW3tLQiRcfvxtGUmxoTre1B0stjpuM=
+github.com/xtls/xray-core v1.260327.1-0.20260622185510-b99c3e56574f h1:HUoSL0tR7z/zIxF5wADdesW+aW3wWAyNs98w+Hahiw8=
+github.com/xtls/xray-core v1.260327.1-0.20260622185510-b99c3e56574f/go.mod h1:Dl7hjwR994w85OTYMab1VMHjrlC+IPlvsgSjFZIzgqw=
 github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
 github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
 github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0=

+ 29 - 29
internal/sub/clash_service_test.go

@@ -320,33 +320,33 @@ func TestBuildProxy_VLESSNoneEncryptionOmittedForClash(t *testing.T) {
 
 func TestBuildXhttpClashOpts_FullFieldMapping(t *testing.T) {
 	xhttp := map[string]any{
-		"path":    "/api/v1",
-		"mode":    "stream-up",
-		"host":    "example.com",
-		"xPaddingBytes":    "100-1000",
-		"xPaddingObfsMode": true,
-		"xPaddingKey":       "mykey",
-		"xPaddingHeader":    "X-Trace-ID",
-		"xPaddingPlacement": "queryInHeader",
-		"xPaddingMethod":    "tokenish",
-		"uplinkHTTPMethod":  "POST",
-		"sessionPlacement":  "query",
-		"sessionKey":        "sess",
-		"seqPlacement":      "header",
-		"seqKey":            "seq",
-		"uplinkDataPlacement": "body",
-		"uplinkDataKey":      "udata",
-		"uplinkChunkSize":    "64-256",
-		"noGRPCHeader":       true,
-		"scMaxEachPostBytes": "500000",
+		"path":                 "/api/v1",
+		"mode":                 "stream-up",
+		"host":                 "example.com",
+		"xPaddingBytes":        "100-1000",
+		"xPaddingObfsMode":     true,
+		"xPaddingKey":          "mykey",
+		"xPaddingHeader":       "X-Trace-ID",
+		"xPaddingPlacement":    "queryInHeader",
+		"xPaddingMethod":       "tokenish",
+		"uplinkHTTPMethod":     "POST",
+		"sessionPlacement":     "query",
+		"sessionKey":           "sess",
+		"seqPlacement":         "header",
+		"seqKey":               "seq",
+		"uplinkDataPlacement":  "body",
+		"uplinkDataKey":        "udata",
+		"uplinkChunkSize":      "64-256",
+		"noGRPCHeader":         true,
+		"scMaxEachPostBytes":   "500000",
 		"scMinPostsIntervalMs": "50",
 		"xmux": map[string]any{
 			"maxConcurrency":   "16-32",
 			"maxConnections":   "4",
 			"cMaxReuseTimes":   "8",
-			"hMaxRequestTimes":  "600-900",
-			"hMaxReusableSecs":  "1800-3000",
-			"hKeepAlivePeriod":  float64(60),
+			"hMaxRequestTimes": "600-900",
+			"hMaxReusableSecs": "1800-3000",
+			"hKeepAlivePeriod": float64(60),
 		},
 		"headers": map[string]any{
 			"User-Agent": "chrome",
@@ -473,8 +473,8 @@ func TestBuildXhttpClashOpts_FullFieldMapping(t *testing.T) {
 
 func TestBuildXhttpClashOpts_DPIDefaultsFiltered(t *testing.T) {
 	xhttp := map[string]any{
-		"path":                "/",
-		"mode":                "stream-up",
+		"path":                 "/",
+		"mode":                 "stream-up",
 		"scMaxEachPostBytes":   "1000000",
 		"scMinPostsIntervalMs": "30",
 	}
@@ -494,9 +494,9 @@ func TestBuildXhttpClashOpts_PaddingObfsGate(t *testing.T) {
 	// Sub-test 1: obfs mode false — gated fields should not appear
 	t.Run("ObfsModeFalse", func(t *testing.T) {
 		xhttp := map[string]any{
-			"path":            "/",
+			"path":             "/",
 			"xPaddingObfsMode": false,
-			"xPaddingKey":     "should-not-appear",
+			"xPaddingKey":      "should-not-appear",
 		}
 		opts := buildXhttpClashOpts(xhttp)
 		if opts == nil {
@@ -553,9 +553,9 @@ func TestBuildXhttpClashOpts_XmuxMapsToReuseSettings(t *testing.T) {
 				"maxConcurrency":   "16-32",
 				"maxConnections":   "4",
 				"cMaxReuseTimes":   "8",
-				"hMaxRequestTimes":  "600-900",
-				"hMaxReusableSecs":  "1800-3000",
-				"hKeepAlivePeriod":  float64(60),
+				"hMaxRequestTimes": "600-900",
+				"hMaxReusableSecs": "1800-3000",
+				"hKeepAlivePeriod": float64(60),
 			},
 		}
 		opts := buildXhttpClashOpts(xhttp)

+ 8 - 2
internal/sub/external_subscription.go

@@ -78,14 +78,20 @@ func doFetchSubscriptionLinks(rawURL string) ([]string, error) {
 	if resp.StatusCode < 200 || resp.StatusCode >= 300 {
 		return nil, errBadStatus
 	}
-	body, err := io.ReadAll(io.LimitReader(resp.Body, subscriptionMaxBytes))
+	body, err := io.ReadAll(io.LimitReader(resp.Body, subscriptionMaxBytes+1))
 	if err != nil {
 		return nil, err
 	}
+	if len(body) > subscriptionMaxBytes {
+		return nil, errSubscriptionBodyTooLarge
+	}
 	return decodeSubscriptionBody(body), nil
 }
 
-var errBadStatus = &subError{"non-2xx subscription response"}
+var (
+	errBadStatus                = &subError{"non-2xx subscription response"}
+	errSubscriptionBodyTooLarge = &subError{"subscription response body exceeds size limit"}
+)
 
 type subError struct{ msg string }
 

+ 43 - 0
internal/sub/external_subscription_test.go

@@ -0,0 +1,43 @@
+package sub
+
+import (
+	"net/http"
+	"net/http/httptest"
+	"strings"
+	"testing"
+)
+
+func TestDoFetchSubscriptionLinks_RejectsOversizedBody(t *testing.T) {
+	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		_, _ = w.Write([]byte(strings.Repeat("a", subscriptionMaxBytes+1)))
+	}))
+	defer srv.Close()
+
+	links, err := doFetchSubscriptionLinks(srv.URL)
+	if err != errSubscriptionBodyTooLarge {
+		t.Fatalf("err = %v, want errSubscriptionBodyTooLarge", err)
+	}
+	if links != nil {
+		t.Fatalf("links = %v, want nil", links)
+	}
+}
+
+func TestDoFetchSubscriptionLinks_AcceptsBodyAtLimit(t *testing.T) {
+	link := "vless://example"
+	body := link + "\n" + strings.Repeat("#", subscriptionMaxBytes-len(link)-1)
+	if len(body) != subscriptionMaxBytes {
+		t.Fatalf("fixture size = %d, want %d", len(body), subscriptionMaxBytes)
+	}
+	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		_, _ = w.Write([]byte(body))
+	}))
+	defer srv.Close()
+
+	links, err := doFetchSubscriptionLinks(srv.URL)
+	if err != nil {
+		t.Fatalf("unexpected err: %v", err)
+	}
+	if len(links) != 1 || links[0] != link {
+		t.Fatalf("links = %v, want [%q]", links, link)
+	}
+}

+ 1 - 1
internal/sub/service.go

@@ -747,7 +747,7 @@ func (s *SubService) genShadowsocksLink(inbound *model.Inbound, email string) st
 			url.QueryEscape(inboundPassword),
 			url.QueryEscape(clients[clientIndex].Password))
 	} else {
-		userInfo = base64.RawURLEncoding.EncodeToString([]byte(fmt.Sprintf("%s:%s", method, clients[clientIndex].Password)))
+		userInfo = base64.RawURLEncoding.EncodeToString(fmt.Appendf(nil, "%s:%s", method, clients[clientIndex].Password))
 	}
 
 	externalProxies, _ := stream["externalProxy"].([]any)

+ 20 - 20
internal/web/entity/entity.go

@@ -39,29 +39,29 @@ type AllSetting struct {
 	Datepicker     string `json:"datepicker" form:"datepicker"`                            // Date picker format
 
 	// Telegram bot settings
-	TgBotEnable     bool   `json:"tgBotEnable" form:"tgBotEnable"`              // Enable Telegram bot notifications
-	TgBotToken      string `json:"tgBotToken" form:"tgBotToken"`                // Telegram bot token
-	TgBotProxy      string `json:"tgBotProxy" form:"tgBotProxy"`                // Proxy URL for Telegram bot
-	TgBotAPIServer  string `json:"tgBotAPIServer" form:"tgBotAPIServer"`        // Custom API server for Telegram bot
-	TgBotChatId     string `json:"tgBotChatId" form:"tgBotChatId"`              // Telegram chat ID for notifications
-	TgRunTime       string `json:"tgRunTime" form:"tgRunTime"`                  // Cron schedule for Telegram notifications
-	TgBotBackup     bool   `json:"tgBotBackup" form:"tgBotBackup"`              // Enable database backup via Telegram
-	TgCpu           int    `json:"tgCpu" form:"tgCpu" validate:"gte=0,lte=100"` // CPU usage threshold for alerts (percent)
+	TgBotEnable     bool   `json:"tgBotEnable" form:"tgBotEnable"`                    // Enable Telegram bot notifications
+	TgBotToken      string `json:"tgBotToken" form:"tgBotToken"`                      // Telegram bot token
+	TgBotProxy      string `json:"tgBotProxy" form:"tgBotProxy"`                      // Proxy URL for Telegram bot
+	TgBotAPIServer  string `json:"tgBotAPIServer" form:"tgBotAPIServer"`              // Custom API server for Telegram bot
+	TgBotChatId     string `json:"tgBotChatId" form:"tgBotChatId"`                    // Telegram chat ID for notifications
+	TgRunTime       string `json:"tgRunTime" form:"tgRunTime"`                        // Cron schedule for Telegram notifications
+	TgBotBackup     bool   `json:"tgBotBackup" form:"tgBotBackup"`                    // Enable database backup via Telegram
+	TgCpu           int    `json:"tgCpu" form:"tgCpu" validate:"gte=0,lte=100"`       // CPU usage threshold for alerts (percent)
 	TgMemory        int    `json:"tgMemory" form:"tgMemory" validate:"gte=0,lte=100"` // Memory usage threshold for alerts (percent)
-	TgLang          string `json:"tgLang" form:"tgLang"`                        // Telegram bot language
-	TgEnabledEvents string `json:"tgEnabledEvents" form:"tgEnabledEvents"`      // Comma-separated event types to send via Telegram
+	TgLang          string `json:"tgLang" form:"tgLang"`                              // Telegram bot language
+	TgEnabledEvents string `json:"tgEnabledEvents" form:"tgEnabledEvents"`            // Comma-separated event types to send via Telegram
 
 	// Email (SMTP) notification settings
-	SmtpEnable         bool   `json:"smtpEnable" form:"smtpEnable"`                        // Enable email notifications
-	SmtpHost           string `json:"smtpHost" form:"smtpHost"`                            // SMTP server host
-	SmtpPort           int    `json:"smtpPort" form:"smtpPort" validate:"gte=1,lte=65535"` // SMTP server port
-	SmtpUsername       string `json:"smtpUsername" form:"smtpUsername"`                    // SMTP username
-	SmtpPassword       string `json:"smtpPassword" form:"smtpPassword"`                    // SMTP password
-	SmtpTo             string `json:"smtpTo" form:"smtpTo"`                                // Comma-separated recipient emails
-	SmtpEncryptionType string `json:"smtpEncryptionType" form:"smtpEncryptionType"`        // SMTP encryption: none, starttls, tls
-	SmtpEnabledEvents  string `json:"smtpEnabledEvents" form:"smtpEnabledEvents"`          // Comma-separated event types to send via email
-	SmtpCpu           int    `json:"smtpCpu" form:"smtpCpu" validate:"gte=0,lte=100"`                                          // CPU threshold for email notifications
-	SmtpMemory        int    `json:"smtpMemory" form:"smtpMemory" validate:"gte=0,lte=100"`                                    // Memory threshold for email notifications
+	SmtpEnable         bool   `json:"smtpEnable" form:"smtpEnable"`                          // Enable email notifications
+	SmtpHost           string `json:"smtpHost" form:"smtpHost"`                              // SMTP server host
+	SmtpPort           int    `json:"smtpPort" form:"smtpPort" validate:"gte=1,lte=65535"`   // SMTP server port
+	SmtpUsername       string `json:"smtpUsername" form:"smtpUsername"`                      // SMTP username
+	SmtpPassword       string `json:"smtpPassword" form:"smtpPassword"`                      // SMTP password
+	SmtpTo             string `json:"smtpTo" form:"smtpTo"`                                  // Comma-separated recipient emails
+	SmtpEncryptionType string `json:"smtpEncryptionType" form:"smtpEncryptionType"`          // SMTP encryption: none, starttls, tls
+	SmtpEnabledEvents  string `json:"smtpEnabledEvents" form:"smtpEnabledEvents"`            // Comma-separated event types to send via email
+	SmtpCpu            int    `json:"smtpCpu" form:"smtpCpu" validate:"gte=0,lte=100"`       // CPU threshold for email notifications
+	SmtpMemory         int    `json:"smtpMemory" form:"smtpMemory" validate:"gte=0,lte=100"` // Memory threshold for email notifications
 
 	// Security settings
 	TimeLocation    string `json:"timeLocation" form:"timeLocation"`       // Time zone location

+ 48 - 143
internal/web/job/check_client_ip_job.go

@@ -1,14 +1,11 @@
 package job
 
 import (
-	"bufio"
 	"encoding/json"
 	"errors"
-	"io"
 	"log"
 	"os"
 	"os/exec"
-	"regexp"
 	"runtime"
 	"sort"
 	"time"
@@ -30,10 +27,9 @@ type IPWithTimestamp struct {
 
 // CheckClientIpJob monitors client IP addresses and manages IP blocking based
 // on configured limits. The per-client IPs come from the core's online-stats
-// API when the running core supports it (no access log needed), falling back
-// to access-log parsing on older cores.
+// API; no access log is involved. On a core too old to expose that API the job
+// simply skips the run (the bundled core always supports it).
 type CheckClientIpJob struct {
-	lastClear     int64
 	disAllowedIps []string
 	xrayService   service.XrayService
 }
@@ -51,41 +47,24 @@ func NewCheckClientIpJob() *CheckClientIpJob {
 }
 
 func (j *CheckClientIpJob) Run() {
-	if j.lastClear == 0 {
-		j.lastClear = time.Now().Unix()
-	}
-
-	fail2BanEnabled := isFail2BanEnabled()
-	hasLimit := fail2BanEnabled && j.hasLimitIp()
-	f2bInstalled := false
-	if hasLimit {
-		f2bInstalled = j.checkFail2BanInstalled()
-	}
-
-	if observed, apiMode := j.collectFromOnlineAPI(); apiMode {
-		if fail2BanEnabled {
-			j.processObserved(observed, j.resolveEnforce(hasLimit, f2bInstalled), true)
-		}
-		// The core tracks online IPs itself, so no access log is needed in this
-		// mode; still rotate a user-configured access log hourly so it doesn't
-		// grow unboundedly. The enforcement-triggered rotation is skipped —
-		// nothing here reads the log.
-		if j.checkAccessLogAvailable(false) && time.Now().Unix()-j.lastClear > 3600 {
-			j.clearAccessLog()
-		}
+	observed, apiMode := j.collectFromOnlineAPI()
+	if !apiMode {
+		// xray is down or predates the online-stats API. There is no access-log
+		// fallback anymore, so there is nothing to do this run.
+		logger.Debug("[LimitIP] online-stats API unavailable this run; skipping")
 		return
 	}
 
-	shouldClearAccessLog := false
-	isAccessLogAvailable := j.checkAccessLogAvailable(hasLimit)
-
-	if fail2BanEnabled && isAccessLogAvailable {
-		shouldClearAccessLog = j.processLogFile(j.resolveEnforce(hasLimit, f2bInstalled))
+	if !isFail2BanEnabled() {
+		return
 	}
 
-	if shouldClearAccessLog || (isAccessLogAvailable && time.Now().Unix()-j.lastClear > 3600) {
-		j.clearAccessLog()
+	hasLimit := j.hasLimitIp()
+	f2bInstalled := false
+	if hasLimit {
+		f2bInstalled = j.checkFail2BanInstalled()
 	}
+	j.processObserved(observed, j.resolveEnforce(hasLimit, f2bInstalled), true)
 }
 
 // resolveEnforce decides whether limits can actually be enforced this run.
@@ -102,7 +81,7 @@ func (j *CheckClientIpJob) resolveEnforce(hasLimit, f2bInstalled bool) bool {
 // collectFromOnlineAPI builds per-email IP observations (email -> ip ->
 // last-seen unix seconds) from the core's online-stats API. ok=false means the
 // API is unavailable — xray not running, an older core, or a transient gRPC
-// failure — and the caller must fall back to access-log parsing.
+// failure — and the caller skips the run (there is no access-log fallback).
 func (j *CheckClientIpJob) collectFromOnlineAPI() (map[string]map[string]int64, bool) {
 	onlineUsers, ok, err := j.xrayService.GetOnlineUsers()
 	if err != nil {
@@ -133,27 +112,6 @@ func (j *CheckClientIpJob) collectFromOnlineAPI() (map[string]map[string]int64,
 	return observed, true
 }
 
-func (j *CheckClientIpJob) clearAccessLog() {
-	logAccessP, err := os.OpenFile(xray.GetAccessPersistentLogPath(), os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o644)
-	j.checkError(err)
-	defer logAccessP.Close()
-
-	accessLogPath, err := xray.GetAccessLogPath()
-	j.checkError(err)
-
-	file, err := os.Open(accessLogPath)
-	j.checkError(err)
-	defer file.Close()
-
-	_, err = io.Copy(logAccessP, file)
-	j.checkError(err)
-
-	err = os.Truncate(accessLogPath, 0)
-	j.checkError(err)
-
-	j.lastClear = time.Now().Unix()
-}
-
 func (j *CheckClientIpJob) hasLimitIp() bool {
 	db := database.GetDB()
 	var inbounds []*model.Inbound
@@ -183,74 +141,11 @@ func (j *CheckClientIpJob) hasLimitIp() bool {
 	return false
 }
 
-func (j *CheckClientIpJob) processLogFile(enforce bool) bool {
-
-	ipRegex := regexp.MustCompile(`from (?:tcp:|udp:)?\[?([0-9a-fA-F\.:]+)\]?:\d+ accepted`)
-	emailRegex := regexp.MustCompile(`email: (.+)$`)
-	timestampRegex := regexp.MustCompile(`^(\d{4}/\d{2}/\d{2} \d{2}:\d{2}:\d{2})`)
-
-	accessLogPath, _ := xray.GetAccessLogPath()
-	file, _ := os.Open(accessLogPath)
-	defer file.Close()
-
-	// Track IPs with their last seen timestamp
-	inboundClientIps := make(map[string]map[string]int64, 100)
-
-	scanner := bufio.NewScanner(file)
-	for scanner.Scan() {
-		line := scanner.Text()
-
-		ipMatches := ipRegex.FindStringSubmatch(line)
-		if len(ipMatches) < 2 {
-			continue
-		}
-
-		ip := ipMatches[1]
-
-		if ip == "127.0.0.1" || ip == "::1" {
-			continue
-		}
-
-		emailMatches := emailRegex.FindStringSubmatch(line)
-		if len(emailMatches) < 2 {
-			continue
-		}
-		email := emailMatches[1]
-
-		// Extract timestamp from log line
-		var timestamp int64
-		timestampMatches := timestampRegex.FindStringSubmatch(line)
-		if len(timestampMatches) >= 2 {
-			t, err := time.ParseInLocation("2006/01/02 15:04:05", timestampMatches[1], time.Local)
-			if err == nil {
-				timestamp = t.Unix()
-			} else {
-				timestamp = time.Now().Unix()
-			}
-		} else {
-			timestamp = time.Now().Unix()
-		}
-
-		if _, exists := inboundClientIps[email]; !exists {
-			inboundClientIps[email] = make(map[string]int64)
-		}
-		// Update timestamp - keep the latest
-		if existingTime, ok := inboundClientIps[email][ip]; !ok || timestamp > existingTime {
-			inboundClientIps[email][ip] = timestamp
-		}
-	}
-	if err := scanner.Err(); err != nil {
-		j.checkError(err)
-	}
-
-	return j.processObserved(inboundClientIps, enforce, false)
-}
-
 // processObserved runs collection + enforcement for one scan's observations
 // (email -> ip -> last-seen unix seconds). observedAreLive marks the
-// observations as live connections (online-stats API) rather than recent log
-// lines: live entries bypass the stale cutoff, since a connection that opened
-// hours ago is still live even though its timestamp is old.
+// observations as live connections, which bypass the stale cutoff: a connection
+// that opened hours ago is still live even though its timestamp is old. The
+// online-stats API always reports live connections, so the job passes true.
 func (j *CheckClientIpJob) processObserved(observed map[string]map[string]int64, enforce, observedAreLive bool) bool {
 	shouldCleanLog := false
 	now := time.Now().Unix()
@@ -391,22 +286,6 @@ func isFail2BanEnabled() bool {
 	return !ok || value == "true"
 }
 
-func (j *CheckClientIpJob) checkAccessLogAvailable(iplimitActive bool) bool {
-	accessLogPath, err := xray.GetAccessLogPath()
-	if err != nil {
-		return false
-	}
-
-	if accessLogPath == "none" || accessLogPath == "" {
-		if iplimitActive {
-			logger.Warning("[LimitIP] Access log path is not set, Please configure the access log path in Xray configs.")
-		}
-		return false
-	}
-
-	return true
-}
-
 func (j *CheckClientIpJob) checkError(e error) {
 	if e != nil {
 		logger.Warning("client ip job err:", e)
@@ -682,14 +561,40 @@ func getAPIPortFromConfigData(configData []byte) (int, error) {
 	return 0, errors.New("api inbound port not found")
 }
 
+// getInboundByEmail resolves the inbound that owns a client email. It prefers
+// the exact clients/client_inbounds relation; a substring "settings LIKE
+// %email%" can match the wrong inbound (an email that is a substring of another,
+// or text that merely appears elsewhere in the settings JSON). The LIKE + JSON
+// scan stays only as a fallback for clients not yet present in the relation, so
+// nothing regresses when the join finds no row.
 func (j *CheckClientIpJob) getInboundByEmail(clientEmail string) (*model.Inbound, error) {
 	db := database.GetDB()
 	inbound := &model.Inbound{}
 
-	err := db.Model(&model.Inbound{}).Where("settings LIKE ?", "%"+clientEmail+"%").First(inbound).Error
-	if err != nil {
-		return nil, err
+	err := db.Model(&model.Inbound{}).
+		Joins("JOIN client_inbounds ON client_inbounds.inbound_id = inbounds.id").
+		Joins("JOIN clients ON clients.id = client_inbounds.client_id").
+		Where("clients.email = ?", clientEmail).
+		First(inbound).Error
+	if err == nil {
+		return inbound, nil
+	}
+
+	var candidates []model.Inbound
+	if listErr := db.Model(&model.Inbound{}).Where("settings LIKE ?", "%"+clientEmail+"%").Find(&candidates).Error; listErr != nil {
+		return nil, listErr
+	}
+	for i := range candidates {
+		settings := map[string][]model.Client{}
+		if jsonErr := json.Unmarshal([]byte(candidates[i].Settings), &settings); jsonErr != nil {
+			continue
+		}
+		for _, client := range settings["clients"] {
+			if client.Email == clientEmail {
+				return &candidates[i], nil
+			}
+		}
 	}
 
-	return inbound, nil
+	return nil, err
 }

+ 79 - 67
internal/web/job/check_client_ip_job_integration_test.go

@@ -59,6 +59,11 @@ func setupIntegrationDB(t *testing.T) {
 // seed an inbound whose settings json has a single client with the
 // given email and ip limit.
 func seedInboundWithClient(t *testing.T, tag, email string, limitIp int) {
+	t.Helper()
+	seedInboundOnlyWithClient(t, tag, email, limitIp)
+}
+
+func seedInboundOnlyWithClient(t *testing.T, tag, email string, limitIp int) *model.Inbound {
 	t.Helper()
 	settings := map[string]any{
 		"clients": []map[string]any{
@@ -83,6 +88,21 @@ func seedInboundWithClient(t *testing.T, tag, email string, limitIp int) {
 	if err := database.GetDB().Create(inbound).Error; err != nil {
 		t.Fatalf("seed inbound: %v", err)
 	}
+	return inbound
+}
+
+func seedLinkedInboundWithClient(t *testing.T, tag, email string, limitIp int) *model.Inbound {
+	t.Helper()
+	inbound := seedInboundOnlyWithClient(t, tag, email, limitIp)
+	client := &model.ClientRecord{Email: email}
+	if err := database.GetDB().Create(client).Error; err != nil {
+		t.Fatalf("seed client record: %v", err)
+	}
+	link := &model.ClientInbound{ClientId: client.Id, InboundId: inbound.Id}
+	if err := database.GetDB().Create(link).Error; err != nil {
+		t.Fatalf("seed client inbound link: %v", err)
+	}
+	return inbound
 }
 
 // seed an InboundClientIps row with the given blob.
@@ -128,46 +148,32 @@ func ipSet(entries []IPWithTimestamp) map[string]int64 {
 	return out
 }
 
-func TestRun_DisabledFail2BanSkipsProbeAndBanLog(t *testing.T) {
+// With the access-log fallback removed, an unavailable online-stats API (xray
+// down, as in this unit test) must make Run a clean no-op: no fail2ban probe, no
+// ban log, and no inbound_client_ips rows — never a crash or partial work.
+func TestRun_NoOpWhenOnlineApiUnavailable(t *testing.T) {
 	setupIntegrationDB(t)
-	t.Setenv("XUI_ENABLE_FAIL2BAN", "false")
+	t.Setenv("XUI_ENABLE_FAIL2BAN", "true")
 	marker := fakeFail2BanClient(t)
 
-	const email = "disabled-fail2ban"
-	seedInboundWithClient(t, "inbound-disabled-fail2ban", email, 1)
+	const email = "no-api-user"
+	seedInboundWithClient(t, "inbound-no-api", email, 1)
 
-	binDir := t.TempDir()
-	accessLog := filepath.Join(t.TempDir(), "access.log")
-	t.Setenv("XUI_BIN_FOLDER", binDir)
-	configData, err := json.Marshal(map[string]any{
-		"log": map[string]any{"access": accessLog},
-	})
-	if err != nil {
-		t.Fatalf("marshal xray config: %v", err)
-	}
-	if err := os.WriteFile(filepath.Join(binDir, "config.json"), configData, 0644); err != nil {
-		t.Fatalf("write xray config: %v", err)
-	}
-	if err := os.WriteFile(accessLog, []byte("2026/05/26 12:00:00 from tcp:203.0.113.10:443 accepted tcp:example.com:443 email: disabled-fail2ban\n"), 0644); err != nil {
-		t.Fatalf("write access log: %v", err)
-	}
-
-	j := NewCheckClientIpJob()
-	j.Run()
+	NewCheckClientIpJob().Run()
 
 	if _, err := os.Stat(marker); !os.IsNotExist(err) {
-		t.Fatalf("fail2ban-client should not have been executed, stat error: %v", err)
+		t.Fatalf("fail2ban-client should not have been probed when the online API is unavailable, stat error: %v", err)
 	}
 	if info, err := os.Stat(readIpLimitLogPath()); err == nil && info.Size() > 0 {
 		body, _ := os.ReadFile(readIpLimitLogPath())
-		t.Fatalf("3xipl.log should be empty when fail2ban is disabled, got:\n%s", body)
+		t.Fatalf("3xipl.log should be empty when Run no-ops, got:\n%s", body)
 	}
 	var count int64
 	if err := database.GetDB().Model(&model.InboundClientIps{}).Where("client_email = ?", email).Count(&count).Error; err != nil {
 		t.Fatalf("count InboundClientIps: %v", err)
 	}
 	if count != 0 {
-		t.Fatalf("disabled fail2ban should not persist IP-limit rows, got %d", count)
+		t.Fatalf("no IP-limit rows should be persisted when Run no-ops, got %d", count)
 	}
 }
 
@@ -280,47 +286,24 @@ func TestUpdateInboundClientIps_ExcessLiveIpIsStillBanned(t *testing.T) {
 	}
 }
 
-// writeXrayAccessLog points bin/config.json at a fresh access.log holding a
-// single default-format Xray line (`from tcp:<ip>:<port> accepted … email: <e>`)
-// for the given client, so Run() has something to scrape.
-func writeXrayAccessLog(t *testing.T, email, ip string) {
-	t.Helper()
-	binDir := t.TempDir()
-	accessLog := filepath.Join(t.TempDir(), "access.log")
-	t.Setenv("XUI_BIN_FOLDER", binDir)
-	configData, err := json.Marshal(map[string]any{
-		"log": map[string]any{"access": accessLog},
-	})
-	if err != nil {
-		t.Fatalf("marshal xray config: %v", err)
-	}
-	if err := os.WriteFile(filepath.Join(binDir, "config.json"), configData, 0644); err != nil {
-		t.Fatalf("write xray config: %v", err)
-	}
-	line := "2026/06/02 13:35:53 from tcp:" + ip + ":2387 accepted tcp:example.com:443 email: " + email + "\n"
-	if err := os.WriteFile(accessLog, []byte(line), 0644); err != nil {
-		t.Fatalf("write access log: %v", err)
-	}
-}
-
-// #4800: the per-client IP log must populate even when no client has an IP
-// limit. Before the fix, Run() only scraped the access log when an IP limit
-// was active, so a limit-free install always showed an empty IP log despite
-// valid access-log lines. No ban may be written since there's no limit.
-func TestRun_CollectsIpsWithoutLimit(t *testing.T) {
+// #4800: per-client IP tracking must populate even when no client has an IP
+// limit. processObserved records observed IPs for the panel regardless of any
+// limit; only enforcement is gated, so a limit-free install still shows IPs. No
+// ban may be written since there's no limit.
+func TestProcessObserved_CollectsIpsWithoutLimit(t *testing.T) {
 	setupIntegrationDB(t)
-	t.Setenv("XUI_ENABLE_FAIL2BAN", "true")
-	fakeFail2BanClient(t)
 
 	const email = "no-limit-user"
 	seedInboundWithClient(t, "inbound-no-limit", email, 0) // limitIp = 0
-	writeXrayAccessLog(t, email, "203.0.113.10")
 
-	NewCheckClientIpJob().Run()
+	observed := map[string]map[string]int64{
+		email: {"203.0.113.10": time.Now().Unix()},
+	}
+	NewCheckClientIpJob().processObserved(observed, true, true)
 
 	ips := readClientIps(t, email)
 	if len(ips) != 1 || ips[0].IP != "203.0.113.10" {
-		t.Fatalf("expected the access-log IP to be collected without a limit, got %v", ips)
+		t.Fatalf("expected the observed IP to be collected without a limit, got %v", ips)
 	}
 
 	if info, err := os.Stat(readIpLimitLogPath()); err == nil && info.Size() > 0 {
@@ -329,22 +312,21 @@ func TestRun_CollectsIpsWithoutLimit(t *testing.T) {
 	}
 }
 
-// #4963: a stale access-log entry for a renamed/deleted client (its email no
-// longer maps to any inbound) must not create or resurrect an
-// inbound_client_ips row, and must drop any orphan left behind — instead of
-// spamming "failed to fetch inbound settings" every run.
-func TestRun_StaleAccessLogEmailIsSkippedAndOrphanDropped(t *testing.T) {
+// #4963: an observed IP for a renamed/deleted client (its email no longer maps
+// to any inbound) must not create or resurrect an inbound_client_ips row, and
+// must drop any orphan left behind — instead of erroring every run.
+func TestProcessObserved_StaleEmailIsSkippedAndOrphanDropped(t *testing.T) {
 	setupIntegrationDB(t)
-	t.Setenv("XUI_ENABLE_FAIL2BAN", "true")
-	fakeFail2BanClient(t)
 
 	const staleEmail = "renamed-away"
 	// No inbound references staleEmail. Pre-seed an orphan tracking row to
 	// confirm the job removes it rather than leaving it to error forever.
 	seedClientIps(t, staleEmail, []IPWithTimestamp{{IP: "203.0.113.5", Timestamp: time.Now().Unix()}})
-	writeXrayAccessLog(t, staleEmail, "203.0.113.5")
 
-	NewCheckClientIpJob().Run()
+	observed := map[string]map[string]int64{
+		staleEmail: {"203.0.113.5": time.Now().Unix()},
+	}
+	NewCheckClientIpJob().processObserved(observed, true, true)
 
 	var count int64
 	if err := database.GetDB().Model(&model.InboundClientIps{}).Where("client_email = ?", staleEmail).Count(&count).Error; err != nil {
@@ -375,3 +357,33 @@ func contains(haystack, needle string) bool {
 	}
 	return false
 }
+
+// the exact clients/client_inbounds relation must win over the substring scan,
+// so a client is resolved to its own inbound even when another inbound holds a
+// superstring email.
+func TestGetInboundByEmailUsesClientInboundLink(t *testing.T) {
+	setupIntegrationDB(t)
+
+	want := seedLinkedInboundWithClient(t, "linked-inbound", "[email protected]", 1)
+	seedInboundOnlyWithClient(t, "other-inbound", "[email protected]", 1)
+
+	got, err := (&CheckClientIpJob{}).getInboundByEmail("[email protected]")
+	if err != nil {
+		t.Fatalf("getInboundByEmail returned error: %v", err)
+	}
+	if got.Id != want.Id {
+		t.Fatalf("getInboundByEmail returned inbound %d, want %d", got.Id, want.Id)
+	}
+}
+
+// the substring fallback must still verify the exact email inside settings, so
+// "[email protected]" does not match an inbound holding "[email protected]".
+func TestGetInboundByEmailRejectsSubstringFallbackMatch(t *testing.T) {
+	setupIntegrationDB(t)
+
+	seedInboundOnlyWithClient(t, "substring-only", "[email protected]", 1)
+
+	if got, err := (&CheckClientIpJob{}).getInboundByEmail("[email protected]"); err == nil {
+		t.Fatalf("substring email matched inbound %d; want no exact match", got.Id)
+	}
+}

+ 18 - 2
internal/web/job/clear_logs_job.go

@@ -34,8 +34,8 @@ func ensureFileExists(path string) error {
 
 // Here Run is an interface method of the Job interface
 func (j *ClearLogsJob) Run() {
-	logFiles := []string{xray.GetIPLimitLogPath(), xray.GetIPLimitBannedLogPath(), xray.GetAccessPersistentLogPath()}
-	logFilesPrev := []string{xray.GetIPLimitBannedPrevLogPath(), xray.GetAccessPersistentPrevLogPath()}
+	logFiles := []string{xray.GetIPLimitLogPath(), xray.GetIPLimitBannedLogPath()}
+	logFilesPrev := []string{xray.GetIPLimitBannedPrevLogPath()}
 
 	// Ensure all log files and their paths exist
 	for _, path := range append(logFiles, logFilesPrev...) {
@@ -75,4 +75,20 @@ func (j *ClearLogsJob) Run() {
 			logger.Warning("Failed to truncate log file:", logFiles[i], "-", err)
 		}
 	}
+
+	wipeAccessLog()
+}
+
+// wipeAccessLog truncates the user-configured Xray access log so it can't grow
+// unbounded. The IP-limit job no longer reads or rotates it, so this daily wipe
+// is the only thing that caps it. A disabled ("none") or unset access log is
+// left alone, and a missing file is fine — there's nothing to wipe.
+func wipeAccessLog() {
+	accessLogPath, err := xray.GetAccessLogPath()
+	if err != nil || accessLogPath == "none" || accessLogPath == "" {
+		return
+	}
+	if err := os.Truncate(accessLogPath, 0); err != nil && !os.IsNotExist(err) {
+		logger.Warning("Failed to truncate access log:", accessLogPath, "-", err)
+	}
 }

+ 55 - 0
internal/web/job/clear_logs_job_test.go

@@ -0,0 +1,55 @@
+package job
+
+import (
+	"encoding/json"
+	"os"
+	"path/filepath"
+	"testing"
+)
+
+// writeAccessLogConfig points bin/config.json at the given access log path (use
+// "none" to disable), so GetAccessLogPath resolves it the way the job does.
+func writeAccessLogConfig(t *testing.T, accessPath string) {
+	t.Helper()
+	binDir := t.TempDir()
+	t.Setenv("XUI_BIN_FOLDER", binDir)
+	configData, err := json.Marshal(map[string]any{
+		"log": map[string]any{"access": accessPath},
+	})
+	if err != nil {
+		t.Fatalf("marshal xray config: %v", err)
+	}
+	if err := os.WriteFile(filepath.Join(binDir, "config.json"), configData, 0644); err != nil {
+		t.Fatalf("write xray config: %v", err)
+	}
+}
+
+func TestWipeAccessLog_TruncatesEnabledLog(t *testing.T) {
+	accessLog := filepath.Join(t.TempDir(), "access.log")
+	if err := os.WriteFile(accessLog, []byte("2026/06/23 12:00:00 from tcp:203.0.113.10:443 accepted\n"), 0644); err != nil {
+		t.Fatalf("seed access log: %v", err)
+	}
+	writeAccessLogConfig(t, accessLog)
+
+	wipeAccessLog()
+
+	info, err := os.Stat(accessLog)
+	if err != nil {
+		t.Fatalf("access log should still exist: %v", err)
+	}
+	if info.Size() != 0 {
+		t.Fatalf("access log should be truncated to 0, got %d bytes", info.Size())
+	}
+}
+
+func TestWipeAccessLog_LeavesDisabledLogAlone(t *testing.T) {
+	writeAccessLogConfig(t, "none")
+
+	// Must not panic or create a file literally named "none".
+	wipeAccessLog()
+
+	if _, err := os.Stat("none"); err == nil {
+		os.Remove("none")
+		t.Fatal(`wipeAccessLog must not create a file named "none"`)
+	}
+}

+ 1 - 4
internal/web/job/node_traffic_sync_job.go

@@ -246,10 +246,7 @@ func (j *NodeTrafficSyncJob) nodeInboundSpeed() []*xray.Traffic {
 		if dDown < 0 {
 			dDown = 0
 		}
-		elapsed := now - prev.at
-		if elapsed < nodeInboundSpeedWindowMs {
-			elapsed = nodeInboundSpeedWindowMs
-		}
+		elapsed := max(now-prev.at, nodeInboundSpeedWindowMs)
 		up := dUp * nodeInboundSpeedWindowMs / elapsed
 		down := dDown * nodeInboundSpeedWindowMs / elapsed
 		if up > 0 || down > 0 {

+ 20 - 1
internal/web/service/outbound_subscription.go

@@ -3,6 +3,7 @@ package service
 import (
 	"context"
 	"encoding/json"
+	"errors"
 	"fmt"
 	"io"
 	"net/http"
@@ -18,6 +19,24 @@ import (
 	"github.com/mhsanaei/3x-ui/v3/internal/util/link"
 )
 
+// maxOutboundSubscriptionBytes caps a single outbound subscription response.
+// It is larger than the 2 MiB user-facing subscription cap because an outbound
+// subscription may aggregate many upstream outbounds into one document.
+const maxOutboundSubscriptionBytes int64 = 8 << 20
+
+var errOutboundSubscriptionBodyTooLarge = errors.New("outbound subscription response body exceeds size limit")
+
+func readBoundedOutboundSubscriptionBody(r io.Reader) ([]byte, error) {
+	body, err := io.ReadAll(io.LimitReader(r, maxOutboundSubscriptionBytes+1))
+	if err != nil {
+		return nil, err
+	}
+	if int64(len(body)) > maxOutboundSubscriptionBytes {
+		return nil, fmt.Errorf("%w (limit: %d bytes)", errOutboundSubscriptionBodyTooLarge, maxOutboundSubscriptionBytes)
+	}
+	return body, nil
+}
+
 // OutboundSubscriptionService manages remote outbound subscriptions.
 type OutboundSubscriptionService struct {
 	settingService SettingService
@@ -281,7 +300,7 @@ func (s *OutboundSubscriptionService) fetchAndStore(sub *model.OutboundSubscript
 		s.recordError(sub, err)
 		return nil, err
 	}
-	body, err := io.ReadAll(resp.Body)
+	body, err := readBoundedOutboundSubscriptionBody(resp.Body)
 	if err != nil {
 		s.recordError(sub, err)
 		return nil, err

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

@@ -1,12 +1,38 @@
 package service
 
 import (
+	"bytes"
+	"errors"
 	"testing"
 
 	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
 	"github.com/mhsanaei/3x-ui/v3/internal/util/link"
 )
 
+func TestReadBoundedOutboundSubscriptionBody(t *testing.T) {
+	t.Run("accepts body at the limit", func(t *testing.T) {
+		want := bytes.Repeat([]byte("a"), int(maxOutboundSubscriptionBytes))
+		got, err := readBoundedOutboundSubscriptionBody(bytes.NewReader(want))
+		if err != nil {
+			t.Fatalf("readBoundedOutboundSubscriptionBody: %v", err)
+		}
+		if !bytes.Equal(got, want) {
+			t.Fatalf("body mismatch: got %d bytes, want %d", len(got), len(want))
+		}
+	})
+
+	t.Run("rejects body over the limit", func(t *testing.T) {
+		body := bytes.Repeat([]byte("b"), int(maxOutboundSubscriptionBytes)+1)
+		got, err := readBoundedOutboundSubscriptionBody(bytes.NewReader(body))
+		if !errors.Is(err, errOutboundSubscriptionBodyTooLarge) {
+			t.Fatalf("error = %v, want errOutboundSubscriptionBodyTooLarge", err)
+		}
+		if got != nil {
+			t.Fatalf("oversized body returned %d bytes, want nil", len(got))
+		}
+	})
+}
+
 func TestDefaultPrefixNumber(t *testing.T) {
 	mk := func(id int, prefix string) *model.OutboundSubscription {
 		return &model.OutboundSubscription{Id: id, TagPrefix: prefix}

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

@@ -936,7 +936,7 @@ func (s *ServerService) fetchXrayDigestSHA256(client *http.Client, dgstURL strin
 // parseXrayDigestSHA256 extracts the lowercase SHA2-256 hex from an XTLS .dgst
 // file, whose lines are "ALGO= <hex>" (the relevant one being "SHA2-256= ...").
 func parseXrayDigestSHA256(dgst []byte) (string, error) {
-	for _, line := range strings.Split(string(dgst), "\n") {
+	for line := range strings.SplitSeq(string(dgst), "\n") {
 		rest, ok := strings.CutPrefix(strings.TrimSpace(line), "SHA2-256=")
 		if !ok {
 			continue

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

@@ -562,7 +562,7 @@
         "tcpCongestion": "TCP Congestion",
         "dialerProxy": "Dialer Proxy",
         "trustedXForwardedFor": "X-Forwarded-For موثوق",
-        "trustedXForwardedForHint": "ثِق بترويسة الطلب هذه للحصول على IP الحقيقي للعميل (مثل CF-Connecting-IP خلف CDN الخاص بـ Cloudflare). تعمل فقط على وسائل النقل WebSocket و HTTPUpgrade و XHTTP. اتركها فارغة لتجاهل ترويسات التمرير.",
+        "trustedXForwardedForHint": "ثِق بترويسة الطلب هذه للحصول على IP الحقيقي للعميل (مثل CF-Connecting-IP خلف CDN الخاص بـ Cloudflare). تعمل فقط على وسائل النقل WebSocket و HTTPUpgrade و XHTTP و gRPC. اتركها فارغة لتجاهل ترويسات التمرير.",
         "proxyProtocolHint": "اقبل ترويسة PROXY protocol لمعرفة IP الحقيقي للعميل من نفق/مُرحِّل L4 أعلى (HAProxy و gost و nginx-stream و Xray dokodemo-door) أو Cloudflare Spectrum. يجب على الجهة الأعلى إرسال PROXY protocol. تعمل على TCP و WebSocket و HTTPUpgrade و gRPC؛ ولا تعمل على mKCP.",
         "realClientIp": "IP الحقيقي للعميل",
         "realClientIpHint": "احصل على IP الحقيقي للزائر عندما يصل المرور إلى هذا الـ inbound عبر CDN أو مُرحِّل، بدلاً من تسجيل عنوان الوسيط. اختر إعدادًا مسبقًا لملء حقول sockopt المقابلة أدناه. لا تُرسَل هذه الحقول أبدًا إلى العملاء في الاشتراكات.",
@@ -1489,7 +1489,6 @@
         "overrideXrayPrivateIp": "تجاوز حظر IP الخاص الافتراضي في Xray",
         "blockDelay": "تأخير الحظر (ms)",
         "reverseSniffing": "Sniffing عكسي",
-        "workers": "Workers",
         "reserved": "محجوز",
         "minUploadInterval": "أدنى فاصل رفع (ms)",
         "maxUploadSizeBytes": "حجم الرفع الأقصى (بايت)",

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

@@ -562,7 +562,7 @@
         "tcpCongestion": "TCP Congestion",
         "dialerProxy": "Dialer Proxy",
         "trustedXForwardedFor": "Trusted X-Forwarded-For",
-        "trustedXForwardedForHint": "Trust this request header for the real client IP (e.g. CF-Connecting-IP behind Cloudflare's CDN). Only honored on WebSocket, HTTPUpgrade and XHTTP transports. Leave empty to ignore forwarded headers.",
+        "trustedXForwardedForHint": "Trust this request header for the real client IP (e.g. CF-Connecting-IP behind Cloudflare's CDN). Only honored on WebSocket, HTTPUpgrade, XHTTP and gRPC transports. Leave empty to ignore forwarded headers.",
         "proxyProtocolHint": "Accept the PROXY-protocol header to learn the real client IP from an upstream L4 tunnel or relay (HAProxy, gost, nginx-stream, Xray dokodemo-door) or Cloudflare Spectrum. The upstream MUST emit PROXY protocol. Works on TCP, WebSocket, HTTPUpgrade and gRPC; not on mKCP.",
         "realClientIp": "Real client IP",
         "realClientIpHint": "Capture the visitor's real IP when traffic reaches this inbound through a CDN or relay, instead of recording the intermediary's address. Pick a preset to fill the matching sockopt fields below. These fields are never sent to clients in subscriptions.",
@@ -1597,7 +1597,6 @@
         "overrideXrayPrivateIp": "Override Xray's default private-IP block",
         "blockDelay": "Block delay (ms)",
         "reverseSniffing": "Reverse Sniffing",
-        "workers": "Workers",
         "reserved": "Reserved",
         "minUploadInterval": "Min upload interval (ms)",
         "maxUploadSizeBytes": "Max upload size (bytes)",

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

@@ -562,7 +562,7 @@
         "tcpCongestion": "TCP Congestion",
         "dialerProxy": "Dialer Proxy",
         "trustedXForwardedFor": "X-Forwarded-For de confianza",
-        "trustedXForwardedForHint": "Confía en esta cabecera de solicitud para obtener la IP real del cliente (p. ej. CF-Connecting-IP detrás del CDN de Cloudflare). Solo válido en los transportes WebSocket, HTTPUpgrade y XHTTP. Déjalo vacío para ignorar las cabeceras reenviadas.",
+        "trustedXForwardedForHint": "Confía en esta cabecera de solicitud para obtener la IP real del cliente (p. ej. CF-Connecting-IP detrás del CDN de Cloudflare). Solo válido en los transportes WebSocket, HTTPUpgrade, XHTTP y gRPC. Déjalo vacío para ignorar las cabeceras reenviadas.",
         "proxyProtocolHint": "Acepta la cabecera PROXY protocol para obtener la IP real del cliente desde un túnel/relé L4 superior (HAProxy, gost, nginx-stream, Xray dokodemo-door) o Cloudflare Spectrum. El nodo superior DEBE enviar PROXY protocol. Funciona en TCP, WebSocket, HTTPUpgrade y gRPC; no en mKCP.",
         "realClientIp": "IP real del cliente",
         "realClientIpHint": "Captura la IP real del visitante cuando el tráfico llega a este inbound a través de un CDN o relé, en lugar de registrar la dirección del intermediario. Elige un preajuste para rellenar los campos sockopt correspondientes más abajo. Estos campos nunca se envían a los clientes en las suscripciones.",
@@ -1489,7 +1489,6 @@
         "overrideXrayPrivateIp": "Sobrescribir el bloqueo de IP privada por defecto de Xray",
         "blockDelay": "Retraso de bloqueo (ms)",
         "reverseSniffing": "Sniffing inverso",
-        "workers": "Workers",
         "reserved": "Reservado",
         "minUploadInterval": "Intervalo mín. de subida (ms)",
         "maxUploadSizeBytes": "Tamaño máx. de subida (bytes)",

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

@@ -562,7 +562,7 @@
         "tcpCongestion": "تراکم TCP",
         "dialerProxy": "Dialer Proxy",
         "trustedXForwardedFor": "X-Forwarded-For مورد اعتماد",
-        "trustedXForwardedForHint": "این هدر درخواست برای گرفتن IP واقعی کاربر مورد اعتماد قرار می‌گیرد (مثلاً CF-Connecting-IP پشت CDN کلودفلر). فقط روی ترنسپورت‌های WebSocket، HTTPUpgrade و XHTTP اعمال می‌شود. برای نادیده‌گرفتن هدرها خالی بگذارید.",
+        "trustedXForwardedForHint": "این هدر درخواست برای گرفتن IP واقعی کاربر مورد اعتماد قرار می‌گیرد (مثلاً CF-Connecting-IP پشت CDN کلودفلر). فقط روی ترنسپورت‌های WebSocket، HTTPUpgrade، XHTTP و gRPC اعمال می‌شود. برای نادیده‌گرفتن هدرها خالی بگذارید.",
         "proxyProtocolHint": "پذیرش هدر PROXY protocol برای گرفتن IP واقعی کاربر از یک تونل/رله L4 بالادست (HAProxy، gost، nginx-stream، Xray dokodemo-door) یا Cloudflare Spectrum. بالادست باید PROXY protocol را ارسال کند. روی TCP، WebSocket، HTTPUpgrade و gRPC کار می‌کند؛ روی mKCP خیر.",
         "realClientIp": "IP واقعی کاربر",
         "realClientIpHint": "وقتی ترافیک از طریق CDN یا رله به این ورودی می‌رسد، به‌جای ثبت آدرس واسط، IP واقعی کاربر گرفته می‌شود. یک پریست انتخاب کنید تا فیلدهای sockopt مربوطه پایین تکمیل شوند. این فیلدها هرگز در اشتراک‌ها به کلاینت‌ها ارسال نمی‌شوند.",
@@ -1489,7 +1489,6 @@
         "overrideXrayPrivateIp": "override بلاک پیش‌فرض IP خصوصی Xray",
         "blockDelay": "تأخیر بلاک (ms)",
         "reverseSniffing": "Sniffing معکوس",
-        "workers": "Workerها",
         "reserved": "رزرو شده",
         "minUploadInterval": "حداقل بازه آپلود (ms)",
         "maxUploadSizeBytes": "حداکثر اندازه آپلود (بایت)",

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

@@ -562,7 +562,7 @@
         "tcpCongestion": "TCP Congestion",
         "dialerProxy": "Dialer Proxy",
         "trustedXForwardedFor": "X-Forwarded-For tepercaya",
-        "trustedXForwardedForHint": "Percayai header permintaan ini untuk IP klien asli (mis. CF-Connecting-IP di belakang CDN Cloudflare). Hanya berlaku pada transport WebSocket, HTTPUpgrade, dan XHTTP. Kosongkan untuk mengabaikan header yang diteruskan.",
+        "trustedXForwardedForHint": "Percayai header permintaan ini untuk IP klien asli (mis. CF-Connecting-IP di belakang CDN Cloudflare). Hanya berlaku pada transport WebSocket, HTTPUpgrade, XHTTP, dan gRPC. Kosongkan untuk mengabaikan header yang diteruskan.",
         "proxyProtocolHint": "Terima header PROXY protocol untuk mengetahui IP klien asli dari tunnel/relay L4 di hulu (HAProxy, gost, nginx-stream, Xray dokodemo-door) atau Cloudflare Spectrum. Hulu HARUS mengirim PROXY protocol. Berfungsi pada TCP, WebSocket, HTTPUpgrade, dan gRPC; tidak pada mKCP.",
         "realClientIp": "IP klien asli",
         "realClientIpHint": "Tangkap IP asli pengunjung saat lalu lintas mencapai inbound ini melalui CDN atau relay, alih-alih mencatat alamat perantara. Pilih preset untuk mengisi kolom sockopt terkait di bawah. Kolom ini tidak pernah dikirim ke klien dalam langganan.",
@@ -1489,7 +1489,6 @@
         "overrideXrayPrivateIp": "Timpa blok IP privat default Xray",
         "blockDelay": "Penundaan blokir (ms)",
         "reverseSniffing": "Sniffing terbalik",
-        "workers": "Workers",
         "reserved": "Dicadangkan",
         "minUploadInterval": "Min. interval upload (ms)",
         "maxUploadSizeBytes": "Ukuran upload maks. (byte)",

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

@@ -583,7 +583,7 @@
         "tcpCongestion": "TCP Congestion",
         "dialerProxy": "Dialer Proxy",
         "trustedXForwardedFor": "信頼できる X-Forwarded-For",
-        "trustedXForwardedForHint": "実際のクライアント IP を取得するためにこのリクエストヘッダーを信頼します(例: Cloudflare CDN の背後の CF-Connecting-IP)。WebSocket、HTTPUpgrade、XHTTP トランスポートでのみ有効です。空欄にすると転送ヘッダーを無視します。",
+        "trustedXForwardedForHint": "実際のクライアント IP を取得するためにこのリクエストヘッダーを信頼します(例: Cloudflare CDN の背後の CF-Connecting-IP)。WebSocket、HTTPUpgrade、XHTTP、gRPC トランスポートでのみ有効です。空欄にすると転送ヘッダーを無視します。",
         "proxyProtocolHint": "PROXY protocol ヘッダーを受け入れ、上流の L4 トンネル/リレー(HAProxy、gost、nginx-stream、Xray dokodemo-door)または Cloudflare Spectrum から実際のクライアント IP を取得します。上流は必ず PROXY protocol を送信する必要があります。TCP、WebSocket、HTTPUpgrade、gRPC で動作します。mKCP では動作しません。",
         "realClientIp": "実際のクライアント IP",
         "realClientIpHint": "トラフィックが CDN やリレーを経由してこのインバウンドに到達したときに、中継ノードのアドレスではなく訪問者の実際の IP を取得します。プリセットを選ぶと、下の対応する sockopt フィールドが自動入力されます。これらのフィールドはサブスクリプションでクライアントに送信されることはありません。",
@@ -1489,7 +1489,6 @@
         "overrideXrayPrivateIp": "Xray のデフォルトプライベート IP ブロックを上書き",
         "blockDelay": "ブロック遅延 (ms)",
         "reverseSniffing": "逆 sniffing",
-        "workers": "Workers",
         "reserved": "予約",
         "minUploadInterval": "最小アップロード間隔 (ms)",
         "maxUploadSizeBytes": "最大アップロードサイズ (バイト)",

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

@@ -583,7 +583,7 @@
         "tcpCongestion": "TCP Congestion",
         "dialerProxy": "Dialer Proxy",
         "trustedXForwardedFor": "X-Forwarded-For confiável",
-        "trustedXForwardedForHint": "Confie neste cabeçalho de requisição para obter o IP real do cliente (ex.: CF-Connecting-IP atrás do CDN da Cloudflare). Válido apenas nos transportes WebSocket, HTTPUpgrade e XHTTP. Deixe vazio para ignorar cabeçalhos encaminhados.",
+        "trustedXForwardedForHint": "Confie neste cabeçalho de requisição para obter o IP real do cliente (ex.: CF-Connecting-IP atrás do CDN da Cloudflare). Válido apenas nos transportes WebSocket, HTTPUpgrade, XHTTP e gRPC. Deixe vazio para ignorar cabeçalhos encaminhados.",
         "proxyProtocolHint": "Aceite o cabeçalho PROXY protocol para obter o IP real do cliente a partir de um túnel/relay L4 upstream (HAProxy, gost, nginx-stream, Xray dokodemo-door) ou Cloudflare Spectrum. O upstream DEVE enviar PROXY protocol. Funciona em TCP, WebSocket, HTTPUpgrade e gRPC; não em mKCP.",
         "realClientIp": "IP real do cliente",
         "realClientIpHint": "Capture o IP real do visitante quando o tráfego chega a este inbound através de um CDN ou relay, em vez de registrar o endereço do intermediário. Escolha uma predefinição para preencher os campos sockopt correspondentes abaixo. Esses campos nunca são enviados aos clientes nas assinaturas.",
@@ -1489,7 +1489,6 @@
         "overrideXrayPrivateIp": "Sobrescrever o bloqueio de IP privado padrão do Xray",
         "blockDelay": "Atraso do bloqueio (ms)",
         "reverseSniffing": "Sniffing reverso",
-        "workers": "Workers",
         "reserved": "Reservado",
         "minUploadInterval": "Intervalo mín. de upload (ms)",
         "maxUploadSizeBytes": "Tamanho máx. de upload (bytes)",

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

@@ -583,7 +583,7 @@
         "tcpCongestion": "TCP Congestion",
         "dialerProxy": "Dialer Proxy",
         "trustedXForwardedFor": "Доверенный X-Forwarded-For",
-        "trustedXForwardedForHint": "Доверять этому заголовку запроса для определения реального IP клиента (например, CF-Connecting-IP за CDN Cloudflare). Работает только на транспортах WebSocket, HTTPUpgrade и XHTTP. Оставьте пустым, чтобы игнорировать заголовки пересылки.",
+        "trustedXForwardedForHint": "Доверять этому заголовку запроса для определения реального IP клиента (например, CF-Connecting-IP за CDN Cloudflare). Работает только на транспортах WebSocket, HTTPUpgrade, XHTTP и gRPC. Оставьте пустым, чтобы игнорировать заголовки пересылки.",
         "proxyProtocolHint": "Принимать заголовок PROXY protocol, чтобы получить реальный IP клиента от вышестоящего L4-туннеля или релея (HAProxy, gost, nginx-stream, Xray dokodemo-door) либо Cloudflare Spectrum. Вышестоящий узел ДОЛЖЕН отправлять PROXY protocol. Работает на TCP, WebSocket, HTTPUpgrade и gRPC; не работает на mKCP.",
         "realClientIp": "Реальный IP клиента",
         "realClientIpHint": "Получать реальный IP посетителя, когда трафик приходит на этот входящий через CDN или релей, вместо адреса промежуточного узла. Выберите пресет, чтобы заполнить соответствующие поля sockopt ниже. Эти поля никогда не отправляются клиентам в подписках.",
@@ -1489,7 +1489,6 @@
         "overrideXrayPrivateIp": "Переопределить дефолтный блок частных IP в Xray",
         "blockDelay": "Задержка блока (мс)",
         "reverseSniffing": "Обратный sniffing",
-        "workers": "Воркеры",
         "reserved": "Зарезервировано",
         "minUploadInterval": "Мин. интервал загрузки (мс)",
         "maxUploadSizeBytes": "Макс. размер загрузки (байт)",

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

@@ -562,7 +562,7 @@
         "tcpCongestion": "TCP Congestion",
         "dialerProxy": "Dialer Proxy",
         "trustedXForwardedFor": "Güvenilir X-Forwarded-For",
-        "trustedXForwardedForHint": "Gerçek istemci IP'sini almak için bu istek başlığına güven (örn. Cloudflare CDN arkasındaki CF-Connecting-IP). Yalnızca WebSocket, HTTPUpgrade ve XHTTP taşımalarında geçerlidir. İletilen başlıkları yok saymak için boş bırakın.",
+        "trustedXForwardedForHint": "Gerçek istemci IP'sini almak için bu istek başlığına güven (örn. Cloudflare CDN arkasındaki CF-Connecting-IP). Yalnızca WebSocket, HTTPUpgrade, XHTTP ve gRPC taşımalarında geçerlidir. İletilen başlıkları yok saymak için boş bırakın.",
         "proxyProtocolHint": "Gerçek istemci IP'sini bir üst L4 tüneli veya rölesi (HAProxy, gost, nginx-stream, Xray dokodemo-door) ya da Cloudflare Spectrum üzerinden öğrenmek için PROXY protocol başlığını kabul et. Üst sunucu PROXY protocol göndermek ZORUNDADIR. TCP, WebSocket, HTTPUpgrade ve gRPC üzerinde çalışır; mKCP üzerinde çalışmaz.",
         "realClientIp": "Gerçek istemci IP'si",
         "realClientIpHint": "Trafik bu gelen bağlantıya bir CDN veya röle üzerinden ulaştığında, aracı adresini kaydetmek yerine ziyaretçinin gerçek IP'sini al. Aşağıdaki ilgili sockopt alanlarını doldurmak için bir hazır ayar seç. Bu alanlar aboneliklerde istemcilere asla gönderilmez.",
@@ -1489,7 +1489,6 @@
         "overrideXrayPrivateIp": "Xray'in varsayılan özel IP bloğunu geçersiz kıl",
         "blockDelay": "Engelleme Gecikmesi (ms)",
         "reverseSniffing": "Ters Sniffing",
-        "workers": "Workers",
         "reserved": "Ayrılmış",
         "minUploadInterval": "Min. Yükleme Aralığı (ms)",
         "maxUploadSizeBytes": "Maks. Yükleme Boyutu (bayt)",

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

@@ -562,7 +562,7 @@
         "tcpCongestion": "TCP Congestion",
         "dialerProxy": "Dialer Proxy",
         "trustedXForwardedFor": "Довірений X-Forwarded-For",
-        "trustedXForwardedForHint": "Довіряти цьому заголовку запиту для визначення справжнього IP клієнта (наприклад, CF-Connecting-IP за CDN Cloudflare). Працює лише на транспортах WebSocket, HTTPUpgrade та XHTTP. Залиште порожнім, щоб ігнорувати заголовки пересилання.",
+        "trustedXForwardedForHint": "Довіряти цьому заголовку запиту для визначення справжнього IP клієнта (наприклад, CF-Connecting-IP за CDN Cloudflare). Працює лише на транспортах WebSocket, HTTPUpgrade, XHTTP та gRPC. Залиште порожнім, щоб ігнорувати заголовки пересилання.",
         "proxyProtocolHint": "Приймати заголовок PROXY protocol, щоб отримати справжній IP клієнта від висхідного L4-тунелю чи релея (HAProxy, gost, nginx-stream, Xray dokodemo-door) або Cloudflare Spectrum. Висхідний вузол МУСИТЬ надсилати PROXY protocol. Працює на TCP, WebSocket, HTTPUpgrade та gRPC; не працює на mKCP.",
         "realClientIp": "Справжній IP клієнта",
         "realClientIpHint": "Отримувати справжній IP відвідувача, коли трафік надходить на цей вхідний через CDN або релей, замість адреси проміжного вузла. Виберіть пресет, щоб заповнити відповідні поля sockopt нижче. Ці поля ніколи не надсилаються клієнтам у підписках.",
@@ -1489,7 +1489,6 @@
         "overrideXrayPrivateIp": "Перевизначити дефолтний блок приватних IP у Xray",
         "blockDelay": "Затримка блоку (мс)",
         "reverseSniffing": "Зворотний sniffing",
-        "workers": "Воркери",
         "reserved": "Зарезервовано",
         "minUploadInterval": "Мін. інтервал завантаження (мс)",
         "maxUploadSizeBytes": "Макс. розмір завантаження (байт)",

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

@@ -583,7 +583,7 @@
         "tcpCongestion": "TCP Congestion",
         "dialerProxy": "Dialer Proxy",
         "trustedXForwardedFor": "X-Forwarded-For tin cậy",
-        "trustedXForwardedForHint": "Tin cậy header yêu cầu này để lấy IP thật của client (ví dụ CF-Connecting-IP phía sau CDN của Cloudflare). Chỉ có hiệu lực trên các transport WebSocket, HTTPUpgrade và XHTTP. Để trống để bỏ qua các header chuyển tiếp.",
+        "trustedXForwardedForHint": "Tin cậy header yêu cầu này để lấy IP thật của client (ví dụ CF-Connecting-IP phía sau CDN của Cloudflare). Chỉ có hiệu lực trên các transport WebSocket, HTTPUpgrade, XHTTP và gRPC. Để trống để bỏ qua các header chuyển tiếp.",
         "proxyProtocolHint": "Chấp nhận header PROXY protocol để lấy IP thật của client từ tunnel/relay L4 phía trên (HAProxy, gost, nginx-stream, Xray dokodemo-door) hoặc Cloudflare Spectrum. Phía trên PHẢI gửi PROXY protocol. Hoạt động trên TCP, WebSocket, HTTPUpgrade và gRPC; không hoạt động trên mKCP.",
         "realClientIp": "IP thật của client",
         "realClientIpHint": "Lấy IP thật của khách khi lưu lượng đến inbound này qua CDN hoặc relay, thay vì ghi lại địa chỉ của trung gian. Chọn một preset để tự điền các trường sockopt tương ứng bên dưới. Các trường này không bao giờ được gửi đến client trong subscription.",
@@ -1489,7 +1489,6 @@
         "overrideXrayPrivateIp": "Ghi đè chặn IP riêng mặc định của Xray",
         "blockDelay": "Trễ chặn (ms)",
         "reverseSniffing": "Sniffing ngược",
-        "workers": "Workers",
         "reserved": "Đã đặt trước",
         "minUploadInterval": "Khoảng upload tối thiểu (ms)",
         "maxUploadSizeBytes": "Kích thước upload tối đa (byte)",

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

@@ -582,7 +582,7 @@
         "tcpCongestion": "TCP Congestion",
         "dialerProxy": "Dialer Proxy",
         "trustedXForwardedFor": "可信 X-Forwarded-For",
-        "trustedXForwardedForHint": "信任此请求头来获取真实客户端 IP(例如 Cloudflare CDN 后的 CF-Connecting-IP)。仅在 WebSocket、HTTPUpgrade 和 XHTTP 传输上生效。留空则忽略转发头。",
+        "trustedXForwardedForHint": "信任此请求头来获取真实客户端 IP(例如 Cloudflare CDN 后的 CF-Connecting-IP)。仅在 WebSocket、HTTPUpgrade、XHTTP 和 gRPC 传输上生效。留空则忽略转发头。",
         "proxyProtocolHint": "接受 PROXY protocol 头,从上游 L4 隧道或中继(HAProxy、gost、nginx-stream、Xray dokodemo-door)或 Cloudflare Spectrum 获取真实客户端 IP。上游必须发送 PROXY protocol。适用于 TCP、WebSocket、HTTPUpgrade 和 gRPC;不适用于 mKCP。",
         "realClientIp": "真实客户端 IP",
         "realClientIpHint": "当流量通过 CDN 或中继到达此入站时,获取访客的真实 IP,而不是记录中间节点的地址。选择一个预设以自动填写下方对应的 sockopt 字段。这些字段绝不会在订阅中发送给客户端。",
@@ -1489,7 +1489,6 @@
         "overrideXrayPrivateIp": "覆盖 Xray 默认的私有 IP 阻止",
         "blockDelay": "阻塞延迟 (ms)",
         "reverseSniffing": "反向 sniffing",
-        "workers": "Workers",
         "reserved": "保留",
         "minUploadInterval": "最小上传间隔 (ms)",
         "maxUploadSizeBytes": "最大上传大小 (字节)",

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

@@ -562,7 +562,7 @@
         "tcpCongestion": "TCP Congestion",
         "dialerProxy": "Dialer Proxy",
         "trustedXForwardedFor": "信任的 X-Forwarded-For",
-        "trustedXForwardedForHint": "信任此請求標頭以取得真實用戶端 IP(例如 Cloudflare CDN 後的 CF-Connecting-IP)。僅在 WebSocket、HTTPUpgrade 和 XHTTP 傳輸上生效。留空則忽略轉發標頭。",
+        "trustedXForwardedForHint": "信任此請求標頭以取得真實用戶端 IP(例如 Cloudflare CDN 後的 CF-Connecting-IP)。僅在 WebSocket、HTTPUpgrade、XHTTP 和 gRPC 傳輸上生效。留空則忽略轉發標頭。",
         "proxyProtocolHint": "接受 PROXY protocol 標頭,從上游 L4 隧道或中繼(HAProxy、gost、nginx-stream、Xray dokodemo-door)或 Cloudflare Spectrum 取得真實用戶端 IP。上游必須傳送 PROXY protocol。適用於 TCP、WebSocket、HTTPUpgrade 和 gRPC;不適用於 mKCP。",
         "realClientIp": "真實用戶端 IP",
         "realClientIpHint": "當流量透過 CDN 或中繼到達此入站時,取得訪客的真實 IP,而非記錄中間節點的位址。選擇一個預設以自動填入下方對應的 sockopt 欄位。這些欄位絕不會在訂閱中傳送給用戶端。",
@@ -1489,7 +1489,6 @@
         "overrideXrayPrivateIp": "覆寫 Xray 預設的私有 IP 封鎖",
         "blockDelay": "阻斷延遲 (ms)",
         "reverseSniffing": "反向 sniffing",
-        "workers": "Workers",
         "reserved": "保留",
         "minUploadInterval": "最小上傳間隔 (ms)",
         "maxUploadSizeBytes": "最大上傳大小 (位元組)",

+ 1 - 1
internal/web/web.go

@@ -429,7 +429,7 @@ func (s *Server) memoryAlarmWanted() bool {
 		if threshold <= 0 {
 			return false
 		}
-		for _, e := range strings.Split(events, ",") {
+		for e := range strings.SplitSeq(events, ",") {
 			if strings.TrimSpace(e) == string(eventbus.EventMemoryHigh) {
 				return true
 			}

+ 4 - 4
internal/xray/log_writer_race_test.go

@@ -16,18 +16,18 @@ func TestLogWriterLastLineConcurrent(t *testing.T) {
 	var wg sync.WaitGroup
 	wg.Add(writers + readers)
 
-	for i := 0; i < writers; i++ {
+	for range writers {
 		go func() {
 			defer wg.Done()
-			for j := 0; j < iterations; j++ {
+			for range iterations {
 				_, _ = lw.Write([]byte("2024/01/01 00:00:00.000000 [Info] connection accepted"))
 			}
 		}()
 	}
-	for i := 0; i < readers; i++ {
+	for range readers {
 		go func() {
 			defer wg.Done()
-			for j := 0; j < iterations; j++ {
+			for range iterations {
 				_ = lw.LastLine()
 			}
 		}()

+ 49 - 11
internal/xray/process.go

@@ -65,16 +65,6 @@ func GetIPLimitBannedPrevLogPath() string {
 	return config.GetLogFolder() + "/3xipl-banned.prev.log"
 }
 
-// GetAccessPersistentLogPath returns the path to the persistent access log file.
-func GetAccessPersistentLogPath() string {
-	return config.GetLogFolder() + "/3xipl-ap.log"
-}
-
-// GetAccessPersistentPrevLogPath returns the path to the previous persistent access log file.
-func GetAccessPersistentPrevLogPath() string {
-	return config.GetLogFolder() + "/3xipl-ap.prev.log"
-}
-
 // GetAccessLogPath reads the Xray config and returns the access log file path.
 func GetAccessLogPath() (string, error) {
 	config, err := os.ReadFile(GetConfigPath())
@@ -526,7 +516,7 @@ func (p *process) Start() (err error) {
 	if p.configPath != "" {
 		configPath = p.configPath
 	}
-	err = os.WriteFile(configPath, data, 0644)
+	err = writeFileAtomic(configPath, data, 0o600)
 	if err != nil {
 		return common.NewErrorf("Failed to write configuration file: %v", err)
 	}
@@ -546,6 +536,54 @@ func (p *process) Start() (err error) {
 	return nil
 }
 
+// writeFileAtomic writes data to path via a same-directory temp file that is
+// permissioned, synced, and renamed into place, so a crash can never leave a
+// partial config; the config holds credentials, hence the 0600 perm. After the
+// rename the parent directory is fsynced to persist the directory entry. That
+// final step is skipped on Windows, where directory fsync is unsupported and
+// os.Rename already uses replace-existing semantics.
+func writeFileAtomic(path string, data []byte, perm os.FileMode) (err error) {
+	dir := filepath.Dir(path)
+	tmp, err := os.CreateTemp(dir, ".config-*.tmp")
+	if err != nil {
+		return err
+	}
+	tmpPath := tmp.Name()
+	defer func() {
+		_ = tmp.Close()
+		if err != nil {
+			_ = os.Remove(tmpPath)
+		}
+	}()
+	if err = tmp.Chmod(perm); err != nil {
+		return err
+	}
+	if _, err = tmp.Write(data); err != nil {
+		return err
+	}
+	if err = tmp.Sync(); err != nil {
+		return err
+	}
+	if err = tmp.Close(); err != nil {
+		return err
+	}
+	if err = renameFile(tmpPath, path); err != nil {
+		return err
+	}
+	if runtime.GOOS == "windows" {
+		return nil
+	}
+	dirHandle, err := os.Open(dir)
+	if err != nil {
+		return err
+	}
+	err = dirHandle.Sync()
+	_ = dirHandle.Close()
+	return err
+}
+
+var renameFile = os.Rename
+
 func (p *process) startCommand(cmd *exec.Cmd) error {
 	p.mu.Lock()
 	p.cmd = cmd

+ 5 - 9
internal/xray/process_race_test.go

@@ -19,9 +19,7 @@ func TestProcessLifecycleFieldsRaceSafe(t *testing.T) {
 	stop := make(chan struct{})
 
 	// Writer: churn cmd/done/exitErr like Start + waitForCommand.
-	wg.Add(1)
-	go func() {
-		defer wg.Done()
+	wg.Go(func() {
 		for {
 			select {
 			case <-stop:
@@ -34,13 +32,11 @@ func TestProcessLifecycleFieldsRaceSafe(t *testing.T) {
 			p.mu.Unlock()
 			p.setExitErr(errors.New("boom"))
 		}
-	}()
+	})
 
 	// Readers: the concurrent status getters.
-	for i := 0; i < 4; i++ {
-		wg.Add(1)
-		go func() {
-			defer wg.Done()
+	for range 4 {
+		wg.Go(func() {
 			for {
 				select {
 				case <-stop:
@@ -51,7 +47,7 @@ func TestProcessLifecycleFieldsRaceSafe(t *testing.T) {
 				_ = p.GetErr()
 				_ = p.GetResult()
 			}
-		}()
+		})
 	}
 
 	time.Sleep(50 * time.Millisecond)

+ 47 - 0
internal/xray/process_test.go

@@ -3,6 +3,7 @@
 package xray
 
 import (
+	"errors"
 	"os"
 	"os/exec"
 	"os/signal"
@@ -15,6 +16,52 @@ import (
 	"github.com/op/go-logging"
 )
 
+func TestWriteFileAtomicModeAndRenameFailure(t *testing.T) {
+	dir := t.TempDir()
+	path := filepath.Join(dir, "config.json")
+	if err := os.WriteFile(path, []byte("old"), 0o644); err != nil {
+		t.Fatalf("seed: %v", err)
+	}
+	if err := writeFileAtomic(path, []byte("new"), 0o600); err != nil {
+		t.Fatalf("writeFileAtomic: %v", err)
+	}
+	data, err := os.ReadFile(path)
+	if err != nil {
+		t.Fatalf("read: %v", err)
+	}
+	if string(data) != "new" {
+		t.Fatalf("content = %q, want new", data)
+	}
+	info, err := os.Stat(path)
+	if err != nil {
+		t.Fatalf("stat: %v", err)
+	}
+	if info.Mode().Perm() != 0o600 {
+		t.Fatalf("mode = %o, want 600", info.Mode().Perm())
+	}
+
+	originalRename := renameFile
+	renameFile = func(_, _ string) error { return errors.New("injected rename failure") }
+	t.Cleanup(func() { renameFile = originalRename })
+	if err := writeFileAtomic(path, []byte("partial"), 0o600); err == nil {
+		t.Fatal("rename failure = nil")
+	}
+	data, err = os.ReadFile(path)
+	if err != nil {
+		t.Fatalf("read preserved file: %v", err)
+	}
+	if string(data) != "new" {
+		t.Fatalf("content after failed rename = %q, want committed content", data)
+	}
+	matches, err := filepath.Glob(filepath.Join(dir, ".config-*.tmp"))
+	if err != nil {
+		t.Fatalf("glob: %v", err)
+	}
+	if len(matches) != 0 {
+		t.Fatalf("temporary files leaked: %v", matches)
+	}
+}
+
 func TestStopWaitsForGracefulExit(t *testing.T) {
 	initProcessTestLogger(t)