1
0

21 Ревизии 65b9bfed8b ... 2ddcf53020

Автор SHA1 Съобщение Дата
  duqigit 2ddcf53020 Feature/fix external subscription client expiry (#6333) преди 3 часа
  Sanaei 13e87a18c8 chore(ci): give the race job a 25m test timeout преди 3 часа
  kuzzrus bd1c27b03d fix(amneziawg): H1-H4 generator + queue-depth throughput fixes (#6330) преди 3 часа
  ilyusha 0ff3c23948 fix(api-docs): generate request bodies for all encodings (#6296) преди 4 часа
  ilyusha f294e1806d feat(release): publish SHA-256 sums and verify them in install.sh/update.sh (#6393) преди 4 часа
  ilyusha 4019f47de2 fix(x-ui.sh): put the fail2ban backend override in jail.d, not jail.conf (#6392) преди 4 часа
  Sanaei 8411b1dd9e chore: upgrade Vitest to v5 преди 4 часа
  Sanaei a31fa9abfa fix(node): refuse a node's claim on another inbound's client преди 7 часа
  Sanaei f17e4684e0 fix(sub): apply the device limit to ?view=raw преди 7 часа
  Sanaei f9de0226fe fix(xray): confine log paths written under any key case преди 7 часа
  Sanaei 25d0c06f89 fix(ci): skip a head the review bot already reviewed, and report a refused run преди 8 часа
  MRVX 47964afbc5 fix(clients): render all tunnel configs for multi-inbound client (#6346) (#6349) преди 8 часа
  Mapioe de18c5a006 fix: do not type successfull login twice (#6374) преди 8 часа
  ilyusha 195988bdc1 fix(install): fetch x-ui.sh and unit files from the installed release tag (#6391) преди 8 часа
  ilyusha 23511108bf fix(database): keep the SQLite store owner-only (#6390) преди 8 часа
  Rouzbeh† 540caa4e93 fix(hysteria): standard geco share links and persistent uTLS None (#6325) преди 9 часа
  ilyusha f9898e0b24 fix(sub): randomize fresh panel subscription paths (#6375) преди 9 часа
  dawn ded2aa150c fix(frontend): isolate subscription language preference (#6394) преди 9 часа
  dawn 0c72dd8384 fix(sub): restore compatible SOCKS subscription inbound (#6395) преди 9 часа
  dawn 04e8458054 fix(frontend): improve dense QR readability (#6396) преди 9 часа
  dawn e95fe80fc4 fix(amneziawg): avoid manager lock inversion (#6397) преди 9 часа
променени са 91 файла, в които са добавени 4463 реда и са изтрити 1112 реда
  1. 2 1
      .github/workflows/ci.yml
  2. 51 3
      .github/workflows/claude-bot.yml
  3. 16 8
      .github/workflows/release.yml
  4. 2 1
      Makefile
  5. 4 4
      docs/components/tools/subscription-builder.tsx
  6. 4 4
      docs/content/docs/en/config/amneziawg.mdx
  7. 7 7
      docs/content/docs/en/config/subscription.mdx
  8. 20 12
      docs/content/docs/en/reference/api/clients.mdx
  9. 6 6
      docs/content/docs/en/reference/api/server.mdx
  10. 6 6
      docs/content/docs/en/reference/api/subscription-balancers.mdx
  11. 25 22
      docs/content/docs/en/reference/api/subscription-server.mdx
  12. 7 7
      docs/content/docs/fa/config/subscription.mdx
  13. 10 8
      docs/content/docs/fa/reference/api/clients.mdx
  14. 22 21
      docs/content/docs/fa/reference/api/subscription-server.mdx
  15. 7 7
      docs/content/docs/ru/config/subscription.mdx
  16. 10 8
      docs/content/docs/ru/reference/api/clients.mdx
  17. 22 21
      docs/content/docs/ru/reference/api/subscription-server.mdx
  18. 7 7
      docs/content/docs/zh/config/subscription.mdx
  19. 4 4
      docs/content/docs/zh/reference/api/clients.mdx
  20. 14 14
      docs/content/docs/zh/reference/api/subscription-server.mdx
  21. 13 0
      docs/lib/xray/subscription.test.ts
  22. 1 1
      docs/lib/xray/subscription.ts
  23. 670 66
      docs/public/openapi.json
  24. 146 242
      frontend/package-lock.json
  25. 9 4
      frontend/package.json
  26. 670 66
      frontend/public/openapi.json
  27. 109 27
      frontend/scripts/build-openapi.mjs
  28. 21 6
      frontend/src/components/form/DateTimePicker.tsx
  29. 1 1
      frontend/src/entries/subpage.tsx
  30. 9 8
      frontend/src/i18n/react.ts
  31. 10 0
      frontend/src/lib/clients/external-link.ts
  32. 23 0
      frontend/src/lib/inbounds/label.ts
  33. 5 9
      frontend/src/lib/xray/amneziawg-obfuscation.ts
  34. 13 5
      frontend/src/lib/xray/inbound-link.ts
  35. 6 0
      frontend/src/lib/xray/inbound-tls-defaults.ts
  36. 33 6
      frontend/src/lib/xray/outbound-link-parser.ts
  37. 278 114
      frontend/src/pages/api-docs/endpoints.ts
  38. 33 22
      frontend/src/pages/clients/ClientFormModal.tsx
  39. 67 41
      frontend/src/pages/clients/ClientInfoModal.tsx
  40. 65 54
      frontend/src/pages/clients/ClientQrModal.tsx
  41. 5 5
      frontend/src/pages/clients/amneziawgConfig.ts
  42. 6 5
      frontend/src/pages/clients/wireguardConfig.ts
  43. 6 2
      frontend/src/pages/hosts/HostFormModal.tsx
  44. 2 0
      frontend/src/pages/inbounds/qr/QrPanel.tsx
  45. 7 5
      frontend/src/pages/sub/SubPage.tsx
  46. 4 2
      frontend/src/schemas/protocols/security/tls.ts
  47. 14 2
      frontend/src/test/amneziawg-obfuscation.test.ts
  48. 17 0
      frontend/src/test/client-external-link-expiry.test.ts
  49. 53 2
      frontend/src/test/date-time-picker.test.tsx
  50. 52 0
      frontend/src/test/language-scope.test.ts
  51. 183 0
      frontend/src/test/multi-tunnel-client-config.test.tsx
  52. 140 0
      frontend/src/test/openapi-request-bodies.test.ts
  53. 32 0
      frontend/src/test/outbound-link-parser.test.ts
  54. 89 0
      frontend/src/test/qr-panel-readable.test.tsx
  55. 77 0
      frontend/src/test/tls-fingerprint-default.test.ts
  56. 26 9
      frontend/src/utils/index.ts
  57. 1 1
      go.mod
  58. 72 7
      install.sh
  59. 5 11
      internal/amneziawg/params.go
  60. 10 15
      internal/amneziawg/params_test.go
  61. 49 53
      internal/amneziawgnet/manager.go
  62. 29 0
      internal/amneziawgnet/manager_test.go
  63. 50 77
      internal/amneziawgnet/netstack.go
  64. 36 1
      internal/database/db.go
  65. 79 0
      internal/database/db_permissions_test.go
  66. 51 0
      internal/database/db_seed_test.go
  67. 1 1
      internal/database/dump_sqlite.go
  68. 13 4
      internal/sub/clash_service.go
  69. 12 5
      internal/sub/controller.go
  70. 1 1
      internal/sub/default.json
  71. 6 5
      internal/sub/external_config.go
  72. 20 8
      internal/sub/hwid_controller_test.go
  73. 152 0
      internal/sub/hysteria_geco_uri_test.go
  74. 115 0
      internal/sub/inactive_external_sub_test.go
  75. 11 2
      internal/sub/json_service.go
  76. 38 0
      internal/sub/json_service_test.go
  77. 26 6
      internal/sub/mutation_audit_test.go
  78. 6 1
      internal/sub/remote_routing.go
  79. 2 0
      internal/sub/remote_routing_test.go
  80. 37 4
      internal/sub/salamander_uri_test.go
  81. 65 10
      internal/sub/service.go
  82. 41 6
      internal/util/link/outbound.go
  83. 0 1
      internal/web/controller/index.go
  84. 65 0
      internal/web/service/inbound_node.go
  85. 90 0
      internal/web/service/node_foreign_client_test.go
  86. 11 6
      internal/web/service/setting.go
  87. 39 0
      internal/web/service/setting_security_test.go
  88. 32 1
      internal/web/service/xray.go
  89. 73 0
      internal/web/service/xray_log_confine_test.go
  90. 61 6
      update.sh
  91. 23 5
      x-ui.sh

+ 2 - 1
.github/workflows/ci.yml

@@ -138,7 +138,8 @@ jobs:
       - name: Race + shuffle
         run: |
           go list ./... | grep -v '/frontend/node_modules/' > /tmp/go-packages.txt
-          go test -race -shuffle=on -count=1 $(cat /tmp/go-packages.txt)
+          # internal/web/service runs ~10x slower under -race and overruns the 10m default.
+          go test -race -shuffle=on -count=1 -timeout 25m $(cat /tmp/go-packages.txt)
 
   # Brief native-fuzz smoke on the security-/parser-critical decoders. Each runs the
   # generated corpus plus 30s of exploration; a crash here is a real input-handling bug.

+ 51 - 3
.github/workflows/claude-bot.yml

@@ -80,9 +80,28 @@ jobs:
             exit 1
           fi
           echo "sha=${HEAD_SHA}" >> "$GITHUB_OUTPUT"
+      # An automatic re-review of a head that already has one spends a whole run
+      # to reach the same conclusion, so settle it here rather than in the model.
+      - name: Skip a head that already has a review
+        id: reviewed
+        if: github.event_name == 'pull_request_target'
+        env:
+          GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+          REPO: ${{ github.repository }}
+          PR: ${{ github.event.pull_request.number }}
+          HEAD_SHA: ${{ steps.pinned-sha.outputs.sha }}
+        run: |
+          set -euo pipefail
+          posted=$(gh api "repos/${REPO}/issues/${PR}/comments" --paginate \
+            --jq "[.[] | select(.user.login == \"github-actions[bot]\") | select((.body | contains(\"Reviewed head:\")) and (.body | contains(\"${HEAD_SHA}\")))] | length")
+          if [ "$posted" != "0" ]; then
+            echo "done=true" >> "$GITHUB_OUTPUT"
+            echo "::notice::#${PR} already carries a review of ${HEAD_SHA}; nothing to review."
+          fi
       # Read-only, and pinned to one immutable commit: this job holds a
       # write-scoped token, so running anything out of pr-head/ would be a pwn-request.
       - uses: actions/checkout@v7
+        if: steps.reviewed.outputs.done != 'true'
         with:
           ref: ${{ steps.pinned-sha.outputs.sha }}
           path: pr-head
@@ -91,6 +110,7 @@ jobs:
       # The skill reads CLAUDE.md on its own but not REVIEW.md, and knows nothing
       # of pr-head/ or this run's head: the brief is the only way both reach it.
       - name: Brief the reviewer
+        if: steps.reviewed.outputs.done != 'true'
         env:
           REPO: ${{ github.repository }}
           PR: ${{ github.event.pull_request.number || github.event.issue.number }}
@@ -108,6 +128,11 @@ jobs:
             printf -- '- CI on that head: gh api repos/%s/commits/%s/check-runs\n' "$REPO" "$HEAD_SHA"
           } > "$RUNNER_TEMP/review-brief.md"
       - uses: anthropics/claude-code-action@v1
+        id: review
+        if: steps.reviewed.outputs.done != 'true'
+        # A refused run fails this step exactly like a real defect would, so the
+        # job classifies the failure below instead of going red on both alike.
+        continue-on-error: true
         with:
           github_token: ${{ secrets.GITHUB_TOKEN }}
           claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
@@ -134,8 +159,31 @@ jobs:
           path: ${{ runner.temp }}/claude-execution-output.json
           if-no-files-found: ignore
           retention-days: 7
+      # An exhausted usage window or an overloaded API is not a broken workflow.
+      # Say so where the maintainer will see it, and leave the job green.
+      - name: Report a review the API refused to run
+        id: throttled
+        if: ${{ !cancelled() && steps.review.outcome == 'failure' }}
+        env:
+          GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+          REPO: ${{ github.repository }}
+          PR: ${{ github.event.pull_request.number || github.event.issue.number }}
+          TRANSCRIPT: ${{ runner.temp }}/claude-execution-output.json
+        run: |
+          set -euo pipefail
+          [ -f "$TRANSCRIPT" ] || exit 0
+          if jq -e 'any(.[]; .type == "rate_limit_event" and .rate_limit_info.status == "rejected")' "$TRANSCRIPT" >/dev/null 2>&1; then
+            reason="the account's usage limit was already spent when this run started"
+          elif jq -e 'any(.[]; .subtype == "api_retry" and .error_status == 529)' "$TRANSCRIPT" >/dev/null 2>&1; then
+            reason="the API stayed overloaded through every retry"
+          else
+            exit 0
+          fi
+          echo "skipped=true" >> "$GITHUB_OUTPUT"
+          echo "::notice::No review of #${PR}: ${reason}."
+          gh pr comment "$PR" --repo "$REPO" --body "No review ran on this head: ${reason}. Nothing in this pull request was examined. A maintainer can ask for one with \`@claude review\`."
       - name: Fail if the review posted nothing
-        if: ${{ !cancelled() && steps.pinned-sha.outcome == 'success' }}
+        if: ${{ !cancelled() && steps.pinned-sha.outcome == 'success' && steps.reviewed.outputs.done != 'true' && steps.throttled.outputs.skipped != 'true' }}
         env:
           GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
           REPO: ${{ github.repository }}
@@ -145,9 +193,9 @@ jobs:
           set -euo pipefail
           head=$(gh api "repos/${REPO}/pulls/${PR}" --jq '.head.sha')
           # updated_at, not created_at: the skill may update its existing sticky comment.
-          # A pre-existing comment naming the current head SHA means a legitimate skip.
+          # "Reviewed head:" as well as the SHA — the bot's other comments quote SHAs too.
           posted=$(gh api "repos/${REPO}/issues/${PR}/comments" --paginate \
-            --jq "[.[] | select(.user.login == \"github-actions[bot]\") | select((.updated_at >= \"${STARTED_AT}\") or (.body | contains(\"${head}\")))] | length")
+            --jq "[.[] | select(.user.login == \"github-actions[bot]\") | select((.updated_at >= \"${STARTED_AT}\") or ((.body | contains(\"Reviewed head:\")) and (.body | contains(\"${head}\"))))] | length")
           inline=$(gh api "repos/${REPO}/pulls/${PR}/comments" --paginate \
             --jq "[.[] | select(.user.login == \"github-actions[bot]\") | select(.updated_at >= \"${STARTED_AT}\")] | length")
           if [ "$posted" = "0" ] && [ "$inline" = "0" ]; then

+ 16 - 8
.github/workflows/release.yml

@@ -183,13 +183,17 @@ jobs:
           cd ../..
 
       - name: Package
-        run: tar -zcvf x-ui-linux-${{ matrix.platform }}.tar.gz x-ui
+        run: |
+          tar -zcvf x-ui-linux-${{ matrix.platform }}.tar.gz x-ui
+          sha256sum x-ui-linux-${{ matrix.platform }}.tar.gz > x-ui-linux-${{ matrix.platform }}.tar.gz.sha256
 
       - name: Upload files to Artifacts
         uses: actions/upload-artifact@v7
         with:
           name: x-ui-linux-${{ matrix.platform }}
-          path: ./x-ui-linux-${{ matrix.platform }}.tar.gz
+          path: |
+            ./x-ui-linux-${{ matrix.platform }}.tar.gz
+            ./x-ui-linux-${{ matrix.platform }}.tar.gz.sha256
 
       - name: Upload files to GH release
         uses: svenstaro/upload-release-action@v2
@@ -197,8 +201,8 @@ jobs:
         with:
           repo_token: ${{ secrets.GITHUB_TOKEN }}
           tag: ${{ github.ref_name }}
-          file: x-ui-linux-${{ matrix.platform }}.tar.gz
-          asset_name: x-ui-linux-${{ matrix.platform }}.tar.gz
+          file: x-ui-linux-${{ matrix.platform }}.tar.gz*
+          file_glob: true
           overwrite: true
           prerelease: true
 
@@ -316,12 +320,16 @@ jobs:
         shell: pwsh
         run: |
           Compress-Archive -Path .\x-ui -DestinationPath "x-ui-windows-amd64.zip"
+          $hash = (Get-FileHash x-ui-windows-amd64.zip -Algorithm SHA256).Hash.ToLower()
+          [IO.File]::WriteAllText("$PWD\x-ui-windows-amd64.zip.sha256", "$hash  x-ui-windows-amd64.zip`n")
 
       - name: Upload files to Artifacts
         uses: actions/upload-artifact@v7
         with:
           name: x-ui-windows-amd64
-          path: ./x-ui-windows-amd64.zip
+          path: |
+            ./x-ui-windows-amd64.zip
+            ./x-ui-windows-amd64.zip.sha256
 
       - name: Upload files to GH release
         uses: svenstaro/upload-release-action@v2
@@ -329,8 +337,8 @@ jobs:
         with:
           repo_token: ${{ secrets.GITHUB_TOKEN }}
           tag: ${{ github.ref_name }}
-          file: x-ui-windows-amd64.zip
-          asset_name: x-ui-windows-amd64.zip
+          file: x-ui-windows-amd64.zip*
+          file_glob: true
           overwrite: true
           prerelease: true
 
@@ -398,4 +406,4 @@ jobs:
               --target "${COMMIT}" --title "Dev build ${short}" --notes "${notes}"
           fi
 
-          retry gh release upload dev-latest dev-artifacts/*.tar.gz dev-artifacts/*.zip --clobber
+          retry gh release upload dev-latest dev-artifacts/*.tar.gz dev-artifacts/*.zip dev-artifacts/*.sha256 --clobber

+ 2 - 1
Makefile

@@ -54,8 +54,9 @@ test-go: dist-stub ## Go tests (shuffle, no cache)
 	go test -shuffle=on -count=1 $(GO_PKGS)
 
 .PHONY: race
+# internal/web/service runs ~10x slower under -race and overruns go test's 10m default.
 race: dist-stub ## Go tests with the race detector (needs a C compiler)
-	go test -race -shuffle=on -count=1 $(GO_PKGS)
+	go test -race -shuffle=on -count=1 -timeout 25m $(GO_PKGS)
 
 .PHONY: test-fe
 test-fe: ## Frontend tests (vitest)

+ 4 - 4
docs/components/tools/subscription-builder.tsx

@@ -69,8 +69,8 @@ export function SubscriptionBuilder() {
   const [scheme, setScheme] = useState<'http' | 'https'>('https');
   const [host, setHost] = useState('sub.example.com');
   const [port, setPort] = useState('2096');
-  const [subPath, setSubPath] = useState('/sub/');
-  const [jsonPath, setJsonPath] = useState('/json/');
+  const [subPath, setSubPath] = useState('/your-sub-path/');
+  const [jsonPath, setJsonPath] = useState('/your-json-path/');
   const [subId, setSubId] = useState('user-1');
   const [behindProxy, setBehindProxy] = useState(false);
   const [clients, setClients] = useState<ClientRow[]>(DEFAULT_CLIENTS);
@@ -95,8 +95,8 @@ export function SubscriptionBuilder() {
     setScheme('https');
     setHost('sub.example.com');
     setPort('2096');
-    setSubPath('/sub/');
-    setJsonPath('/json/');
+    setSubPath('/your-sub-path/');
+    setJsonPath('/your-json-path/');
     setSubId('user-1');
     setBehindProxy(false);
     setClients(DEFAULT_CLIENTS);

+ 4 - 4
docs/content/docs/en/config/amneziawg.mdx

@@ -141,10 +141,10 @@ S1 = 87
 S2 = 44
 S3 = 21
 S4 = 9
-H1 = 462980921-463150218
-H2 = 1177681572-1177787900
-H3 = 1907413509-1907903969
-H4 = 2029908558-2030313135
+H1 = 463065432
+H2 = 912345678
+H3 = 1345678901
+H4 = 1987654321
 I1 = <r 148>
 HeaderProtectionKey = 8Iu83eHDA3fMKKSGaEsVW9Ycd2lYYzc0MYlk1jJTvE4=
 ContentPaddingAddition = 17-49

+ 7 - 7
docs/content/docs/en/config/subscription.mdx

@@ -18,7 +18,7 @@ panel's subscription settings:
 | ------------- | ------- | --------------------------------------------------------------- |
 | `subPort`     | `2096`  | Listen port (separate from the panel).                          |
 | `subListen`   | _(all)_ | Bind address.                                                   |
-| `subPath`     | `/sub/` | Base path for raw subscription URLs.                            |
+| `subPath`     | _(random per panel)_ | Base path for raw subscription URLs.                 |
 | `subDomain`   | _(none)_| Public host; if set, the server only answers for that Host.     |
 | `subCertFile` / `subKeyFile` | _(none)_ | TLS cert + key — when set, the server serves **HTTPS**. |
 | `subEncrypt`  | `true`  | Base64-encode the raw subscription body.                        |
@@ -27,7 +27,7 @@ panel's subscription settings:
 A subscription URL looks like:
 
 ```text
-https://<sub-host>:<sub-port>/sub/<sub-id>
+https://<sub-host>:<sub-port>/<sub-path>/<sub-id>
 ```
 
 where `<sub-id>` is the client's **Sub ID**.
@@ -44,12 +44,12 @@ The **format is chosen by path**, each with its own enable toggle:
 
 | Format                | Path      | Enabled by       | Output                                              |
 | --------------------- | --------- | ---------------- | --------------------------------------------------- |
-| **Raw links**         | `/sub/`   | always (if on)   | A list of `vless://`, `vmess://`, … links (base64-encoded when `subEncrypt` is on). |
-| **JSON**              | `/json/`  | `subJsonEnable`  | Full Xray client config(s).                         |
-| **Clash / Mihomo**    | `/clash/` | `subClashEnable` | YAML profile.                                       |
+| **Raw links**         | `subPath`      | always (if on)   | A list of `vless://`, `vmess://`, … links (base64-encoded when `subEncrypt` is on). |
+| **JSON**              | `subJsonPath`  | `subJsonEnable`  | Full Xray client config(s).                         |
+| **Clash / Mihomo**    | `subClashPath` | `subClashEnable` | YAML profile.                                       |
 
 Only enabled inbounds using **VLESS, VMess, Trojan, Shadowsocks, or Hysteria2**
-appear in a subscription, ordered by their sub-sort index. Requesting `/sub/`
+appear in a subscription, ordered by their sub-sort index. Requesting `subPath`
 with an `Accept: text/html` header (or `?html=1`) returns a human-readable info
 page instead of the raw body.
 
@@ -57,7 +57,7 @@ page instead of the raw body.
 
 The **Base64** body is just the newline-joined share links, standard-base64
 encoded (toggle with `subEncrypt`). The **JSON** body wraps each client in a
-complete Xray client config — a fixed skeleton (local mixed/HTTP inbounds, DNS,
+complete Xray client config — a fixed skeleton (local SOCKS/HTTP inbounds, DNS,
 routing, policy) plus a `proxy` outbound pointing at the inbound. 3x-ui emits a
 **single config object for one client and an array for several**, uses the flat
 outbound `settings` form (`address`/`port`/`id`, `level: 8`), and strips

+ 20 - 12
docs/content/docs/en/reference/api/clients.mdx

@@ -56,8 +56,11 @@ _openapi:
     - depth: 2
       title: Replace a client's external links and external subscriptions. Sends the
         full set; the server replaces all rows. Disabled rows stay saved for
-        editing but are not emitted in generated subscriptions.
-      url: '#replace-a-clients-external-links-and-external-subscriptions-sends-the-full-set-the-server-replaces-all-rows-disabled-rows-stay-saved-for-editing-but-are-not-emitted-in-generated-subscriptions'
+        editing but are not emitted in generated subscriptions. The owning
+        client's disabled or expired state also stops these rows from being
+        emitted on future subscription fetches; credentials already imported by
+        an app remain valid until the external provider revokes them.
+      url: '#replace-a-clients-external-links-and-external-subscriptions-sends-the-full-set-the-server-replaces-all-rows-disabled-rows-stay-saved-for-editing-but-are-not-emitted-in-generated-subscriptions-the-owning-clients-disabled-or-expired-state-also-stops-these-rows-from-being-emitted-on-future-subscription-fetches-credentials-already-imported-by-an-app-remain-valid-until-the-external-provider-revokes-them'
     - depth: 2
       title: Reset the up/down counters for every client globally. Quotas and expiry
         are not affected. Triggers an Xray restart if any counter actually
@@ -264,10 +267,11 @@ _openapi:
     - depth: 2
       title: Return every protocol URL (vless://, vmess://, trojan://, ss://,
         hysteria://, hy2://) for clients matching the subscription ID. Same
-        result set as /sub/<subId>, but as a JSON array — no base64. When an
-        inbound has streamSettings.externalProxy set, one URL is emitted per
-        external proxy. Empty array when the subId has no enabled clients.
-      url: '#return-every-protocol-url-vless-vmess-trojan-ss-hysteria-hy2-for-clients-matching-the-subscription-id-same-result-set-as-subsubid-but-as-a-json-array--no-base64-when-an-inbound-has-streamsettingsexternalproxy-set-one-url-is-emitted-per-external-proxy-empty-array-when-the-subid-has-no-enabled-clients'
+        result set as the configured subPath endpoint, but as a JSON array — no
+        base64. When an inbound has streamSettings.externalProxy set, one URL is
+        emitted per external proxy. Empty array when the subId has no enabled
+        clients.
+      url: '#return-every-protocol-url-vless-vmess-trojan-ss-hysteria-hy2-for-clients-matching-the-subscription-id-same-result-set-as-the-configured-subpath-endpoint-but-as-a-json-array--no-base64-when-an-inbound-has-streamsettingsexternalproxy-set-one-url-is-emitted-per-external-proxy-empty-array-when-the-subid-has-no-enabled-clients'
     - depth: 2
       title: 'Return every URL for one client across all attached inbounds — the same
         strings the Copy URL button copies in the panel UI. Supported protocols:
@@ -317,8 +321,11 @@ _openapi:
         id: detach-a-client-from-one-or-more-inbounds-without-deleting-the-client
       - content: Replace a client's external links and external subscriptions. Sends the
           full set; the server replaces all rows. Disabled rows stay saved for
-          editing but are not emitted in generated subscriptions.
-        id: replace-a-clients-external-links-and-external-subscriptions-sends-the-full-set-the-server-replaces-all-rows-disabled-rows-stay-saved-for-editing-but-are-not-emitted-in-generated-subscriptions
+          editing but are not emitted in generated subscriptions. The owning
+          client's disabled or expired state also stops these rows from being
+          emitted on future subscription fetches; credentials already imported
+          by an app remain valid until the external provider revokes them.
+        id: replace-a-clients-external-links-and-external-subscriptions-sends-the-full-set-the-server-replaces-all-rows-disabled-rows-stay-saved-for-editing-but-are-not-emitted-in-generated-subscriptions-the-owning-clients-disabled-or-expired-state-also-stops-these-rows-from-being-emitted-on-future-subscription-fetches-credentials-already-imported-by-an-app-remain-valid-until-the-external-provider-revokes-them
       - content: Reset the up/down counters for every client globally. Quotas and expiry
           are not affected. Triggers an Xray restart if any counter actually
           moved.
@@ -496,10 +503,11 @@ _openapi:
         id: traffic-counters-for-a-client-identified-by-email
       - content: Return every protocol URL (vless://, vmess://, trojan://, ss://,
           hysteria://, hy2://) for clients matching the subscription ID. Same
-          result set as /sub/<subId>, but as a JSON array — no base64. When an
-          inbound has streamSettings.externalProxy set, one URL is emitted per
-          external proxy. Empty array when the subId has no enabled clients.
-        id: return-every-protocol-url-vless-vmess-trojan-ss-hysteria-hy2-for-clients-matching-the-subscription-id-same-result-set-as-subsubid-but-as-a-json-array--no-base64-when-an-inbound-has-streamsettingsexternalproxy-set-one-url-is-emitted-per-external-proxy-empty-array-when-the-subid-has-no-enabled-clients
+          result set as the configured subPath endpoint, but as a JSON array —
+          no base64. When an inbound has streamSettings.externalProxy set, one
+          URL is emitted per external proxy. Empty array when the subId has no
+          enabled clients.
+        id: return-every-protocol-url-vless-vmess-trojan-ss-hysteria-hy2-for-clients-matching-the-subscription-id-same-result-set-as-the-configured-subpath-endpoint-but-as-a-json-array--no-base64-when-an-inbound-has-streamsettingsexternalproxy-set-one-url-is-emitted-per-external-proxy-empty-array-when-the-subid-has-no-enabled-clients
       - content: 'Return every URL for one client across all attached inbounds — the
           same strings the Copy URL button copies in the panel UI. Supported
           protocols: vmess, vless, trojan, shadowsocks, hysteria. If

+ 6 - 6
docs/content/docs/en/reference/api/server.mdx

@@ -123,9 +123,9 @@ _openapi:
         dev release. Only effective on dev builds.
       url: '#toggle-the-panel-update-channel-between-stable-and-the-rolling-per-commit-dev-release-only-effective-on-dev-builds'
     - depth: 2
-      title: Refresh the default GeoIP / GeoSite data files. Body can include a
-        fileName, or use the /:fileName variant.
-      url: '#refresh-the-default-geoip--geosite-data-files-body-can-include-a-filename-or-use-the-filename-variant'
+      title: Refresh the default GeoIP / GeoSite data files. Use the /:fileName
+        variant to update one file.
+      url: '#refresh-the-default-geoip--geosite-data-files-use-the-filename-variant-to-update-one-file'
     - depth: 2
       title: Refresh a single Geo file by filename (e.g. geoip.dat, geosite.dat).
       url: '#refresh-a-single-geo-file-by-filename-eg-geoipdat-geositedat'
@@ -271,9 +271,9 @@ _openapi:
       - content: Toggle the panel update channel between stable and the rolling
           per-commit dev release. Only effective on dev builds.
         id: toggle-the-panel-update-channel-between-stable-and-the-rolling-per-commit-dev-release-only-effective-on-dev-builds
-      - content: Refresh the default GeoIP / GeoSite data files. Body can include a
-          fileName, or use the /:fileName variant.
-        id: refresh-the-default-geoip--geosite-data-files-body-can-include-a-filename-or-use-the-filename-variant
+      - content: Refresh the default GeoIP / GeoSite data files. Use the /:fileName
+          variant to update one file.
+        id: refresh-the-default-geoip--geosite-data-files-use-the-filename-variant-to-update-one-file
       - content: Refresh a single Geo file by filename (e.g. geoip.dat, geosite.dat).
         id: refresh-a-single-geo-file-by-filename-eg-geoipdat-geositedat
       - content: Return the last N lines of the panel’s own log.

+ 6 - 6
docs/content/docs/en/reference/api/subscription-balancers.mdx

@@ -18,9 +18,9 @@ _openapi:
       url: '#create-a-subscription-balancer-it-appears-in-the-json-subscription-of-every-client-that-sits-on-at-least-one-selected-inbound'
     - depth: 2
       title: Update a balancer by id. Accepts the same form fields as create (full-row
-        update, including the enabled toggle); omitting memberWeights clears
-        stored weights.
-      url: '#update-a-balancer-by-id-accepts-the-same-form-fields-as-create-full-row-update-including-the-enabled-toggle-omitting-memberweights-clears-stored-weights'
+        update); omitting memberWeights clears stored weights, while omitting
+        enabled keeps its current value.
+      url: '#update-a-balancer-by-id-accepts-the-same-form-fields-as-create-full-row-update-omitting-memberweights-clears-stored-weights-while-omitting-enabled-keeps-its-current-value'
     - depth: 2
       title: Delete a balancer by id.
       url: '#delete-a-balancer-by-id'
@@ -36,9 +36,9 @@ _openapi:
           every client that sits on at least one selected inbound.
         id: create-a-subscription-balancer-it-appears-in-the-json-subscription-of-every-client-that-sits-on-at-least-one-selected-inbound
       - content: Update a balancer by id. Accepts the same form fields as create
-          (full-row update, including the enabled toggle); omitting
-          memberWeights clears stored weights.
-        id: update-a-balancer-by-id-accepts-the-same-form-fields-as-create-full-row-update-including-the-enabled-toggle-omitting-memberweights-clears-stored-weights
+          (full-row update); omitting memberWeights clears stored weights, while
+          omitting enabled keeps its current value.
+        id: update-a-balancer-by-id-accepts-the-same-form-fields-as-create-full-row-update-omitting-memberweights-clears-stored-weights-while-omitting-enabled-keeps-its-current-value
       - content: Delete a balancer by id.
         id: delete-a-balancer-by-id
       - content: Delete a balancer by id (POST alias of DELETE for clients that cannot

+ 25 - 22
docs/content/docs/en/reference/api/subscription-server.mdx

@@ -2,9 +2,10 @@
 title: Subscription Server
 description: A separate HTTP/HTTPS server that serves proxy subscription links
   (standard, JSON, and Clash) to clients. The server listens on its own port
-  (default 10882) and is configured in Settings → Subscription. Paths are
-  configurable; defaults are shown below. All subscription endpoints set
-  response headers for client apps to read traffic/expiry info.
+  (default 2096) and is configured in Settings → Subscription. Fresh panels
+  generate random path prefixes for each format; all paths remain configurable.
+  Every subscription endpoint sets response headers for client apps to read
+  traffic/expiry info.
 full: true
 _openapi:
   preload:
@@ -15,34 +16,36 @@ _openapi:
         matching the subscription ID. When the request has an Accept: text/html
         header or ?html=1, renders a styled info page instead. With
         ?format=info, returns the page view-model as JSON (traffic, expiry,
-        online status; no links) for live polling. Default path: /sub/:subid.'
-      url: '#return-base64-encoded-subscription-links-for-all-enabled-clients-matching-the-subscription-id-when-the-request-has-an-accept-texthtml-header-or-html1-renders-a-styled-info-page-instead-with-formatinfo-returns-the-page-view-model-as-json-traffic-expiry-online-status-no-links-for-live-polling-default-path-subsubid'
+        online status; no links) for live polling. The path prefix is configured
+        by subPath.'
+      url: '#return-base64-encoded-subscription-links-for-all-enabled-clients-matching-the-subscription-id-when-the-request-has-an-accept-texthtml-header-or-html1-renders-a-styled-info-page-instead-with-formatinfo-returns-the-page-view-model-as-json-traffic-expiry-online-status-no-links-for-live-polling-the-path-prefix-is-configured-by-subpath'
     - depth: 2
-      title: 'Return subscription as a JSON array of proxy configs (one per enabled
-        client). Only when JSON subscription is enabled in settings. Default
-        path: /json/:subid.'
-      url: '#return-subscription-as-a-json-array-of-proxy-configs-one-per-enabled-client-only-when-json-subscription-is-enabled-in-settings-default-path-jsonsubid'
+      title: Return subscription as a JSON array of proxy configs (one per enabled
+        client). Only when JSON subscription is enabled in settings. The path
+        prefix is configured by subJsonPath.
+      url: '#return-subscription-as-a-json-array-of-proxy-configs-one-per-enabled-client-only-when-json-subscription-is-enabled-in-settings-the-path-prefix-is-configured-by-subjsonpath'
     - depth: 2
-      title: 'Return subscription as a Clash/Mihomo-compatible YAML config, including
+      title: Return subscription as a Clash/Mihomo-compatible YAML config, including
         configured global Clash routing rules. Only when Clash subscription is
-        enabled in settings. Default path: /clash/:subid.'
-      url: '#return-subscription-as-a-clashmihomo-compatible-yaml-config-including-configured-global-clash-routing-rules-only-when-clash-subscription-is-enabled-in-settings-default-path-clashsubid'
+        enabled in settings. The path prefix is configured by subClashPath.
+      url: '#return-subscription-as-a-clashmihomo-compatible-yaml-config-including-configured-global-clash-routing-rules-only-when-clash-subscription-is-enabled-in-settings-the-path-prefix-is-configured-by-subclashpath'
   structuredData:
     headings:
       - content: 'Return base64-encoded subscription links for all enabled clients
           matching the subscription ID. When the request has an Accept:
           text/html header or ?html=1, renders a styled info page instead. With
           ?format=info, returns the page view-model as JSON (traffic, expiry,
-          online status; no links) for live polling. Default path: /sub/:subid.'
-        id: return-base64-encoded-subscription-links-for-all-enabled-clients-matching-the-subscription-id-when-the-request-has-an-accept-texthtml-header-or-html1-renders-a-styled-info-page-instead-with-formatinfo-returns-the-page-view-model-as-json-traffic-expiry-online-status-no-links-for-live-polling-default-path-subsubid
-      - content: 'Return subscription as a JSON array of proxy configs (one per enabled
-          client). Only when JSON subscription is enabled in settings. Default
-          path: /json/:subid.'
-        id: return-subscription-as-a-json-array-of-proxy-configs-one-per-enabled-client-only-when-json-subscription-is-enabled-in-settings-default-path-jsonsubid
-      - content: 'Return subscription as a Clash/Mihomo-compatible YAML config,
-          including configured global Clash routing rules. Only when Clash
-          subscription is enabled in settings. Default path: /clash/:subid.'
-        id: return-subscription-as-a-clashmihomo-compatible-yaml-config-including-configured-global-clash-routing-rules-only-when-clash-subscription-is-enabled-in-settings-default-path-clashsubid
+          online status; no links) for live polling. The path prefix is
+          configured by subPath.'
+        id: return-base64-encoded-subscription-links-for-all-enabled-clients-matching-the-subscription-id-when-the-request-has-an-accept-texthtml-header-or-html1-renders-a-styled-info-page-instead-with-formatinfo-returns-the-page-view-model-as-json-traffic-expiry-online-status-no-links-for-live-polling-the-path-prefix-is-configured-by-subpath
+      - content: Return subscription as a JSON array of proxy configs (one per enabled
+          client). Only when JSON subscription is enabled in settings. The path
+          prefix is configured by subJsonPath.
+        id: return-subscription-as-a-json-array-of-proxy-configs-one-per-enabled-client-only-when-json-subscription-is-enabled-in-settings-the-path-prefix-is-configured-by-subjsonpath
+      - content: Return subscription as a Clash/Mihomo-compatible YAML config, including
+          configured global Clash routing rules. Only when Clash subscription is
+          enabled in settings. The path prefix is configured by subClashPath.
+        id: return-subscription-as-a-clashmihomo-compatible-yaml-config-including-configured-global-clash-routing-rules-only-when-clash-subscription-is-enabled-in-settings-the-path-prefix-is-configured-by-subclashpath
     contents: []
 ---
 

+ 7 - 7
docs/content/docs/fa/config/subscription.mdx

@@ -18,7 +18,7 @@ icon: Rss
 | ------------- | ------- | --------------------------------------------------------------- |
 | `subPort`     | `2096`  | پورت گوش‌دادن (جدا از پنل).                                      |
 | `subListen`   | _(همه)_ | آدرس اتصال (bind).                                              |
-| `subPath`     | `/sub/` | مسیر پایه برای URLهای خام اشتراک.                                |
+| `subPath`     | _(تصادفی برای هر پنل)_ | مسیر پایه برای URLهای خام اشتراک.                  |
 | `subDomain`   | _(هیچ)_ | میزبان عمومی؛ اگر تنظیم شود، سرور فقط به همان Host پاسخ می‌دهد.   |
 | `subCertFile` / `subKeyFile` | _(هیچ)_ | گواهی و کلید TLS — هنگام تنظیم، سرور **HTTPS** ارائه می‌دهد. |
 | `subEncrypt`  | `true`  | بدنه‌ی خام اشتراک را با base64 رمزگذاری می‌کند.                   |
@@ -27,7 +27,7 @@ icon: Rss
 یک URL اشتراک به این شکل است:
 
 ```text
-https://<sub-host>:<sub-port>/sub/<sub-id>
+https://<sub-host>:<sub-port>/<sub-path>/<sub-id>
 ```
 
 که در آن `<sub-id>` همان **Sub ID** کلاینت است.
@@ -44,13 +44,13 @@ https://<sub-host>:<sub-port>/sub/<sub-id>
 
 | Format                | Path      | Enabled by       | Output                                              |
 | --------------------- | --------- | ---------------- | --------------------------------------------------- |
-| **لینک‌های خام**       | `/sub/`   | همیشه (اگر روشن باشد) | فهرستی از لینک‌های `vless://`، `vmess://`، … (هنگام فعال‌بودن `subEncrypt` با base64 رمزگذاری می‌شود). |
-| **JSON**              | `/json/`  | `subJsonEnable`  | پیکربندی(های) کامل کلاینت Xray.                      |
-| **Clash / Mihomo**    | `/clash/` | `subClashEnable` | پروفایل YAML.                                        |
+| **لینک‌های خام**       | `subPath`      | همیشه (اگر روشن باشد) | فهرستی از لینک‌های `vless://`، `vmess://`، … (هنگام فعال‌بودن `subEncrypt` با base64 رمزگذاری می‌شود). |
+| **JSON**              | `subJsonPath`  | `subJsonEnable`  | پیکربندی(های) کامل کلاینت Xray.                      |
+| **Clash / Mihomo**    | `subClashPath` | `subClashEnable` | پروفایل YAML.                                        |
 
 فقط ورودی‌های فعالی که از **VLESS، VMess، Trojan، Shadowsocks یا Hysteria2**
 استفاده می‌کنند در یک اشتراک ظاهر می‌شوند و بر اساس شاخص sub-sort آن‌ها مرتب می‌شوند.
-درخواست `/sub/` همراه با هدر `Accept: text/html` (یا `?html=1`) به‌جای بدنه‌ی خام،
+درخواست `subPath` همراه با هدر `Accept: text/html` (یا `?html=1`) به‌جای بدنه‌ی خام،
 یک صفحه‌ی اطلاعات خوانا برای انسان برمی‌گرداند.
 
 ### Base64 vs JSON
@@ -58,7 +58,7 @@ https://<sub-host>:<sub-port>/sub/<sub-id>
 بدنه‌ی **Base64** صرفاً همان لینک‌های اشتراک‌گذاری است که با خط جدید به هم پیوسته و
 با standard-base64 رمزگذاری شده‌اند (با `subEncrypt` قابل تغییر است). بدنه‌ی **JSON**
 هر کلاینت را در یک پیکربندی کامل کلاینت Xray می‌پیچد — یک اسکلت ثابت (ورودی‌های محلی
-mixed/HTTP، DNS، مسیریابی، policy) به‌علاوه‌ی یک outbound از نوع `proxy` که به ورودی
+SOCKS/HTTP، DNS، مسیریابی، policy) به‌علاوه‌ی یک outbound از نوع `proxy` که به ورودی
 اشاره می‌کند. 3x-ui **برای یک کلاینت یک شیء پیکربندی واحد و برای چند کلاینت یک آرایه**
 تولید می‌کند، از فرم تخت `settings` در outbound استفاده می‌کند
 (`address`/`port`/`id`، `level: 8`) و `sockopt` را از `streamSettings` حذف می‌کند.

Файловите разлики са ограничени, защото са твърде много
+ 10 - 8
docs/content/docs/fa/reference/api/clients.mdx


+ 22 - 21
docs/content/docs/fa/reference/api/subscription-server.mdx

@@ -3,10 +3,10 @@ title: سرور اشتراک
 description: >-
   یک سرور HTTP/HTTPS جداگانه که لینک‌های اشتراک پراکسی (استاندارد، JSON و Clash)
   را به کلاینت‌ها ارائه می‌دهد. این سرور روی پورت اختصاصی خودش (به‌صورت پیش‌فرض
-  10882) گوش می‌دهد و در بخش Settings ← Subscription پیکربندی می‌شود. مسیرها قابل
-  پیکربندی هستند؛ مقادیر پیش‌فرض در ادامه نشان داده شده‌اند. همه‌ی نقاط پایانی
-  اشتراک، هدرهای پاسخ را برای خواندن اطلاعات ترافیک/انقضا توسط برنامه‌های کلاینت
-  تنظیم می‌کنند.
+  2096) گوش می‌دهد و در بخش Settings ← Subscription پیکربندی می‌شود. پنل‌های جدید
+  برای هر قالب پیشوند مسیر تصادفی تولید می‌کنند و همه‌ی مسیرها قابل پیکربندی
+  می‌مانند. همه‌ی نقاط پایانی اشتراک، هدرهای پاسخ را برای خواندن اطلاعات
+  ترافیک/انقضا توسط برنامه‌های کلاینت تنظیم می‌کنند.
 full: true
 _openapi:
   preload:
@@ -16,45 +16,46 @@ _openapi:
       title: >-
         Return base64-encoded subscription links for all enabled clients
         matching the subscription ID. When the request has an Accept: text/html
-        header or ?html=1, renders a styled info page instead. Default path:
-        /sub/:subid.
+        header or ?html=1, renders a styled info page instead. The path prefix is
+        configured by subPath.
       url: >-
-        #return-base64-encoded-subscription-links-for-all-enabled-clients-matching-the-subscription-id-when-the-request-has-an-accept-texthtml-header-or-html1-renders-a-styled-info-page-instead-default-path-subsubid
+        #return-base64-encoded-subscription-links-for-all-enabled-clients-matching-the-subscription-id-when-the-request-has-an-accept-texthtml-header-or-html1-renders-a-styled-info-page-instead-with-formatinfo-returns-the-page-view-model-as-json-traffic-expiry-online-status-no-links-for-live-polling-the-path-prefix-is-configured-by-subpath
     - depth: 2
       title: >-
         Return subscription as a JSON array of proxy configs (one per enabled
-        client). Only when JSON subscription is enabled in settings. Default
-        path: /json/:subid.
+        client). Only when JSON subscription is enabled in settings. The path
+        prefix is configured by subJsonPath.
       url: >-
-        #return-subscription-as-a-json-array-of-proxy-configs-one-per-enabled-client-only-when-json-subscription-is-enabled-in-settings-default-path-jsonsubid
+        #return-subscription-as-a-json-array-of-proxy-configs-one-per-enabled-client-only-when-json-subscription-is-enabled-in-settings-the-path-prefix-is-configured-by-subjsonpath
     - depth: 2
       title: >-
         Return subscription as a Clash/Mihomo-compatible YAML config, including
         configured global Clash routing rules. Only when Clash subscription is
-        enabled in settings. Default path: /clash/:subid.
+        enabled in settings. The path prefix is configured by subClashPath.
       url: >-
-        #return-subscription-as-a-clashmihomo-compatible-yaml-config-including-configured-global-clash-routing-rules-only-when-clash-subscription-is-enabled-in-settings-default-path-clashsubid
+        #return-subscription-as-a-clashmihomo-compatible-yaml-config-including-configured-global-clash-routing-rules-only-when-clash-subscription-is-enabled-in-settings-the-path-prefix-is-configured-by-subclashpath
   structuredData:
     headings:
       - content: >-
           Return base64-encoded subscription links for all enabled clients
           matching the subscription ID. When the request has an Accept:
-          text/html header or ?html=1, renders a styled info page instead.
-          Default path: /sub/:subid.
+          text/html header or ?html=1, renders a styled info page instead. The
+          path prefix is configured by subPath.
         id: >-
-          return-base64-encoded-subscription-links-for-all-enabled-clients-matching-the-subscription-id-when-the-request-has-an-accept-texthtml-header-or-html1-renders-a-styled-info-page-instead-default-path-subsubid
+          return-base64-encoded-subscription-links-for-all-enabled-clients-matching-the-subscription-id-when-the-request-has-an-accept-texthtml-header-or-html1-renders-a-styled-info-page-instead-with-formatinfo-returns-the-page-view-model-as-json-traffic-expiry-online-status-no-links-for-live-polling-the-path-prefix-is-configured-by-subpath
       - content: >-
           Return subscription as a JSON array of proxy configs (one per enabled
-          client). Only when JSON subscription is enabled in settings. Default
-          path: /json/:subid.
+          client). Only when JSON subscription is enabled in settings. The path
+          prefix is configured by subJsonPath.
         id: >-
-          return-subscription-as-a-json-array-of-proxy-configs-one-per-enabled-client-only-when-json-subscription-is-enabled-in-settings-default-path-jsonsubid
+          return-subscription-as-a-json-array-of-proxy-configs-one-per-enabled-client-only-when-json-subscription-is-enabled-in-settings-the-path-prefix-is-configured-by-subjsonpath
       - content: >-
           Return subscription as a Clash/Mihomo-compatible YAML config,
           including configured global Clash routing rules. Only when Clash
-          subscription is enabled in settings. Default path: /clash/:subid.
+          subscription is enabled in settings. The path prefix is configured by
+          subClashPath.
         id: >-
-          return-subscription-as-a-clashmihomo-compatible-yaml-config-including-configured-global-clash-routing-rules-only-when-clash-subscription-is-enabled-in-settings-default-path-clashsubid
+          return-subscription-as-a-clashmihomo-compatible-yaml-config-including-configured-global-clash-routing-rules-only-when-clash-subscription-is-enabled-in-settings-the-path-prefix-is-configured-by-subclashpath
     contents: []
 ---
 
@@ -70,4 +71,4 @@ export default function Layout(props) {
       <Comp document="./public/openapi.json" webhooks={[]} operations={[{"path":"/{subPath}{subid}","method":"get"},{"path":"/{jsonPath}{subid}","method":"get"},{"path":"/{clashPath}{subid}","method":"get"}]} showTitle />
     </>
   );
-}
+}

+ 7 - 7
docs/content/docs/ru/config/subscription.mdx

@@ -18,7 +18,7 @@ icon: Rss
 | ------------- | ------- | --------------------------------------------------------------- |
 | `subPort`     | `2096`  | Порт прослушивания (отдельный от панели).                       |
 | `subListen`   | _(все)_ | Адрес привязки.                                                 |
-| `subPath`     | `/sub/` | Базовый путь для необработанных URL подписок.                   |
+| `subPath`     | _(случайный для каждой панели)_ | Базовый путь для необработанных URL подписок. |
 | `subDomain`   | _(нет)_ | Публичный хост; если задан, сервер отвечает только для этого Host. |
 | `subCertFile` / `subKeyFile` | _(нет)_ | Сертификат + ключ TLS — когда заданы, сервер работает по **HTTPS**. |
 | `subEncrypt`  | `true`  | Кодировать тело необработанной подписки в base64.               |
@@ -27,7 +27,7 @@ icon: Rss
 URL подписки выглядит так:
 
 ```text
-https://<sub-host>:<sub-port>/sub/<sub-id>
+https://<sub-host>:<sub-port>/<sub-path>/<sub-id>
 ```
 
 где `<sub-id>` — это **Sub ID** клиента.
@@ -44,13 +44,13 @@ https://<sub-host>:<sub-port>/sub/<sub-id>
 
 | Формат                | Путь      | Включается       | Вывод                                               |
 | --------------------- | --------- | ---------------- | --------------------------------------------------- |
-| **Необработанные ссылки** | `/sub/`   | всегда (если включён) | Список ссылок `vless://`, `vmess://`, … (закодированных в base64, когда включён `subEncrypt`). |
-| **JSON**              | `/json/`  | `subJsonEnable`  | Полные клиентские конфигурации Xray.                |
-| **Clash / Mihomo**    | `/clash/` | `subClashEnable` | YAML-профиль.                                       |
+| **Необработанные ссылки** | `subPath`      | всегда (если включён) | Список ссылок `vless://`, `vmess://`, … (закодированных в base64, когда включён `subEncrypt`). |
+| **JSON**              | `subJsonPath`  | `subJsonEnable`  | Полные клиентские конфигурации Xray.                |
+| **Clash / Mihomo**    | `subClashPath` | `subClashEnable` | YAML-профиль.                                       |
 
 В подписке появляются только включённые входящие соединения, использующие
 **VLESS, VMess, Trojan, Shadowsocks или Hysteria2**, упорядоченные по их индексу
-сортировки подписки. Запрос `/sub/` с заголовком `Accept: text/html` (или
+сортировки подписки. Запрос `subPath` с заголовком `Accept: text/html` (или
 `?html=1`) возвращает удобочитаемую информационную страницу вместо
 необработанного тела.
 
@@ -59,7 +59,7 @@ https://<sub-host>:<sub-port>/sub/<sub-id>
 Тело **Base64** — это просто ссылки для обмена, объединённые через перевод
 строки и закодированные в стандартный base64 (переключается через `subEncrypt`).
 Тело **JSON** оборачивает каждого клиента в полную клиентскую конфигурацию
-Xray — фиксированный каркас (локальные входящие mixed/HTTP, DNS, маршрутизация,
+Xray — фиксированный каркас (локальные входящие SOCKS/HTTP, DNS, маршрутизация,
 policy) плюс исходящее соединение `proxy`, указывающее на входящее. 3x-ui
 выдаёт **единый объект конфигурации для одного клиента и массив для
 нескольких**, использует плоскую форму `settings` исходящего соединения

Файловите разлики са ограничени, защото са твърде много
+ 10 - 8
docs/content/docs/ru/reference/api/clients.mdx


+ 22 - 21
docs/content/docs/ru/reference/api/subscription-server.mdx

@@ -3,10 +3,10 @@ title: Сервер подписок
 description: >-
   Отдельный HTTP/HTTPS-сервер, который отдаёт клиентам ссылки на подписки
   прокси (стандартные, JSON и Clash). Сервер слушает на собственном порту (по
-  умолчанию 10882) и настраивается в разделе Settings → Subscription. Пути
-  настраиваемы; значения по умолчанию показаны ниже. Все конечные точки подписок
-  устанавливают заголовки ответа, по которым клиентские приложения считывают
-  информацию о трафике и сроке действия.
+  умолчанию 2096) и настраивается в разделе Settings → Subscription. Новые
+  панели генерируют случайные префиксы путей для каждого формата; все пути можно
+  изменить. Все конечные точки подписок устанавливают заголовки ответа, по
+  которым клиентские приложения считывают информацию о трафике и сроке действия.
 full: true
 _openapi:
   preload:
@@ -16,45 +16,46 @@ _openapi:
       title: >-
         Return base64-encoded subscription links for all enabled clients
         matching the subscription ID. When the request has an Accept: text/html
-        header or ?html=1, renders a styled info page instead. Default path:
-        /sub/:subid.
+        header or ?html=1, renders a styled info page instead. The path prefix is
+        configured by subPath.
       url: >-
-        #return-base64-encoded-subscription-links-for-all-enabled-clients-matching-the-subscription-id-when-the-request-has-an-accept-texthtml-header-or-html1-renders-a-styled-info-page-instead-default-path-subsubid
+        #return-base64-encoded-subscription-links-for-all-enabled-clients-matching-the-subscription-id-when-the-request-has-an-accept-texthtml-header-or-html1-renders-a-styled-info-page-instead-with-formatinfo-returns-the-page-view-model-as-json-traffic-expiry-online-status-no-links-for-live-polling-the-path-prefix-is-configured-by-subpath
     - depth: 2
       title: >-
         Return subscription as a JSON array of proxy configs (one per enabled
-        client). Only when JSON subscription is enabled in settings. Default
-        path: /json/:subid.
+        client). Only when JSON subscription is enabled in settings. The path
+        prefix is configured by subJsonPath.
       url: >-
-        #return-subscription-as-a-json-array-of-proxy-configs-one-per-enabled-client-only-when-json-subscription-is-enabled-in-settings-default-path-jsonsubid
+        #return-subscription-as-a-json-array-of-proxy-configs-one-per-enabled-client-only-when-json-subscription-is-enabled-in-settings-the-path-prefix-is-configured-by-subjsonpath
     - depth: 2
       title: >-
         Return subscription as a Clash/Mihomo-compatible YAML config, including
         configured global Clash routing rules. Only when Clash subscription is
-        enabled in settings. Default path: /clash/:subid.
+        enabled in settings. The path prefix is configured by subClashPath.
       url: >-
-        #return-subscription-as-a-clashmihomo-compatible-yaml-config-including-configured-global-clash-routing-rules-only-when-clash-subscription-is-enabled-in-settings-default-path-clashsubid
+        #return-subscription-as-a-clashmihomo-compatible-yaml-config-including-configured-global-clash-routing-rules-only-when-clash-subscription-is-enabled-in-settings-the-path-prefix-is-configured-by-subclashpath
   structuredData:
     headings:
       - content: >-
           Return base64-encoded subscription links for all enabled clients
           matching the subscription ID. When the request has an Accept:
-          text/html header or ?html=1, renders a styled info page instead.
-          Default path: /sub/:subid.
+          text/html header or ?html=1, renders a styled info page instead. The
+          path prefix is configured by subPath.
         id: >-
-          return-base64-encoded-subscription-links-for-all-enabled-clients-matching-the-subscription-id-when-the-request-has-an-accept-texthtml-header-or-html1-renders-a-styled-info-page-instead-default-path-subsubid
+          return-base64-encoded-subscription-links-for-all-enabled-clients-matching-the-subscription-id-when-the-request-has-an-accept-texthtml-header-or-html1-renders-a-styled-info-page-instead-with-formatinfo-returns-the-page-view-model-as-json-traffic-expiry-online-status-no-links-for-live-polling-the-path-prefix-is-configured-by-subpath
       - content: >-
           Return subscription as a JSON array of proxy configs (one per enabled
-          client). Only when JSON subscription is enabled in settings. Default
-          path: /json/:subid.
+          client). Only when JSON subscription is enabled in settings. The path
+          prefix is configured by subJsonPath.
         id: >-
-          return-subscription-as-a-json-array-of-proxy-configs-one-per-enabled-client-only-when-json-subscription-is-enabled-in-settings-default-path-jsonsubid
+          return-subscription-as-a-json-array-of-proxy-configs-one-per-enabled-client-only-when-json-subscription-is-enabled-in-settings-the-path-prefix-is-configured-by-subjsonpath
       - content: >-
           Return subscription as a Clash/Mihomo-compatible YAML config,
           including configured global Clash routing rules. Only when Clash
-          subscription is enabled in settings. Default path: /clash/:subid.
+          subscription is enabled in settings. The path prefix is configured by
+          subClashPath.
         id: >-
-          return-subscription-as-a-clashmihomo-compatible-yaml-config-including-configured-global-clash-routing-rules-only-when-clash-subscription-is-enabled-in-settings-default-path-clashsubid
+          return-subscription-as-a-clashmihomo-compatible-yaml-config-including-configured-global-clash-routing-rules-only-when-clash-subscription-is-enabled-in-settings-the-path-prefix-is-configured-by-subclashpath
     contents: []
 ---
 
@@ -70,4 +71,4 @@ export default function Layout(props) {
       <Comp document="./public/openapi.json" webhooks={[]} operations={[{"path":"/{subPath}{subid}","method":"get"},{"path":"/{jsonPath}{subid}","method":"get"},{"path":"/{clashPath}{subid}","method":"get"}]} showTitle />
     </>
   );
-}
+}

+ 7 - 7
docs/content/docs/zh/config/subscription.mdx

@@ -14,7 +14,7 @@ icon: Rss
 | ------------- | ------- | --------------------------------------------------------------- |
 | `subPort`     | `2096`  | 监听端口(与面板分开)。                                        |
 | `subListen`   | _(全部)_ | 绑定地址。                                                      |
-| `subPath`     | `/sub/` | 原始订阅 URL 的基础路径。                                       |
+| `subPath`     | _(每个面板随机生成)_ | 原始订阅 URL 的基础路径。                         |
 | `subDomain`   | _(无)_  | 公开主机名;若设置,服务器仅响应该 Host。                       |
 | `subCertFile` / `subKeyFile` | _(无)_ | TLS 证书 + 密钥 —— 设置后,服务器以 **HTTPS** 提供服务。 |
 | `subEncrypt`  | `true`  | 对原始订阅内容进行 base64 编码。                               |
@@ -23,7 +23,7 @@ icon: Rss
 一个订阅 URL 形如:
 
 ```text
-https://<sub-host>:<sub-port>/sub/<sub-id>
+https://<sub-host>:<sub-port>/<sub-path>/<sub-id>
 ```
 
 其中 `<sub-id>` 是客户端的 **Sub ID**。
@@ -38,15 +38,15 @@ https://<sub-host>:<sub-port>/sub/<sub-id>
 
 | 格式                  | 路径      | 启用方式         | 输出                                                |
 | --------------------- | --------- | ---------------- | --------------------------------------------------- |
-| **原始链接**          | `/sub/`   | 始终(若已开启) | 一组 `vless://`、`vmess://` 等链接的列表(当 `subEncrypt` 开启时进行 base64 编码)。 |
-| **JSON**              | `/json/`  | `subJsonEnable`  | 完整的 Xray 客户端配置。                            |
-| **Clash / Mihomo**    | `/clash/` | `subClashEnable` | YAML 配置文件。                                     |
+| **原始链接**          | `subPath`      | 始终(若已开启) | 一组 `vless://`、`vmess://` 等链接的列表(当 `subEncrypt` 开启时进行 base64 编码)。 |
+| **JSON**              | `subJsonPath`  | `subJsonEnable`  | 完整的 Xray 客户端配置。                            |
+| **Clash / Mihomo**    | `subClashPath` | `subClashEnable` | YAML 配置文件。                                     |
 
-只有使用 **VLESS、VMess、Trojan、Shadowsocks 或 Hysteria2** 的已启用入站才会出现在订阅中,并按其订阅排序索引排列。使用 `Accept: text/html` 头(或 `?html=1`)请求 `/sub/` 会返回一个人类可读的信息页面,而非原始内容。
+只有使用 **VLESS、VMess、Trojan、Shadowsocks 或 Hysteria2** 的已启用入站才会出现在订阅中,并按其订阅排序索引排列。使用 `Accept: text/html` 头(或 `?html=1`)请求 `subPath` 会返回一个人类可读的信息页面,而非原始内容。
 
 ### Base64 与 JSON
 
-**Base64** 内容只是用换行符连接的分享链接,经标准 base64 编码(通过 `subEncrypt` 开关控制)。**JSON** 内容则将每个客户端包装为一份完整的 Xray 客户端配置 —— 一套固定的骨架(本地 mixed/HTTP 入站、DNS、路由、策略)加上一个指向该入站的 `proxy` 出站。3x-ui **对单个客户端输出单个配置对象,对多个客户端输出数组**,使用扁平的出站 `settings` 形式(`address`/`port`/`id`,`level: 8`),并从 `streamSettings` 中剥离 `sockopt`。
+**Base64** 内容只是用换行符连接的分享链接,经标准 base64 编码(通过 `subEncrypt` 开关控制)。**JSON** 内容则将每个客户端包装为一份完整的 Xray 客户端配置 —— 一套固定的骨架(本地 SOCKS/HTTP 入站、DNS、路由、策略)加上一个指向该入站的 `proxy` 出站。3x-ui **对单个客户端输出单个配置对象,对多个客户端输出数组**,使用扁平的出站 `settings` 形式(`address`/`port`/`id`,`level: 8`),并从 `streamSettings` 中剥离 `sockopt`。
 
 ## 响应头
 

Файловите разлики са ограничени, защото са твърде много
+ 4 - 4
docs/content/docs/zh/reference/api/clients.mdx


+ 14 - 14
docs/content/docs/zh/reference/api/subscription-server.mdx

@@ -2,7 +2,7 @@
 title: 订阅服务器
 description: >-
   一个独立的 HTTP/HTTPS 服务器,用于向客户端提供代理订阅链接(标准、JSON 和 Clash)。该服务器监听自己的端口(默认
-  10882),并在“设置 → 订阅”中进行配置。路径可自定义;下方展示的是默认值。所有订阅端点都会设置响应头,供客户端应用读取流量/到期信息。
+  2096),并在“设置 → 订阅”中进行配置。新面板会为每种格式生成随机路径前缀,所有路径仍可自定义。所有订阅端点都会设置响应头,供客户端应用读取流量/到期信息。
 full: true
 _openapi:
   preload:
@@ -11,36 +11,36 @@ _openapi:
     - depth: 2
       title: >-
         返回与该订阅 ID 匹配的所有已启用客户端的 base64 编码订阅链接。当请求带有 Accept: text/html
-        头或 ?html=1 时,改为渲染一个带样式的信息页面。默认路径:/sub/:subid
+        头或 ?html=1 时,改为渲染一个带样式的信息页面。路径前缀由 subPath 配置
       url: >-
-        #return-base64-encoded-subscription-links-for-all-enabled-clients-matching-the-subscription-id-when-the-request-has-an-accept-texthtml-header-or-html1-renders-a-styled-info-page-instead-default-path-subsubid
+        #return-base64-encoded-subscription-links-for-all-enabled-clients-matching-the-subscription-id-when-the-request-has-an-accept-texthtml-header-or-html1-renders-a-styled-info-page-instead-with-formatinfo-returns-the-page-view-model-as-json-traffic-expiry-online-status-no-links-for-live-polling-the-path-prefix-is-configured-by-subpath
     - depth: 2
       title: >-
-        以代理配置的 JSON 数组形式返回订阅(每个已启用客户端一项)。仅在设置中启用 JSON 订阅时可用。默认路径:/json/:subid
+        以代理配置的 JSON 数组形式返回订阅(每个已启用客户端一项)。仅在设置中启用 JSON 订阅时可用。路径前缀由 subJsonPath 配置
       url: >-
-        #return-subscription-as-a-json-array-of-proxy-configs-one-per-enabled-client-only-when-json-subscription-is-enabled-in-settings-default-path-jsonsubid
+        #return-subscription-as-a-json-array-of-proxy-configs-one-per-enabled-client-only-when-json-subscription-is-enabled-in-settings-the-path-prefix-is-configured-by-subjsonpath
     - depth: 2
       title: >-
         以兼容 Clash/Mihomo 的 YAML 配置形式返回订阅,其中包含已配置的全局 Clash 路由规则。仅在设置中启用 Clash
-        订阅时可用。默认路径:/clash/:subid
+        订阅时可用。路径前缀由 subClashPath 配置
       url: >-
-        #return-subscription-as-a-clashmihomo-compatible-yaml-config-including-configured-global-clash-routing-rules-only-when-clash-subscription-is-enabled-in-settings-default-path-clashsubid
+        #return-subscription-as-a-clashmihomo-compatible-yaml-config-including-configured-global-clash-routing-rules-only-when-clash-subscription-is-enabled-in-settings-the-path-prefix-is-configured-by-subclashpath
   structuredData:
     headings:
       - content: >-
           返回与该订阅 ID 匹配的所有已启用客户端的 base64 编码订阅链接。当请求带有 Accept:
-          text/html 头或 ?html=1 时,改为渲染一个带样式的信息页面。默认路径:/sub/:subid
+          text/html 头或 ?html=1 时,改为渲染一个带样式的信息页面。路径前缀由 subPath 配置
         id: >-
-          return-base64-encoded-subscription-links-for-all-enabled-clients-matching-the-subscription-id-when-the-request-has-an-accept-texthtml-header-or-html1-renders-a-styled-info-page-instead-default-path-subsubid
+          return-base64-encoded-subscription-links-for-all-enabled-clients-matching-the-subscription-id-when-the-request-has-an-accept-texthtml-header-or-html1-renders-a-styled-info-page-instead-with-formatinfo-returns-the-page-view-model-as-json-traffic-expiry-online-status-no-links-for-live-polling-the-path-prefix-is-configured-by-subpath
       - content: >-
-          以代理配置的 JSON 数组形式返回订阅(每个已启用客户端一项)。仅在设置中启用 JSON 订阅时可用。默认路径:/json/:subid
+          以代理配置的 JSON 数组形式返回订阅(每个已启用客户端一项)。仅在设置中启用 JSON 订阅时可用。路径前缀由 subJsonPath 配置
         id: >-
-          return-subscription-as-a-json-array-of-proxy-configs-one-per-enabled-client-only-when-json-subscription-is-enabled-in-settings-default-path-jsonsubid
+          return-subscription-as-a-json-array-of-proxy-configs-one-per-enabled-client-only-when-json-subscription-is-enabled-in-settings-the-path-prefix-is-configured-by-subjsonpath
       - content: >-
           以兼容 Clash/Mihomo 的 YAML 配置形式返回订阅,其中包含已配置的全局 Clash 路由规则。仅在设置中启用 Clash
-          订阅时可用。默认路径:/clash/:subid
+          订阅时可用。路径前缀由 subClashPath 配置
         id: >-
-          return-subscription-as-a-clashmihomo-compatible-yaml-config-including-configured-global-clash-routing-rules-only-when-clash-subscription-is-enabled-in-settings-default-path-clashsubid
+          return-subscription-as-a-clashmihomo-compatible-yaml-config-including-configured-global-clash-routing-rules-only-when-clash-subscription-is-enabled-in-settings-the-path-prefix-is-configured-by-subclashpath
     contents: []
 ---
 
@@ -56,4 +56,4 @@ export default function Layout(props) {
       <Comp document="./public/openapi.json" webhooks={[]} operations={[{"path":"/{subPath}{subid}","method":"get"},{"path":"/{jsonPath}{subid}","method":"get"},{"path":"/{clashPath}{subid}","method":"get"}]} showTitle />
     </>
   );
-}
+}

+ 13 - 0
docs/lib/xray/subscription.test.ts

@@ -120,6 +120,19 @@ describe('buildJsonSubscription', () => {
     expect(cfg.remarks).toBe('HK-01');
   });
 
+  it('uses the iOS-compatible SOCKS inbound while preserving the mixed tag and HTTP inbound', () => {
+    const cfg = JSON.parse(buildJsonSubscription([vlessClient]));
+    const socks = cfg.inbounds.find((inbound: { port: number }) => inbound.port === 10808);
+    const http = cfg.inbounds.find((inbound: { port: number }) => inbound.port === 10809);
+
+    expect(socks).toMatchObject({
+      protocol: 'socks',
+      tag: 'mixed',
+      settings: { udp: true },
+    });
+    expect(http).toMatchObject({ protocol: 'http' });
+  });
+
   it('trojan uses servers[] with a password and no method', () => {
     const trojan: SubClient = {
       protocol: 'trojan',

+ 1 - 1
docs/lib/xray/subscription.ts

@@ -146,7 +146,7 @@ function subJsonSkeleton(): Record<string, unknown> {
     inbounds: [
       {
         port: 10808,
-        protocol: 'mixed',
+        protocol: 'socks',
         settings: { auth: 'noauth', udp: true, userLevel: 8 },
         sniffing: { destOverride: ['http', 'tls', 'quic', 'fakedns'], enabled: true },
         tag: 'mixed',

Файловите разлики са ограничени, защото са твърде много
+ 670 - 66
docs/public/openapi.json


+ 146 - 242
frontend/package-lock.json

@@ -38,11 +38,11 @@
         "@testing-library/dom": "^10.4.1",
         "@testing-library/react": "^16.3.3",
         "@types/react": "^19.2.18",
-        "@types/react-dom": "^19.2.5",
+        "@types/react-dom": "^19.2.7",
         "@types/swagger-ui-react": "^5.18.0",
         "@vitejs/plugin-react": "^6.1.1",
-        "@vitest/browser-playwright": "4.1.11",
-        "@vitest/coverage-v8": "^4.1.11",
+        "@vitest/browser-playwright": "5.0.0",
+        "@vitest/coverage-v8": "^5.0.0",
         "husky": "^9.1.7",
         "jsdom": "^30.0.1",
         "lint-staged": "^17.4.1",
@@ -54,7 +54,7 @@
         "storybook": "^10.6.0",
         "typescript": "7.0.2",
         "vite": "8.2.2",
-        "vitest": "^4.1.11"
+        "vitest": "^5.0.0"
       },
       "engines": {
         "node": ">=24.0.0",
@@ -496,9 +496,9 @@
       }
     },
     "node_modules/@blazediff/core": {
-      "version": "1.9.1",
-      "resolved": "https://registry.npmjs.org/@blazediff/core/-/core-1.9.1.tgz",
-      "integrity": "sha512-ehg3jIkYKulZh+8om/O25vkvSsXXwC+skXmyA87FFx6A/45eqOkZsBltMw/TVteb0mloiGT8oGRTcjRAz66zaA==",
+      "version": "1.10.0",
+      "resolved": "https://registry.npmjs.org/@blazediff/core/-/core-1.10.0.tgz",
+      "integrity": "sha512-AOQff0zgR7cGsZL+4E7hVkmujoPUpm0J9xzWGWZj5wCjd3gmxESXAPfKyuzs93VdpQNFhHlBhfOjrcZ+XTERtQ==",
       "dev": true,
       "license": "MIT"
     },
@@ -4195,13 +4195,6 @@
       "hasInstallScript": true,
       "license": "Apache-2.0"
     },
-    "node_modules/@standard-schema/spec": {
-      "version": "1.1.0",
-      "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
-      "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==",
-      "devOptional": true,
-      "license": "MIT"
-    },
     "node_modules/@standard-schema/utils": {
       "version": "0.3.0",
       "resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz",
@@ -4422,16 +4415,6 @@
         }
       }
     },
-    "node_modules/@storybook/react-vite/node_modules/magic-string": {
-      "version": "1.2.3",
-      "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-1.2.3.tgz",
-      "integrity": "sha512-Bpb0W2TbLKOZ7vJnOUnVRGq3WL2p+ISV29M6hYPL1AFCpyKZpdr5ytiXoTSSxRVhg8YW7f65+6gbG8WG6PCa/g==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "@jridgewell/sourcemap-codec": "^1.5.5"
-      }
-    },
     "node_modules/@swagger-api/apidom-ast": {
       "version": "1.12.0",
       "resolved": "https://registry.npmjs.org/@swagger-api/apidom-ast/-/apidom-ast-1.12.0.tgz",
@@ -5430,9 +5413,9 @@
       }
     },
     "node_modules/@types/react-dom": {
-      "version": "19.2.5",
-      "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.5.tgz",
-      "integrity": "sha512-fMPwH9v7r/pp43yUd2/Mbiex5KouJwwR3dzHkhLREUC6764VyDsqxhAxv6OFEYR1RhjOyD1naqba8ECDBe7ZQg==",
+      "version": "19.2.7",
+      "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.7.tgz",
+      "integrity": "sha512-I8bPpDLcHBv1qiIiXDCy71Rt8eQDKJP0sMSWJphDdAcdqiJ1sGpZamavoEIRZmYzjia9LuEb2HlYdDpmoENpvQ==",
       "dev": true,
       "license": "MIT",
       "peerDependencies": {
@@ -5843,45 +5826,46 @@
       }
     },
     "node_modules/@vitest/browser": {
-      "version": "4.1.11",
-      "resolved": "https://registry.npmjs.org/@vitest/browser/-/browser-4.1.11.tgz",
-      "integrity": "sha512-bwMovvAeuTFOK5kIFevw4VEf+1gVEICv4SYK4k3knJOxl6b1zEWud8mYKD73e1B0odAn174h1MofURy2TPWf3w==",
+      "version": "5.0.0",
+      "resolved": "https://registry.npmjs.org/@vitest/browser/-/browser-5.0.0.tgz",
+      "integrity": "sha512-JC9FG5xIRxPHXJPcdCaluIJcEoeM0IwGQ3xneuJk09LXKHRNs40BqWDWymQikbb20yOpYvzpKzmYgPRuSzKmvg==",
       "dev": true,
       "license": "MIT",
       "dependencies": {
-        "@blazediff/core": "1.9.1",
-        "@vitest/mocker": "4.1.11",
-        "@vitest/utils": "4.1.11",
-        "magic-string": "^0.30.21",
+        "@blazediff/core": "1.10.0",
+        "@vitest/mocker": "5.0.0",
+        "@vitest/ui": "5.0.0",
+        "@vitest/utils": "5.0.0",
+        "magic-string": "^1.2.3",
         "pngjs": "^7.0.0",
         "sirv": "^3.0.2",
-        "tinyrainbow": "^3.1.0",
-        "ws": "^8.19.0"
+        "tinyrainbow": "^3.1.1",
+        "ws": "^8.21.3"
       },
       "funding": {
         "url": "https://opencollective.com/vitest"
       },
       "peerDependencies": {
-        "vitest": "4.1.11"
+        "vitest": "5.0.0"
       }
     },
     "node_modules/@vitest/browser-playwright": {
-      "version": "4.1.11",
-      "resolved": "https://registry.npmjs.org/@vitest/browser-playwright/-/browser-playwright-4.1.11.tgz",
-      "integrity": "sha512-riLBxPqwnJ0lWs2DN2WeUfYeKLoAjbP2Xx8cLQdSddzMi20sksIa6K2mPz79DyMZKKVKH2ksOC2yJvtNcZg8cg==",
+      "version": "5.0.0",
+      "resolved": "https://registry.npmjs.org/@vitest/browser-playwright/-/browser-playwright-5.0.0.tgz",
+      "integrity": "sha512-N+gED9y4/8pypaHjz/x0ah3CjoBr+N0hWWT+Gq4VXtMzyB+rUIdHni0ulzpD1j4H77iWy5Jg8PRMlVn6kjxo6g==",
       "dev": true,
       "license": "MIT",
       "dependencies": {
-        "@vitest/browser": "4.1.11",
-        "@vitest/mocker": "4.1.11",
-        "tinyrainbow": "^3.1.0"
+        "@vitest/browser": "5.0.0",
+        "@vitest/mocker": "5.0.0",
+        "tinyrainbow": "^3.1.1"
       },
       "funding": {
         "url": "https://opencollective.com/vitest"
       },
       "peerDependencies": {
         "playwright": "*",
-        "vitest": "4.1.11"
+        "vitest": "5.0.0"
       },
       "peerDependenciesMeta": {
         "playwright": {
@@ -5890,29 +5874,27 @@
       }
     },
     "node_modules/@vitest/coverage-v8": {
-      "version": "4.1.11",
-      "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.11.tgz",
-      "integrity": "sha512-8MVGEFnJIcdGjcbfKmeq8z0pZHH0JlVtoVZH9Q/qwUp6wyFnEJUBMrw9DCaj+ra3vShGmhavjalMIhPNxZAUcw==",
+      "version": "5.0.0",
+      "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-5.0.0.tgz",
+      "integrity": "sha512-toMg6PZGCIa/lQNCDoASrfb1ly4hsUKXFtFYC9kD4t78o5Y6LyNJU7AENt8eHPr3quYdxaxK7hj2mnbFfUk9NA==",
       "dev": true,
       "license": "MIT",
       "dependencies": {
         "@bcoe/v8-coverage": "^1.0.2",
-        "@vitest/utils": "4.1.11",
-        "ast-v8-to-istanbul": "^1.0.0",
-        "istanbul-lib-coverage": "^3.2.2",
-        "istanbul-lib-report": "^3.0.1",
-        "istanbul-reports": "^3.2.0",
-        "magicast": "^0.5.2",
-        "obug": "^2.1.1",
-        "std-env": "^4.0.0-rc.1",
-        "tinyrainbow": "^3.1.0"
+        "@vitest/istanbul-lib-coverage": "^1.0.0",
+        "@vitest/istanbul-lib-report": "^1.0.0",
+        "ast-v8-to-istanbul": "^1.0.5",
+        "magicast": "^0.5.4",
+        "obug": "^2.1.4",
+        "std-env": "^4.2.0",
+        "tinyrainbow": "^3.1.1"
       },
       "funding": {
         "url": "https://opencollective.com/vitest"
       },
       "peerDependencies": {
-        "@vitest/browser": "4.1.11",
-        "vitest": "4.1.11"
+        "@vitest/browser": "5.0.0",
+        "vitest": "5.0.0"
       },
       "peerDependenciesMeta": {
         "@vitest/browser": {
@@ -5988,16 +5970,40 @@
         "node": ">=14.0.0"
       }
     },
+    "node_modules/@vitest/istanbul-lib-coverage": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/@vitest/istanbul-lib-coverage/-/istanbul-lib-coverage-1.0.1.tgz",
+      "integrity": "sha512-k3DJZ8LhMBK9NS4SclF1ASD3OgXEWDorbIcPTRDK0/Zae6fRvu+fJRxtFdLfHsa9Y24beCdPnoNZ4LviTNstfA==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=22"
+      }
+    },
+    "node_modules/@vitest/istanbul-lib-report": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/@vitest/istanbul-lib-report/-/istanbul-lib-report-1.0.1.tgz",
+      "integrity": "sha512-1EOLRfsTMnyAr3+kEAsP4o9dhaDlGPpD7H5iLBBeq//YpNB1VIahkPhB+eRp9N2Dkfw8oySROjE3yf9XDeaIkQ==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@vitest/istanbul-lib-coverage": "1.0.1"
+      },
+      "engines": {
+        "node": ">=22"
+      }
+    },
     "node_modules/@vitest/mocker": {
-      "version": "4.1.11",
-      "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.11.tgz",
-      "integrity": "sha512-2XJVD55d1o5AZous5CCGKS74g/riOj9odEt2bQpCVZeblHyHdnMeFl4jl0XjU21stf4mbjUkew2eXQZt65g5CQ==",
+      "version": "5.0.0",
+      "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-5.0.0.tgz",
+      "integrity": "sha512-66PGTMIiVJP3t4a5yxU9qPtf7MdTBs8jmToMvy+HVflB3Yy13WJZTtPePdvU+wjRV02SKK5doLbSA6o9pwOmiA==",
       "dev": true,
       "license": "MIT",
       "dependencies": {
-        "@vitest/spy": "4.1.11",
+        "@jridgewell/trace-mapping": "0.3.31",
+        "@vitest/spy": "5.0.0",
         "estree-walker": "^3.0.3",
-        "magic-string": "^0.30.21"
+        "magic-string": "^1.2.3"
       },
       "funding": {
         "url": "https://opencollective.com/vitest"
@@ -6026,68 +6032,59 @@
       }
     },
     "node_modules/@vitest/pretty-format": {
-      "version": "4.1.11",
-      "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.11.tgz",
-      "integrity": "sha512-yiZzPbGTS9Sr/JpFl8zHrcIkAofNbFV6k21vIgQN/cY/oxZeXhJv5sc/MBJ5jFKWmWs+oJHw0UXLZjmf931+Vw==",
+      "version": "5.0.0",
+      "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-5.0.0.tgz",
+      "integrity": "sha512-PVRNuB3wpReb4SQEs4zTKM4KWFhQ5pw3spE8naoDJNB5T5aWRzGKHwXcLUllr0WeOTXpB6bSr3CJLo5+7XQSSQ==",
       "dev": true,
       "license": "MIT",
       "dependencies": {
-        "tinyrainbow": "^3.1.0"
+        "tinyrainbow": "^3.1.1"
       },
       "funding": {
         "url": "https://opencollective.com/vitest"
       }
     },
-    "node_modules/@vitest/runner": {
-      "version": "4.1.11",
-      "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.11.tgz",
-      "integrity": "sha512-LztvUgdwMNJMIkj3hQnnxiC2Xy1zNxq928W/xhjCLaNCzqTZOudjwbQf6v9IntZGPw132i2Lq2rgTRZHD3JHNw==",
+    "node_modules/@vitest/spy": {
+      "version": "5.0.0",
+      "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-5.0.0.tgz",
+      "integrity": "sha512-uy+luWBAPw9XfthoHi5AkfHUnuPYEESjl0p/r+meoBnU8bxg5GDQ3Ey8MjcJ6sqahkL4PFyrvfMJJBw7LbU06g==",
       "dev": true,
       "license": "MIT",
-      "dependencies": {
-        "@vitest/utils": "4.1.11",
-        "pathe": "^2.0.3"
-      },
       "funding": {
         "url": "https://opencollective.com/vitest"
       }
     },
-    "node_modules/@vitest/snapshot": {
-      "version": "4.1.11",
-      "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.11.tgz",
-      "integrity": "sha512-pN7ikn1ON7h8ee4gIAp4AzyK+zBtJPzVbqOgu5LCEh4VaJVbPQcgYQYJIMGQPXVeJJq1fnfazis7a5pFNPahog==",
+    "node_modules/@vitest/ui": {
+      "version": "5.0.0",
+      "resolved": "https://registry.npmjs.org/@vitest/ui/-/ui-5.0.0.tgz",
+      "integrity": "sha512-h2FIFwggCY2GxUd2UdQoYNVQkOIqEQLPhNREcl3FUiRsdzQep7NWwYbSmhGEA9nFLPDq5pXzRMcBZQU8Py83sg==",
       "dev": true,
       "license": "MIT",
       "dependencies": {
-        "@vitest/pretty-format": "4.1.11",
-        "@vitest/utils": "4.1.11",
-        "magic-string": "^0.30.21",
-        "pathe": "^2.0.3"
+        "@vitest/utils": "5.0.0",
+        "fflate": "^0.8.3",
+        "flatted": "^3.4.4",
+        "pathe": "^2.0.3",
+        "sirv": "^3.0.2",
+        "tinyrainbow": "^3.1.1"
       },
       "funding": {
         "url": "https://opencollective.com/vitest"
-      }
-    },
-    "node_modules/@vitest/spy": {
-      "version": "4.1.11",
-      "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.11.tgz",
-      "integrity": "sha512-apNa/prQy2qCeywhnixOHPRCgGNhvg7T4Dapfl1GahLp/R+uhBm5cPyFoNVyqsNd2h1nJxL6BqqdIjiABL60YA==",
-      "dev": true,
-      "license": "MIT",
-      "funding": {
-        "url": "https://opencollective.com/vitest"
+      },
+      "peerDependencies": {
+        "vitest": "5.0.0"
       }
     },
     "node_modules/@vitest/utils": {
-      "version": "4.1.11",
-      "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.11.tgz",
-      "integrity": "sha512-zTCVGpyFsGWBhllOyKlTw/vnr6D9qxsfSDyfbyZmTyjHw5N/VuvzHpHoQjm2ZJzn4RJgx5w4r7V0er69CmLgPQ==",
+      "version": "5.0.0",
+      "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-5.0.0.tgz",
+      "integrity": "sha512-dO++xL3vDfvhTAVimfkuQUA3k+JClIF1i1vAkPqpcGAthRmeWnXmHB7YPViPvgCwviX8u7Y5W1u2N//AaQr3fw==",
       "dev": true,
       "license": "MIT",
       "dependencies": {
-        "@vitest/pretty-format": "4.1.11",
+        "@vitest/pretty-format": "5.0.0",
         "convert-source-map": "^2.0.0",
-        "tinyrainbow": "^3.1.0"
+        "tinyrainbow": "^3.1.1"
       },
       "funding": {
         "url": "https://opencollective.com/vitest"
@@ -7273,6 +7270,20 @@
         }
       }
     },
+    "node_modules/fflate": {
+      "version": "0.8.3",
+      "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.3.tgz",
+      "integrity": "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/flatted": {
+      "version": "3.4.4",
+      "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.4.tgz",
+      "integrity": "sha512-5+ybhBZANEJxaH3X5evAFatUxLfEHSr7n6kYJ+1Qd0mUqr4eu9gIf6GDbWHf8RJijHrjjO8G+la14SlL2SeS1Q==",
+      "dev": true,
+      "license": "ISC"
+    },
     "node_modules/follow-redirects": {
       "version": "1.16.0",
       "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz",
@@ -7453,16 +7464,6 @@
         "node": "^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0"
       }
     },
-    "node_modules/has-flag": {
-      "version": "4.0.0",
-      "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
-      "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==",
-      "dev": true,
-      "license": "MIT",
-      "engines": {
-        "node": ">=8"
-      }
-    },
     "node_modules/has-property-descriptors": {
       "version": "1.0.2",
       "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz",
@@ -7583,13 +7584,6 @@
         "node": "^20.19.0 || ^22.12.0 || >=24.0.0"
       }
     },
-    "node_modules/html-escaper": {
-      "version": "2.0.2",
-      "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz",
-      "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==",
-      "dev": true,
-      "license": "MIT"
-    },
     "node_modules/html-parse-stringify": {
       "version": "4.0.1",
       "resolved": "https://registry.npmjs.org/html-parse-stringify/-/html-parse-stringify-4.0.1.tgz",
@@ -7881,45 +7875,6 @@
       "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==",
       "license": "MIT"
     },
-    "node_modules/istanbul-lib-coverage": {
-      "version": "3.2.2",
-      "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz",
-      "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==",
-      "dev": true,
-      "license": "BSD-3-Clause",
-      "engines": {
-        "node": ">=8"
-      }
-    },
-    "node_modules/istanbul-lib-report": {
-      "version": "3.0.1",
-      "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz",
-      "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==",
-      "dev": true,
-      "license": "BSD-3-Clause",
-      "dependencies": {
-        "istanbul-lib-coverage": "^3.0.0",
-        "make-dir": "^4.0.0",
-        "supports-color": "^7.1.0"
-      },
-      "engines": {
-        "node": ">=10"
-      }
-    },
-    "node_modules/istanbul-reports": {
-      "version": "3.2.0",
-      "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz",
-      "integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==",
-      "dev": true,
-      "license": "BSD-3-Clause",
-      "dependencies": {
-        "html-escaper": "^2.0.0",
-        "istanbul-lib-report": "^3.0.0"
-      },
-      "engines": {
-        "node": ">=8"
-      }
-    },
     "node_modules/js-file-download": {
       "version": "0.4.12",
       "resolved": "https://registry.npmjs.org/js-file-download/-/js-file-download-0.4.12.tgz",
@@ -8400,9 +8355,9 @@
       }
     },
     "node_modules/magic-string": {
-      "version": "0.30.21",
-      "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
-      "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==",
+      "version": "1.2.3",
+      "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-1.2.3.tgz",
+      "integrity": "sha512-Bpb0W2TbLKOZ7vJnOUnVRGq3WL2p+ISV29M6hYPL1AFCpyKZpdr5ytiXoTSSxRVhg8YW7f65+6gbG8WG6PCa/g==",
       "dev": true,
       "license": "MIT",
       "dependencies": {
@@ -8421,22 +8376,6 @@
         "source-map-js": "^1.2.1"
       }
     },
-    "node_modules/make-dir": {
-      "version": "4.0.0",
-      "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz",
-      "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "semver": "^7.5.3"
-      },
-      "engines": {
-        "node": ">=10"
-      },
-      "funding": {
-        "url": "https://github.com/sponsors/sindresorhus"
-      }
-    },
     "node_modules/math-intrinsics": {
       "version": "1.1.0",
       "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
@@ -10124,19 +10063,6 @@
       "integrity": "sha512-5Z9ZpRzfuH6l/UAvCPAPUo3665Nk2wLaZU3x+TLHKVzIz33+sbJqbtrYoC3KD4/uVOr2Zp+L0LySezP9OHV9yA==",
       "license": "MIT"
     },
-    "node_modules/supports-color": {
-      "version": "7.2.0",
-      "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
-      "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "has-flag": "^4.0.0"
-      },
-      "engines": {
-        "node": ">=8"
-      }
-    },
     "node_modules/supports-preserve-symlinks-flag": {
       "version": "1.0.0",
       "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz",
@@ -10285,11 +10211,14 @@
       "license": "MIT"
     },
     "node_modules/tinybench": {
-      "version": "2.9.0",
-      "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",
-      "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==",
+      "version": "6.1.4",
+      "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-6.1.4.tgz",
+      "integrity": "sha512-9APumHG7r4yOk4X4WlkmE71aZcv1gvin1czO3OQ1U9iJcFA5Ja/ygyb0vPOVHTthFozUYs8CLoLUlM8grb2lTQ==",
       "dev": true,
-      "license": "MIT"
+      "license": "MIT",
+      "engines": {
+        "node": ">=20.0.0"
+      }
     },
     "node_modules/tinyexec": {
       "version": "1.3.0",
@@ -10772,38 +10701,31 @@
       }
     },
     "node_modules/vitest": {
-      "version": "4.1.11",
-      "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.11.tgz",
-      "integrity": "sha512-fhACrNXUidIbGSBr5FlbuBkO7VWC1ZyLl0DO4CU2DrQoAPxX84Ysxs+HeGQpii5lZWV1Q4gBZTTu49mF+A6Edw==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "@vitest/expect": "4.1.11",
-        "@vitest/mocker": "4.1.11",
-        "@vitest/pretty-format": "4.1.11",
-        "@vitest/runner": "4.1.11",
-        "@vitest/snapshot": "4.1.11",
-        "@vitest/spy": "4.1.11",
-        "@vitest/utils": "4.1.11",
-        "es-module-lexer": "^2.0.0",
-        "expect-type": "^1.3.0",
-        "magic-string": "^0.30.21",
-        "obug": "^2.1.1",
-        "pathe": "^2.0.3",
-        "picomatch": "^4.0.3",
-        "std-env": "^4.0.0-rc.1",
-        "tinybench": "^2.9.0",
-        "tinyexec": "^1.0.2",
-        "tinyglobby": "^0.2.15",
-        "tinyrainbow": "^3.1.0",
-        "vite": "^6.0.0 || ^7.0.0 || ^8.0.0",
+      "version": "5.0.0",
+      "resolved": "https://registry.npmjs.org/vitest/-/vitest-5.0.0.tgz",
+      "integrity": "sha512-gpsMNoRhMjMktVxPtstOH4/PJuPyovVaMDr4oDilXaGH1EcqM2OE96SoHT2VIQ6fTGtTjqmHDrEu2X9RQiXf8Q==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@types/chai": "^5.2.2",
+        "@vitest/mocker": "5.0.0",
+        "chai": "^6.2.2",
+        "es-module-lexer": "^2.3.2",
+        "expect-type": "^1.4.0",
+        "magic-string": "^1.2.3",
+        "obug": "^2.1.4",
+        "picomatch": "^4.0.7",
+        "std-env": "^4.2.0",
+        "tinybench": "6.1.4",
+        "tinyexec": "1.3.0",
+        "tinyglobby": "^0.2.17",
         "why-is-node-running": "^2.3.0"
       },
       "bin": {
         "vitest": "vitest.mjs"
       },
       "engines": {
-        "node": "^20.0.0 || ^22.0.0 || >=24.0.0"
+        "node": "^22.12.0 || ^24.0.0 || >=26.0.0"
       },
       "funding": {
         "url": "https://opencollective.com/vitest"
@@ -10811,16 +10733,16 @@
       "peerDependencies": {
         "@edge-runtime/vm": "*",
         "@opentelemetry/api": "^1.9.0",
-        "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0",
-        "@vitest/browser-playwright": "4.1.11",
-        "@vitest/browser-preview": "4.1.11",
-        "@vitest/browser-webdriverio": "4.1.11",
-        "@vitest/coverage-istanbul": "4.1.11",
-        "@vitest/coverage-v8": "4.1.11",
-        "@vitest/ui": "4.1.11",
+        "@types/node": "^22.0.0 || >=24.0.0",
+        "@vitest/browser-playwright": "5.0.0",
+        "@vitest/browser-preview": "5.0.0",
+        "@vitest/browser-webdriverio": "^5.0.0-beta.5 || >=5.0.0",
+        "@vitest/coverage-istanbul": "5.0.0",
+        "@vitest/coverage-v8": "5.0.0",
+        "@vitest/ui": "5.0.0",
         "happy-dom": "*",
         "jsdom": "*",
-        "vite": "^6.0.0 || ^7.0.0 || ^8.0.0"
+        "vite": "^6.4.0 || ^7.0.0 || ^8.0.0"
       },
       "peerDependenciesMeta": {
         "@edge-runtime/vm": {
@@ -10861,24 +10783,6 @@
         }
       }
     },
-    "node_modules/vitest/node_modules/@vitest/expect": {
-      "version": "4.1.11",
-      "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.11.tgz",
-      "integrity": "sha512-VX2x5vNJXET47KAFzwERI+KRMtTTCSWTfSMKsW7JsUsXV4psq++e3DvZpuTDOpHcxytiDs6p2nhVb2tVDiiUYw==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "@standard-schema/spec": "^1.1.0",
-        "@types/chai": "^5.2.2",
-        "@vitest/spy": "4.1.11",
-        "@vitest/utils": "4.1.11",
-        "chai": "^6.2.2",
-        "tinyrainbow": "^3.1.0"
-      },
-      "funding": {
-        "url": "https://opencollective.com/vitest"
-      }
-    },
     "node_modules/vitest/node_modules/chai": {
       "version": "6.2.2",
       "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz",

+ 9 - 4
frontend/package.json

@@ -64,11 +64,11 @@
     "@testing-library/dom": "^10.4.1",
     "@testing-library/react": "^16.3.3",
     "@types/react": "^19.2.18",
-    "@types/react-dom": "^19.2.5",
+    "@types/react-dom": "^19.2.7",
     "@types/swagger-ui-react": "^5.18.0",
     "@vitejs/plugin-react": "^6.1.1",
-    "@vitest/browser-playwright": "4.1.11",
-    "@vitest/coverage-v8": "^4.1.11",
+    "@vitest/browser-playwright": "5.0.0",
+    "@vitest/coverage-v8": "^5.0.0",
     "husky": "^9.1.7",
     "jsdom": "^30.0.1",
     "lint-staged": "^17.4.1",
@@ -80,7 +80,7 @@
     "storybook": "^10.6.0",
     "typescript": "7.0.2",
     "vite": "8.2.2",
-    "vitest": "^4.1.11"
+    "vitest": "^5.0.0"
   },
   "overrides": {
     "dompurify": "^3.4.11",
@@ -95,6 +95,11 @@
     },
     "@typeschema/valibot": {
       "valibot": "^1.1.0"
+    },
+    "@storybook/addon-vitest": {
+      "vitest": "$vitest",
+      "@vitest/browser-playwright": "$@vitest/browser-playwright",
+      "@vitest/browser": "5.0.0"
     }
   },
   "allowScripts": {

Файловите разлики са ограничени, защото са твърде много
+ 670 - 66
frontend/public/openapi.json


+ 109 - 27
frontend/scripts/build-openapi.mjs

@@ -16,7 +16,8 @@ const SECURITY_SCHEMES = {
   bearerAuth: {
     type: 'http',
     scheme: 'bearer',
-    description: 'API token from Settings → Security → API Token. Send as `Authorization: Bearer <token>`.',
+    description:
+      'API token from Settings → Security → API Token. Send as `Authorization: Bearer <token>`.',
   },
   cookieAuth: {
     type: 'apiKey',
@@ -55,9 +56,35 @@ function schemaFromType(t) {
     const itemType = v.slice(0, -2);
     return { type: 'array', items: { type: mapType(itemType) } };
   }
+  if (v === 'file') return { type: 'string', format: 'binary' };
   return { type: mapType(v) };
 }
 
+function schemaFromParam(p) {
+  const schema = schemaFromType(p.type);
+  if (p.defaultValue !== undefined) schema.default = p.defaultValue;
+  if (p.minLength !== undefined) schema.minLength = p.minLength;
+  if (p.pattern !== undefined) schema.pattern = p.pattern;
+  return schema;
+}
+
+function requestBodyContentType(ep, bodyParams) {
+  const locations = new Set(bodyParams.map((p) => p.in));
+  if (locations.size > 1) {
+    throw new Error(
+      `${ep.method} ${ep.path}: request body mixes parameter locations: ${[...locations].join(', ')}`,
+    );
+  }
+  switch (bodyParams[0]?.in) {
+    case 'body (form)':
+      return 'application/x-www-form-urlencoded';
+    case 'body (multipart)':
+      return 'multipart/form-data';
+    default:
+      return 'application/json';
+  }
+}
+
 function tryParseJson(raw) {
   if (typeof raw !== 'string') return undefined;
   try {
@@ -73,9 +100,8 @@ function paramToOpenApi(p) {
     in: p.in,
     required: p.in === 'path' ? true : !p.optional,
     description: p.desc || '',
-    schema: schemaFromType(p.type),
+    schema: schemaFromParam(p),
   };
-  if (p.defaultValue !== undefined) out.schema.default = p.defaultValue;
   return out;
 }
 
@@ -91,7 +117,7 @@ function buildOperation(ep, tag) {
   const params = [];
   const bodyParams = [];
   for (const p of ep.params || []) {
-    if (p.in === 'body') {
+    if (p.in.startsWith('body')) {
       bodyParams.push(p);
     } else if (p.in === 'path' || p.in === 'query' || p.in === 'header') {
       params.push(paramToOpenApi(p));
@@ -113,26 +139,80 @@ function buildOperation(ep, tag) {
 
   if (params.length > 0) op.parameters = params;
 
-  if (ep.body || bodyParams.length > 0) {
-    const example = tryParseJson(ep.body);
+  if (ep.body || bodyParams.length > 0 || ep.requestSchema) {
+    const contentType = requestBodyContentType(ep, bodyParams);
+    const example = contentType === 'application/json' ? tryParseJson(ep.body) : undefined;
     const properties = {};
     const required = [];
     for (const bp of bodyParams) {
       properties[bp.name] = {
-        ...schemaFromType(bp.type),
+        ...schemaFromParam(bp),
         description: bp.desc || '',
       };
       if (!bp.optional) required.push(bp.name);
     }
-    const schema = bodyParams.length > 0
-      ? { type: 'object', properties, ...(required.length > 0 ? { required } : {}) }
-      : { type: 'object' };
+    let schema;
+    if (ep.requestSchema) {
+      if (bodyParams.length > 0 || ep.bodyRequiredOneOf?.length) {
+        throw new Error(
+          `${ep.method} ${ep.path}: requestSchema cannot be combined with body parameters or bodyRequiredOneOf`,
+        );
+      }
+      schema = ep.requestSchema;
+    } else {
+      schema =
+        bodyParams.length > 0
+          ? { type: 'object', properties, ...(required.length > 0 ? { required } : {}) }
+          : { type: 'object' };
+      if (ep.bodyRequiredOneOf?.length) {
+        schema = {
+          anyOf: ep.bodyRequiredOneOf.map((name) => {
+            if (!properties[name]) {
+              throw new Error(
+                `${ep.method} ${ep.path}: bodyRequiredOneOf "${name}" is not a declared body parameter`,
+              );
+            }
+            const branchProperties = { ...properties };
+            for (const other of ep.bodyRequiredOneOf) {
+              if (other === name || !branchProperties[other]) continue;
+              const { pattern: _pattern, minLength: _minLength, ...rest } =
+                branchProperties[other];
+              branchProperties[other] = rest;
+            }
+            return {
+              type: 'object',
+              properties: branchProperties,
+              required: [...required, name],
+            };
+          }),
+        };
+      }
+    }
+
+    const encoding = {};
+    if (contentType === 'application/x-www-form-urlencoded') {
+      for (const bp of bodyParams) {
+        const kind = schemaFromType(bp.type).type;
+        if (kind === 'array') {
+          encoding[bp.name] = { style: 'form', explode: true };
+        } else if (kind === 'object') {
+          // The panel reads such a field with json.Unmarshal, so it must be sent
+          // as JSON text rather than form-style key/value pairs.
+          encoding[bp.name] = { contentType: 'application/json' };
+        }
+      }
+    }
 
     op.requestBody = {
-      required: required.length > 0 || bodyParams.length === 0,
+      required:
+        Boolean(ep.requestSchema) ||
+        Boolean(ep.bodyRequiredOneOf?.length) ||
+        required.length > 0 ||
+        bodyParams.length === 0,
       content: {
-        'application/json': {
+        [contentType]: {
           schema,
+          ...(Object.keys(encoding).length > 0 ? { encoding } : {}),
           ...(example !== undefined ? { example } : {}),
         },
       },
@@ -145,10 +225,14 @@ function buildOperation(ep, tag) {
   if (ep.responseSchema) {
     const obj = EXAMPLES[ep.responseSchema];
     if (obj === undefined) {
-      throw new Error(`${ep.method} ${ep.path}: responseSchema "${ep.responseSchema}" has no generated example`);
+      throw new Error(
+        `${ep.method} ${ep.path}: responseSchema "${ep.responseSchema}" has no generated example`,
+      );
     }
     if (SCHEMAS[ep.responseSchema] === undefined) {
-      throw new Error(`${ep.method} ${ep.path}: responseSchema "${ep.responseSchema}" has no generated schema`);
+      throw new Error(
+        `${ep.method} ${ep.path}: responseSchema "${ep.responseSchema}" has no generated schema`,
+      );
     }
     const ref = { $ref: `#/components/schemas/${ep.responseSchema}` };
     objSchema = ep.responseSchemaArray ? { type: 'array', items: ref } : ref;
@@ -197,7 +281,7 @@ function buildOperation(ep, tag) {
   return op;
 }
 
-function buildSpec() {
+export function buildSpec() {
   const paths = {};
   for (const section of sections) {
     const tag = section.title;
@@ -221,9 +305,7 @@ function buildSpec() {
       description:
         'Programmatic interface to a 3X-UI panel. Authenticate either by logging in (cookie) or with an API token from Settings → Security → API Token (Bearer). All endpoints under /panel/api/* honour both modes — an API token is a full-admin credential, so treat it like the panel password.',
     },
-    servers: [
-      { url: '/', description: 'Current panel (basePath aware)' },
-    ],
+    servers: [{ url: '/', description: 'Current panel (basePath aware)' }],
     components: {
       securitySchemes: SECURITY_SCHEMES,
       schemas: SCHEMAS,
@@ -234,13 +316,13 @@ function buildSpec() {
   };
 }
 
-const spec = buildSpec();
-writeFileSync(outPath, JSON.stringify(spec, null, 2) + '\n');
-
-const pathCount = Object.keys(spec.paths).length;
-let opCount = 0;
-for (const ops of Object.values(spec.paths)) opCount += Object.keys(ops).length;
-console.log(`[openapi] wrote ${outPath}`);
-console.log(`[openapi] paths: ${pathCount}, operations: ${opCount}, tags: ${spec.tags.length}`);
+if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
+  const spec = buildSpec();
+  writeFileSync(outPath, JSON.stringify(spec, null, 2) + '\n');
 
-void pathToFileURL;
+  const pathCount = Object.keys(spec.paths).length;
+  let opCount = 0;
+  for (const ops of Object.values(spec.paths)) opCount += Object.keys(ops).length;
+  console.log(`[openapi] wrote ${outPath}`);
+  console.log(`[openapi] paths: ${pathCount}, operations: ${opCount}, tags: ${spec.tags.length}`);
+}

+ 21 - 6
frontend/src/components/form/DateTimePicker.tsx

@@ -17,6 +17,8 @@ interface DateTimePickerProps {
   format?: string;
   placeholder?: string;
   disabled?: boolean;
+  allowClear?: boolean;
+  maxDate?: Dayjs;
 }
 
 const LIGHT_THEME = {
@@ -53,6 +55,8 @@ export default function DateTimePicker({
   format = 'YYYY-MM-DD HH:mm:ss',
   placeholder = '',
   disabled = false,
+  allowClear = true,
+  maxDate,
 }: DateTimePickerProps) {
   const { t } = useTranslation();
   const { datepicker } = useDatepicker();
@@ -78,6 +82,14 @@ export default function DateTimePicker({
     return LIGHT_THEME;
   }, [isDark, isUltra]);
 
+  const commitChange = (next: Dayjs | null) => {
+    if (next && maxDate && next.isAfter(maxDate)) {
+      if (datepicker === 'jalalian') setClearNonce((n) => n + 1);
+      return;
+    }
+    onChange(next);
+  };
+
   // The library hardcodes a Persian placeholder and exposes no working prop to
   // override it, so clear it (or apply the caller's) on the input directly so
   // the empty field shows no leftover Persian text. No dep array: re-apply
@@ -100,19 +112,20 @@ export default function DateTimePicker({
           onChange={(next: number | string | null) => {
             if (suppressMountEmit.current) return;
             if (next == null || next === '') {
-              onChange(null);
+              commitChange(null);
               return;
             }
             const ms = typeof next === 'number' ? next : Number(next);
-            if (Number.isFinite(ms)) onChange(dayjs(ms));
+            if (Number.isFinite(ms)) commitChange(dayjs(ms));
           }}
           showTime={showTime}
           outputFormat="timestamp"
+          maxDate={maxDate?.toDate()}
           persianNumbers
           rtlCalendar
           theme={persianTheme}
         />
-        {value && !disabled && (
+        {value && allowClear && !disabled && (
           <button
             type="button"
             className="jdp-clear"
@@ -120,7 +133,7 @@ export default function DateTimePicker({
             onMouseDown={(e) => e.preventDefault()}
             onClick={(e) => {
               e.stopPropagation();
-              onChange(null);
+              commitChange(null);
               setClearNonce((n) => n + 1);
             }}
           >
@@ -134,13 +147,15 @@ export default function DateTimePicker({
   return (
     <DatePicker
       value={value}
-      onChange={(next) => onChange(next || null)}
-      onCalendarChange={(next) => onChange((Array.isArray(next) ? next[0] : next) || null)}
+      onChange={(next) => commitChange(next || null)}
+      onCalendarChange={(next) => commitChange((Array.isArray(next) ? next[0] : next) || null)}
       showTime={showTime ? { format: 'HH:mm:ss' } : false}
       needConfirm={false}
       format={format}
       placeholder={placeholder}
       disabled={disabled}
+      allowClear={allowClear}
+      maxDate={maxDate}
       style={{ width: '100%' }}
     />
   );

+ 1 - 1
frontend/src/entries/subpage.tsx

@@ -12,7 +12,7 @@ if (messageContainer) {
   message.config({ getContainer: () => messageContainer });
 }
 
-readyI18n().then(() => {
+readyI18n('subscription').then(() => {
   const root = document.getElementById('app');
   if (root) {
     createRoot(root).render(

+ 9 - 8
frontend/src/i18n/react.ts

@@ -2,6 +2,7 @@ import i18next from 'i18next';
 import { initReactI18next } from 'react-i18next';
 
 import { LanguageManager } from '@/utils';
+import type { LanguageScope } from '@/utils';
 import enUS from '../../../internal/web/translation/en-US.json';
 
 const FALLBACK = 'en-US';
@@ -15,15 +16,15 @@ function moduleKeyFor(code: string): string {
   return `../../../internal/web/translation/${code}.json`;
 }
 
-let active: string = LanguageManager.getLanguage();
-if (
-  active !== FALLBACK &&
-  !Object.prototype.hasOwnProperty.call(lazyModules, moduleKeyFor(active))
-) {
-  active = FALLBACK;
-}
+export async function readyI18n(scope: LanguageScope = 'panel') {
+  let active = LanguageManager.getLanguage(scope);
+  if (
+    active !== FALLBACK &&
+    !Object.prototype.hasOwnProperty.call(lazyModules, moduleKeyFor(active))
+  ) {
+    active = FALLBACK;
+  }
 
-export async function readyI18n() {
   await i18next.use(initReactI18next).init({
     lng: active,
     fallbackLng: FALLBACK,

+ 10 - 0
frontend/src/lib/clients/external-link.ts

@@ -0,0 +1,10 @@
+export function resolveExternalLinkExpiry(
+  externalExpiry: number | null | undefined,
+  clientExpiry: number | null | undefined,
+): number {
+  const explicitExpiry = Number(externalExpiry) || 0;
+  if (explicitExpiry > 0) return explicitExpiry;
+
+  const inheritedExpiry = Number(clientExpiry) || 0;
+  return inheritedExpiry > 0 ? inheritedExpiry : 0;
+}

+ 23 - 0
frontend/src/lib/inbounds/label.ts

@@ -7,3 +7,26 @@ export function formatInboundLabel(tag?: string, remark?: string): string {
   if (remarkText) return remarkText;
   return (tag || '').trim();
 }
+
+export function formatTunnelConfigMeta(
+  inbound: { id?: number; tag?: string; remark?: string },
+  email?: string,
+  totalCount = 1,
+): {
+  label?: string;
+  fileName: string;
+  qrRemark: string;
+} {
+  const inboundName =
+    formatInboundLabel(inbound.tag, inbound.remark) ||
+    (inbound.id != null ? `inbound-${inbound.id}` : '');
+  const label = totalCount > 1 ? inboundName : undefined;
+  const suffix = inbound.remark || inbound.tag || (inbound.id != null ? `${inbound.id}` : '');
+  const safeSuffix = suffix ? `-${suffix.replace(/[^\w.-]+/g, '_')}` : '';
+  const emailPrefix = email || 'client';
+  const fileName = `${emailPrefix}${totalCount > 1 ? safeSuffix : ''}.conf`;
+  const qrRemark =
+    totalCount > 1 && inboundName ? [inboundName, email].filter(Boolean).join(' - ') : email || '';
+
+  return { label, fileName, qrRemark };
+}

+ 5 - 9
frontend/src/lib/xray/amneziawg-obfuscation.ts

@@ -51,21 +51,17 @@ const generateHeaderProtectionKey = (): string => {
 };
 
 /*
- * Four non-overlapping "low-high" ranges for H1-H4: split the space into
- * four bands and take a random sub-range from each (>= 1000 wide, low
- * bound >= 5 since 1-4 are reserved for vanilla WireGuard message types).
+ * Four distinct values for H1-H4, one per band; low bound >= 5 (1-4 are vanilla WG message types).
+ * Single values, not ranges: with randomTrailers on, a wide range misclassifies transport packets as handshakes (amnezia-vpn/amneziawg-go#183).
  */
-const generateHRanges = (): [string, string, string, string] => {
+const generateHValues = (): [string, string, string, string] => {
   const hMax = 2147483647;
-  const hMinWidth = 1000;
   const lo = 5;
   const bandSize = Math.floor((hMax - lo + 1) / 4);
   return Array.from({ length: 4 }, (_, i) => {
     const bandLo = lo + i * bandSize;
     const bandHi = bandLo + bandSize - 1;
-    const start = randInt(bandLo, bandHi - hMinWidth - 1);
-    const end = randInt(start + hMinWidth, bandHi - 1);
-    return `${start}-${end}`;
+    return `${randInt(bandLo, bandHi)}`;
   }) as [string, string, string, string];
 };
 
@@ -76,7 +72,7 @@ export function generateAwgObfuscation(): AwgObfuscation {
   while (s1 + 56 === s2) {
     s2 = randInt(15, 150);
   }
-  const [h1, h2, h3, h4] = generateHRanges();
+  const [h1, h2, h3, h4] = generateHValues();
 
   /*
    * Timing windows bracket WireGuard's stock constants (rekey 120s, reject

+ 13 - 5
frontend/src/lib/xray/inbound-link.ts

@@ -12,6 +12,7 @@ import type { ExternalProxyEntry } from '@/schemas/protocols/stream/external-pro
 import type { FinalMaskStreamSettings } from '@/schemas/protocols/stream/finalmask';
 import type { XHttpStreamSettings } from '@/schemas/protocols/stream/xhttp';
 
+import { parseGeckoPacketSize } from '@/lib/xray/forms/transport/FinalMaskForm';
 import { getHeaderValue } from './headers';
 import { canEnableTlsFlow } from './protocol-capabilities';
 import { deriveSpiderX } from './spider-x';
@@ -437,7 +438,7 @@ export function genVlessLink(input: GenVlessLinkInput): string {
     params.set('security', 'tls');
     if (stream.security === 'tls') {
       const tls = stream.tlsSettings;
-      params.set('fp', tls.settings.fingerprint);
+      if (tls.settings.fingerprint.length > 0) params.set('fp', tls.settings.fingerprint);
       params.set('alpn', tls.alpn.join(','));
       if (tls.serverName.length > 0) params.set('sni', tls.serverName);
       if (tls.settings.echConfigList.length > 0) params.set('ech', tls.settings.echConfigList);
@@ -543,7 +544,7 @@ function writeTlsParams(
 ): void {
   if (stream.security !== 'tls') return;
   const tls = stream.tlsSettings;
-  params.set('fp', tls.settings.fingerprint);
+  if (tls.settings.fingerprint.length > 0) params.set('fp', tls.settings.fingerprint);
   params.set('alpn', tls.alpn.join(','));
   if (tls.settings.echConfigList.length > 0) params.set('ech', tls.settings.echConfigList);
   if (tls.serverName.length > 0) params.set('sni', tls.serverName);
@@ -801,13 +802,20 @@ export function genHysteriaLink(input: GenHysteriaLinkInput): string {
     const salamander = udpMasks.find((m) => m?.type === 'salamander');
     const obfsPassword = salamander?.settings?.password;
     if (typeof obfsPassword === 'string' && obfsPassword.length > 0) {
-      params.set('obfs', 'salamander');
+      // packetSize (Gecko mode) exports via v2rayN's native fields; the
+      // experimental fm=<json> dump breaks mihomo and other strict clients.
+      const range = parseGeckoPacketSize(salamander?.settings?.packetSize);
+      if (range) {
+        params.set('obfs', 'gecko');
+        params.set('minPacketSize', String(range.min));
+        params.set('maxPacketSize', String(range.max));
+      } else {
+        params.set('obfs', 'salamander');
+      }
       params.set('obfs-password', obfsPassword);
     }
   }
 
-  applyFinalMaskToParams(stream.finalmask, params);
-
   const hopPorts = stream.finalmask?.quicParams?.udpHop?.ports?.trim() ?? '';
   if (hopPorts.length > 0) {
     params.set('mport', hopPorts);

+ 6 - 0
frontend/src/lib/xray/inbound-tls-defaults.ts

@@ -17,6 +17,12 @@ function defaultCertificate(): Record<string, unknown> {
 export function createTlsSettingsWithDefaultCert(): Record<string, unknown> {
   const tls = TlsStreamSettingsSchema.parse({}) as Record<string, unknown>;
   tls.certificates = [defaultCertificate()];
+  const settings =
+    tls.settings && typeof tls.settings === 'object' && !Array.isArray(tls.settings)
+      ? { ...(tls.settings as Record<string, unknown>) }
+      : {};
+  settings.fingerprint = 'chrome';
+  tls.settings = settings;
   return tls;
 }
 

+ 33 - 6
frontend/src/lib/xray/outbound-link-parser.ts

@@ -258,14 +258,34 @@ function ensureFinalMask(stream: Raw): Raw {
   return stream.finalmask as Raw;
 }
 
-// Rebuild the salamander mask from the standard Hysteria2 obfs pair (every
-// non-3x-ui client, and this panel's own generator, speak it instead of the
-// private fm=<json> dump). A salamander mask already carrying a password via fm=
-// wins; a password-less one is completed rather than left empty.
+// Rebuild the salamander mask from the standard Hysteria2 obfs pair; an fm=
+// password wins. obfs=gecko adds min/maxPacketSize stored as packetSize.
 function applyHysteria2Obfs(stream: Raw, params: URLSearchParams): void {
-  if ((params.get('obfs') ?? '').toLowerCase() !== 'salamander') return;
+  const obfs = (params.get('obfs') ?? '').toLowerCase();
+  const isGecko = obfs === 'gecko';
+  if (!isGecko && obfs !== 'salamander') return;
   const password = firstParam(params, 'obfs-password', 'obfs_password', 'obfsPassword');
   if (!password) return;
+  let packetSize = '';
+  if (isGecko) {
+    // Both halves required and numeric, matching the export side; anything
+    // else is dropped rather than stored as a malformed range.
+    const minSize = (params.get('minPacketSize') ?? '').trim();
+    const maxSize = (params.get('maxPacketSize') ?? '').trim();
+    const min = Number(minSize);
+    const max = Number(maxSize);
+    if (
+      /^\d+$/.test(minSize) &&
+      /^\d+$/.test(maxSize) &&
+      Number.isSafeInteger(min) &&
+      Number.isSafeInteger(max) &&
+      min >= 1 &&
+      max >= min &&
+      max <= 2048
+    ) {
+      packetSize = `${min}-${max}`;
+    }
+  }
   const finalmask = ensureFinalMask(stream);
   const udp = Array.isArray(finalmask.udp) ? (finalmask.udp as Raw[]) : [];
   const existing = udp.find(
@@ -279,9 +299,16 @@ function applyHysteria2Obfs(stream: Raw, params: URLSearchParams): void {
     ) as Raw;
     if (typeof settings.password !== 'string' || settings.password.length === 0)
       settings.password = password;
+    if (
+      packetSize !== '' &&
+      !(typeof settings.packetSize === 'string' && settings.packetSize.length > 0)
+    )
+      settings.packetSize = packetSize;
     return;
   }
-  finalmask.udp = [...udp, { type: 'salamander', settings: { password } }];
+  const settings: Raw = { password };
+  if (packetSize !== '') settings.packetSize = packetSize;
+  finalmask.udp = [...udp, { type: 'salamander', settings }];
 }
 
 // Rebuild the UDP port-hopping range from the standard mport param, which the

+ 278 - 114
frontend/src/pages/api-docs/endpoints.ts

@@ -11,6 +11,7 @@ export type ParamType =
   | 'string'
   | 'integer'
   | 'integer[]'
+  | 'string[]'
   | 'number'
   | 'boolean'
   | 'object'
@@ -25,6 +26,8 @@ export interface EndpointParam {
   desc?: string;
   optional?: boolean;
   defaultValue?: string | number | boolean;
+  minLength?: number;
+  pattern?: string;
 }
 
 export interface Endpoint {
@@ -38,6 +41,8 @@ export interface Endpoint {
   response?: string;
   errorResponse?: string;
   errorStatus?: number;
+  requestSchema?: Record<string, unknown>;
+  bodyRequiredOneOf?: string[];
   responseSchema?: string;
   responseSchemaArray?: boolean;
 }
@@ -55,6 +60,118 @@ export interface Section {
   endpoints: Endpoint[];
 }
 
+// /inbounds/update replaces the whole row, so it takes the same payload as /add.
+const inboundBody =
+  '{\n  "enable": true,\n  "remark": "VLESS-443",\n  "listen": "",\n  "port": 443,\n  "protocol": "vless",\n  "expiryTime": 0,\n  "total": 0,\n  "settings": {\n    "clients": [{ "id": "...", "email": "user1" }],\n    "decryption": "none",\n    "fallbacks": []\n  },\n  "streamSettings": {\n    "network": "tcp",\n    "security": "reality",\n    "realitySettings": { "show": false, "dest": "..." }\n  },\n  "sniffing": {\n    "enabled": true,\n    "destOverride": ["http", "tls"]\n  }\n}';
+
+const outboundSubscriptionBodyParams: EndpointParam[] = [
+  {
+    name: 'remark',
+    in: 'body (form)',
+    type: 'string',
+    desc: 'Optional display label.',
+    optional: true,
+  },
+  {
+    name: 'url',
+    in: 'body (form)',
+    type: 'string',
+    desc: 'Subscription URL (required). Must be a public http(s) address; private/internal targets are blocked unless allowPrivate is true.',
+  },
+  {
+    name: 'tagPrefix',
+    in: 'body (form)',
+    type: 'string',
+    desc: 'Prefix for generated outbound tags. Defaults to the lowest free "sub<N>-" prefix.',
+    optional: true,
+  },
+  {
+    name: 'updateInterval',
+    in: 'body (form)',
+    type: 'integer',
+    desc: 'Seconds between auto-refreshes. Default 600.',
+    optional: true,
+    defaultValue: 600,
+  },
+  {
+    name: 'enabled',
+    in: 'body (form)',
+    type: 'boolean',
+    desc: 'Whether the subscription is active. Default true.',
+    optional: true,
+    defaultValue: true,
+  },
+  {
+    name: 'allowPrivate',
+    in: 'body (form)',
+    type: 'boolean',
+    desc: 'Allow the URL to point at a private/internal/loopback address. Default false.',
+    optional: true,
+    defaultValue: false,
+  },
+  {
+    name: 'allowInsecure',
+    in: 'body (form)',
+    type: 'boolean',
+    desc: "Skip TLS certificate verification when fetching the subscription's URL. Default false.",
+    optional: true,
+    defaultValue: false,
+  },
+  {
+    name: 'prepend',
+    in: 'body (form)',
+    type: 'boolean',
+    desc: "Place this subscription's outbounds before the manual template outbounds. Default false.",
+    optional: true,
+    defaultValue: false,
+  },
+];
+
+const subBalancerBodyParams: EndpointParam[] = [
+  {
+    name: 'remark',
+    in: 'body (form)',
+    type: 'string',
+    desc: 'Display label, used as the config remarks (required).',
+  },
+  {
+    name: 'strategy',
+    in: 'body (form)',
+    type: 'string',
+    desc: 'Balancer strategy: "leastLoad", "leastPing", "roundRobin" or "random". Default "random".',
+    optional: true,
+    defaultValue: 'random',
+  },
+  {
+    name: 'inboundIds',
+    in: 'body (form)',
+    type: 'integer[]',
+    desc: 'Repeated form keys selecting the member inbounds (required, at least one).',
+  },
+  {
+    name: 'memberWeights',
+    in: 'body (form)',
+    type: 'object',
+    desc: 'leastLoad only: JSON object mapping inbound id to a static weight > 0, e.g. {"3":0.2}. Lower weight = picked more often; absent ids weigh 1. Rejected for other strategies; entries for unselected inbounds are dropped.',
+    optional: true,
+  },
+  {
+    name: 'sortOrder',
+    in: 'body (form)',
+    type: 'integer',
+    desc: '1-based position in the subscription list. Default 1.',
+    optional: true,
+    defaultValue: 1,
+  },
+  {
+    name: 'enabled',
+    in: 'body (form)',
+    type: 'boolean',
+    desc: 'Whether the balancer is emitted. Default true on create; unchanged when omitted on update.',
+    optional: true,
+  },
+];
+
 export const sections: readonly Section[] = [
   {
     id: 'authentication',
@@ -75,6 +192,7 @@ export const sections: readonly Section[] = [
             in: 'body',
             type: 'string',
             desc: 'OTP code when 2FA is enabled. Omit otherwise.',
+            optional: true,
           },
         ],
         body: '{\n  "username": "admin",\n  "password": "admin",\n  "twoFactorCode": "123456"\n}',
@@ -153,7 +271,7 @@ export const sections: readonly Section[] = [
         path: '/panel/api/inbounds/add',
         summary:
           'Create a new inbound. Send the full inbound payload (protocol, port, settings, streamSettings, sniffing, remark, expiryTime, total, enable). settings, streamSettings, and sniffing may be sent as nested JSON objects (preferred) or as JSON-encoded strings (legacy).',
-        body: '{\n  "enable": true,\n  "remark": "VLESS-443",\n  "listen": "",\n  "port": 443,\n  "protocol": "vless",\n  "expiryTime": 0,\n  "total": 0,\n  "settings": {\n    "clients": [{ "id": "...", "email": "user1" }],\n    "decryption": "none",\n    "fallbacks": []\n  },\n  "streamSettings": {\n    "network": "tcp",\n    "security": "reality",\n    "realitySettings": { "show": false, "dest": "..." }\n  },\n  "sniffing": {\n    "enabled": true,\n    "destOverride": ["http", "tls"]\n  }\n}',
+        body: inboundBody,
         errorResponse: '{\n  "success": false,\n  "msg": "Port 443 is already in use"\n}',
       },
       {
@@ -177,6 +295,7 @@ export const sections: readonly Section[] = [
         summary:
           'Replace an inbound’s configuration. Body shape mirrors /add. Heavy on inbounds with thousands of clients — prefer /setEnable for enable-only flips.',
         params: [{ name: 'id', in: 'path', type: 'number', desc: 'Inbound ID.' }],
+        body: inboundBody,
       },
       {
         method: 'POST',
@@ -517,6 +636,15 @@ export const sections: readonly Section[] = [
         method: 'POST',
         path: '/panel/api/server/updatePanel',
         summary: 'Self-update the panel to the latest version. The server restarts on success.',
+        params: [
+          {
+            name: 'dev',
+            in: 'body (form)',
+            type: 'boolean',
+            desc: "Override this run's channel. Omit to use the panel's configured channel.",
+            optional: true,
+          },
+        ],
         response: '{\n  "success": true,\n  "obj": {\n    "runId": "1735689600123456789"\n  }\n}',
       },
       {
@@ -538,16 +666,7 @@ export const sections: readonly Section[] = [
         method: 'POST',
         path: '/panel/api/server/updateGeofile',
         summary:
-          'Refresh the default GeoIP / GeoSite data files. Body can include a fileName, or use the /:fileName variant.',
-        params: [
-          {
-            name: 'fileName',
-            in: 'body (form)',
-            type: 'string',
-            desc: 'Filename to update (e.g. geoip.dat, geosite.dat). Omit to update all defaults.',
-          },
-        ],
-        body: 'fileName=geoip.dat',
+          'Refresh the default GeoIP / GeoSite data files. Use the /:fileName variant to update one file.',
       },
       {
         method: 'POST',
@@ -568,8 +687,22 @@ export const sections: readonly Section[] = [
         summary: 'Return the last N lines of the panel\u2019s own log.',
         params: [
           { name: 'count', in: 'path', type: 'number', desc: 'Number of trailing log lines.' },
+          {
+            name: 'level',
+            in: 'body (form)',
+            type: 'string',
+            desc: 'Minimum log level filter.',
+            optional: true,
+          },
+          {
+            name: 'syslog',
+            in: 'body (form)',
+            type: 'boolean',
+            desc: 'Read system logs instead of the panel log.',
+            optional: true,
+          },
         ],
-        body: '{\n  "level": "info",\n  "syslog": false\n}',
+        body: 'level=info&syslog=false',
         response:
           '{\n  "success": true,\n  "obj": "2025/01/01 12:00:00 [INFO] Server started\\n2025/01/01 12:00:01 [INFO] Xray is running"\n}',
       },
@@ -584,24 +717,28 @@ export const sections: readonly Section[] = [
             in: 'body (form)',
             type: 'string',
             desc: 'Keyword filter — only lines containing this string.',
+            optional: true,
           },
           {
             name: 'showDirect',
             in: 'body (form)',
             type: 'string',
             desc: '"true" to include direct (freedom) traffic lines.',
+            optional: true,
           },
           {
             name: 'showBlocked',
             in: 'body (form)',
             type: 'string',
             desc: '"true" to include blocked (blackhole) traffic lines.',
+            optional: true,
           },
           {
             name: 'showProxy',
             in: 'body (form)',
             type: 'string',
             desc: '"true" to include proxy traffic lines.',
+            optional: true,
           },
         ],
         body: 'filter=error&showDirect=false&showBlocked=true&showProxy=true',
@@ -625,6 +762,7 @@ export const sections: readonly Section[] = [
             in: 'body (form)',
             type: 'string',
             desc: 'Keyword filter — only rows/lines containing this string.',
+            optional: true,
           },
         ],
         body: 'filter=awg1',
@@ -642,6 +780,14 @@ export const sections: readonly Section[] = [
             type: 'file',
             desc: 'Database backup or migration file to upload.',
           },
+          {
+            name: 'keepHostSettings',
+            in: 'body (multipart)',
+            type: 'boolean',
+            desc: "Keep this machine's addresses, certificates and node identity. Default true.",
+            optional: true,
+            defaultValue: true,
+          },
         ],
       },
       {
@@ -666,18 +812,23 @@ export const sections: readonly Section[] = [
         path: '/panel/api/server/getCertHash',
         summary:
           'Compute the hex SHA-256 of a certificate (DER) for pinning (pinnedPeerCertSha256). Provide either a server file path or inline PEM/DER content.',
+        bodyRequiredOneOf: ['certFile', 'certContent'],
         params: [
           {
             name: 'certFile',
             in: 'body (form)',
             type: 'string',
             desc: 'Path to a certificate file on the server. Takes precedence over certContent.',
+            optional: true,
+            pattern: '.*\\S.*',
           },
           {
             name: 'certContent',
             in: 'body (form)',
             type: 'string',
             desc: 'Inline PEM (or DER) certificate content, used when certFile is empty.',
+            optional: true,
+            pattern: '.*\\S.*',
           },
         ],
         body: 'certFile=/root/cert.crt',
@@ -767,14 +918,25 @@ export const sections: readonly Section[] = [
         path: '/panel/api/server/clientIps',
         summary:
           'Submit a list of recently active IP timestamps. The panel merges them with the existing database to maintain a unified global IP-limit view.',
-        params: [
-          {
-            name: 'ips',
-            in: 'body (json)',
-            type: 'object[]',
-            desc: 'Array of InboundClientIps to merge.',
-          },
-        ],
+        requestSchema: {
+          type: 'array',
+          items: {
+            type: 'object',
+            properties: {
+              clientEmail: { type: 'string' },
+              ips: {
+                type: 'array',
+                nullable: true,
+                items: {
+                  type: 'object',
+                  properties: { ip: { type: 'string' }, timestamp: { type: 'integer' } },
+                  required: ['ip', 'timestamp'],
+                },
+              },
+            },
+            required: ['clientEmail', 'ips'],
+          },
+        },
       },
     ],
   },
@@ -956,14 +1118,14 @@ export const sections: readonly Section[] = [
         method: 'POST',
         path: '/panel/api/clients/:email/externalLinks',
         summary:
-          "Replace a client's external links and external subscriptions. Sends the full set; the server replaces all rows. Disabled rows stay saved for editing but are not emitted in generated subscriptions.",
+          "Replace a client's external links and external subscriptions. Sends the full set; the server replaces all rows. Disabled rows stay saved for editing but are not emitted in generated subscriptions. The owning client's disabled or expired state also stops these rows from being emitted on future subscription fetches; credentials already imported by an app remain valid until the external provider revokes them.",
         params: [
           { name: 'email', in: 'path', type: 'string', desc: 'Client email (unique identifier).' },
           {
             name: 'externalLinks',
             in: 'body',
             type: 'object[]',
-            desc: 'Full replacement list; the server replaces all rows. Each row supports { kind, value, remark, enable, expiryTime, namePrefix }. kind=link: value must be a supported share link such as vless://, vmess://, trojan://, ss://, hysteria2://, or wireguard://, and remark overrides the exported node name. kind=subscription: value must be an http(s) subscription URL, and namePrefix is prepended to fetched node names. Omit enable to default true; enable=false or an expired expiryTime keeps the row saved but excludes it from generated subscriptions. expiryTime is a unix millisecond timestamp where 0 means never expire; a negative value is rejected. Rows are matched by kind+value across saves, so id is ignored on write. lastFetchAt and lastFetchError are read-only status fields returned by GET.',
+            desc: "Full replacement list; the server replaces all rows. Each row supports { kind, value, remark, enable, expiryTime, namePrefix }. kind=link: value must be a supported share link such as vless://, vmess://, trojan://, ss://, hysteria2://, or wireguard://, and remark overrides the exported node name. kind=subscription: value must be an http(s) subscription URL, and namePrefix is prepended to fetched node names. Omit enable to default true; enable=false or an expired expiryTime keeps the row saved but excludes it from generated subscriptions. expiryTime is a unix millisecond timestamp where 0 means no link-specific expiry; the owning client's enabled state and expiry still apply. A negative value is rejected. Rows are matched by kind+value across saves, so id is ignored on write. lastFetchAt and lastFetchError are read-only status fields returned by GET.",
           },
         ],
         body: '{\n  "externalLinks": [\n    { "kind": "link", "value": "vless://uuid@host:443?...#srv", "remark": "DE", "enable": true, "expiryTime": 0 },\n    { "kind": "subscription", "value": "https://provider.example/sub/abc", "remark": "Provider", "enable": false, "expiryTime": 1767225600000, "namePrefix": "[zjh] " }\n  ]\n}',
@@ -1077,7 +1239,7 @@ export const sections: readonly Section[] = [
           {
             name: 'emails',
             in: 'body (json)',
-            type: 'array',
+            type: 'string[]',
             desc: 'Emails of existing clients to attach.',
           },
           {
@@ -1100,7 +1262,7 @@ export const sections: readonly Section[] = [
           {
             name: 'emails',
             in: 'body (json)',
-            type: 'array',
+            type: 'string[]',
             desc: 'Emails of existing clients to detach.',
           },
           {
@@ -1279,7 +1441,7 @@ export const sections: readonly Section[] = [
         method: 'GET',
         path: '/panel/api/clients/subLinks/:subId',
         summary:
-          'Return every protocol URL (vless://, vmess://, trojan://, ss://, hysteria://, hy2://) for clients matching the subscription ID. Same result set as /sub/<subId>, but as a JSON array — no base64. When an inbound has streamSettings.externalProxy set, one URL is emitted per external proxy. Empty array when the subId has no enabled clients.',
+          'Return every protocol URL (vless://, vmess://, trojan://, ss://, hysteria://, hy2://) for clients matching the subscription ID. Same result set as the configured subPath endpoint, but as a JSON array — no base64. When an inbound has streamSettings.externalProxy set, one URL is emitted per external proxy. Empty array when the subId has no enabled clients.',
         params: [
           {
             name: 'subId',
@@ -1666,12 +1828,14 @@ export const sections: readonly Section[] = [
             in: 'body',
             type: 'string',
             desc: 'admin (default), monitor, or node-sync.',
+            optional: true,
           },
           {
             name: 'expiresAt',
             in: 'body',
             type: 'number',
             desc: 'Future Unix milliseconds, or 0 for no expiry.',
+            optional: true,
           },
         ],
         body: '{\n  "name": "central-panel-a",\n  "scope": "node-sync",\n  "expiresAt": 1798761600000\n}',
@@ -1766,6 +1930,7 @@ export const sections: readonly Section[] = [
             in: 'body (form)',
             type: 'string',
             desc: 'URL used for outbound reachability tests. Defaults to https://www.google.com/generate_204.',
+            optional: true,
           },
         ],
       },
@@ -1778,25 +1943,35 @@ export const sections: readonly Section[] = [
             name: 'action',
             in: 'path',
             type: 'string',
-            desc: 'data — return Warp stats (quota, remaining). del — delete Warp data. config — return current Warp config. reg — register a new Warp endpoint (sends privateKey, publicKey). license — set a Warp+ license key (sends license).',
+            desc: 'data — return Warp stats. del — delete Warp data. config — return current config. reg — register (sends keys). changeIp — rotate the endpoint. license — set a Warp+ key. interval — set automatic rotation in hours.',
           },
           {
             name: 'privateKey',
             in: 'body (form)',
             type: 'string',
             desc: 'Required when action=reg.',
+            optional: true,
           },
           {
             name: 'publicKey',
             in: 'body (form)',
             type: 'string',
             desc: 'Required when action=reg.',
+            optional: true,
           },
           {
             name: 'license',
             in: 'body (form)',
             type: 'string',
             desc: 'Required when action=license.',
+            optional: true,
+          },
+          {
+            name: 'interval',
+            in: 'body (form)',
+            type: 'integer',
+            desc: 'Non-negative hours between automatic rotations. Required when action=interval; 0 disables rotation.',
+            optional: true,
           },
         ],
       },
@@ -1816,9 +1991,22 @@ export const sections: readonly Section[] = [
             in: 'body (form)',
             type: 'string',
             desc: 'Required when action=servers.',
+            optional: true,
+          },
+          {
+            name: 'token',
+            in: 'body (form)',
+            type: 'string',
+            desc: 'Required when action=reg.',
+            optional: true,
+          },
+          {
+            name: 'key',
+            in: 'body (form)',
+            type: 'string',
+            desc: 'Required when action=setKey.',
+            optional: true,
           },
-          { name: 'token', in: 'body (form)', type: 'string', desc: 'Required when action=reg.' },
-          { name: 'key', in: 'body (form)', type: 'string', desc: 'Required when action=setKey.' },
         ],
       },
       {
@@ -1837,24 +2025,28 @@ export const sections: readonly Section[] = [
             in: 'body (form)',
             type: 'string',
             desc: 'Required when action=reg.',
+            optional: true,
           },
           {
             name: 'password',
             in: 'body (form)',
             type: 'string',
             desc: 'Required when action=reg.',
+            optional: true,
           },
           {
             name: 'countryCode',
             in: 'body (form)',
             type: 'string',
             desc: 'Required when action=servers.',
+            optional: true,
           },
           {
             name: 'hostname',
             in: 'body (form)',
             type: 'string',
             desc: 'Required when action=addKey.',
+            optional: true,
           },
         ],
       },
@@ -1889,12 +2081,14 @@ export const sections: readonly Section[] = [
             in: 'body (form)',
             type: 'string',
             desc: 'JSON array of all outbounds — used to resolve dialerProxy chains.',
+            optional: true,
           },
           {
             name: 'mode',
             in: 'body (form)',
             type: 'string',
             desc: '"tcp" for a fast dial-only probe (parallel-safe), "real" for a real-delay probe whose delay is the full request time including tunnel establishment. Default/empty uses a full HTTP probe reporting the warm per-request round-trip. Both HTTP variants run through a temp xray instance.',
+            optional: true,
           },
         ],
         body: 'outbound={"protocol":"freedom","settings":{}}&mode=tcp',
@@ -1916,12 +2110,14 @@ export const sections: readonly Section[] = [
             in: 'body (form)',
             type: 'string',
             desc: 'JSON array of all outbounds — used to resolve dialerProxy chains.',
+            optional: true,
           },
           {
             name: 'mode',
             in: 'body (form)',
             type: 'string',
             desc: '"tcp" for fast dial-only probes (UDP-transport outbounds are still probed over HTTP), "real" for real-delay probes whose delay is the full request time including tunnel establishment. Default/empty routes an HTTP request through each outbound and reports the warm per-request round-trip.',
+            optional: true,
           },
         ],
         body: 'outbounds=[{"tag":"direct","protocol":"freedom","settings":{}}]&mode=http',
@@ -1953,6 +2149,7 @@ export const sections: readonly Section[] = [
             in: 'body (form)',
             type: 'string',
             desc: 'Outbound tag to force. Empty clears the override and returns control to the strategy.',
+            optional: true,
           },
         ],
         body: 'tag=b1&target=proxy',
@@ -1962,38 +2159,58 @@ export const sections: readonly Section[] = [
         path: '/panel/api/xray/routeTest',
         summary:
           'Ask the running core which outbound its router would pick for a synthetic connection (RoutingService.TestRoute). No traffic is sent.',
+        bodyRequiredOneOf: ['domain', 'ip'],
         params: [
           {
             name: 'domain',
             in: 'body (form)',
             type: 'string',
             desc: 'Target domain. Either domain or ip is required.',
+            optional: true,
+            minLength: 1,
           },
           {
             name: 'ip',
             in: 'body (form)',
             type: 'string',
             desc: 'Target IP. Either domain or ip is required.',
+            optional: true,
+            minLength: 1,
+          },
+          {
+            name: 'port',
+            in: 'body (form)',
+            type: 'number',
+            desc: 'Target port (optional).',
+            optional: true,
+          },
+          {
+            name: 'network',
+            in: 'body (form)',
+            type: 'string',
+            desc: '"tcp" (default) or "udp".',
+            optional: true,
           },
-          { name: 'port', in: 'body (form)', type: 'number', desc: 'Target port (optional).' },
-          { name: 'network', in: 'body (form)', type: 'string', desc: '"tcp" (default) or "udp".' },
           {
             name: 'inboundTag',
             in: 'body (form)',
             type: 'string',
             desc: 'Simulate arrival on this inbound (optional).',
+            optional: true,
           },
           {
             name: 'protocol',
             in: 'body (form)',
             type: 'string',
             desc: 'Sniffed protocol such as http, tls, bittorrent (optional).',
+            optional: true,
           },
           {
             name: 'email',
             in: 'body (form)',
             type: 'string',
             desc: 'User attribution for user-based rules (optional).',
+            optional: true,
           },
         ],
         body: 'domain=example.com&port=443&network=tcp',
@@ -2097,6 +2314,7 @@ export const sections: readonly Section[] = [
             in: 'body (form)',
             type: 'string',
             desc: '"ip" to parse the tokens as IP rules (geoip:, ext-ip:, leading !). Anything else parses them as domain rules (geosite:, ext-site:).',
+            optional: true,
           },
         ],
         body: 'kind=domain&tokens=geosite:google,geosite:blabla',
@@ -2112,52 +2330,17 @@ export const sections: readonly Section[] = [
         path: '/panel/api/xray/outbound-subs',
         summary:
           'Create an outbound subscription. The URL is fetched, parsed into outbounds with stable tags, and merged additively into the running Xray config.',
-        params: [
-          { name: 'remark', in: 'body (form)', type: 'string', desc: 'Optional display label.' },
-          {
-            name: 'url',
-            in: 'body (form)',
-            type: 'string',
-            desc: 'Subscription URL (required). Must be a public http(s) address; private/internal targets are blocked unless allowPrivate is true.',
-          },
-          {
-            name: 'tagPrefix',
-            in: 'body (form)',
-            type: 'string',
-            desc: 'Prefix for generated outbound tags. Defaults to "sub<id>-".',
-          },
-          {
-            name: 'updateInterval',
-            in: 'body (form)',
-            type: 'integer',
-            desc: 'Seconds between auto-refreshes. Default 600.',
-          },
-          {
-            name: 'enabled',
-            in: 'body (form)',
-            type: 'boolean',
-            desc: 'Whether the subscription is active. Default true.',
-          },
-          {
-            name: 'allowPrivate',
-            in: 'body (form)',
-            type: 'boolean',
-            desc: 'Allow the URL to point at a private/internal/loopback address (localhost/LAN). Default false (SSRF guard blocks private targets).',
-          },
-          {
-            name: 'prepend',
-            in: 'body (form)',
-            type: 'boolean',
-            desc: "Place this subscription's outbounds before the manual template outbounds (so one can become the default). Default false.",
-          },
-        ],
+        params: outboundSubscriptionBodyParams,
       },
       {
         method: 'POST',
         path: '/panel/api/xray/outbound-subs/:id',
         summary:
           'Update an existing outbound subscription by id. Accepts the same form fields as create.',
-        params: [{ name: 'id', in: 'path', type: 'integer', desc: 'Subscription id.' }],
+        params: [
+          { name: 'id', in: 'path', type: 'integer', desc: 'Subscription id.' },
+          ...outboundSubscriptionBodyParams,
+        ],
       },
       {
         method: 'DELETE',
@@ -2191,6 +2374,7 @@ export const sections: readonly Section[] = [
             in: 'body (form)',
             type: 'string',
             desc: '"up" to raise priority, anything else to lower it.',
+            optional: true,
           },
         ],
       },
@@ -2206,6 +2390,20 @@ export const sections: readonly Section[] = [
             type: 'string',
             desc: 'Subscription URL to preview (required).',
           },
+          {
+            name: 'allowPrivate',
+            in: 'body (form)',
+            type: 'boolean',
+            desc: 'Allow a private/internal/loopback URL. Default false.',
+            optional: true,
+          },
+          {
+            name: 'allowInsecure',
+            in: 'body (form)',
+            type: 'boolean',
+            desc: 'Skip TLS certificate verification. Default false.',
+            optional: true,
+          },
         ],
       },
     ],
@@ -2229,52 +2427,18 @@ export const sections: readonly Section[] = [
         path: '/panel/api/sub-balancers',
         summary:
           'Create a subscription balancer. It appears in the JSON subscription of every client that sits on at least one selected inbound.',
-        params: [
-          {
-            name: 'remark',
-            in: 'body (form)',
-            type: 'string',
-            desc: 'Display label, used as the config remarks (required).',
-          },
-          {
-            name: 'strategy',
-            in: 'body (form)',
-            type: 'string',
-            desc: 'Balancer strategy: "leastLoad", "leastPing", "roundRobin" or "random" (xray routing balancer strategies). Default "random".',
-          },
-          {
-            name: 'inboundIds',
-            in: 'body (form)',
-            type: 'integer[]',
-            desc: 'Repeated form keys selecting the member inbounds, e.g. inboundIds=1&inboundIds=3 (required, at least one).',
-          },
-          {
-            name: 'memberWeights',
-            in: 'body (form)',
-            type: 'object',
-            desc: 'leastLoad only: JSON object mapping inbound id to a static weight > 0, e.g. {"3":0.2}. Lower weight = picked more often; absent ids weigh 1. Rejected for other strategies; entries for unselected inbounds are dropped.',
-          },
-          {
-            name: 'sortOrder',
-            in: 'body (form)',
-            type: 'integer',
-            desc: '1-based position in the subscription list, interleaved with the inbounds subSortIndex. Default 1.',
-          },
-          {
-            name: 'enabled',
-            in: 'body (form)',
-            type: 'boolean',
-            desc: 'Whether the balancer is emitted. Default true.',
-          },
-        ],
+        params: subBalancerBodyParams,
         responseSchema: 'SubBalancer',
       },
       {
         method: 'POST',
         path: '/panel/api/sub-balancers/:id',
         summary:
-          'Update a balancer by id. Accepts the same form fields as create (full-row update, including the enabled toggle); omitting memberWeights clears stored weights.',
-        params: [{ name: 'id', in: 'path', type: 'integer', desc: 'Balancer id.' }],
+          'Update a balancer by id. Accepts the same form fields as create (full-row update); omitting memberWeights clears stored weights, while omitting enabled keeps its current value.',
+        params: [
+          { name: 'id', in: 'path', type: 'integer', desc: 'Balancer id.' },
+          ...subBalancerBodyParams,
+        ],
         responseSchema: 'SubBalancer',
       },
       {
@@ -2299,7 +2463,7 @@ export const sections: readonly Section[] = [
     id: 'subscription',
     title: 'Subscription Server',
     description:
-      'A separate HTTP/HTTPS server that serves proxy subscription links (standard, JSON, and Clash) to clients. The server listens on its own port (default 10882) and is configured in Settings → Subscription. Paths are configurable; defaults are shown below. All subscription endpoints set response headers for client apps to read traffic/expiry info.',
+      'A separate HTTP/HTTPS server that serves proxy subscription links (standard, JSON, and Clash) to clients. The server listens on its own port (default 2096) and is configured in Settings → Subscription. Fresh panels generate random path prefixes for each format; all paths remain configurable. Every subscription endpoint sets response headers for client apps to read traffic/expiry info.',
     subHeader: [
       {
         name: 'Subscription-Userinfo',
@@ -2327,7 +2491,7 @@ export const sections: readonly Section[] = [
         method: 'GET',
         path: '/{subPath}:subid',
         summary:
-          'Return base64-encoded subscription links for all enabled clients matching the subscription ID. When the request has an Accept: text/html header or ?html=1, renders a styled info page instead. With ?format=info, returns the page view-model as JSON (traffic, expiry, online status; no links) for live polling. Default path: /sub/:subid.',
+          'Return base64-encoded subscription links for all enabled clients matching the subscription ID. When the request has an Accept: text/html header or ?html=1, renders a styled info page instead. With ?format=info, returns the page view-model as JSON (traffic, expiry, online status; no links) for live polling. The path prefix is configured by subPath.',
         params: [
           { name: 'subid', in: 'path', type: 'string', desc: 'Client subscription ID.' },
           {
@@ -2343,14 +2507,14 @@ export const sections: readonly Section[] = [
         method: 'GET',
         path: '/{jsonPath}:subid',
         summary:
-          'Return subscription as a JSON array of proxy configs (one per enabled client). Only when JSON subscription is enabled in settings. Default path: /json/:subid.',
+          'Return subscription as a JSON array of proxy configs (one per enabled client). Only when JSON subscription is enabled in settings. The path prefix is configured by subJsonPath.',
         params: [{ name: 'subid', in: 'path', type: 'string', desc: 'Client subscription ID.' }],
       },
       {
         method: 'GET',
         path: '/{clashPath}:subid',
         summary:
-          'Return subscription as a Clash/Mihomo-compatible YAML config, including configured global Clash routing rules. Only when Clash subscription is enabled in settings. Default path: /clash/:subid.',
+          'Return subscription as a Clash/Mihomo-compatible YAML config, including configured global Clash routing rules. Only when Clash subscription is enabled in settings. The path prefix is configured by subClashPath.',
         params: [{ name: 'subid', in: 'path', type: 'string', desc: 'Client subscription ID.' }],
       },
     ],

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

@@ -34,6 +34,7 @@ import { HttpUtil, IntlUtil, RandomUtil, Wireguard } from '@/utils';
 import { formatInboundLabel } from '@/lib/inbounds/label';
 import { generateMtprotoSecret } from '@/lib/xray/inbound-defaults';
 import { normalizeClientIps, type ClientIpInfo } from '@/lib/clients/ip-log';
+import { resolveExternalLinkExpiry } from '@/lib/clients/external-link';
 import { useDatepicker } from '@/hooks/useDatepicker';
 import { useClientHwids } from '@/hooks/useClientHwids';
 import { DateTimePicker, SelectAllClearButtons } from '@/components/form';
@@ -1370,17 +1371,22 @@ export default function ClientFormModal({
                                 <Controller
                                   control={methods.control}
                                   name={`externalLinks.${index}.expiryTime`}
-                                  render={({ field: expiryField }) => (
-                                    <DateTimePicker
-                                      value={
-                                        Number(expiryField.value) > 0
-                                          ? dayjs(Number(expiryField.value))
-                                          : null
-                                      }
-                                      onChange={(v) => expiryField.onChange(v ? v.valueOf() : 0)}
-                                      placeholder={t('pages.inbounds.leaveBlankToNeverExpire')}
-                                    />
-                                  )}
+                                  render={({ field: expiryField }) => {
+                                    const displayedExpiry = resolveExternalLinkExpiry(
+                                      expiryField.value,
+                                      expiryDate,
+                                    );
+                                    const hasSpecificExpiry = Number(expiryField.value) > 0;
+                                    return (
+                                      <DateTimePicker
+                                        value={displayedExpiry > 0 ? dayjs(displayedExpiry) : null}
+                                        onChange={(v) => expiryField.onChange(v ? v.valueOf() : 0)}
+                                        placeholder={t('pages.inbounds.leaveBlankToNeverExpire')}
+                                        allowClear={hasSpecificExpiry}
+                                        maxDate={expiryDate > 0 ? dayjs(expiryDate) : undefined}
+                                      />
+                                    );
+                                  }}
                                 />
                               </div>
                             </div>
@@ -1442,17 +1448,22 @@ export default function ClientFormModal({
                                 <Controller
                                   control={methods.control}
                                   name={`externalLinks.${index}.expiryTime`}
-                                  render={({ field: expiryField }) => (
-                                    <DateTimePicker
-                                      value={
-                                        Number(expiryField.value) > 0
-                                          ? dayjs(Number(expiryField.value))
-                                          : null
-                                      }
-                                      onChange={(v) => expiryField.onChange(v ? v.valueOf() : 0)}
-                                      placeholder={t('pages.inbounds.leaveBlankToNeverExpire')}
-                                    />
-                                  )}
+                                  render={({ field: expiryField }) => {
+                                    const displayedExpiry = resolveExternalLinkExpiry(
+                                      expiryField.value,
+                                      expiryDate,
+                                    );
+                                    const hasSpecificExpiry = Number(expiryField.value) > 0;
+                                    return (
+                                      <DateTimePicker
+                                        value={displayedExpiry > 0 ? dayjs(displayedExpiry) : null}
+                                        onChange={(v) => expiryField.onChange(v ? v.valueOf() : 0)}
+                                        placeholder={t('pages.inbounds.leaveBlankToNeverExpire')}
+                                        allowClear={hasSpecificExpiry}
+                                        maxDate={expiryDate > 0 ? dayjs(expiryDate) : undefined}
+                                      />
+                                    );
+                                  }}
                                 />
                               </div>
                               <Typography.Text

+ 67 - 41
frontend/src/pages/clients/ClientInfoModal.tsx

@@ -10,7 +10,7 @@ import {
 } from '@ant-design/icons';
 
 import { ClipboardManager, FileManager, HttpUtil, IntlUtil, SizeFormatter } from '@/utils';
-import { formatInboundLabel } from '@/lib/inbounds/label';
+import { formatInboundLabel, formatTunnelConfigMeta } from '@/lib/inbounds/label';
 import { normalizeClientIps, type ClientIpInfo } from '@/lib/clients/ip-log';
 import { useDatepicker } from '@/hooks/useDatepicker';
 import { useClientHwids } from '@/hooks/useClientHwids';
@@ -22,12 +22,12 @@ import ClientHwidListModal from '@/components/clients/ClientHwidList';
 import ConfigBlock from '@/components/clients/ConfigBlock';
 import {
   buildWireguardClientConfig,
-  findWireguardInbound,
+  findWireguardInbounds,
   isWireguardClient,
 } from './wireguardConfig';
 import {
   buildAmneziaWGClientConfig,
-  findAmneziaWGInbound,
+  findAmneziaWGInbounds,
   isAmneziaWGClient,
 } from './amneziawgConfig';
 import './ClientInfoModal.css';
@@ -180,35 +180,47 @@ export default function ClientInfoModal({
       : '';
 
   const showSubscription = !!(subSettings?.enable && client?.subId);
-  const wgInbound = useMemo(
-    () => findWireguardInbound(client, inboundsById),
+  const wgInbounds = useMemo(
+    () => findWireguardInbounds(client, inboundsById),
     [client, inboundsById],
   );
-  const wgConfigText = useMemo(() => {
-    if (!client || !wgInbound || !isWireguardClient(client)) return '';
-    return buildWireguardClientConfig(
-      client,
-      wgInbound,
-      window.location.hostname,
-      subSettings?.publicHost ?? '',
-    );
-  }, [client, wgInbound, subSettings?.publicHost]);
+  const wgConfigs = useMemo(() => {
+    if (!client || !isWireguardClient(client)) return [];
+    return wgInbounds
+      .map((ib) => {
+        const address = tunnelAllowedIPs?.[ib.id] ?? '';
+        const text = buildWireguardClientConfig(
+          client,
+          ib,
+          window.location.hostname,
+          subSettings?.publicHost ?? '',
+          address,
+        );
+        return { inbound: ib, text };
+      })
+      .filter((c) => !!c.text);
+  }, [client, wgInbounds, tunnelAllowedIPs, subSettings?.publicHost]);
 
-  const awgInbound = useMemo(
-    () => findAmneziaWGInbound(client, inboundsById),
+  const awgInbounds = useMemo(
+    () => findAmneziaWGInbounds(client, inboundsById),
     [client, inboundsById],
   );
-  const awgConfigText = useMemo(() => {
-    if (!client || !awgInbound || !isAmneziaWGClient(client)) return '';
-    const address = awgInbound ? (tunnelAllowedIPs?.[awgInbound.id] ?? '') : '';
-    return buildAmneziaWGClientConfig(
-      client,
-      awgInbound,
-      window.location.hostname,
-      subSettings?.publicHost ?? '',
-      address,
-    );
-  }, [client, awgInbound, tunnelAllowedIPs, subSettings?.publicHost]);
+  const awgConfigs = useMemo(() => {
+    if (!client || !isAmneziaWGClient(client)) return [];
+    return awgInbounds
+      .map((ib) => {
+        const address = tunnelAllowedIPs?.[ib.id] ?? '';
+        const text = buildAmneziaWGClientConfig(
+          client,
+          ib,
+          window.location.hostname,
+          subSettings?.publicHost ?? '',
+          address,
+        );
+        return { inbound: ib, text };
+      })
+      .filter((c) => !!c.text);
+  }, [client, awgInbounds, tunnelAllowedIPs, subSettings?.publicHost]);
 
   async function copyValue(text: string) {
     if (!text) return;
@@ -779,27 +791,41 @@ export default function ClientInfoModal({
               </>
             )}
 
-            {wgConfigText && client && (
+            {wgConfigs.length > 0 && client && (
               <>
                 <Divider>{t('pages.clients.wireguardConfig')}</Divider>
-                <ConfigBlock
-                  label={t('pages.clients.config')}
-                  text={wgConfigText}
-                  fileName={`${client.email}.conf`}
-                  qrRemark={client.email || 'peer'}
-                />
+                {wgConfigs.map(({ inbound, text }) => {
+                  const meta = formatTunnelConfigMeta(inbound, client.email, wgConfigs.length);
+                  return (
+                    <ConfigBlock
+                      key={`wg-${inbound.id}`}
+                      label={meta.label || t('pages.clients.config')}
+                      text={text}
+                      fileName={meta.fileName}
+                      qrRemark={meta.qrRemark}
+                      tagColor="cyan"
+                    />
+                  );
+                })}
               </>
             )}
 
-            {awgConfigText && client && (
+            {awgConfigs.length > 0 && client && (
               <>
                 <Divider>{t('pages.clients.amneziaWgConfig')}</Divider>
-                <ConfigBlock
-                  label={t('pages.clients.config')}
-                  text={awgConfigText}
-                  fileName={`${client.email}.conf`}
-                  qrRemark={client.email || 'peer'}
-                />
+                {awgConfigs.map(({ inbound, text }) => {
+                  const meta = formatTunnelConfigMeta(inbound, client.email, awgConfigs.length);
+                  return (
+                    <ConfigBlock
+                      key={`awg-${inbound.id}`}
+                      label={meta.label || t('pages.clients.config')}
+                      text={text}
+                      fileName={meta.fileName}
+                      qrRemark={meta.qrRemark}
+                      tagColor="purple"
+                    />
+                  );
+                })}
               </>
             )}
           </>

+ 65 - 54
frontend/src/pages/clients/ClientQrModal.tsx

@@ -6,14 +6,15 @@ import { isPostQuantumLink } from '@/lib/xray/inbound-link';
 import { LinkTags, linkMetaText, parseLinkParts } from '@/lib/xray/link-label';
 import { QrPanel } from '@/pages/inbounds/qr';
 import type { ClientRecord, InboundOption } from '@/hooks/useClients';
+import { formatTunnelConfigMeta } from '@/lib/inbounds/label';
 import {
   buildWireguardClientConfig,
-  findWireguardInbound,
+  findWireguardInbounds,
   isWireguardClient,
 } from './wireguardConfig';
 import {
   buildAmneziaWGClientConfig,
-  findAmneziaWGInbound,
+  findAmneziaWGInbounds,
   isAmneziaWGClient,
 } from './amneziawgConfig';
 
@@ -67,38 +68,50 @@ export default function ClientQrModal({
       ? subSettings.subJsonURI + subId
       : '';
 
-  const wgInbound = useMemo(
-    () => findWireguardInbound(client, inboundsById),
+  const wgInbounds = useMemo(
+    () => findWireguardInbounds(client, inboundsById),
     [client, inboundsById],
   );
-  const wgConfigText = useMemo(() => {
-    if (!client || !wgInbound || !isWireguardClient(client)) return '';
-    return buildWireguardClientConfig(
-      client,
-      wgInbound,
-      window.location.hostname,
-      subSettings?.publicHost ?? '',
-    );
-  }, [client, wgInbound, subSettings?.publicHost]);
+  const wgConfigs = useMemo(() => {
+    if (!client || !isWireguardClient(client)) return [];
+    return wgInbounds
+      .map((ib) => {
+        const address = tunnelAllowedIPs?.[ib.id] ?? '';
+        const text = buildWireguardClientConfig(
+          client,
+          ib,
+          window.location.hostname,
+          subSettings?.publicHost ?? '',
+          address,
+        );
+        return { inbound: ib, text };
+      })
+      .filter((c) => !!c.text);
+  }, [client, wgInbounds, tunnelAllowedIPs, subSettings?.publicHost]);
 
-  const awgInbound = useMemo(
-    () => findAmneziaWGInbound(client, inboundsById),
+  const awgInbounds = useMemo(
+    () => findAmneziaWGInbounds(client, inboundsById),
     [client, inboundsById],
   );
-  const awgConfigText = useMemo(() => {
-    if (!client || !awgInbound || !isAmneziaWGClient(client)) return '';
-    const address = awgInbound ? (tunnelAllowedIPs?.[awgInbound.id] ?? '') : '';
-    return buildAmneziaWGClientConfig(
-      client,
-      awgInbound,
-      window.location.hostname,
-      subSettings?.publicHost ?? '',
-      address,
-    );
-  }, [client, awgInbound, tunnelAllowedIPs, subSettings?.publicHost]);
+  const awgConfigs = useMemo(() => {
+    if (!client || !isAmneziaWGClient(client)) return [];
+    return awgInbounds
+      .map((ib) => {
+        const address = tunnelAllowedIPs?.[ib.id] ?? '';
+        const text = buildAmneziaWGClientConfig(
+          client,
+          ib,
+          window.location.hostname,
+          subSettings?.publicHost ?? '',
+          address,
+        );
+        return { inbound: ib, text };
+      })
+      .filter((c) => !!c.text);
+  }, [client, awgInbounds, tunnelAllowedIPs, subSettings?.publicHost]);
 
   const hasAnything =
-    !!subLink || !!subJsonLink || !!wgConfigText || !!awgConfigText || links.length > 0;
+    !!subLink || !!subJsonLink || wgConfigs.length > 0 || awgConfigs.length > 0 || links.length > 0;
 
   // The reset runs during render so the effect only carries the request.
   const openSubId = open ? (client?.subId ?? '') : '';
@@ -172,42 +185,40 @@ export default function ClientQrModal({
         ),
       });
     });
-    if (wgConfigText) {
-      out.push({
-        key: 'wg-config',
-        label: (
+    wgConfigs.forEach(({ inbound, text }) => {
+      const meta = formatTunnelConfigMeta(inbound, client?.email, wgConfigs.length);
+      const label = (
+        <span style={{ display: 'inline-flex', alignItems: 'center', gap: 6 }}>
           <Tag color="cyan" style={{ margin: 0 }}>
             {t('pages.clients.wireguardConfig')}
           </Tag>
-        ),
-        children: (
-          <QrPanel
-            value={wgConfigText}
-            remark={client?.email || 'peer'}
-            downloadName={`${client?.email || 'peer'}.conf`}
-          />
-        ),
-      });
-    }
-    if (awgConfigText) {
+          {meta.label && <span style={{ opacity: 0.85, fontSize: 12 }}>{meta.label}</span>}
+        </span>
+      );
       out.push({
-        key: 'awg-config',
-        label: (
+        key: `wg-config-${inbound.id}`,
+        label,
+        children: <QrPanel value={text} remark={meta.qrRemark} downloadName={meta.fileName} />,
+      });
+    });
+    awgConfigs.forEach(({ inbound, text }) => {
+      const meta = formatTunnelConfigMeta(inbound, client?.email, awgConfigs.length);
+      const label = (
+        <span style={{ display: 'inline-flex', alignItems: 'center', gap: 6 }}>
           <Tag color="purple" style={{ margin: 0 }}>
             {t('pages.clients.amneziaWgConfig')}
           </Tag>
-        ),
-        children: (
-          <QrPanel
-            value={awgConfigText}
-            remark={client?.email || 'peer'}
-            downloadName={`${client?.email || 'peer'}.conf`}
-          />
-        ),
+          {meta.label && <span style={{ opacity: 0.85, fontSize: 12 }}>{meta.label}</span>}
+        </span>
+      );
+      out.push({
+        key: `awg-config-${inbound.id}`,
+        label,
+        children: <QrPanel value={text} remark={meta.qrRemark} downloadName={meta.fileName} />,
       });
-    }
+    });
     return out;
-  }, [subLink, subJsonLink, wgConfigText, awgConfigText, links, client?.email, t]);
+  }, [subLink, subJsonLink, wgConfigs, awgConfigs, links, client?.email, t]);
 
   // Expanding the first panel is a render-time adjustment, not a side effect.
   const firstKey = open && items.length > 0 ? items[0].key : null;

+ 5 - 5
frontend/src/pages/clients/amneziawgConfig.ts

@@ -5,7 +5,7 @@ import type { ClientRecord, InboundOption } from '@/hooks/useClients';
 // AmneziaWG clients are wire-identical to WireGuard clients (same
 // privateKey/publicKey/allowedIPs/preSharedKey/keepAlive fields on
 // model.Client — see wireguardConfig.ts's isWireguardClient), so this duck
-// type can't tell the two protocols apart on its own; findAmneziaWGInbound's
+// type can't tell the two protocols apart on its own; findAmneziaWGInbounds's
 // protocol==='amneziawg' filter below is what actually disambiguates.
 export function isAmneziaWGClient(client: ClientRecord | null | undefined): boolean {
   if (!client) return false;
@@ -18,13 +18,13 @@ export function isAmneziaWGClient(client: ClientRecord | null | undefined): bool
   );
 }
 
-export function findAmneziaWGInbound(
+export function findAmneziaWGInbounds(
   client: ClientRecord | null | undefined,
   inboundsById: Record<number, InboundOption>,
-): InboundOption | undefined {
+): InboundOption[] {
   return (client?.inboundIds || [])
-    .map((id) => inboundsById[id])
-    .find((ib) => ib?.protocol === 'amneziawg');
+    .map((id) => inboundsById?.[id])
+    .filter((ib): ib is InboundOption => ib?.protocol === 'amneziawg');
 }
 
 // h4Line renders one H magic-header line, matching the Go backend's

+ 6 - 5
frontend/src/pages/clients/wireguardConfig.ts

@@ -13,13 +13,13 @@ export function isWireguardClient(client: ClientRecord | null | undefined): bool
   );
 }
 
-export function findWireguardInbound(
+export function findWireguardInbounds(
   client: ClientRecord | null | undefined,
   inboundsById: Record<number, InboundOption>,
-): InboundOption | undefined {
+): InboundOption[] {
   return (client?.inboundIds || [])
-    .map((id) => inboundsById[id])
-    .find((ib) => ib?.protocol === 'wireguard');
+    .map((id) => inboundsById?.[id])
+    .filter((ib): ib is InboundOption => ib?.protocol === 'wireguard');
 }
 
 export function buildWireguardClientConfig(
@@ -27,13 +27,14 @@ export function buildWireguardClientConfig(
   inbound: InboundOption | undefined,
   host = window.location.hostname,
   publicHost = '',
+  addressOverride = '',
 ): string {
   const endpointHost = resolveShareHost(
     inbound ?? {},
     inbound?.nodeAddress ?? '',
     preferPublicHost(host, publicHost),
   );
-  const address = client.allowedIPs || '10.0.0.2/32';
+  const address = addressOverride || client.allowedIPs || '10.0.0.2/32';
   const endpoint = `${endpointHost}:${inbound?.port || ''}`;
   const inboundName = inbound ? formatInboundLabel(inbound.tag, inbound.remark) : '';
   const remark = [inboundName, client.email, client.comment].filter(Boolean).join(' - ');

+ 6 - 2
frontend/src/pages/hosts/HostFormModal.tsx

@@ -130,8 +130,12 @@ export default function HostFormModal({
     [],
   );
   const fpOptions = useMemo(
-    () => Object.values(UTLS_FINGERPRINT).map((v) => ({ value: v, label: v })),
-    [],
+    // '' = None first: Hysteria (and any no-uTLS host) must be selectable.
+    () => [
+      { value: '', label: t('none') },
+      ...Object.values(UTLS_FINGERPRINT).map((v) => ({ value: v, label: v })),
+    ],
+    [t],
   );
 
   const hostOptions = useMemo(() => {

+ 2 - 0
frontend/src/pages/inbounds/qr/QrPanel.tsx

@@ -140,6 +140,8 @@ export default function QrPanel({
               className="qr-code"
               value={value}
               size={size}
+              errorLevel="L"
+              marginSize={4}
               type="svg"
               bordered={false}
               color="#000000"

+ 7 - 5
frontend/src/pages/sub/SubPage.tsx

@@ -93,11 +93,11 @@ export default function SubPage() {
     setMessageInstance(messageApi);
   }, [messageApi]);
   const { isMobile } = useMediaQuery(576);
-  const [lang, setLang] = useState<string>(() => LanguageManager.getLanguage());
+  const [lang, setLang] = useState<string>(() => LanguageManager.getLanguage('subscription'));
 
   const onLangChange = useCallback((next: string) => {
     setLang(next);
-    LanguageManager.setLanguage(next);
+    LanguageManager.setLanguage(next, 'subscription');
   }, []);
 
   const cycleTheme = useCallback(() => {
@@ -186,16 +186,18 @@ export default function SubPage() {
     items.push({
       key: 'lastOnline',
       label: t('lastOnline'),
-      children: lastOnlineMs > 0 ? IntlUtil.formatDate(lastOnlineMs, datepicker) : '-',
+      children: lastOnlineMs > 0 ? IntlUtil.formatDate(lastOnlineMs, datepicker, lang) : '-',
     });
     items.push({
       key: 'expiry',
       label: t('subscription.expiry'),
       children:
-        expireMs === 0 ? t('subscription.noExpiry') : IntlUtil.formatDate(expireMs, datepicker),
+        expireMs === 0
+          ? t('subscription.noExpiry')
+          : IntlUtil.formatDate(expireMs, datepicker, lang),
     });
     return items;
-  }, [t]);
+  }, [t, lang]);
 
   const androidMenuItems = useMemo(
     () => [

+ 4 - 2
frontend/src/schemas/protocols/security/tls.ts

@@ -56,7 +56,9 @@ export const TlsCertSchema = z.union([TlsCertFileSchema, TlsCertInlineSchema]);
 export type TlsCert = z.infer<typeof TlsCertSchema>;
 
 export const TlsClientSettingsSchema = z.object({
-  fingerprint: TlsFingerprintSchema.default('chrome'),
+  // '' = None. Hysteria rejects uTLS fingerprints, and a chrome default
+  // silently flipped the form's None back to chrome on every save.
+  fingerprint: TlsFingerprintSchema.default(''),
   echConfigList: z.string().default(''),
   pinnedPeerCertSha256: z.array(z.string()).default([]),
   // Panel-only client directive (v2rayN `vcn`): verify the server certificate
@@ -87,7 +89,7 @@ export const TlsStreamSettingsSchema = z.object({
   masterKeyLog: z.string().optional(),
   echSockopt: SockoptStreamSettingsSchema.optional(),
   settings: TlsClientSettingsSchema.default({
-    fingerprint: 'chrome',
+    fingerprint: '',
     echConfigList: '',
     pinnedPeerCertSha256: [],
     verifyPeerCertByName: '',

+ 14 - 2
frontend/src/test/amneziawg-obfuscation.test.ts

@@ -20,6 +20,16 @@ function expectRangeWithin(value: string, min: number, max: number): [number, nu
   return [lo, hi];
 }
 
+/* Parses a plain integer and asserts min <= n <= max (see expectRangeWithin above for the range form). */
+function expectIntWithin(value: string, min: number, max: number): number {
+  const m = /^(\d+)$/.exec(value);
+  expect(m, `${value} is not a plain integer`).not.toBeNull();
+  const n = Number(m![1]);
+  expect(n).toBeGreaterThanOrEqual(min);
+  expect(n).toBeLessThanOrEqual(max);
+  return n;
+}
+
 describe('generateAwgObfuscation', () => {
   it('stays inside the Go generator ranges and invariants', () => {
     for (let i = 0; i < 200; i++) {
@@ -37,9 +47,11 @@ describe('generateAwgObfuscation', () => {
       expect(o.s4).toBeGreaterThanOrEqual(12);
       expect(o.s4).toBeLessThanOrEqual(27);
 
-      const hBounds = [o.h1, o.h2, o.h3, o.h4].map((h) => expectRangeWithin(h, 5, 2147483647));
+      const hValues = [o.h1, o.h2, o.h3, o.h4].map((h) => expectIntWithin(h, 5, 2147483647));
       for (let j = 1; j < 4; j++) {
-        expect(hBounds[j][0], 'H ranges must not overlap').toBeGreaterThan(hBounds[j - 1][1]);
+        expect(hValues[j], 'H values must be strictly increasing across bands').toBeGreaterThan(
+          hValues[j - 1],
+        );
       }
 
       expect(o.i1).toMatch(/^<r \d+>$/);

+ 17 - 0
frontend/src/test/client-external-link-expiry.test.ts

@@ -0,0 +1,17 @@
+import { describe, expect, it } from 'vitest';
+
+import { resolveExternalLinkExpiry } from '@/lib/clients/external-link';
+
+describe('resolveExternalLinkExpiry', () => {
+  it('uses the client expiry when the external link has no specific expiry', () => {
+    expect(resolveExternalLinkExpiry(0, 1_800_000_000_000)).toBe(1_800_000_000_000);
+  });
+
+  it('keeps an explicit external-link expiry', () => {
+    expect(resolveExternalLinkExpiry(1_700_000_000_000, 1_800_000_000_000)).toBe(1_700_000_000_000);
+  });
+
+  it('stays empty when neither expiry is set', () => {
+    expect(resolveExternalLinkExpiry(0, 0)).toBe(0);
+  });
+});

+ 53 - 2
frontend/src/test/date-time-picker.test.tsx

@@ -1,11 +1,32 @@
-import { fireEvent } from '@testing-library/react';
+import { fireEvent, screen } from '@testing-library/react';
 import dayjs from 'dayjs';
 import type { Dayjs } from 'dayjs';
-import { describe, expect, it, vi } from 'vitest';
+import { afterEach, describe, expect, it, vi } from 'vitest';
 
 import DateTimePicker from '@/components/form/DateTimePicker';
+import { setDatepicker } from '@/hooks/useDatepicker';
 import { renderWithProviders } from './test-utils';
 
+vi.mock('persian-calendar-suite', () => ({
+  PersianDateTimePicker: ({
+    maxDate,
+    onChange,
+  }: {
+    maxDate?: Date;
+    onChange?: (value: number) => void;
+  }) => (
+    <button
+      type="button"
+      aria-label="Persian date time picker"
+      data-testid="persian-date-time-picker"
+      data-max-date={maxDate?.toISOString()}
+      onClick={() => onChange?.((maxDate?.getTime() ?? 0) + 1)}
+    />
+  ),
+}));
+
+afterEach(() => setDatepicker('gregorian'));
+
 function openPicker(): void {
   const input = document.querySelector('.ant-picker input');
   if (!input) throw new Error('picker input not rendered');
@@ -40,4 +61,34 @@ describe('DateTimePicker', () => {
 
     expect(document.querySelector('.ant-picker-ok')).toBeNull();
   });
+
+  it('hides the Gregorian clear control and disables dates after maxDate', () => {
+    const maxDate = dayjs().add(1, 'day').startOf('day');
+    renderWithProviders(
+      <DateTimePicker value={maxDate} onChange={vi.fn()} allowClear={false} maxDate={maxDate} />,
+    );
+
+    expect(document.querySelector('.ant-picker-clear')).toBeNull();
+    openPicker();
+    const blockedCell = document.querySelector(
+      `.ant-picker-cell[title="${maxDate.add(1, 'day').format('YYYY-MM-DD')}"]`,
+    );
+    expect(blockedCell?.classList.contains('ant-picker-cell-disabled')).toBe(true);
+  });
+
+  it('applies clear and max-date constraints to the Jalali picker', () => {
+    setDatepicker('jalalian');
+    const maxDate = dayjs('2030-01-02T03:04:05');
+    const onChange = vi.fn();
+    renderWithProviders(
+      <DateTimePicker value={maxDate} onChange={onChange} allowClear={false} maxDate={maxDate} />,
+    );
+
+    expect(document.querySelector('.jdp-clear')).toBeNull();
+    expect(screen.getByTestId('persian-date-time-picker').dataset.maxDate).toBe(
+      maxDate.toDate().toISOString(),
+    );
+    fireEvent.click(screen.getByTestId('persian-date-time-picker'));
+    expect(onChange).not.toHaveBeenCalled();
+  });
 });

+ 52 - 0
frontend/src/test/language-scope.test.ts

@@ -0,0 +1,52 @@
+import { afterEach, describe, expect, it, vi } from 'vitest';
+
+describe('subscription language scope', () => {
+  afterEach(() => {
+    vi.restoreAllMocks();
+    vi.unstubAllGlobals();
+  });
+
+  it('initializes lazily and changes the subscription language without changing the panel', async () => {
+    vi.resetModules();
+    const utils = await import('@/utils');
+    const cookies = new Map<string, string>([['lang', 'en-US']]);
+    vi.spyOn(utils.CookieManager, 'getCookie').mockImplementation(
+      (name) => cookies.get(name) ?? '',
+    );
+    vi.spyOn(utils.CookieManager, 'setCookie').mockImplementation((name, value) => {
+      cookies.set(name, value);
+    });
+    const getLanguage = vi.spyOn(utils.LanguageManager, 'getLanguage');
+    const reload = vi.fn();
+    vi.stubGlobal('window', { navigator: { language: 'en-US' }, location: { reload } });
+
+    const { readyI18n } = await import('@/i18n/react');
+    expect(getLanguage).not.toHaveBeenCalled();
+
+    await readyI18n('subscription');
+    expect(cookies.get('subLang')).toBe('en-US');
+
+    utils.LanguageManager.setLanguage('fa-IR', 'subscription');
+    expect(cookies.get('lang')).toBe('en-US');
+    expect(cookies.get('subLang')).toBe('fa-IR');
+    expect(reload).toHaveBeenCalledOnce();
+
+    const dateTimeFormat = vi.spyOn(Intl, 'DateTimeFormat').mockImplementation(function (
+      locale?: Intl.LocalesArgument,
+    ) {
+      return { format: () => String(locale) } as Intl.DateTimeFormat;
+    } as typeof Intl.DateTimeFormat);
+    expect(utils.IntlUtil.formatDate(0, 'gregorian', 'fa-IR')).toBe('fa-IR');
+    expect(dateTimeFormat).toHaveBeenLastCalledWith('fa-IR', expect.any(Object));
+  });
+
+  it('does not resolve the language for empty or invalid dates', async () => {
+    const utils = await import('@/utils');
+    const getLanguage = vi.spyOn(utils.LanguageManager, 'getLanguage').mockReturnValue('en-US');
+
+    expect(utils.IntlUtil.formatDate(null)).toBe('');
+    expect(utils.IntlUtil.formatDate(undefined)).toBe('');
+    expect(utils.IntlUtil.formatDate('not-a-date')).toBe('');
+    expect(getLanguage).not.toHaveBeenCalled();
+  });
+});

+ 183 - 0
frontend/src/test/multi-tunnel-client-config.test.tsx

@@ -0,0 +1,183 @@
+import { describe, it, expect } from 'vitest';
+import { screen } from '@testing-library/react';
+
+import ClientInfoModal from '@/pages/clients/ClientInfoModal';
+import ClientQrModal from '@/pages/clients/ClientQrModal';
+import type { ClientRecord, InboundOption } from '@/hooks/useClients';
+import { renderWithProviders } from './test-utils';
+
+const deAwgInbound: InboundOption = {
+  id: 101,
+  tag: 'awg-de',
+  remark: 'DE · Kelsterbach',
+  port: 52716,
+  protocol: 'amneziawg',
+  nodeAddress: 'de.vpn.example.com',
+  awgServer: {
+    publicKey: 'deServerPublicKey==',
+    primaryDns: '1.1.1.1',
+    secondaryDns: '1.0.0.1',
+    mtu: 1420,
+    jc: 4,
+    jmin: 40,
+    jmax: 100,
+    s1: 30,
+    s2: 90,
+    s3: 0,
+    s4: 0,
+    h1: '123',
+    h2: '456',
+    h3: '789',
+    h4: '101112',
+  },
+};
+
+const fiAwgInbound: InboundOption = {
+  id: 102,
+  tag: 'awg-fi',
+  remark: 'FI · Helsinki',
+  port: 26641,
+  protocol: 'amneziawg',
+  nodeAddress: 'fi.vpn.example.com',
+  awgServer: {
+    publicKey: 'fiServerPublicKey==',
+    primaryDns: '8.8.8.8',
+    secondaryDns: '8.8.4.4',
+    mtu: 1380,
+    jc: 10,
+    jmin: 20,
+    jmax: 80,
+    s1: 25,
+    s2: 50,
+    s3: 0,
+    s4: 0,
+    h1: '999',
+    h2: '888',
+    h3: '777',
+    h4: '666',
+  },
+};
+
+const usWgInbound: InboundOption = {
+  id: 201,
+  tag: 'wg-us',
+  remark: 'US · New York',
+  port: 51820,
+  protocol: 'wireguard',
+  nodeAddress: 'us.vpn.example.com',
+  wgPublicKey: 'usWgServerPublicKey==',
+  wgDns: '1.1.1.1',
+  wgMtu: 1420,
+};
+
+const euWgInbound: InboundOption = {
+  id: 202,
+  tag: 'wg-eu',
+  remark: 'EU · Frankfurt',
+  port: 51821,
+  protocol: 'wireguard',
+  nodeAddress: 'eu.vpn.example.com',
+  wgPublicKey: 'euWgServerPublicKey==',
+  wgDns: '9.9.9.9',
+  wgMtu: 1400,
+};
+
+const multiAwgClient: ClientRecord = {
+  id: 'c1',
+  email: 'NSK-RT-01',
+  privateKey: 'clientPrivateKey==',
+  publicKey: 'clientPublicKey==',
+  preSharedKey: 'clientPsk==',
+  allowedIPs: '10.8.0.2/32',
+  keepAlive: 25,
+  inboundIds: [101, 102],
+  enable: true,
+} as unknown as ClientRecord;
+
+const multiWgClient: ClientRecord = {
+  id: 'c2',
+  email: 'WG-CLIENT',
+  privateKey: 'wgClientPrivateKey==',
+  publicKey: 'wgClientPublicKey==',
+  preSharedKey: 'wgClientPsk==',
+  allowedIPs: '10.0.0.2/32',
+  keepAlive: 25,
+  inboundIds: [201, 202],
+  enable: true,
+} as unknown as ClientRecord;
+
+const singleAwgClient: ClientRecord = {
+  id: 'c3',
+  email: 'SINGLE-CLIENT',
+  privateKey: 'clientPrivateKey==',
+  publicKey: 'clientPublicKey==',
+  allowedIPs: '10.8.0.2/32',
+  inboundIds: [101],
+  enable: true,
+} as unknown as ClientRecord;
+
+describe('Multi-tunnel Client Modals', () => {
+  it('renders distinct labeled ConfigBlocks in ClientInfoModal for multiple AmneziaWG inbounds', () => {
+    renderWithProviders(
+      <ClientInfoModal
+        open
+        client={multiAwgClient}
+        inboundsById={{ 101: deAwgInbound, 102: fiAwgInbound }}
+        isOnline={false}
+        tunnelAllowedIPs={{ 101: '10.8.1.5/32', 102: '10.8.2.10/32' }}
+        onOpenChange={() => {}}
+      />,
+    );
+
+    expect(screen.getAllByText('DE · Kelsterbach')).toHaveLength(2);
+    expect(screen.getByText('FI · Helsinki')).toBeTruthy();
+    expect(document.querySelectorAll('.config-block')).toHaveLength(2);
+  });
+
+  it('renders distinct labeled ConfigBlocks in ClientInfoModal for multiple WireGuard inbounds', () => {
+    renderWithProviders(
+      <ClientInfoModal
+        open
+        client={multiWgClient}
+        inboundsById={{ 201: usWgInbound, 202: euWgInbound }}
+        isOnline={false}
+        tunnelAllowedIPs={{ 201: '10.0.1.2/32', 202: '10.0.2.2/32' }}
+        onOpenChange={() => {}}
+      />,
+    );
+
+    expect(screen.getAllByText('US · New York')).toHaveLength(2);
+    expect(screen.getByText('EU · Frankfurt')).toBeTruthy();
+    expect(document.querySelectorAll('.config-block')).toHaveLength(2);
+  });
+
+  it('renders single default-labeled ConfigBlock in ClientInfoModal for single inbound', () => {
+    renderWithProviders(
+      <ClientInfoModal
+        open
+        client={singleAwgClient}
+        inboundsById={{ 101: deAwgInbound }}
+        isOnline={false}
+        onOpenChange={() => {}}
+      />,
+    );
+
+    expect(document.querySelectorAll('.config-block')).toHaveLength(1);
+    expect(screen.getByText('Config')).toBeTruthy();
+  });
+
+  it('renders separate collapse panels in ClientQrModal for multiple AmneziaWG inbounds', () => {
+    renderWithProviders(
+      <ClientQrModal
+        open
+        client={multiAwgClient}
+        inboundsById={{ 101: deAwgInbound, 102: fiAwgInbound }}
+        tunnelAllowedIPs={{ 101: '10.8.1.5/32', 102: '10.8.2.10/32' }}
+        onOpenChange={() => {}}
+      />,
+    );
+
+    expect(screen.getByText('DE · Kelsterbach')).toBeTruthy();
+    expect(screen.getByText('FI · Helsinki')).toBeTruthy();
+  });
+});

+ 140 - 0
frontend/src/test/openapi-request-bodies.test.ts

@@ -0,0 +1,140 @@
+import { describe, expect, it } from 'vitest';
+
+import { buildSpec } from '../../scripts/build-openapi.mjs';
+
+interface OpenApiSchema {
+  type?: string;
+  format?: string;
+  description?: string;
+  default?: string | number | boolean;
+  minLength?: number;
+  pattern?: string;
+  properties?: Record<string, OpenApiSchema>;
+  required?: string[];
+  items?: OpenApiSchema;
+  anyOf?: OpenApiSchema[];
+}
+
+interface OpenApiRequestBody {
+  required?: boolean;
+  content: Record<
+    string,
+    {
+      schema: OpenApiSchema;
+      encoding?: Record<string, { style?: string; explode?: boolean; contentType?: string }>;
+    }
+  >;
+}
+
+interface OpenApiOperation {
+  requestBody?: OpenApiRequestBody;
+}
+
+const paths = buildSpec().paths as Record<string, Record<string, OpenApiOperation>>;
+
+function requestBody(path: string): OpenApiRequestBody {
+  const body = paths[path]?.post?.requestBody;
+  if (!body) throw new Error(`${path} has no POST request body`);
+  return body;
+}
+
+describe('generated OpenAPI request bodies', () => {
+  it('preserves JSON, form, and multipart parameter declarations', () => {
+    const login = requestBody('/login').content['application/json'];
+    expect(login.schema.properties).toHaveProperty('username');
+    expect(login.schema.required).toEqual(['username', 'password']);
+
+    const json = requestBody('/panel/api/inbounds/pushClientTraffics').content['application/json'];
+    expect(json.schema.properties).toHaveProperty('traffics');
+
+    const form = requestBody('/panel/api/inbounds/import').content[
+      'application/x-www-form-urlencoded'
+    ];
+    expect(form.schema.properties).toHaveProperty('data');
+
+    const logs = requestBody('/panel/api/server/logs/{count}');
+    expect(logs.content).toHaveProperty('application/x-www-form-urlencoded');
+    expect(logs.content['application/x-www-form-urlencoded'].schema.properties).toHaveProperty(
+      'syslog',
+    );
+
+    const outboundTest = requestBody('/panel/api/xray/testOutbound').content[
+      'application/x-www-form-urlencoded'
+    ];
+    expect(outboundTest.schema.required).toEqual(['outbound']);
+
+    const outboundUpdate = requestBody('/panel/api/xray/outbound-subs/{id}').content[
+      'application/x-www-form-urlencoded'
+    ];
+    expect(outboundUpdate.schema.required).toEqual(['url']);
+    expect(outboundUpdate.schema.properties).toHaveProperty('allowInsecure');
+
+    const balancerUpdate = requestBody('/panel/api/sub-balancers/{id}').content[
+      'application/x-www-form-urlencoded'
+    ];
+    expect(balancerUpdate.schema.required).toEqual(['remark', 'inboundIds']);
+    expect(balancerUpdate.encoding?.inboundIds).toEqual({ style: 'form', explode: true });
+    expect(balancerUpdate.encoding?.memberWeights).toEqual({ contentType: 'application/json' });
+
+    const inboundUpdate = requestBody('/panel/api/inbounds/update/{id}').content[
+      'application/json'
+    ];
+    expect(inboundUpdate.schema).toEqual({ type: 'object' });
+
+    const multipart = requestBody('/panel/api/server/importDB').content['multipart/form-data'];
+    expect(multipart.schema.properties?.db).toEqual({
+      type: 'string',
+      format: 'binary',
+      description: 'Database backup or migration file to upload.',
+    });
+    expect(multipart.schema.properties).toHaveProperty('keepHostSettings');
+    expect(multipart.schema.properties?.keepHostSettings?.default).toBe(true);
+
+    const array = requestBody('/panel/api/server/clientIps').content['application/json'];
+    expect(array.schema).toEqual({
+      type: 'array',
+      items: {
+        type: 'object',
+        properties: {
+          clientEmail: { type: 'string' },
+          ips: {
+            type: 'array',
+            nullable: true,
+            items: {
+              type: 'object',
+              properties: { ip: { type: 'string' }, timestamp: { type: 'integer' } },
+              required: ['ip', 'timestamp'],
+            },
+          },
+        },
+        required: ['clientEmail', 'ips'],
+      },
+    });
+
+    const certHash = requestBody('/panel/api/server/getCertHash');
+    expect(certHash.required).toBe(true);
+    const certSchema = certHash.content['application/x-www-form-urlencoded'].schema;
+    expect(certSchema.anyOf?.map((branch) => branch.required)).toEqual([
+      ['certFile'],
+      ['certContent'],
+    ]);
+    expect(certSchema.anyOf?.[0].properties?.certFile.pattern).toBe('.*\\S.*');
+    expect(certSchema.anyOf?.[0].properties?.certContent).not.toHaveProperty('pattern');
+    expect(certSchema.anyOf?.[1].properties?.certFile).not.toHaveProperty('pattern');
+    expect(certSchema.anyOf?.[1].properties?.certContent.pattern).toBe('.*\\S.*');
+
+    const routeSchema = requestBody('/panel/api/xray/routeTest').content[
+      'application/x-www-form-urlencoded'
+    ].schema;
+    expect(routeSchema.anyOf?.map((branch) => branch.required)).toEqual([['domain'], ['ip']]);
+    expect(routeSchema.anyOf?.[0].properties?.domain?.minLength).toBe(1);
+    expect(routeSchema.anyOf?.[0].properties?.ip).not.toHaveProperty('minLength');
+    expect(routeSchema.anyOf?.[1].properties?.domain).not.toHaveProperty('minLength');
+    expect(routeSchema.anyOf?.[1].properties?.ip?.minLength).toBe(1);
+
+    const bulkAttach = requestBody('/panel/api/clients/bulkAttach').content['application/json'];
+    expect(bulkAttach.schema.properties?.emails?.items).toEqual({ type: 'string' });
+
+    expect(paths['/panel/api/server/updateGeofile'].post).not.toHaveProperty('requestBody');
+  });
+});

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

@@ -808,3 +808,35 @@ describe('parseOutboundLink dispatcher', () => {
     expect(parseOutboundLink('   ')).toBeNull();
   });
 });
+
+describe('obfs=gecko packetSize validation', () => {
+  const base = 'hysteria2://[email protected]:443?security=tls&obfs=gecko&obfs-password=pw';
+
+  const packetSizeOf = (link: string): string | undefined => {
+    const out = parseHysteria2Link(link);
+    expect(out).not.toBeNull();
+    const finalmask = (out!.streamSettings as Record<string, unknown>).finalmask as
+      | Record<string, unknown>
+      | undefined;
+    const udp = (finalmask?.udp ?? []) as Array<Record<string, unknown>>;
+    const mask = udp.find((m) => m.type === 'salamander');
+    return (mask?.settings as Record<string, unknown> | undefined)?.packetSize as
+      | string
+      | undefined;
+  };
+
+  it('stores a valid range', () => {
+    expect(packetSizeOf(`${base}&minPacketSize=512&maxPacketSize=1200`)).toBe('512-1200');
+  });
+
+  it.each([
+    ['min only', `${base}&minPacketSize=512`],
+    ['max only', `${base}&maxPacketSize=1200`],
+    ['non-numeric', `${base}&minPacketSize=abc&maxPacketSize=def`],
+    ['zero min', `${base}&minPacketSize=0&maxPacketSize=1200`],
+    ['inverted', `${base}&minPacketSize=1200&maxPacketSize=512`],
+    ['over cap', `${base}&minPacketSize=512&maxPacketSize=4096`],
+  ])('drops the %s range', (_name, link) => {
+    expect(packetSizeOf(link)).toBeUndefined();
+  });
+});

+ 89 - 0
frontend/src/test/qr-panel-readable.test.tsx

@@ -0,0 +1,89 @@
+import { render } from '@testing-library/react';
+import { describe, expect, it } from 'vitest';
+
+import { genAmneziaWGConfig } from '@/lib/xray/inbound-link';
+import QrPanel from '@/pages/inbounds/qr/QrPanel';
+import { AmneziawgInboundSettingsSchema } from '@/schemas/protocols/inbound/amneziawg';
+
+const KEY = `${'A'.repeat(43)}=`;
+
+function awgConfig(disableCookies: boolean): string {
+  const settings = AmneziawgInboundSettingsSchema.parse({
+    server: {
+      publicKey: KEY,
+      mtu: 1420,
+      primaryDns: '8.8.8.8',
+      secondaryDns: '8.8.4.4',
+      jc: 4,
+      jmin: 65,
+      jmax: 220,
+      s1: 87,
+      s2: 44,
+      s3: 21,
+      s4: 19,
+      h1: '462980921-463150218',
+      h2: '1177681572-1177787900',
+      h3: '1907413509-1907903969',
+      h4: '2029908558-2030313135',
+      i1: '<r 148>',
+      headerProtectionKey: KEY,
+      contentPaddingAddition: '17-49',
+      rekeyAfterTime: '111-139',
+      rekeyTimeout: '4-7',
+      rejectAfterTime: '187-251',
+      keepaliveTimeout: '9-14',
+      maxHandshakeAttempts: '19-36',
+      randomTrailers: true,
+      disableCookies,
+    },
+    clients: [
+      {
+        email: 'my-client',
+        privateKey: KEY,
+        preSharedKey: KEY,
+        allowedIPs: ['10.8.1.2/32'],
+        keepAlive: 25,
+      },
+    ],
+  });
+
+  return genAmneziaWGConfig({
+    settings,
+    address: 'your-server.example.com',
+    port: 443,
+    remark: 'my-client',
+    peerIndex: 0,
+  });
+}
+
+function qrGeometry(value: string): { viewBox: string; foreground: string } {
+  const { container } = render(<QrPanel value={value} />);
+  const svg = container.querySelector('.qr-code svg');
+  const paths = svg?.querySelectorAll('path');
+
+  expect(svg).not.toBeNull();
+  expect(paths).toHaveLength(2);
+
+  return {
+    viewBox: svg?.getAttribute('viewBox') ?? '',
+    foreground: paths?.item(1).getAttribute('d') ?? '',
+  };
+}
+
+describe('QrPanel dense AmneziaWG config', () => {
+  it('keeps the complete 3.1 config readable across the DisableCookies QR boundary', () => {
+    const complete = awgConfig(true);
+    const withoutDisableCookies = awgConfig(false);
+
+    expect(complete).toContain('DisableCookies = on\n');
+    expect(complete.length - withoutDisableCookies.length).toBe(20);
+
+    const completeQr = qrGeometry(complete);
+    const shorterQr = qrGeometry(withoutDisableCookies);
+
+    expect(completeQr.viewBox).toBe('0 0 105 105');
+    expect(shorterQr.viewBox).toBe('0 0 101 101');
+    expect(completeQr.foreground).toMatch(/^M4 4h7/);
+    expect(shorterQr.foreground).toMatch(/^M4 4h7/);
+  });
+});

+ 77 - 0
frontend/src/test/tls-fingerprint-default.test.ts

@@ -0,0 +1,77 @@
+/// <reference types="vite/client" />
+import { describe, expect, it } from 'vitest';
+
+import { TlsStreamSettingsSchema } from '@/schemas/protocols/security/tls';
+import {
+  createTlsSettingsWithDefaultCert,
+  createHysteriaTlsSettingsWithDefaultCert,
+} from '@/lib/xray/inbound-tls-defaults';
+import { genHysteriaLink } from '@/lib/xray/inbound-link';
+import type { Inbound } from '@/schemas/api/inbound';
+
+// uTLS None ('') must survive a schema parse; the old default flipped it to
+// chrome on every save.
+describe('TlsClientSettingsSchema fingerprint default', () => {
+  it('parses an omitted fingerprint as None, not chrome', () => {
+    const parsed = TlsStreamSettingsSchema.parse({});
+    expect(parsed.settings.fingerprint).toBe('');
+  });
+
+  it('keeps an explicit empty-string fingerprint through parse', () => {
+    const parsed = TlsStreamSettingsSchema.parse({
+      settings: {
+        fingerprint: '',
+        echConfigList: '',
+        pinnedPeerCertSha256: [],
+        verifyPeerCertByName: '',
+      },
+    });
+    expect(parsed.settings.fingerprint).toBe('');
+  });
+
+  it('initializes generic TLS inbounds with chrome fingerprint default', () => {
+    const tls = createTlsSettingsWithDefaultCert();
+    expect((tls.settings as Record<string, unknown>)?.fingerprint).toBe('chrome');
+  });
+
+  it('initializes hysteria TLS inbounds with empty fingerprint default', () => {
+    const tls = createHysteriaTlsSettingsWithDefaultCert();
+    expect((tls.settings as Record<string, unknown>)?.fingerprint).toBe('');
+  });
+
+  it('does not inject fp into the hysteria share link when fingerprint is None', () => {
+    const raw = {
+      id: 1,
+      port: 443,
+      protocol: 'hysteria',
+      settings: { version: 2, clients: [{ auth: 'secret' }] },
+      streamSettings: {
+        security: 'tls',
+        tlsSettings: {
+          serverName: 'hy.test',
+          alpn: ['h3'],
+          settings: {
+            fingerprint: '',
+            echConfigList: '',
+            pinnedPeerCertSha256: [],
+            verifyPeerCertByName: '',
+          },
+        },
+        finalmask: {
+          udp: [{ type: 'salamander', settings: { password: 'pw', packetSize: '512-1200' } }],
+        },
+      },
+    };
+    const link = genHysteriaLink({
+      inbound: raw as unknown as Inbound,
+      address: 'example.test',
+      remark: 'gecko',
+      clientAuth: 'secret',
+    });
+    expect(link).toContain('obfs=gecko');
+    expect(link).toContain('minPacketSize=512');
+    expect(link).toContain('maxPacketSize=1200');
+    expect(link).not.toContain('fp=');
+    expect(link).not.toContain('fm=');
+  });
+});

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

@@ -879,6 +879,13 @@ export interface SupportedLanguage {
   icon: string;
 }
 
+export type LanguageScope = 'panel' | 'subscription';
+
+const languageCookieNames: Record<LanguageScope, string> = {
+  panel: 'lang',
+  subscription: 'subLang',
+};
+
 export class LanguageManager {
   static readonly supportedLanguages: readonly SupportedLanguage[] = [
     { name: 'العربية', value: 'ar-EG', icon: '🇪🇬' },
@@ -896,10 +903,19 @@ export class LanguageManager {
     { name: 'Português', value: 'pt-BR', icon: '🇧🇷' },
   ];
 
-  static getLanguage(): string {
-    let lang = CookieManager.getCookie('lang');
+  static getLanguage(scope: LanguageScope = 'panel'): string {
+    const cookieName = languageCookieNames[scope];
+    let lang = CookieManager.getCookie(cookieName);
     if (lang) return lang;
 
+    if (scope === 'subscription') {
+      const legacyLang = CookieManager.getCookie(languageCookieNames.panel);
+      if (LanguageManager.isSupportLanguage(legacyLang)) {
+        CookieManager.setCookie(cookieName, legacyLang, 365);
+        return legacyLang;
+      }
+    }
+
     if (window.navigator) {
       const nav = window.navigator as Navigator & { userLanguage?: string };
       lang = nav.language || nav.userLanguage || '';
@@ -924,24 +940,24 @@ export class LanguageManager {
       });
 
       if (LanguageManager.isSupportLanguage(lang)) {
-        CookieManager.setCookie('lang', lang, 365);
+        CookieManager.setCookie(cookieName, lang, 365);
       } else {
-        CookieManager.setCookie('lang', 'en-US', 365);
+        CookieManager.setCookie(cookieName, 'en-US', 365);
         window.location.reload();
       }
     } else {
-      CookieManager.setCookie('lang', 'en-US', 365);
+      CookieManager.setCookie(cookieName, 'en-US', 365);
       window.location.reload();
     }
 
     return lang;
   }
 
-  static setLanguage(language: string): void {
+  static setLanguage(language: string, scope: LanguageScope = 'panel'): void {
     if (!LanguageManager.isSupportLanguage(language)) {
       language = 'en-US';
     }
-    CookieManager.setCookie('lang', language, 365);
+    CookieManager.setCookie(languageCookieNames[scope], language, 365);
     window.location.reload();
   }
 
@@ -977,12 +993,13 @@ export class IntlUtil {
   static formatDate(
     date: string | number | Date | null | undefined,
     calendar: CalendarKind = 'gregorian',
+    language?: string,
   ): string {
     if (date == null) return '';
     const d = new Date(date);
     if (!isFinite(d.getTime())) return '';
-    const language = LanguageManager.getLanguage();
-    const locale = calendar === 'jalalian' ? 'fa-IR' : language;
+    const resolvedLanguage = language ?? LanguageManager.getLanguage();
+    const locale = calendar === 'jalalian' ? 'fa-IR' : resolvedLanguage;
 
     const intlOptions: Intl.DateTimeFormatOptions = {
       year: 'numeric',

+ 1 - 1
go.mod

@@ -1,6 +1,6 @@
 module github.com/mhsanaei/3x-ui/v3
 
-go 1.27.0
+go 1.27.1
 
 require (
 	github.com/amnezia-vpn/amneziawg-go/v3 v3.1.20260828

+ 72 - 7
install.sh

@@ -155,7 +155,7 @@ write_install_result() {
     local u="$1" p="$2" port="$3" wbp="$4" scheme="$5" host="$6" token="$7" dbtype="$8"
     local result_file="/etc/x-ui/install-result.env"
     local url_host="${host:-SERVER_IP_UNKNOWN}"
-    install -d -m 755 /etc/x-ui 2> /dev/null
+    install -d -m 700 /etc/x-ui 2> /dev/null
     local prev_umask
     prev_umask=$(umask)
     umask 077
@@ -1368,6 +1368,13 @@ setup_fail2ban() {
         return 0
     fi
 
+    # Scripts older than v3.4.0 have no setup-fail2ban and exit 0 from the
+    # usage banner, which would read as success here.
+    if ! grep -q '"setup-fail2ban")' /usr/bin/x-ui; then
+        echo -e "${yellow}This x-ui.sh predates 'x-ui setup-fail2ban'; skipping Fail2ban auto-setup.${plain}"
+        return 0
+    fi
+
     echo -e "${green}Setting up Fail2ban for the IP Limit feature...${plain}"
     if /usr/bin/x-ui setup-fail2ban; then
         echo -e "${green}Fail2ban setup complete.${plain}"
@@ -1426,6 +1433,51 @@ resolve_latest_tag() {
     curl -Ls --retry 5 --retry-delay 3 --connect-timeout 15 --max-time 60 "https://api.github.com/repos/MHSanaei/3x-ui/releases/latest" | grep '"tag_name":' | sed -E 's/.*"([^"]+)".*/\1/'
 }
 
+# Releases publish <asset>.sha256 next to each archive. A mismatch or a failed
+# sidecar download aborts the install; only a 404 (releases predating the
+# sidecar) is tolerated with a warning.
+verify_release_checksum() {
+    local url="$1" file="$2" sums="$2.sha256" code expected actual
+    rm -f "${sums}"
+    code=$(curl -sL --retry 3 --retry-delay 3 --connect-timeout 15 --max-time 60 -o "${sums}" -w '%{http_code}' "${url}.sha256")
+    if [[ "${code}" == "404" ]]; then
+        rm -f "${sums}"
+        echo -e "${yellow}No checksum published for this release, skipping verification${plain}"
+        return 0
+    fi
+    if [[ "${code}" != "200" ]]; then
+        rm -f "${sums}" "${file}"
+        echo -e "${red}Failed to download the checksum for $(basename "${file}") (HTTP ${code})${plain}"
+        exit 1
+    fi
+    expected=$(awk 'NR == 1 {print $1}' "${sums}")
+    actual=$(sha256sum "${file}" | awk '{print $1}')
+    rm -f "${sums}"
+    if [[ ! "${expected}" =~ ^[0-9a-f]{64}$ || "${expected}" != "${actual}" ]]; then
+        rm -f "${file}"
+        echo -e "${red}Checksum mismatch for $(basename "${file}"): expected ${expected:-<none>}, got ${actual}${plain}"
+        exit 1
+    fi
+    echo -e "${green}Checksum verified: ${actual}${plain}"
+}
+
+# Older tags predate some of these files (x-ui.rc arrived in v2.8.4). Serving
+# main's copy against an old binary is the mismatch this pinning exists to
+# prevent, so probe before anything is stopped or removed and refuse the tag.
+require_repo_files() {
+    local ref="$1" name status
+    shift
+    [[ "${ref}" == "main" ]] && return 0
+    for name in "$@"; do
+        status=$(curl -sIL --retry 3 --connect-timeout 15 -o /dev/null -w '%{http_code}' "https://raw.githubusercontent.com/MHSanaei/3x-ui/${ref}/${name}")
+        if [[ "${status}" != "200" ]]; then
+            echo -e "${red}${name} is not available for ${ref} (HTTP ${status})${plain}"
+            echo -e "${red}Install a release that ships it, or 'dev' for the rolling build. Your existing installation has not been touched.${plain}"
+            exit 1
+        fi
+    done
+}
+
 install_x-ui() {
     cd ${xui_folder%/x-ui}/
 
@@ -1447,6 +1499,7 @@ install_x-ui() {
             echo -e "${red}Downloaded x-ui release archive is empty${plain}"
             exit 1
         fi
+        verify_release_checksum "https://github.com/MHSanaei/3x-ui/releases/download/${tag_version}/x-ui-linux-$(arch).tar.gz" "${xui_folder}-linux-$(arch).tar.gz"
     else
         tag_version=$1
         # The rolling dev channel ships under a fixed, non-semver tag that is
@@ -1477,10 +1530,22 @@ install_x-ui() {
             echo -e "${red}Downloaded x-ui release archive is empty${plain}"
             exit 1
         fi
+        verify_release_checksum "${url}" "${xui_folder}-linux-$(arch).tar.gz"
+    fi
+    # x-ui.sh, x-ui.rc and the unit files must come from the same release as
+    # the binary; only the rolling dev build tracks main.
+    local script_ref="${tag_version}"
+    if [[ "${tag_version}" == "dev-latest" ]]; then
+        script_ref="main"
     fi
+    # The unit files are only fetched when the release tarball lacks them, so
+    # they are checked at that point instead of here.
+    local required_files=("x-ui.sh")
+    [[ $release == "alpine" ]] && required_files+=("x-ui.rc")
+    require_repo_files "${script_ref}" "${required_files[@]}"
     local xui_script_temp="/usr/bin/x-ui-temp.$$"
     rm -f "${xui_script_temp}"
-    curl -fLRo "${xui_script_temp}" https://raw.githubusercontent.com/MHSanaei/3x-ui/main/x-ui.sh
+    curl -fLRo "${xui_script_temp}" "https://raw.githubusercontent.com/MHSanaei/3x-ui/${script_ref}/x-ui.sh"
     if [[ $? -ne 0 ]]; then
         rm -f "${xui_script_temp}"
         echo -e "${red}Failed to download x-ui.sh${plain}"
@@ -1631,7 +1696,7 @@ install_x-ui() {
     if [[ $release == "alpine" ]]; then
         xui_rc_temp="/etc/init.d/x-ui.tmp.$$"
         rm -f "${xui_rc_temp}"
-        curl -fLRo "${xui_rc_temp}" https://raw.githubusercontent.com/MHSanaei/3x-ui/main/x-ui.rc
+        curl -fLRo "${xui_rc_temp}" "https://raw.githubusercontent.com/MHSanaei/3x-ui/${script_ref}/x-ui.rc"
         if [[ $? -ne 0 ]]; then
             rm -f "${xui_rc_temp}"
             echo -e "${red}Failed to download x-ui.rc${plain}"
@@ -1696,18 +1761,18 @@ install_x-ui() {
             echo -e "${yellow}Service files not found in tar.gz, downloading from GitHub...${plain}"
             case "${release}" in
                 ubuntu | debian | armbian)
-                    service_unit_url="https://raw.githubusercontent.com/MHSanaei/3x-ui/main/x-ui.service.debian"
+                    service_unit_url="https://raw.githubusercontent.com/MHSanaei/3x-ui/${script_ref}/x-ui.service.debian"
                     ;;
                 arch | manjaro | parch)
-                    service_unit_url="https://raw.githubusercontent.com/MHSanaei/3x-ui/main/x-ui.service.arch"
+                    service_unit_url="https://raw.githubusercontent.com/MHSanaei/3x-ui/${script_ref}/x-ui.service.arch"
                     ;;
                 *)
-                    service_unit_url="https://raw.githubusercontent.com/MHSanaei/3x-ui/main/x-ui.service.rhel"
+                    service_unit_url="https://raw.githubusercontent.com/MHSanaei/3x-ui/${script_ref}/x-ui.service.rhel"
                     ;;
             esac
 
             if ! _install_xui_service_unit "$service_unit_url" "true"; then
-                echo -e "${red}Failed to install x-ui.service from GitHub${plain}"
+                echo -e "${red}Failed to install x-ui.service from GitHub (${script_ref}) -- the release tarball did not ship one either${plain}"
                 exit 1
             fi
             service_installed=true

+ 5 - 11
internal/amneziawg/params.go

@@ -15,9 +15,6 @@ import (
 // but the amneziawg-windows-client config editor rejects anything above.
 const awgHMax = 2147483647
 
-// hMinWidth is the minimum width of each generated H1-H4 range.
-const hMinWidth = 1000
-
 // hMaxValid is the largest value ValidateObfuscation accepts for an H
 // parameter: uint32 max, the kernel's own limit.
 const hMaxValid int64 = 4294967295
@@ -56,7 +53,7 @@ func GenerateObfuscation31() Obfuscation31 {
 	o.S3 = randInt(12, 55) // cookie padding (max 64)
 	o.S4 = randInt(12, 27) // transport padding (max 32)
 
-	h := generateHRanges()
+	h := generateHValues()
 	o.H1, o.H2, o.H3, o.H4 = h[0], h[1], h[2], h[3]
 
 	// CPS signature packet, N random bytes before each handshake. I2-I5 stay
@@ -109,19 +106,16 @@ func generateHeaderProtectionKey() string {
 	return base64.StdEncoding.EncodeToString(key)
 }
 
-// generateHRanges returns four non-overlapping "low-high" ranges for H1-H4,
-// one per band of the space so non-overlap needs no retries. The low bound is
-// >= 5: values 1-4 are reserved for vanilla WireGuard message types.
-func generateHRanges() [4]string {
+// generateHValues returns one distinct value per H1-H4 band; low bound >= 5 (1-4 are vanilla WG message types).
+// Single values, not ranges: with RandomTrailers on, a wide range misclassifies transport packets as handshakes (amnezia-vpn/amneziawg-go#183).
+func generateHValues() [4]string {
 	const lo = 5
 	bandSize := (awgHMax - lo + 1) / 4
 	var out [4]string
 	for i := 0; i < 4; i++ {
 		bandLo := lo + i*bandSize
 		bandHi := bandLo + bandSize - 1
-		start := randInt(bandLo, bandHi-hMinWidth-1)
-		end := randInt(start+hMinWidth, bandHi-1)
-		out[i] = fmt.Sprintf("%d-%d", start, end)
+		out[i] = fmt.Sprintf("%d", randInt(bandLo, bandHi))
 	}
 	return out
 }

+ 10 - 15
internal/amneziawg/params_test.go

@@ -95,24 +95,19 @@ func assertRangeWithin(t *testing.T, name, v string, min, max int64) (lo, hi int
 	return lo, hi
 }
 
-func TestGenerateHRangesNonOverlapping(t *testing.T) {
+func TestGenerateHValuesDistinct(t *testing.T) {
 	for i := 0; i < 50; i++ {
-		h := generateHRanges()
-		var prevHi int64
-		for i, r := range h {
-			lo, hi, ok := strings.Cut(r, "-")
-			if !ok {
-				t.Fatalf("H%d = %q is not a range", i+1, r)
+		h := generateHValues()
+		var prev int64
+		for i, v := range h {
+			n, err := strconv.ParseInt(v, 10, 64)
+			if err != nil {
+				t.Fatalf("H%d = %q is not a plain integer: %v", i+1, v, err)
 			}
-			loN, _ := strconv.ParseInt(lo, 10, 64)
-			hiN, _ := strconv.ParseInt(hi, 10, 64)
-			if loN <= prevHi {
-				t.Fatalf("H%d = %q overlaps or touches the previous range (prev high=%d)", i+1, r, prevHi)
+			if n <= prev {
+				t.Fatalf("H%d = %q is not strictly greater than the previous value (%d)", i+1, v, prev)
 			}
-			if hiN-loN < hMinWidth {
-				t.Fatalf("H%d = %q is narrower than hMinWidth=%d", i+1, r, hMinWidth)
-			}
-			prevHi = hiN
+			prev = n
 		}
 	}
 }

+ 49 - 53
internal/amneziawgnet/manager.go

@@ -6,6 +6,7 @@ import (
 	"os"
 	"strings"
 	"sync"
+	"sync/atomic"
 
 	"github.com/amnezia-vpn/amneziawg-go/v3/device"
 	"gvisor.dev/gvisor/pkg/tcpip/adapters/gonet"
@@ -47,12 +48,36 @@ type managed struct {
 	dev          *Device
 	udpRelay     *UDPRelay
 	portForwards *PortForwardSet
-	peers        *PeerIndex
+	peers        atomic.Pointer[PeerIndex]
 	inst         amneziawg.Instance
 	structFP     string
 	uapiConfig   string
 }
 
+func (m *managed) lookupPeer(addr netip.Addr) (amneziawg.Peer, bool) {
+	peers := m.peers.Load()
+	if peers == nil {
+		return amneziawg.Peer{}, false
+	}
+	return peers.Lookup(addr)
+}
+
+func (m *managed) handleUDP(src, dst netip.AddrPort, payload []byte) {
+	peer, ok := m.lookupPeer(src.Addr())
+	if !ok {
+		return
+	}
+	m.udpRelay.Handle(src, dst, peer.Email, payload)
+}
+
+func (m *managed) close() {
+	m.portForwards.Close()
+	// Stop packet delivery before closing the relay so an in-flight handler
+	// cannot publish a new session after the relay has already been swept.
+	m.dev.Close()
+	m.udpRelay.Close()
+}
+
 // Manager owns the set of running embedded AmneziaWG interfaces, keyed by
 // inbound id -- the same shape as internal/mtproto.Manager (GetManager()
 // + sync.Once, mu-guarded map, Ensure/Reconcile/StopAll/HasRunning), so a
@@ -135,7 +160,7 @@ func (m *Manager) ensureLocked(d Desired) error {
 		// buildUAPIConfig actually reads, the way a hand-maintained field
 		// list could.
 		if conf == cur.uapiConfig {
-			cur.peers = NewPeerIndex(inst.Peers)
+			cur.peers.Store(NewPeerIndex(inst.Peers))
 			cur.inst = inst
 			applyV6Aliases(diffV6Aliases(oldInst, inst))
 			// buildUAPIConfig never reads ForwardedPorts (it's a panel-level
@@ -151,7 +176,7 @@ func (m *Manager) ensureLocked(d Desired) error {
 		if err := cur.dev.IpcSet(conf); err != nil {
 			return fmt.Errorf("amneziawgnet: reconfigure inbound %d: %w", inst.Id, err)
 		}
-		cur.peers = NewPeerIndex(inst.Peers)
+		cur.peers.Store(NewPeerIndex(inst.Peers))
 		cur.inst = inst
 		cur.uapiConfig = conf
 		applyV6Aliases(diffV6Aliases(oldInst, inst))
@@ -160,9 +185,7 @@ func (m *Manager) ensureLocked(d Desired) error {
 	}
 
 	if exists {
-		cur.udpRelay.Close()
-		cur.portForwards.Close()
-		cur.dev.Close()
+		cur.close()
 		delete(m.ifaces, inst.Id)
 	}
 	dev, err := newUnconfiguredDevice(inst, opts)
@@ -173,40 +196,30 @@ func (m *Manager) ensureLocked(d Desired) error {
 	relay := socksRelayForInstance(inst)
 	udpRelay := NewUDPRelay(relay, dev.Stack)
 	portForwards := NewPortForwardSet(dev.Stack, inst.Id)
-	inboundID := inst.Id // captured for the closures below, which outlive this call
+	next := &managed{
+		dev:          dev,
+		udpRelay:     udpRelay,
+		portForwards: portForwards,
+		inst:         inst,
+		structFP:     structFP,
+	}
+	next.peers.Store(NewPeerIndex(inst.Peers))
 	AttachTCPForwarder(dev.Stack, func(conn *gonet.TCPConn, dest netip.AddrPort) {
 		srcAddrPort, err := netip.ParseAddrPort(conn.RemoteAddr().String())
 		if err != nil {
 			conn.Close()
 			return
 		}
-		// Re-fetched on every connection, not captured once at attach time:
-		// a reconfigure-in-place (peers added/removed, no rebuild) replaces
-		// cur.peers without ever re-attaching the forwarder, so a stale
-		// captured index would silently miss newly-added peers.
-		_, peers, ok := m.Lookup(inboundID)
-		if !ok {
-			conn.Close()
-			return
-		}
-		peer, ok := peers.Lookup(srcAddrPort.Addr().Unmap())
+		// Reload for every connection: in-place reconfiguration swaps the peer
+		// index without reattaching handlers and may hold the lifecycle lock.
+		peer, ok := next.lookupPeer(srcAddrPort.Addr().Unmap())
 		if !ok {
 			conn.Close()
 			return
 		}
 		relay.RelayTCP(conn, peer.Email, dest)
 	})
-	AttachUDPHandler(dev.Stack, func(src, dst netip.AddrPort, payload []byte) {
-		_, peers, ok := m.Lookup(inboundID)
-		if !ok {
-			return
-		}
-		peer, ok := peers.Lookup(src.Addr())
-		if !ok {
-			return
-		}
-		udpRelay.Handle(src, dst, peer.Email, payload)
-	})
+	AttachUDPHandler(dev.Stack, next.handleUDP)
 
 	// Handlers are registered on dev.Stack above, BEFORE Configure's IpcSet
 	// can start any peer's receive goroutine -- see newUnconfiguredDevice's
@@ -224,16 +237,8 @@ func (m *Manager) ensureLocked(d Desired) error {
 	// the no-op check above a correct baseline to compare the next tick
 	// against instead of an empty string.
 	conf, _ := buildUAPIConfig(inst, opts)
-
-	m.ifaces[inst.Id] = &managed{
-		dev:          dev,
-		udpRelay:     udpRelay,
-		portForwards: portForwards,
-		peers:        NewPeerIndex(inst.Peers),
-		inst:         inst,
-		structFP:     structFP,
-		uapiConfig:   conf,
-	}
+	next.uapiConfig = conf
+	m.ifaces[inst.Id] = next
 	applyV6Aliases(diffV6Aliases(oldInst, inst))
 	portForwards.Reconcile(inst)
 	logger.Infof("amneziawgnet: started embedded interface %s for inbound %d", inst.InterfaceName, inst.Id)
@@ -275,9 +280,7 @@ func (m *Manager) Reconcile(desired []Desired) {
 			continue
 		}
 		applyV6Aliases(diffV6Aliases(cur.inst, amneziawg.Instance{}))
-		cur.udpRelay.Close()
-		cur.portForwards.Close()
-		cur.dev.Close()
+		cur.close()
 		delete(m.ifaces, id)
 		logger.Infof("amneziawgnet: stopped embedded interface for removed inbound %d", id)
 	}
@@ -300,9 +303,7 @@ func (m *Manager) Remove(id int) {
 		return
 	}
 	applyV6Aliases(diffV6Aliases(cur.inst, amneziawg.Instance{}))
-	cur.udpRelay.Close()
-	cur.portForwards.Close()
-	cur.dev.Close()
+	cur.close()
 	delete(m.ifaces, id)
 	logger.Infof("amneziawgnet: stopped embedded interface for removed inbound %d", id)
 }
@@ -313,9 +314,7 @@ func (m *Manager) StopAll() {
 	defer m.mu.Unlock()
 	for id, cur := range m.ifaces {
 		applyV6Aliases(diffV6Aliases(cur.inst, amneziawg.Instance{}))
-		cur.udpRelay.Close()
-		cur.portForwards.Close()
-		cur.dev.Close()
+		cur.close()
 		delete(m.ifaces, id)
 	}
 }
@@ -327,11 +326,8 @@ func (m *Manager) HasRunning() bool {
 	return len(m.ifaces) > 0
 }
 
-// Lookup returns the running Device and PeerIndex for inbound id, if any --
-// the forwarder/UDP-handler closures ensureLocked attaches use this to
-// re-fetch the current peer index on every connection (see ensureLocked's
-// comment on why), and it's equally available to a test harness or any
-// other caller that wants read access to a managed interface's state.
+// Lookup returns the running device and current peer snapshot for diagnostics,
+// tests, and other callers outside the packet-delivery path.
 func (m *Manager) Lookup(id int) (dev *Device, peers *PeerIndex, ok bool) {
 	m.mu.Lock()
 	defer m.mu.Unlock()
@@ -339,5 +335,5 @@ func (m *Manager) Lookup(id int) (dev *Device, peers *PeerIndex, ok bool) {
 	if !exists {
 		return nil, nil, false
 	}
-	return cur.dev, cur.peers, true
+	return cur.dev, cur.peers.Load(), true
 }

+ 29 - 0
internal/amneziawgnet/manager_test.go

@@ -3,6 +3,7 @@ package amneziawgnet
 import (
 	"fmt"
 	"net"
+	"net/netip"
 	"testing"
 	"time"
 
@@ -89,6 +90,34 @@ func TestManagerLifecycle(t *testing.T) {
 	}
 }
 
+func TestManagedUDPHandlerDoesNotWaitForManagerLock(t *testing.T) {
+	cur := &managed{udpRelay: NewUDPRelay(SocksRelay{Addr: "invalid"}, nil)}
+	cur.peers.Store(NewPeerIndex([]amneziawg.Peer{{
+		Email:      "peer@test",
+		AllowedIPs: []string{"10.210.0.2/32"},
+	}}))
+	m := &Manager{}
+
+	done := make(chan struct{})
+	m.mu.Lock()
+	go func() {
+		cur.handleUDP(
+			netip.MustParseAddrPort("10.210.0.2:1234"),
+			netip.MustParseAddrPort("10.210.0.3:53"),
+			[]byte("query"),
+		)
+		close(done)
+	}()
+
+	select {
+	case <-done:
+		m.mu.Unlock()
+	case <-time.After(time.Second):
+		m.mu.Unlock()
+		t.Fatal("UDP handler blocked on the manager lifecycle lock")
+	}
+}
+
 // TestEnsureUnchangedInstanceDoesNotResetLivePeers is a regression test for a
 // real production bug: an unchanged Ensure call (the common case on every
 // 10s AmneziaWGJob reconcile tick when no admin edit happened) was calling

+ 50 - 77
internal/amneziawgnet/netstack.go

@@ -1,19 +1,12 @@
-// Package amneziawgnet embeds amneziawg-go (a userspace AmneziaWG
-// implementation, https://github.com/amnezia-vpn/amneziawg-go) directly in
-// the panel process, as an alternative to internal/amneziawg's
-// kernel-module (DKMS) + awg-quick approach. A gVisor userspace network
-// stack (gvisor.dev/gvisor/pkg/tcpip -- already an indirect dependency via
-// xray-core's own proxy/wireguard support) terminates each tunnel, and a
-// forwarder recovers each connection's real, dynamically-arbitrary
-// destination for the caller to relay onward (see Phase 2 of the migration
-// plan: a loopback SOCKS5 dial into Xray, giving native stats/routing/
-// sniffing for free).
+// Package amneziawgnet embeds amneziawg-go and gVisor netstack in-process
+// as a userspace alternative to kernel wireguard / awg-quick.
 package amneziawgnet
 
 import (
 	"fmt"
 	"net/netip"
 	"os"
+	"sync"
 	"syscall"
 
 	awgtun "github.com/amnezia-vpn/amneziawg-go/v3/tun"
@@ -30,60 +23,40 @@ import (
 	"gvisor.dev/gvisor/pkg/tcpip/transport/udp"
 )
 
-// tunQueueDepth is the outbound packet queue depth for both the gVisor
-// channel endpoint and the handoff channel to amneziawg-go's TUN reader
-// (see the stackTun literal in createNetTUNWithStack for why both need it).
-const tunQueueDepth = 1024
-
-// stackTun implements amneziawg-go's tun.Device directly against a gVisor
-// channel endpoint, the same approach amneziawg-go's own tun/netstack
-// package and xray-core's proxy/wireguard/netstack.go both take. Neither of
-// those exposes the raw *stack.Stack a forwarder needs (amneziawg-go's Net
-// type keeps it unexported), so this is a local, from-source reimplementation
-// rather than a wrapper -- adapted from amneziawg-go v3.0.3's
-// tun/netstack/tun.go (MIT licensed), trimmed to the constructor this
-// package needs.
+// tunQueueDepth is the outbound queue depth for channel endpoint and handoff.
+// 1024 starved simultaneous TCP slow-starts; channel.Endpoint drops silently when full.
+const tunQueueDepth = 8192
+
+// stackTun implements amneziawg-go tun.Device over a gVisor channel endpoint,
+// exposing *stack.Stack for forwarder attachment.
 type stackTun struct {
 	ep             *channel.Endpoint
 	stack          *stack.Stack
 	events         chan awgtun.Event
 	notifyHandle   *channel.NotificationHandle
 	incomingPacket chan *buffer.View
+	done           chan struct{}
+	closeMu        sync.Mutex
+	closed         bool
 	mtu            int
 }
 
-// createNetTUNWithStack builds a gVisor-backed tun.Device for the given
-// local addresses (interface address(es), one per family) and returns the
-// underlying *stack.Stack alongside it so a caller can attach a forwarder
-// (see forwarder.go / udp.go).
+// createNetTUNWithStack builds a gVisor-backed tun.Device for localAddresses
+// and returns underlying *stack.Stack to attach forwarders.
 func createNetTUNWithStack(localAddresses []netip.Addr, mtu int) (awgtun.Device, *stack.Stack, error) {
 	opts := stack.Options{
 		NetworkProtocols:   []stack.NetworkProtocolFactory{ipv4.NewProtocol, ipv6.NewProtocol},
 		TransportProtocols: []stack.TransportProtocolFactory{tcp.NewProtocol, udp.NewProtocol, icmp.NewProtocol6, icmp.NewProtocol4},
-		// HandleLocal must stay false: promiscuous+spoofing mode (see
-		// forwarder.go) is what lets a destination other than the stack's
-		// own configured address reach the forwarder at all.
+		// HandleLocal stays false so non-local destinations reach forwarder.
 		HandleLocal: false,
 	}
 	dev := &stackTun{
-		// tunQueueDepth matches channel.New's own outbound queue depth
-		// below. WriteNotify (called synchronously from whatever gVisor
-		// goroutine is sending TCP data for the download/server->client
-		// direction) pushes into incomingPacket; RoutineReadFromTUN (a
-		// single amneziawg-go goroutine that encrypts and sends each
-		// packet over UDP) is the only reader. With no buffer, every
-		// outbound packet forced a full synchronous handoff between the
-		// two -- gVisor's sender blocked until the encrypt loop was ready
-		// for the next one, one packet at a time, no pipelining. The
-		// upload/client->server direction has no equivalent stall:
-		// Write->InjectInbound->DeliverNetworkPacket hands off into
-		// gVisor's own ~1MB per-connection TCP receive buffer and returns
-		// immediately. Buffering this channel gives the download
-		// direction the same slack the upload direction already had.
+		// tunQueueDepth buffers channel.New and incomingPacket for pipelining.
 		ep:             channel.New(tunQueueDepth, uint32(mtu), ""),
 		stack:          stack.New(opts),
 		events:         make(chan awgtun.Event, 10),
 		incomingPacket: make(chan *buffer.View, tunQueueDepth),
+		done:           make(chan struct{}),
 		mtu:            mtu,
 	}
 	sackEnabledOpt := tcpip.TCPSACKEnabled(true)
@@ -132,25 +105,13 @@ func (t *stackTun) Events() <-chan awgtun.Event { return t.events }
 func (t *stackTun) MTU() (int, error)           { return t.mtu, nil }
 func (t *stackTun) BatchSize() int              { return 1 }
 
-// Read blocks for the first packet, then opportunistically drains any more
-// that are already buffered (non-blocking), up to len(buf). amneziawg-go's
-// caller (RoutineReadFromTUN) sizes buf/sizes to device.BatchSize(), which
-// is the UDP bind's own batch size (128 on Linux, see conn.IdealBatchSize)
-// since that's larger than BatchSize()'s 1 below -- so real buffer capacity
-// for a batch is already there. Without this drain loop, Read always
-// returned exactly one packet no matter how many buf could hold, so every
-// downstream step (peer lookup, per-peer staging, and ultimately the UDP
-// bind's own genuinely batched Send/sendmmsg) processed the download
-// direction one packet at a time while the upload direction's equivalent
-// (bind.Receive/recvmmsg -> decrypt -> stackTun.Write, which already loops
-// over its whole buf) processed up to 128 per cycle. That asymmetry is
-// real, not gVisor/amneziawg-go's -- both the receive and send paths on the
-// UDP bind support batching identically, only this Read implementation
-// didn't use it.
+// Read drains incomingPacket into buf, supporting batched reads.
 func (t *stackTun) Read(buf [][]byte, sizes []int, offset int) (int, error) {
-	view, ok := <-t.incomingPacket
-	if !ok {
+	var view *buffer.View
+	select {
+	case <-t.done:
 		return 0, os.ErrClosed
+	case view = <-t.incomingPacket:
 	}
 	n, err := view.Read(buf[0][offset:])
 	if err != nil {
@@ -160,10 +121,7 @@ func (t *stackTun) Read(buf [][]byte, sizes []int, offset int) (int, error) {
 	count := 1
 	for count < len(buf) {
 		select {
-		case view, ok := <-t.incomingPacket:
-			if !ok {
-				return count, nil
-			}
+		case view = <-t.incomingPacket:
 			n, err := view.Read(buf[count][offset:])
 			if err != nil {
 				return count, nil
@@ -196,17 +154,41 @@ func (t *stackTun) Write(buf [][]byte, offset int) (int, error) {
 	return len(buf), nil
 }
 
+// WriteNotify runs on gVisor dispatch while Close tears the endpoint down,
+// so it must never block on closeMu across ep.Read or stack teardown.
 func (t *stackTun) WriteNotify() {
+	t.closeMu.Lock()
+	if t.closed {
+		t.closeMu.Unlock()
+		return
+	}
+	t.closeMu.Unlock()
+
 	pkt := t.ep.Read()
 	if pkt == nil {
 		return
 	}
 	view := pkt.ToView()
 	pkt.DecRef()
-	t.incomingPacket <- view
+
+	// Select against done so racing dispatch abandons packet on close
+	// without blocking Close or panicking on closed channel.
+	select {
+	case t.incomingPacket <- view:
+	case <-t.done:
+	}
 }
 
 func (t *stackTun) Close() error {
+	t.closeMu.Lock()
+	if t.closed {
+		t.closeMu.Unlock()
+		return nil
+	}
+	t.closed = true
+	close(t.done)
+	t.closeMu.Unlock()
+
 	t.stack.RemoveNIC(1)
 	t.stack.Close()
 	t.ep.RemoveNotify(t.notifyHandle)
@@ -214,25 +196,16 @@ func (t *stackTun) Close() error {
 	if t.events != nil {
 		close(t.events)
 	}
-	if t.incomingPacket != nil {
-		close(t.incomingPacket)
-	}
 	return nil
 }
 
-// enablePromiscuousRouting puts the NIC into promiscuous + spoofing mode,
-// the precondition both AttachTCPForwarder and AttachUDPHandler need to see
-// packets addressed to a destination other than the stack's own configured
-// local address. Safe to call from both (and more than once): gVisor's
-// SetPromiscuousMode/SetSpoofing just set a bool on the NIC, not something
-// that accumulates or needs undoing between calls.
+// enablePromiscuousRouting configures NIC promiscuous and spoofing modes.
 func enablePromiscuousRouting(gstack *stack.Stack) {
 	gstack.SetPromiscuousMode(1, true)
 	gstack.SetSpoofing(1, true)
 }
 
-// addrFromTcpip converts a gVisor tcpip.Address (4 or 16 raw bytes) to the
-// stdlib netip.Addr type the rest of this package and its callers use.
+// addrFromTcpip converts a gVisor tcpip.Address to netip.Addr.
 func addrFromTcpip(a tcpip.Address) netip.Addr {
 	if a.Len() == 4 {
 		var b [4]byte

+ 36 - 1
internal/database/db.go

@@ -1169,6 +1169,22 @@ func initUser() error {
 	return nil
 }
 
+func seedRandomSubscriptionPaths() error {
+	settings := []model.Setting{
+		{Key: "subPath", Value: "/" + random.NumLower(16) + "/"},
+		{Key: "subJsonPath", Value: "/" + random.NumLower(16) + "/"},
+		{Key: "subClashPath", Value: "/" + random.NumLower(16) + "/"},
+	}
+	return db.Transaction(func(tx *gorm.DB) error {
+		for i := range settings {
+			if err := tx.Where("key = ?", settings[i].Key).FirstOrCreate(&settings[i]).Error; err != nil {
+				return err
+			}
+		}
+		return nil
+	})
+}
+
 func runSeeders(isUsersEmpty bool) error {
 	empty, err := isTableEmpty("history_of_seeders")
 	if err != nil {
@@ -2078,7 +2094,7 @@ func InitDB(dbPath string) error {
 		}
 	default:
 		dir := path.Dir(dbPath)
-		if err = os.MkdirAll(dir, 0o755); err != nil {
+		if err = os.MkdirAll(dir, 0o700); err != nil {
 			return err
 		}
 		if err = cleanupSQLiteBackupDirs(filepath.Dir(dbPath)); err != nil {
@@ -2092,6 +2108,9 @@ func InitDB(dbPath string) error {
 		if err != nil {
 			return err
 		}
+		if err := restrictSQLiteFilePerms(dbPath); err != nil {
+			log.Printf("restrict SQLite file permissions: %v", err)
+		}
 		sqlDB, err := db.DB()
 		if err != nil {
 			return err
@@ -2138,6 +2157,11 @@ func InitDB(dbPath string) error {
 	if err != nil {
 		return err
 	}
+	if isUsersEmpty {
+		if err := seedRandomSubscriptionPaths(); err != nil {
+			return err
+		}
+	}
 
 	if err := initUser(); err != nil {
 		return err
@@ -2192,6 +2216,17 @@ func openPostgresWithRetry(dsn string, c *gorm.Config) (*gorm.DB, error) {
 	return nil, fmt.Errorf("postgres unreachable after %d attempts: %w", len(delays), lastErr)
 }
 
+// The store holds client secrets, so it and its WAL/SHM side files stay
+// owner-only. Best effort: a store the panel cannot chmod still opens.
+func restrictSQLiteFilePerms(dbPath string) error {
+	for _, name := range []string{dbPath, dbPath + "-wal", dbPath + "-shm"} {
+		if err := os.Chmod(name, 0o600); err != nil && !errors.Is(err, os.ErrNotExist) {
+			return err
+		}
+	}
+	return nil
+}
+
 func sqliteJournalMode() string {
 	switch strings.ToUpper(strings.TrimSpace(os.Getenv("XUI_DB_JOURNAL_MODE"))) {
 	case "DELETE":

+ 79 - 0
internal/database/db_permissions_test.go

@@ -0,0 +1,79 @@
+package database
+
+import (
+	"errors"
+	"os"
+	"path/filepath"
+	"runtime"
+	"testing"
+)
+
+func TestInitDBRestrictsSQLiteFilePermissions(t *testing.T) {
+	if runtime.GOOS == "windows" {
+		t.Skip("POSIX permission bits are not meaningful on Windows")
+	}
+	t.Setenv("XUI_DB_JOURNAL_MODE", "")
+	dbDir := filepath.Join(t.TempDir(), "x-ui")
+	dbPath := filepath.Join(dbDir, "x-ui.db")
+
+	if err := InitDB(dbPath); err != nil {
+		t.Fatalf("InitDB: %v", err)
+	}
+	t.Cleanup(func() { _ = CloseDB() })
+
+	if info, err := os.Stat(dbDir); err != nil {
+		t.Fatalf("stat db dir: %v", err)
+	} else if perm := info.Mode().Perm(); perm != 0o700 {
+		t.Fatalf("db dir perm = %o, want 700", perm)
+	}
+	for _, name := range []string{dbPath, dbPath + "-wal", dbPath + "-shm"} {
+		info, err := os.Stat(name)
+		if errors.Is(err, os.ErrNotExist) && name != dbPath {
+			continue
+		}
+		if err != nil {
+			t.Fatalf("stat %s: %v", name, err)
+		}
+		if perm := info.Mode().Perm(); perm != 0o600 {
+			t.Fatalf("%s perm = %o, want 600", filepath.Base(name), perm)
+		}
+	}
+}
+
+func TestInitDBTightensExistingSQLiteFilePermissions(t *testing.T) {
+	if runtime.GOOS == "windows" {
+		t.Skip("POSIX permission bits are not meaningful on Windows")
+	}
+	t.Setenv("XUI_DB_JOURNAL_MODE", "")
+	dbPath := filepath.Join(t.TempDir(), "x-ui.db")
+	if err := InitDB(dbPath); err != nil {
+		t.Fatalf("seed InitDB: %v", err)
+	}
+	if err := CloseDB(); err != nil {
+		t.Fatalf("seed CloseDB: %v", err)
+	}
+	// Simulate a store created by an older release under the default umask.
+	for _, name := range []string{dbPath, dbPath + "-wal", dbPath + "-shm"} {
+		if err := os.Chmod(name, 0o644); err != nil && !errors.Is(err, os.ErrNotExist) {
+			t.Fatalf("chmod %s: %v", name, err)
+		}
+	}
+
+	if err := InitDB(dbPath); err != nil {
+		t.Fatalf("InitDB: %v", err)
+	}
+	t.Cleanup(func() { _ = CloseDB() })
+
+	for _, name := range []string{dbPath, dbPath + "-wal", dbPath + "-shm"} {
+		info, err := os.Stat(name)
+		if errors.Is(err, os.ErrNotExist) && name != dbPath {
+			continue
+		}
+		if err != nil {
+			t.Fatalf("stat %s: %v", name, err)
+		}
+		if perm := info.Mode().Perm(); perm != 0o600 {
+			t.Fatalf("%s perm = %o, want 600", filepath.Base(name), perm)
+		}
+	}
+}

+ 51 - 0
internal/database/db_seed_test.go

@@ -9,6 +9,54 @@ import (
 	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
 )
 
+func TestInitDB_GeneratesPerPanelSubscriptionPaths(t *testing.T) {
+	pathPattern := regexp.MustCompile(`^/[0-9a-z]{16}/$`)
+	loadPaths := func(dbPath string) map[string]string {
+		t.Helper()
+		if err := InitDB(dbPath); err != nil {
+			t.Fatalf("InitDB failed: %v", err)
+		}
+		defer func() {
+			if err := CloseDB(); err != nil {
+				t.Errorf("CloseDB failed: %v", err)
+			}
+		}()
+
+		keys := []string{"subPath", "subJsonPath", "subClashPath"}
+		paths := make(map[string]string, len(keys))
+		for _, key := range keys {
+			var setting model.Setting
+			if err := db.Where("key = ?", key).First(&setting).Error; err != nil {
+				t.Fatalf("read %s: %v", key, err)
+			}
+			if !pathPattern.MatchString(setting.Value) {
+				t.Fatalf("%s = %q, want /<16 lowercase alphanumeric characters>/", key, setting.Value)
+			}
+			paths[key] = setting.Value
+		}
+		if paths["subPath"] == paths["subJsonPath"] || paths["subPath"] == paths["subClashPath"] || paths["subJsonPath"] == paths["subClashPath"] {
+			t.Fatalf("subscription paths must be distinct: %v", paths)
+		}
+		return paths
+	}
+
+	firstDB := filepath.Join(t.TempDir(), "x-ui.db")
+	first := loadPaths(firstDB)
+	reloaded := loadPaths(firstDB)
+	for key, firstPath := range first {
+		if firstPath != reloaded[key] {
+			t.Fatalf("%s changed after restart: %q, then %q", key, firstPath, reloaded[key])
+		}
+	}
+
+	second := loadPaths(filepath.Join(t.TempDir(), "x-ui.db"))
+	for key, firstPath := range first {
+		if firstPath == second[key] {
+			t.Fatalf("%s reused across panels: %q", key, firstPath)
+		}
+	}
+}
+
 func TestSeedClientsFromInboundJSON_IsIdempotentAgainstExistingClients(t *testing.T) {
 	dbDir := t.TempDir()
 	t.Setenv("XUI_DB_FOLDER", dbDir)
@@ -168,6 +216,9 @@ func TestNormalizeSettingPaths_RepairsLegacyValues(t *testing.T) {
 		{Key: "subClashPath", Value: "clash/"},
 		{Key: "webBasePath", Value: "/panel/"},
 	}
+	if err := db.Where("key IN ?", []string{"subPath", "subJsonPath", "subClashPath"}).Delete(&model.Setting{}).Error; err != nil {
+		t.Fatalf("clear generated subscription paths: %v", err)
+	}
 	for i := range seed {
 		if err := db.Create(&seed[i]).Error; err != nil {
 			t.Fatalf("seed setting %s: %v", seed[i].Key, err)

+ 1 - 1
internal/database/dump_sqlite.go

@@ -24,7 +24,7 @@ func DumpSQLite(srcPath, outPath string) error {
 	if err != nil {
 		return err
 	}
-	return os.WriteFile(outPath, data, 0o644)
+	return os.WriteFile(outPath, data, 0o600)
 }
 
 // DumpSQLiteToBytes builds the same `sqlite3 .dump`-style SQL text as DumpSQLite

+ 13 - 4
internal/sub/clash_service.go

@@ -39,6 +39,7 @@ func (s *SubClashService) GetClash(subId string, host string) (string, string, e
 	}
 
 	var proxies []map[string]any
+	var hasInactiveExternal bool
 
 	seenEmails := make(map[string]struct{})
 	for _, inbound := range inbounds {
@@ -56,6 +57,11 @@ func (s *SubClashService) GetClash(subId string, host string) (string, string, e
 		}
 	}
 	for _, ext := range externalLinks {
+		if !ext.Active {
+			seenEmails[ext.Email] = struct{}{}
+			hasInactiveExternal = true
+			continue
+		}
 		for _, el := range expandEntry(ext) {
 			name := el.Name
 			if name == "" {
@@ -68,17 +74,21 @@ func (s *SubClashService) GetClash(subId string, host string) (string, string, e
 		}
 	}
 
-	if len(proxies) == 0 {
+	if len(proxies) == 0 && !hasInactiveExternal {
 		return "", "", nil
 	}
 
-	ensureUniqueProxyNames(proxies)
-
 	emails := make([]string, 0, len(seenEmails))
 	for e := range seenEmails {
 		emails = append(emails, e)
 	}
 	traffic, _ := subReq.AggregateTrafficByEmails(emails)
+	header := fmt.Sprintf("upload=%d; download=%d; total=%d; expire=%d", traffic.Up, traffic.Down, traffic.Total, traffic.ExpiryTime/1000)
+	if len(proxies) == 0 {
+		return "", header, nil
+	}
+
+	ensureUniqueProxyNames(proxies)
 
 	proxyNames := make([]string, 0, len(proxies)+1)
 	for _, proxy := range proxies {
@@ -116,7 +126,6 @@ func (s *SubClashService) GetClash(subId string, host string) (string, string, e
 		return "", "", err
 	}
 
-	header := fmt.Sprintf("upload=%d; download=%d; total=%d; expire=%d", traffic.Up, traffic.Down, traffic.Total, traffic.ExpiryTime/1000)
 	return string(finalYAML), header, nil
 }
 

+ 12 - 5
internal/sub/controller.go

@@ -345,7 +345,7 @@ func (a *SUBController) buildSubPageData(c *gin.Context) (PageData, bool) {
 	subReq := a.subService.ForRequest(host)
 	subReq.subscriptionBody = false
 	subs, emails, lastOnline, traffic, err := subReq.getSubs(subId)
-	if err != nil || len(subs) == 0 {
+	if err != nil || subs == nil {
 		writeSubError(c, err)
 		return PageData{}, false
 	}
@@ -413,7 +413,7 @@ func (a *SUBController) subs(c *gin.Context) {
 	subReq := a.subService.ForRequest(host)
 	subReq.subscriptionBody = true
 	subs, _, _, traffic, err := subReq.getSubs(subId)
-	if err != nil || len(subs) == 0 {
+	if err != nil || subs == nil {
 		writeSubError(c, err)
 	} else {
 		var result strings.Builder
@@ -705,9 +705,13 @@ func (a *SUBController) loadSubTemplate(themeDir string) (*template.Template, er
 	return tmpl, nil
 }
 
-// subJsons handles HTTP requests for JSON subscription configurations.
+// subJsons handles HTTP requests for JSON subscription configurations. The
+// device limit is enforced on every body route, ?view=raw included (#GHSA-7ww3).
 func (a *SUBController) subJsons(c *gin.Context) {
 	if strings.EqualFold(c.Query("view"), "raw") {
+		if !a.enforceHwid(c) {
+			return
+		}
 		if !a.serveJsonBody(c, a.jsonAlwaysArray, "application/json; charset=utf-8", true) {
 			writeSubError(c, nil)
 		}
@@ -738,7 +742,7 @@ func (a *SUBController) serveJsonBody(c *gin.Context, alwaysReturnArray bool, co
 		writeSubError(c, err)
 		return true
 	}
-	if len(jsonSub) == 0 {
+	if len(jsonSub) == 0 && header == "" {
 		return false
 	}
 	profileURL := fmt.Sprintf("%s://%s%s", scheme, hostWithPort, c.Request.RequestURI)
@@ -760,6 +764,9 @@ func (a *SUBController) serveJsonBody(c *gin.Context, alwaysReturnArray bool, co
 
 func (a *SUBController) subClashs(c *gin.Context) {
 	if strings.EqualFold(c.Query("view"), "raw") {
+		if !a.enforceHwid(c) {
+			return
+		}
 		if !a.serveClashBody(c, true) {
 			writeSubError(c, nil)
 		}
@@ -786,7 +793,7 @@ func (a *SUBController) serveClashBody(c *gin.Context, rawDownload bool) bool {
 		writeSubError(c, err)
 		return true
 	}
-	if len(clashSub) == 0 {
+	if len(clashSub) == 0 && header == "" {
 		return false
 	}
 	profileURL := fmt.Sprintf("%s://%s%s", scheme, hostWithPort, c.Request.RequestURI)

+ 1 - 1
internal/sub/default.json

@@ -13,7 +13,7 @@
   "inbounds": [
     {
       "port": 10808,
-      "protocol": "mixed",
+      "protocol": "socks",
       "settings": {
         "auth": "noauth",
         "udp": true,

+ 6 - 5
internal/sub/external_config.go

@@ -14,8 +14,8 @@ import (
 	"github.com/mhsanaei/3x-ui/v3/internal/util/link"
 )
 
-// externalLinkEntry is one client × external-link row, resolved for a
-// subscription request. Email/Enable come from the owning client.
+// externalLinkEntry is one client × external-link row resolved for a request.
+// Active applies the owning client's enabled and expiry state.
 type externalLinkEntry struct {
 	Kind       string
 	Value      string
@@ -23,6 +23,7 @@ type externalLinkEntry struct {
 	NamePrefix string
 	Email      string
 	Enable     bool
+	Active     bool
 }
 
 // expandedLink is a single share link contributed by an entry, with the display
@@ -32,9 +33,8 @@ type expandedLink struct {
 	Name string
 }
 
-// getClientExternalLinksBySubId returns every external-link row attached to a
-// client that carries the given subId, in stable order. Stays inside
-// internal/sub + database + util/link — no dependency on the panel service layer.
+// getClientExternalLinksBySubId returns active rows with owner state attached.
+// Consumers keep inactive owners as metadata but omit their link values.
 func (s *SubService) getClientExternalLinksBySubId(subId string) ([]externalLinkEntry, error) {
 	db := database.GetDB()
 	var recs []model.ClientRecord
@@ -74,6 +74,7 @@ func (s *SubService) getClientExternalLinksBySubId(subId string) ([]externalLink
 			NamePrefix: r.NamePrefix,
 			Email:      rec.Email,
 			Enable:     rec.Enable,
+			Active:     rec.Enable && (rec.ExpiryTime <= 0 || rec.ExpiryTime > now),
 		})
 	}
 	return out, nil

+ 20 - 8
internal/sub/hwid_controller_test.go

@@ -87,7 +87,17 @@ func requestSub(t *testing.T, router *gin.Engine, method string, path string, hw
 func TestSubscriptionHwidGateAcrossBodyRoutes(t *testing.T) {
 	router, subID := initHwidSubRouter(t, 1)
 
-	for _, path := range []string{"/sub/" + subID, "/json/" + subID, "/clash/" + subID} {
+	// ?view=raw only tells /json/ and /clash/ to serve the body instead of the
+	// HTML page, so it stays gated like the plain route (#GHSA-7ww3).
+	bodyRoutes := []string{
+		"/sub/" + subID,
+		"/json/" + subID,
+		"/clash/" + subID,
+		"/json/" + subID + "?view=raw",
+		"/clash/" + subID + "?view=RaW",
+	}
+
+	for _, path := range bodyRoutes {
 		rec := requestSub(t, router, http.MethodGet, path, "", "")
 		if rec.Code != http.StatusNotFound {
 			t.Fatalf("%s missing HWID status = %d, want 404", path, rec.Code)
@@ -102,7 +112,7 @@ func TestSubscriptionHwidGateAcrossBodyRoutes(t *testing.T) {
 		t.Fatalf("HEAD missing HWID = %d %#v", rec.Code, rec.Header())
 	}
 
-	for _, path := range []string{"/sub/" + subID, "/json/" + subID, "/clash/" + subID} {
+	for _, path := range bodyRoutes {
 		rec = requestSub(t, router, http.MethodGet, path, "device-one", "")
 		if rec.Code != http.StatusOK {
 			t.Fatalf("%s registered HWID status = %d, body=%q", path, rec.Code, rec.Body.String())
@@ -112,12 +122,14 @@ func TestSubscriptionHwidGateAcrossBodyRoutes(t *testing.T) {
 		}
 	}
 
-	rec = requestSub(t, router, http.MethodGet, "/json/"+subID, "device-two", "")
-	if rec.Code != http.StatusNotFound {
-		t.Fatalf("new HWID after limit status = %d, want 404", rec.Code)
-	}
-	if rec.Header().Get("X-Hwid-Max-Devices-Reached") != "true" || rec.Header().Get("X-Hwid-Limit") != "true" {
-		t.Fatalf("limit headers missing: %#v", rec.Header())
+	for _, path := range bodyRoutes {
+		rec = requestSub(t, router, http.MethodGet, path, "device-two", "")
+		if rec.Code != http.StatusNotFound {
+			t.Fatalf("%s new HWID after limit status = %d, want 404", path, rec.Code)
+		}
+		if rec.Header().Get("X-Hwid-Max-Devices-Reached") != "true" || rec.Header().Get("X-Hwid-Limit") != "true" {
+			t.Fatalf("%s limit headers missing: %#v", path, rec.Header())
+		}
 	}
 }
 

+ 152 - 0
internal/sub/hysteria_geco_uri_test.go

@@ -0,0 +1,152 @@
+package sub
+
+import (
+	"encoding/json"
+	"strings"
+	"testing"
+
+	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
+	"github.com/mhsanaei/3x-ui/v3/internal/util/link"
+)
+
+// A salamander mask carrying packetSize (Gecko mode) must export the
+// v2rayN-native gecko URI fields, not an fm=<json> dump.
+func TestGenHysteriaLinkEmitsGeckoParamsForPacketSize(t *testing.T) {
+	in := &model.Inbound{
+		Id: 920001, Listen: "203.0.113.1", Port: 443, Protocol: model.Hysteria,
+		Settings: `{"version":2,"clients":[{"auth":"secret","email":"user"}]}`,
+		StreamSettings: `{"security":"tls","finalmask":{"udp":[{"type":"salamander","settings":` +
+			`{"password":"pw","packetSize":"512-1200"}}]}}`,
+	}
+	got := (&SubService{}).genHysteriaLink(in, "user")
+	for _, want := range []string{"obfs=gecko", "obfs-password=pw", "minPacketSize=512", "maxPacketSize=1200"} {
+		if !strings.Contains(got, want) {
+			t.Fatalf("missing %q\n got: %s", want, got)
+		}
+	}
+	if strings.Contains(got, "obfs=salamander") {
+		t.Fatalf("gecko mask exported as plain salamander:\n %s", got)
+	}
+	if strings.Contains(got, "fm=") {
+		t.Fatalf("expressed salamander mask must not leak into fm= dump:\n %s", got)
+	}
+}
+
+// Password-only masks keep the plain salamander export.
+func TestGenHysteriaLinkSalamanderWithoutPacketSizeUnchanged(t *testing.T) {
+	in := &model.Inbound{
+		Id: 920002, Listen: "203.0.113.1", Port: 443, Protocol: model.Hysteria,
+		Settings:       `{"version":2,"clients":[{"auth":"secret","email":"user"}]}`,
+		StreamSettings: `{"security":"tls","finalmask":{"udp":[{"type":"salamander","settings":{"password":"pw"}}]}}`,
+	}
+	got := (&SubService{}).genHysteriaLink(in, "user")
+	if !strings.Contains(got, "obfs=salamander") || !strings.Contains(got, "obfs-password=pw") {
+		t.Fatalf("password-only mask lost its standard export:\n %s", got)
+	}
+	for _, bad := range []string{"minPacketSize=", "maxPacketSize="} {
+		if strings.Contains(got, bad) {
+			t.Fatalf("unexpected %s in:\n %s", bad, got)
+		}
+	}
+}
+
+// Import side: obfs=gecko + min/max rebuild a standard salamander+packetSize mask.
+func TestParseLinkAcceptsGeckoObfs(t *testing.T) {
+	parsed, err := link.ParseLink(
+		"hysteria2://[email protected]:443?security=tls&obfs=gecko&obfs-password=pw&minPacketSize=512&maxPacketSize=1200#geo")
+	if err != nil {
+		t.Fatalf("ParseLink: %v", err)
+	}
+	rawStream, _ := parsed.Outbound["streamSettings"].(map[string]any)
+	if rawStream == nil {
+		t.Fatalf("no streamSettings in outbound: %v", parsed.Outbound)
+	}
+	streamJSON, err := json.Marshal(rawStream)
+	if err != nil {
+		t.Fatalf("marshal stream: %v", err)
+	}
+	var stream map[string]any
+	if err := json.Unmarshal(streamJSON, &stream); err != nil {
+		t.Fatalf("stream json: %v", err)
+	}
+	fm, _ := stream["finalmask"].(map[string]any)
+	if fm == nil {
+		t.Fatalf("no finalmask rebuilt: %s", streamJSON)
+	}
+	udp, _ := fm["udp"].([]any)
+	var mask map[string]any
+	for _, m := range udp {
+		if mm, ok := m.(map[string]any); ok && mm["type"] == "salamander" {
+			mask = mm
+		}
+	}
+	if mask == nil {
+		t.Fatalf("no salamander mask rebuilt: %s", streamJSON)
+	}
+	settings, _ := mask["settings"].(map[string]any)
+	if pw, _ := settings["password"].(string); pw != "pw" {
+		t.Fatalf("password = %v", settings["password"])
+	}
+	if ps, _ := settings["packetSize"].(string); ps != "512-1200" {
+		t.Fatalf("packetSize = %v, want 512-1200", settings["packetSize"])
+	}
+}
+
+// Half-specified or out-of-bounds gecko ranges must be dropped, not stored.
+func TestParseLinkRejectsInvalidGeckoPacketSize(t *testing.T) {
+	cases := map[string]string{
+		"half min only": "hysteria2://[email protected]:443?security=tls&obfs=gecko&obfs-password=pw&minPacketSize=512#geo",
+		"half max only": "hysteria2://[email protected]:443?security=tls&obfs=gecko&obfs-password=pw&maxPacketSize=1200#geo",
+		"non-numeric":   "hysteria2://[email protected]:443?security=tls&obfs=gecko&obfs-password=pw&minPacketSize=abc&maxPacketSize=def#geo",
+		"zero min":      "hysteria2://[email protected]:443?security=tls&obfs=gecko&obfs-password=pw&minPacketSize=0&maxPacketSize=1200#geo",
+		"inverted":      "hysteria2://[email protected]:443?security=tls&obfs=gecko&obfs-password=pw&minPacketSize=1200&maxPacketSize=512#geo",
+		"over cap":      "hysteria2://[email protected]:443?security=tls&obfs=gecko&obfs-password=pw&minPacketSize=512&maxPacketSize=4096#geo",
+	}
+	for name, uri := range cases {
+		t.Run(name, func(t *testing.T) {
+			parsed, err := link.ParseLink(uri)
+			if err != nil {
+				t.Fatalf("ParseLink: %v", err)
+			}
+			rawStream, _ := parsed.Outbound["streamSettings"].(map[string]any)
+			streamJSON, _ := json.Marshal(rawStream)
+			var stream map[string]any
+			_ = json.Unmarshal(streamJSON, &stream)
+			fm, _ := stream["finalmask"].(map[string]any)
+			if fm == nil {
+				t.Fatalf("no finalmask rebuilt: %s", streamJSON)
+			}
+			udp, _ := fm["udp"].([]any)
+			for _, m := range udp {
+				if mm, ok := m.(map[string]any); ok && mm["type"] == "salamander" {
+					settings, _ := mm["settings"].(map[string]any)
+					if ps, _ := settings["packetSize"].(string); ps != "" {
+						t.Fatalf("invalid gecko stored packetSize %q", ps)
+					}
+				}
+			}
+		})
+	}
+}
+
+// Export side must mirror the TS bounds exactly (1 <= min <= max <= 2048).
+func TestParseHysteriaPacketSizeBounds(t *testing.T) {
+	if got := parseHysteriaPacketSize("0-1200"); got != "" {
+		t.Fatalf("min below 1 accepted: %q", got)
+	}
+	if got := parseHysteriaPacketSize("1200-512"); got != "" {
+		t.Fatalf("inverted range accepted: %q", got)
+	}
+	if got := parseHysteriaPacketSize("512-4096"); got != "" {
+		t.Fatalf("range over xray cap accepted: %q", got)
+	}
+	if got := parseHysteriaPacketSize(" 512 - 1200 "); got != "" {
+		t.Fatalf("padded range must be rejected: %q", got)
+	}
+	if got := parseHysteriaPacketSize("+512-1200"); got != "" {
+		t.Fatalf("plus-prefixed range must be rejected: %q", got)
+	}
+	if got := parseHysteriaPacketSize("512-1200"); got != "512-1200" {
+		t.Fatalf("valid range = %q", got)
+	}
+}

+ 115 - 0
internal/sub/inactive_external_sub_test.go

@@ -0,0 +1,115 @@
+package sub
+
+import (
+	"fmt"
+	"net/http"
+	"net/http/httptest"
+	"strings"
+	"testing"
+	"time"
+
+	"github.com/gin-gonic/gin"
+
+	"github.com/mhsanaei/3x-ui/v3/internal/database"
+	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
+	"github.com/mhsanaei/3x-ui/v3/internal/xray"
+)
+
+func seedInactiveExternalOnlySub(t *testing.T, subID, email string, enabled bool, expiry int64) {
+	t.Helper()
+	db := database.GetDB()
+	rec := &model.ClientRecord{Email: email, SubID: subID, UUID: subID + "-uuid", Enable: true, ExpiryTime: expiry}
+	if err := db.Create(rec).Error; err != nil {
+		t.Fatalf("seed client: %v", err)
+	}
+	if !enabled {
+		if err := db.Model(rec).Update("enable", false).Error; err != nil {
+			t.Fatalf("disable client: %v", err)
+		}
+	}
+	if err := db.Create(&xray.ClientTraffic{Email: email, Up: 11, Down: 22, Total: 1024, ExpiryTime: expiry}).Error; err != nil {
+		t.Fatalf("seed traffic: %v", err)
+	}
+	link := "vless://[email protected]:443?type=tcp&security=reality&pbk=abc&sid=12&fp=chrome#external"
+	if err := db.Create(&model.ClientExternalLink{ClientId: rec.Id, Kind: model.ExternalLinkKindLink, Value: link, SortIndex: 1}).Error; err != nil {
+		t.Fatalf("seed external link: %v", err)
+	}
+}
+
+func TestInactiveExternalOnlySubRemainsKnownWithoutExposingLinks(t *testing.T) {
+	gin.SetMode(gin.TestMode)
+	states := []struct {
+		name    string
+		enabled bool
+		expiry  int64
+	}{
+		{name: "disabled", enabled: false, expiry: time.Now().Add(time.Hour).UnixMilli()},
+		{name: "expired", enabled: true, expiry: time.Now().Add(-time.Hour).UnixMilli()},
+	}
+
+	for _, state := range states {
+		t.Run(state.name, func(t *testing.T) {
+			initSubDB(t)
+			subID := "external-" + state.name
+			email := state.name + "@example.com"
+			seedInactiveExternalOnlySub(t, subID, email, state.enabled, state.expiry)
+
+			oldDistFS := distFS
+			distFS = testDistFS
+			t.Cleanup(func() { distFS = oldDistFS })
+
+			router := gin.New()
+			NewSUBController(
+				router.Group("/"),
+				WithSUBJsonEnabled(true),
+				WithSUBClashEnabled(true),
+				WithSUBEncryption(false),
+			)
+
+			wantHeader := fmt.Sprintf("upload=11; download=22; total=1024; expire=%d", state.expiry/1000)
+			for _, path := range []string{"/sub/" + subID, "/json/" + subID + "?view=raw", "/clash/" + subID + "?view=raw"} {
+				t.Run(path, func(t *testing.T) {
+					if err := database.GetDB().Model(&xray.ClientTraffic{}).Where("email = ?", email).Update("last_sub_fetch", 0).Error; err != nil {
+						t.Fatalf("reset last_sub_fetch: %v", err)
+					}
+					req := httptest.NewRequest(http.MethodGet, path, nil)
+					req.Host = "sub.example.com"
+					w := httptest.NewRecorder()
+					router.ServeHTTP(w, req)
+
+					if w.Code != http.StatusOK {
+						t.Fatalf("status = %d, want 200; body=%s", w.Code, w.Body.String())
+					}
+					if w.Body.Len() != 0 {
+						t.Fatalf("inactive external link leaked in body: %s", w.Body.String())
+					}
+					if got := w.Header().Get("Subscription-Userinfo"); got != wantHeader {
+						t.Fatalf("Subscription-Userinfo = %q, want %q", got, wantHeader)
+					}
+					var traffic xray.ClientTraffic
+					if err := database.GetDB().Where("email = ?", email).First(&traffic).Error; err != nil {
+						t.Fatalf("load traffic: %v", err)
+					}
+					if traffic.LastSubFetch == 0 {
+						t.Fatal("successful empty response did not update last_sub_fetch")
+					}
+				})
+			}
+
+			req := httptest.NewRequest(http.MethodGet, "/sub/"+subID, nil)
+			req.Host = "sub.example.com"
+			req.Header.Set("Accept", "text/html")
+			w := httptest.NewRecorder()
+			router.ServeHTTP(w, req)
+			if w.Code != http.StatusOK {
+				t.Fatalf("HTML status = %d, want 200; body=%s", w.Code, w.Body.String())
+			}
+			if strings.Contains(w.Body.String(), "11111111-1111-1111-1111-111111111111") {
+				t.Fatalf("HTML page exposed inactive external link: %s", w.Body.String())
+			}
+			if !strings.Contains(w.Body.String(), `"links":[]`) {
+				t.Fatalf("HTML page did not render an empty links list: %s", w.Body.String())
+			}
+		})
+	}
+}

+ 11 - 2
internal/sub/json_service.go

@@ -82,6 +82,7 @@ func (s *SubJsonService) GetJson(subId string, host string, alwaysReturnArray bo
 	}
 
 	var header string
+	var hasInactiveExternal bool
 
 	seenEmails := make(map[string]struct{})
 	entries := make([]subConfigEntry, 0, len(inbounds))
@@ -127,6 +128,11 @@ func (s *SubJsonService) GetJson(subId string, host string, alwaysReturnArray bo
 		configArray = append(configArray, entry.configs...)
 	}
 	for _, ext := range externalLinks {
+		if !ext.Active {
+			seenEmails[ext.Email] = struct{}{}
+			hasInactiveExternal = true
+			continue
+		}
 		for _, el := range expandEntry(ext) {
 			outbound := parsedExternalOutbound(el.Link)
 			if outbound == nil {
@@ -148,7 +154,7 @@ func (s *SubJsonService) GetJson(subId string, host string, alwaysReturnArray bo
 		}
 	}
 
-	if len(configArray) == 0 {
+	if len(configArray) == 0 && !hasInactiveExternal {
 		return "", "", nil
 	}
 
@@ -157,6 +163,10 @@ func (s *SubJsonService) GetJson(subId string, host string, alwaysReturnArray bo
 		emails = append(emails, e)
 	}
 	traffic, _ := subReq.AggregateTrafficByEmails(emails)
+	header = fmt.Sprintf("upload=%d; download=%d; total=%d; expire=%d", traffic.Up, traffic.Down, traffic.Total, traffic.ExpiryTime/1000)
+	if len(configArray) == 0 {
+		return "", header, nil
+	}
 
 	var finalJson []byte
 	if len(configArray) == 1 && !alwaysReturnArray {
@@ -165,7 +175,6 @@ func (s *SubJsonService) GetJson(subId string, host string, alwaysReturnArray bo
 		finalJson, _ = json.MarshalIndent(configArray, "", "  ")
 	}
 
-	header = fmt.Sprintf("upload=%d; download=%d; total=%d; expire=%d", traffic.Up, traffic.Down, traffic.Total, traffic.ExpiryTime/1000)
 	return string(finalJson), header, nil
 }
 

+ 38 - 0
internal/sub/json_service_test.go

@@ -35,6 +35,44 @@ func outboundSettings(t *testing.T, raw []byte) map[string]any {
 	return settings
 }
 
+func TestDefaultJSONUsesCompatibleLocalInbounds(t *testing.T) {
+	svc := NewSubJsonService("", "", "", nil)
+	inbounds, ok := svc.configJson["inbounds"].([]any)
+	if !ok {
+		t.Fatalf("default JSON inbounds = %#v, want array", svc.configJson["inbounds"])
+	}
+
+	byPort := make(map[float64]map[string]any, len(inbounds))
+	for _, raw := range inbounds {
+		inbound, ok := raw.(map[string]any)
+		if !ok {
+			t.Fatalf("default JSON inbound = %#v, want object", raw)
+		}
+		port, ok := inbound["port"].(float64)
+		if !ok {
+			t.Fatalf("default JSON inbound port = %#v, want number", inbound["port"])
+		}
+		byPort[port] = inbound
+	}
+
+	socks := byPort[10808]
+	if socks == nil {
+		t.Fatal("default JSON is missing the local inbound on port 10808")
+	}
+	if socks["protocol"] != "socks" || socks["tag"] != "mixed" {
+		t.Fatalf("port 10808 protocol/tag = %v/%v, want socks/mixed", socks["protocol"], socks["tag"])
+	}
+	settings, _ := socks["settings"].(map[string]any)
+	if settings == nil || settings["udp"] != true {
+		t.Fatalf("port 10808 settings = %#v, want udp enabled", socks["settings"])
+	}
+
+	http := byPort[10809]
+	if http == nil || http["protocol"] != "http" {
+		t.Fatalf("port 10809 inbound = %#v, want http protocol", http)
+	}
+}
+
 func TestSubJsonServiceInjectsGlobalFinalMask(t *testing.T) {
 	finalMask := `{"tcp":[{"type":"fragment","settings":{"packets":"tlshello","length":"100-200","delay":"10-20"}}],"udp":[{"type":"noise","settings":{"noise":[{"type":"base64","packet":"SGVsbG8="}]}}],"quicParams":{"congestion":"bbr"}}`
 	svc := NewSubJsonService("", "", finalMask, nil)

+ 26 - 6
internal/sub/mutation_audit_test.go

@@ -298,7 +298,7 @@ func TestGetClientExternalLinksBySubId(t *testing.T) {
 
 	// A client with two link rows: ordering by sort_index and email/enable
 	// attribution from the owning client (the loop copies rec.Email/rec.Enable).
-	rec := &model.ClientRecord{Email: "owner@x", SubID: "sub-ok", UUID: "u2", Enable: true}
+	rec := &model.ClientRecord{Email: "owner@x", SubID: "sub-ok", UUID: "u2", Enable: true, ExpiryTime: time.Now().Add(time.Hour).UnixMilli()}
 	if err := db.Create(rec).Error; err != nil {
 		t.Fatalf("seed client: %v", err)
 	}
@@ -331,10 +331,12 @@ func TestGetClientExternalLinksBySubId(t *testing.T) {
 	if out[0].Email != "owner@x" || out[0].Enable != true {
 		t.Fatalf("attribution wrong: email=%q enable=%v", out[0].Email, out[0].Enable)
 	}
+	if !out[0].Active {
+		t.Fatal("active owner marked inactive")
+	}
 
-	// A DISABLED client must produce entries with Enable=false, proving the
-	// value is read from the client row (Enable has a gorm default:true, so
-	// flip it with a raw UPDATE that bypasses the default).
+	// A disabled owner stays visible as metadata but cannot expose its link.
+	// Enable has a gorm default:true, so update it after insertion.
 	dis := &model.ClientRecord{Email: "off@x", SubID: "sub-off", UUID: "u3", Enable: true}
 	if err := db.Create(dis).Error; err != nil {
 		t.Fatalf("seed disabled client: %v", err)
@@ -352,8 +354,26 @@ func TestGetClientExternalLinksBySubId(t *testing.T) {
 	if len(offOut) != 1 {
 		t.Fatalf("disabled client entries = %d, want 1", len(offOut))
 	}
-	if offOut[0].Email != "off@x" || offOut[0].Enable != false {
-		t.Fatalf("disabled attribution wrong: email=%q enable=%v", offOut[0].Email, offOut[0].Enable)
+	if offOut[0].Enable || offOut[0].Active {
+		t.Fatalf("disabled owner state = enable:%v active:%v", offOut[0].Enable, offOut[0].Active)
+	}
+
+	expired := &model.ClientRecord{Email: "expired@x", SubID: "sub-expired", UUID: "u4", Enable: true, ExpiryTime: time.Now().Add(-time.Hour).UnixMilli()}
+	if err := db.Create(expired).Error; err != nil {
+		t.Fatalf("seed expired client: %v", err)
+	}
+	if err := db.Create(&model.ClientExternalLink{ClientId: expired.Id, Kind: model.ExternalLinkKindLink, Value: "trojan://d", SortIndex: 1}).Error; err != nil {
+		t.Fatalf("seed expired client link: %v", err)
+	}
+	expiredOut, err := s.getClientExternalLinksBySubId("sub-expired")
+	if err != nil {
+		t.Fatalf("expired subId err = %v", err)
+	}
+	if len(expiredOut) != 1 {
+		t.Fatalf("expired client entries = %d, want 1", len(expiredOut))
+	}
+	if !expiredOut[0].Enable || expiredOut[0].Active {
+		t.Fatalf("expired owner state = enable:%v active:%v", expiredOut[0].Enable, expiredOut[0].Active)
 	}
 }
 

+ 6 - 1
internal/sub/remote_routing.go

@@ -66,6 +66,7 @@ type remoteRoutingFetch struct {
 }
 
 type remoteRoutingResolver struct {
+	refreshWG    sync.WaitGroup
 	mu           sync.Mutex
 	loadMu       sync.Mutex
 	loaded       bool
@@ -154,7 +155,11 @@ func (r *remoteRoutingResolver) resolveEntry(kind remoteRoutingKind, raw string)
 	r.inflight[key] = fetch
 	r.mu.Unlock()
 
-	common.GoRecover("remote-routing-refresh", func() { r.refresh(key, cached, hasCached, fetch) })
+	r.refreshWG.Add(1)
+	common.GoRecover("remote-routing-refresh", func() {
+		defer r.refreshWG.Done()
+		r.refresh(key, cached, hasCached, fetch)
+	})
 	if hasCached {
 		return cached, true, nil
 	}

+ 2 - 0
internal/sub/remote_routing_test.go

@@ -47,6 +47,8 @@ func remoteRoutingResponse(status int, body string) *http.Response {
 
 func waitRemoteRoutingIdle(t *testing.T, resolver *remoteRoutingResolver) {
 	t.Helper()
+	// Wait on refresh goroutines to prevent logging race after test teardown.
+	resolver.refreshWG.Wait()
 	deadline := time.Now().Add(2 * time.Second)
 	for {
 		resolver.mu.Lock()

+ 37 - 4
internal/sub/salamander_uri_test.go

@@ -11,13 +11,22 @@ import (
 )
 
 func TestExtraSalamanderKeys(t *testing.T) {
-	if got := extraSalamanderKeys(map[string]any{"password": "pw"}); len(got) != 0 {
+	if got := extraSalamanderKeys(map[string]any{"password": "pw"}, false); len(got) != 0 {
 		t.Fatalf("expressible settings reported extras: %v", got)
 	}
-	got := extraSalamanderKeys(map[string]any{"password": "pw", "packetSize": "512-1200"})
-	if want := []string{"packetSize"}; !reflect.DeepEqual(got, want) {
+	// packetSize exports as the v2rayN gecko fields when expressed; a truly
+	// unexpressible key always is. An inexpressible packetSize stays extra.
+	in := map[string]any{"password": "pw", "headerType": "dns"}
+	if got, want := extraSalamanderKeys(in, false), []string{"headerType"}; !reflect.DeepEqual(got, want) {
 		t.Fatalf("extraSalamanderKeys = %v, want %v", got, want)
 	}
+	full := map[string]any{"password": "pw", "packetSize": "512-1200", "headerType": "dns"}
+	if got, want := extraSalamanderKeys(full, true), []string{"headerType"}; !reflect.DeepEqual(got, want) {
+		t.Fatalf("expressed packetSize not excluded: %v, want %v", got, want)
+	}
+	if got, want := extraSalamanderKeys(full, false), []string{"headerType", "packetSize"}; !reflect.DeepEqual(got, want) {
+		t.Fatalf("unexpressed packetSize not reported: %v, want %v", got, want)
+	}
 }
 
 func TestGenHysteriaLinkWarnsOnceForUnsupportedSalamanderSettings(t *testing.T) {
@@ -46,10 +55,34 @@ func TestGenHysteriaLinkWarnsOnceForUnsupportedSalamanderSettings(t *testing.T)
 	}
 
 	const unsupportedID = 910002
-	in := makeInbound(unsupportedID, `{"password":"pw","packetSize":"512-1200"}`)
+	in := makeInbound(unsupportedID, `{"password":"pw","headerType":"dns"}`)
 	(&SubService{}).genHysteriaLink(in, "user")
 	(&SubService{}).genHysteriaLink(in, "user")
 	if got := countWarnings(unsupportedID); got != 1 {
 		t.Fatalf("unsupported-settings warning count = %d, want 1", got)
 	}
 }
+
+// A mask with BOTH an expressible packetSize and another key must still warn
+// about the leftover key while emitting the gecko URI.
+func TestGenHysteriaLinkGeckoStillWarnsOnExtraKeys(t *testing.T) {
+	in := &model.Inbound{
+		Id: 910003, Listen: "203.0.113.1", Port: 443, Protocol: model.Hysteria,
+		Settings:       `{"version":2,"clients":[{"auth":"secret","email":"user"}]}`,
+		StreamSettings: `{"security":"tls","finalmask":{"udp":[{"type":"salamander","settings":{"password":"pw","packetSize":"512-1200","headerType":"dns"}}]}}`,
+	}
+	got := (&SubService{}).genHysteriaLink(in, "user")
+	if !strings.Contains(got, "obfs=gecko") {
+		t.Fatalf("gecko not emitted for valid packetSize:\n %s", got)
+	}
+	needle := "inbound 910003: salamander settings"
+	found := 0
+	for _, line := range logger.GetLogs(100, "warning") {
+		if strings.Contains(line, needle) {
+			found++
+		}
+	}
+	if found == 0 {
+		t.Fatal("leftover salamander key did not warn alongside the gecko export")
+	}
+}

+ 65 - 10
internal/sub/service.go

@@ -340,6 +340,13 @@ func (s *SubService) getSubs(subId string) ([]string, []string, int64, xray.Clie
 		if ext.Enable {
 			hasEnabledClient = true
 		}
+		if !ext.Active {
+			seenEmails[ext.Email] = struct{}{}
+			if result == nil {
+				result = []string{}
+			}
+			continue
+		}
 		for _, el := range expandEntry(ext) {
 			if link := applyRemarkToLink(el.Link, el.Name); link != "" {
 				result = append(result, link)
@@ -1176,9 +1183,8 @@ func (s *SubService) genHysteriaLink(inbound *model.Inbound, email string) strin
 		}
 	}
 
-	// salamander obfs (Hysteria2). Emit only the standard URI fields;
-	// the non-standard fm=<json> finalmask dump breaks mihomo and other
-	// Hysteria2 clients that reject unknown query params.
+	// salamander obfs (Hysteria2): standard URI fields only -- an fm=<json>
+	// dump breaks strict clients. packetSize exports as v2rayN's gecko pair.
 	if finalmask, ok := stream["finalmask"].(map[string]any); ok {
 		if udpMasks, ok := finalmask["udp"].([]any); ok {
 			for _, m := range udpMasks {
@@ -1188,13 +1194,23 @@ func (s *SubService) genHysteriaLink(inbound *model.Inbound, email string) strin
 				}
 				settings, _ := mask["settings"].(map[string]any)
 				if pw, ok := settings["password"].(string); ok && pw != "" {
-					if extra := extraSalamanderKeys(settings); len(extra) > 0 {
+					packetSize, _ := settings["packetSize"].(string)
+					gecko := parseHysteriaPacketSize(packetSize)
+					if gecko != "" {
+						params["obfs"] = "gecko"
+						params["minPacketSize"], params["maxPacketSize"] = splitHysteriaPacketSize(gecko)
+					}
+					// packetSize rides its own URI fields; anything else still
+					// breaks standard clients and must warn even when gecko fires.
+					if extra := extraSalamanderKeys(settings, gecko != ""); len(extra) > 0 {
 						warningKey := fmt.Sprintf("%d:%v", inbound.Id, extra)
 						if _, loaded := salamanderWarningSeen.LoadOrStore(warningKey, struct{}{}); !loaded {
 							logger.Warningf("SubService - inbound %d: salamander settings %v cannot be expressed in a hysteria2 URI; standard clients will fail the handshake", inbound.Id, extra)
 						}
 					}
-					params["obfs"] = "salamander"
+					if params["obfs"] == "" {
+						params["obfs"] = "salamander"
+					}
 					params["obfs-password"] = pw
 					break
 				}
@@ -1260,6 +1276,44 @@ func hysteriaHopPorts(stream map[string]any) string {
 	return strings.TrimSpace(ports)
 }
 
+// gecko packetSize bounds mirror xray-core's salamander buffer cap and the
+// frontend editor, so both link generators emit identical URIs.
+const (
+	geckoMinPacketSize = 1
+	geckoMaxPacketSize = 2048
+)
+
+// parseHysteriaPacketSize validates an xray-core salamander packetSize range
+// ("512-1200", the Gecko obfs marker). Returns canonical "min-max" or "".
+func parseHysteriaPacketSize(value string) string {
+	minStr, maxStr, ok := strings.Cut(value, "-")
+	if !ok || minStr == "" || maxStr == "" {
+		return ""
+	}
+	for _, c := range minStr {
+		if c < '0' || c > '9' {
+			return ""
+		}
+	}
+	for _, c := range maxStr {
+		if c < '0' || c > '9' {
+			return ""
+		}
+	}
+	minVal, err1 := strconv.Atoi(minStr)
+	maxVal, err2 := strconv.Atoi(maxStr)
+	if err1 != nil || err2 != nil ||
+		minVal < geckoMinPacketSize || maxVal < minVal || maxVal > geckoMaxPacketSize {
+		return ""
+	}
+	return fmt.Sprintf("%d-%d", minVal, maxVal)
+}
+
+func splitHysteriaPacketSize(value string) (string, string) {
+	minStr, maxStr, _ := strings.Cut(value, "-")
+	return minStr, maxStr
+}
+
 // loadNodes refreshes nodesByID from the DB. Called once per request so
 // the per-inbound resolveInboundAddress lookups are pure map reads.
 // We filter to address != ” so a half-configured node row doesn't
@@ -2843,14 +2897,15 @@ func getHostFromXFH(s string) (string, error) {
 	return s, nil
 }
 
-// extraSalamanderKeys lists salamander settings the hysteria2 URI cannot carry.
-// A server using them rejects every client built from the emitted link.
-func extraSalamanderKeys(settings map[string]any) []string {
+// extraSalamanderKeys lists salamander settings unexpressible in hysteria2 URI;
+// a server using any reported key rejects clients built from the link.
+func extraSalamanderKeys(settings map[string]any, expressedPacketSize bool) []string {
 	var extra []string
 	for k := range settings {
-		if k != "password" {
-			extra = append(extra, k)
+		if k == "password" || (k == "packetSize" && expressedPacketSize) {
+			continue
 		}
+		extra = append(extra, k)
 	}
 	sort.Strings(extra)
 	return extra

+ 41 - 6
internal/util/link/outbound.go

@@ -688,19 +688,45 @@ func applyFinalMask(stream map[string]any, p url.Values) {
 	}
 }
 
+// gecko packetSize bounds mirror xray-core's salamander buffer cap.
+const (
+	geckoMinPacketSize = 1
+	geckoMaxPacketSize = 2048
+)
+
+// parsePacketSizeRange validates a min/max pair for the Gecko obfs marker.
+func parsePacketSizeRange(minStr, maxStr string) (int, int, bool) {
+	minVal, err1 := strconv.Atoi(minStr)
+	maxVal, err2 := strconv.Atoi(maxStr)
+	if err1 != nil || err2 != nil ||
+		minVal < geckoMinPacketSize || maxVal < minVal || maxVal > geckoMaxPacketSize {
+		return 0, 0, false
+	}
+	return minVal, maxVal, true
+}
+
 // applyHysteria2Obfs rebuilds the salamander mask from the standard Hysteria2
-// obfs=salamander & obfs-password=<pw> pair (every non-3x-ui client, and this
-// panel's own generator, speak it instead of the private fm=<json> dump). A
-// salamander mask already carrying a password via fm= wins; a password-less one
-// is completed rather than left empty.
+// obfs pair. An fm=-carried password wins; gecko adds the packetSize pair.
 func applyHysteria2Obfs(stream map[string]any, p url.Values) {
-	if !strings.EqualFold(p.Get("obfs"), "salamander") {
+	obfs := p.Get("obfs")
+	isGecko := strings.EqualFold(obfs, "gecko")
+	if !isGecko && !strings.EqualFold(obfs, "salamander") {
 		return
 	}
 	password := firstParam(p, "obfs-password", "obfs_password", "obfsPassword")
 	if password == "" {
 		return
 	}
+	packetSize := ""
+	if isGecko {
+		// Both halves required with digit+range validation, matching the
+		// export side; half-specified or non-numeric values are dropped.
+		minSize := strings.TrimSpace(p.Get("minPacketSize"))
+		maxSize := strings.TrimSpace(p.Get("maxPacketSize"))
+		if min, max, ok := parsePacketSizeRange(minSize, maxSize); ok {
+			packetSize = fmt.Sprintf("%d-%d", min, max)
+		}
+	}
 	finalmask := ensureChildMap(stream, "finalmask")
 	udp, _ := finalmask["udp"].([]any)
 	for _, m := range udp {
@@ -716,11 +742,20 @@ func applyHysteria2Obfs(stream map[string]any, p url.Values) {
 		if pw, _ := settings["password"].(string); pw == "" {
 			settings["password"] = password
 		}
+		if packetSize != "" {
+			if ps, _ := settings["packetSize"].(string); ps == "" {
+				settings["packetSize"] = packetSize
+			}
+		}
 		return
 	}
+	settings := map[string]any{"password": password}
+	if packetSize != "" {
+		settings["packetSize"] = packetSize
+	}
 	finalmask["udp"] = append(udp, map[string]any{
 		"type":     "salamander",
-		"settings": map[string]any{"password": password},
+		"settings": settings,
 	})
 }
 

+ 0 - 1
internal/web/controller/index.go

@@ -126,7 +126,6 @@ func (a *IndexController) login(c *gin.Context) {
 		return
 	}
 
-	logger.Infof("%s logged in successfully", safeUser)
 	jsonMsg(c, I18nWeb(c, "pages.login.toasts.successLogin"), nil)
 }
 

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

@@ -21,6 +21,8 @@ import (
 
 var reportedRemoteTagConflict sync.Map
 
+var reportedForeignClientClaim sync.Map
+
 // nodeBulkPushThreshold caps how many per-client RPCs a single operation will
 // stream to a remote node. Above it, the panel marks the node dirty instead and
 // lets one ReconcileNode push converge the whole inbound — far cheaper than M
@@ -412,6 +414,46 @@ func adoptedWireInbound(c, snapIb *model.Inbound, adoptedSettings string) *model
 	return &a
 }
 
+// clientEmailsOwnedElsewhere returns the emails attached only to inbounds of
+// other nodes: email is unique, so adopting one would overwrite a client this
+// node does not serve. Attached nowhere means soft-orphaned, hence adoptable.
+func clientEmailsOwnedElsewhere(tx *gorm.DB, nodeID int, emails []string) (map[string]struct{}, error) {
+	attachedEmails := func(nodeScoped bool) ([]string, error) {
+		q := tx.Table("clients").
+			Joins("JOIN client_inbounds ON client_inbounds.client_id = clients.id").
+			Joins("JOIN inbounds ON inbounds.id = client_inbounds.inbound_id").
+			Where("clients.email IN ?", emails)
+		if nodeScoped {
+			q = q.Where("inbounds.node_id = ?", nodeID)
+		}
+		var rows []string
+		err := q.Pluck("clients.email", &rows).Error
+		return rows, err
+	}
+	attached, err := attachedEmails(false)
+	if err != nil {
+		return nil, err
+	}
+	if len(attached) == 0 {
+		return nil, nil
+	}
+	owned, err := attachedEmails(true)
+	if err != nil {
+		return nil, err
+	}
+	ownedSet := make(map[string]struct{}, len(owned))
+	for _, email := range owned {
+		ownedSet[email] = struct{}{}
+	}
+	foreign := make(map[string]struct{})
+	for _, email := range attached {
+		if _, ok := ownedSet[email]; !ok {
+			foreign[email] = struct{}{}
+		}
+	}
+	return foreign, nil
+}
+
 func (s *InboundService) setRemoteTrafficLocked(nodeID int, snap *runtime.TrafficSnapshot, dirty, justPushed bool) (bool, error) {
 	if snap == nil || nodeID <= 0 {
 		return false, nil
@@ -1184,6 +1226,29 @@ func (s *InboundService) setRemoteTrafficLocked(nodeID int, snap *runtime.Traffi
 				}
 			}
 		}
+		if len(localEmails) > 0 {
+			foreign, err := clientEmailsOwnedElsewhere(tx, nodeID, localEmails)
+			if err != nil {
+				return false, err
+			}
+			if len(foreign) > 0 {
+				kept := filtered[:0]
+				for i := range filtered {
+					if _, claimed := foreign[filtered[i].Email]; !claimed {
+						kept = append(kept, filtered[i])
+						continue
+					}
+					key := fmt.Sprintf("%d:%s", nodeID, filtered[i].Email)
+					if _, seen := reportedForeignClientClaim.LoadOrStore(key, struct{}{}); !seen {
+						logger.Warningf(
+							"setRemoteTraffic: node %d reported client %q, which is attached only to inbounds of another node — not adopting (rename one side to remove the duplicate email)",
+							nodeID, filtered[i].Email,
+						)
+					}
+				}
+				filtered = kept
+			}
+		}
 		if err := s.clientService.SyncInbound(tx, c.Id, filtered); err != nil {
 			logger.Warningf("setRemoteTraffic: sync clients for tag %q failed: %v", snapIb.Tag, err)
 			syncFailedInbounds[c.Id] = struct{}{}

+ 90 - 0
internal/web/service/node_foreign_client_test.go

@@ -0,0 +1,90 @@
+package service
+
+import (
+	"fmt"
+	"testing"
+
+	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
+	"github.com/mhsanaei/3x-ui/v3/internal/xray"
+
+	"gorm.io/gorm"
+)
+
+func readClientUUID(t *testing.T, db *gorm.DB, email string) string {
+	t.Helper()
+	var row model.ClientRecord
+	if err := db.Where("email = ?", email).First(&row).Error; err != nil {
+		t.Fatalf("read client %q: %v", email, err)
+	}
+	return row.UUID
+}
+
+// Emails are globally unique, so a node reporting one that belongs to a master
+// inbound would otherwise overwrite its credentials and lock the real user out.
+func TestNodeCannotClaimClientOfAnotherInbound(t *testing.T) {
+	db := initTrafficTestDB(t)
+	svc := &InboundService{}
+	clientSvc := &ClientService{}
+
+	seedNodeRow(t, db, &model.Node{Id: 1, Name: "n1", Address: "127.0.0.1", Port: 2096, ApiToken: "tok", Enable: true})
+
+	const (
+		victim     = "victim@x"
+		nodeLocal  = "nodelocal@x"
+		legitUUID  = "11111111-1111-1111-1111-111111111111"
+		attackUUID = "99999999-9999-9999-9999-999999999999"
+	)
+
+	master := &model.Inbound{
+		UserId: 1, Tag: "master-in", Enable: true, Port: 40001, Protocol: model.VLESS,
+		Settings: fmt.Sprintf(`{"clients":[{"email":%q,"id":%q,"enable":true}]}`, victim, legitUUID),
+	}
+	if err := db.Create(master).Error; err != nil {
+		t.Fatalf("create master inbound: %v", err)
+	}
+	masterClients, err := svc.GetClients(master)
+	if err != nil {
+		t.Fatalf("parse master clients: %v", err)
+	}
+	if err := clientSvc.SyncInbound(db, master.Id, masterClients); err != nil {
+		t.Fatalf("attach master client: %v", err)
+	}
+	if got := readClientUUID(t, db, victim); got != legitUUID {
+		t.Fatalf("setup: master client uuid = %q, want %q", got, legitUUID)
+	}
+
+	createNodeInbound(t, db, 1, "n1-in", 41001)
+	hostile := fmt.Sprintf(`{"clients":[{"email":%q,"id":%q,"enable":true},{"email":%q,"id":%q,"enable":true}]}`,
+		victim, attackUUID, nodeLocal, attackUUID)
+	syncNodeWithSettings(t, svc, 1, "n1-in", hostile,
+		xray.ClientTraffic{Email: victim, Enable: true},
+		xray.ClientTraffic{Email: nodeLocal, Enable: true})
+
+	if got := readClientUUID(t, db, victim); got != legitUUID {
+		t.Fatalf("node overwrote a master client's uuid: got %q, want %q", got, legitUUID)
+	}
+	nodeAttached, err := clientSvc.ListForInbound(db, nodeInboundID(t, db, "n1-in"))
+	if err != nil {
+		t.Fatalf("list node clients: %v", err)
+	}
+	for _, c := range nodeAttached {
+		if c.Email == victim {
+			t.Fatal("node inbound adopted a client that belongs to a master inbound")
+		}
+	}
+
+	// The node's own client must still be adopted, or the guard has replaced one
+	// bug with a worse one.
+	if got := readClientUUID(t, db, nodeLocal); got != attackUUID {
+		t.Fatalf("node-owned client not adopted: uuid = %q, want %q", got, attackUUID)
+	}
+}
+
+func nodeInboundID(t *testing.T, db *gorm.DB, tag string) int {
+	t.Helper()
+	var ib model.Inbound
+	if err := db.Where("tag = ?", tag).First(&ib).Error; err != nil {
+		t.Fatalf("read inbound %q: %v", tag, err)
+	}
+	return ib.Id
+}

+ 11 - 6
internal/web/service/setting.go

@@ -309,12 +309,17 @@ func getEnv(key, fallback string) string {
 
 func (s *SettingService) ResetSettings() error {
 	db := database.GetDB()
-	err := db.Where("1 = 1").Delete(model.Setting{}).Error
-	if err != nil {
-		return err
-	}
-	return db.Model(model.User{}).
-		Where("1 = 1").Error
+	return db.Transaction(func(tx *gorm.DB) error {
+		if err := tx.Where("1 = 1").Delete(model.Setting{}).Error; err != nil {
+			return err
+		}
+		paths := []model.Setting{
+			{Key: "subPath", Value: "/" + random.NumLower(16) + "/"},
+			{Key: "subJsonPath", Value: "/" + random.NumLower(16) + "/"},
+			{Key: "subClashPath", Value: "/" + random.NumLower(16) + "/"},
+		}
+		return tx.Create(&paths).Error
+	})
 }
 
 func (s *SettingService) getSetting(key string) (*model.Setting, error) {

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

@@ -2,6 +2,7 @@ package service
 
 import (
 	"path/filepath"
+	"regexp"
 	"testing"
 
 	"github.com/xlzd/gotp"
@@ -10,6 +11,44 @@ import (
 	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
 )
 
+func TestResetSettingsRegeneratesSubscriptionPaths(t *testing.T) {
+	setupSettingTestDB(t)
+	s := &SettingService{}
+	for key, value := range map[string]string{
+		"subPath":      "/sub/",
+		"subJsonPath":  "/json/",
+		"subClashPath": "/clash/",
+		"webPort":      "8443",
+	} {
+		if err := s.saveSetting(key, value); err != nil {
+			t.Fatalf("save %s: %v", key, err)
+		}
+	}
+
+	if err := s.ResetSettings(); err != nil {
+		t.Fatalf("ResetSettings: %v", err)
+	}
+
+	pathPattern := regexp.MustCompile(`^/[0-9a-z]{16}/$`)
+	paths := map[string]string{}
+	for _, key := range []string{"subPath", "subJsonPath", "subClashPath"} {
+		value, err := s.getString(key)
+		if err != nil {
+			t.Fatalf("read %s: %v", key, err)
+		}
+		if !pathPattern.MatchString(value) {
+			t.Errorf("%s = %q, want /<16 lowercase alphanumeric characters>/", key, value)
+		}
+		paths[key] = value
+	}
+	if paths["subPath"] == paths["subJsonPath"] || paths["subPath"] == paths["subClashPath"] || paths["subJsonPath"] == paths["subClashPath"] {
+		t.Fatalf("subscription paths must be distinct: %v", paths)
+	}
+	if port, err := s.GetPort(); err != nil || port != 2053 {
+		t.Fatalf("web port after reset = %d, %v; want 2053", port, err)
+	}
+}
+
 func setupSettingTestDB(t *testing.T) {
 	t.Helper()
 	if err := database.InitDB(filepath.Join(t.TempDir(), "x-ui.db")); err != nil {

+ 32 - 1
internal/web/service/xray.go

@@ -7,6 +7,7 @@ import (
 	"path"
 	"path/filepath"
 	"runtime"
+	"slices"
 	"strings"
 	"sync"
 
@@ -1034,6 +1035,19 @@ func ensureStatsPolicy(policy json_util.RawMessage) json_util.RawMessage {
 	return out
 }
 
+// caseVariantKeys returns every key of parsed that equals want ignoring case,
+// lowest first so the fold is deterministic when several variants are present.
+func caseVariantKeys(parsed map[string]any, want string) []string {
+	var keys []string
+	for key := range parsed {
+		if strings.EqualFold(key, want) {
+			keys = append(keys, key)
+		}
+	}
+	slices.Sort(keys)
+	return keys
+}
+
 func resolveXrayLogPaths(logCfg json_util.RawMessage) json_util.RawMessage {
 	if len(logCfg) == 0 {
 		return logCfg
@@ -1044,12 +1058,29 @@ func resolveXrayLogPaths(logCfg json_util.RawMessage) json_util.RawMessage {
 	}
 	changed := false
 	for _, key := range []string{"access", "error"} {
-		v, ok := parsed[key].(string)
+		// xray-core decodes this object with encoding/json, whose case-insensitive
+		// field match makes "Access" reach AccessLog too — fold every variant.
+		variants := caseVariantKeys(parsed, key)
+		value, hasValue := parsed[key]
+		for _, variant := range variants {
+			if variant == key {
+				continue
+			}
+			if !hasValue {
+				value, hasValue = parsed[variant], true
+			}
+			delete(parsed, variant)
+			changed = true
+		}
+		v, ok := value.(string)
 		if !ok {
 			continue
 		}
 		trimmed := strings.TrimSpace(v)
 		if trimmed == "" || strings.EqualFold(trimmed, "none") {
+			if changed {
+				parsed[key] = v
+			}
 			continue
 		}
 		base := path.Base(filepath.ToSlash(trimmed))

+ 73 - 0
internal/web/service/xray_log_confine_test.go

@@ -0,0 +1,73 @@
+package service
+
+import (
+	"encoding/json"
+	"path/filepath"
+	"strings"
+	"testing"
+
+	"github.com/mhsanaei/3x-ui/v3/internal/config"
+	"github.com/mhsanaei/3x-ui/v3/internal/util/json_util"
+)
+
+// A log path must never escape the log folder whatever case the key is written
+// in: xray-core matches JSON keys onto its struct fields case-insensitively.
+func TestResolveXrayLogPathsConfinesEveryKeyCase(t *testing.T) {
+	folder := config.GetLogFolder()
+	tests := []struct {
+		name string
+		in   string
+		want map[string]any
+	}{
+		{
+			name: "lowercase keys",
+			in:   `{"access":"/tmp/pwn.log","error":"/tmp/pwn-err.log"}`,
+			want: map[string]any{"access": filepath.Join(folder, "pwn.log"), "error": filepath.Join(folder, "pwn-err.log")},
+		},
+		{
+			name: "capitalised keys",
+			in:   `{"Access":"/tmp/pwn.log","Error":"/tmp/pwn-err.log"}`,
+			want: map[string]any{"access": filepath.Join(folder, "pwn.log"), "error": filepath.Join(folder, "pwn-err.log")},
+		},
+		{
+			name: "upper-case keys",
+			in:   `{"ACCESS":"/tmp/pwn.log"}`,
+			want: map[string]any{"access": filepath.Join(folder, "pwn.log")},
+		},
+		{
+			name: "a variant cannot smuggle a path past a none",
+			in:   `{"access":"none","Access":"/tmp/pwn.log"}`,
+			want: map[string]any{"access": "none"},
+		},
+		{
+			name: "already confined name is left alone",
+			in:   `{"access":"none","error":"none","loglevel":"warning"}`,
+			want: map[string]any{"access": "none", "error": "none", "loglevel": "warning"},
+		},
+	}
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			out := resolveXrayLogPaths(json_util.RawMessage(tt.in))
+			var got map[string]any
+			if err := json.Unmarshal(out, &got); err != nil {
+				t.Fatalf("unmarshal %s: %v", out, err)
+			}
+			if len(got) != len(tt.want) {
+				t.Fatalf("got %v, want %v", got, tt.want)
+			}
+			for key, want := range tt.want {
+				if got[key] != want {
+					t.Fatalf("key %q: got %v, want %v", key, got[key], want)
+				}
+			}
+			for key := range got {
+				if strings.EqualFold(key, "access") && key != "access" {
+					t.Fatalf("case variant %q survived in %v", key, got)
+				}
+				if strings.EqualFold(key, "error") && key != "error" {
+					t.Fatalf("case variant %q survived in %v", key, got)
+				}
+			}
+		})
+	}
+}

+ 61 - 6
update.sh

@@ -924,6 +924,13 @@ setup_fail2ban() {
         return 0
     fi
 
+    # Scripts older than v3.4.0 have no setup-fail2ban and exit 0 from the
+    # usage banner, which would read as success here.
+    if ! grep -q '"setup-fail2ban")' /usr/bin/x-ui; then
+        echo -e "${yellow}This x-ui.sh predates 'x-ui setup-fail2ban'; skipping Fail2ban auto-setup.${plain}"
+        return 0
+    fi
+
     echo -e "${green}Setting up Fail2ban for the IP Limit feature...${plain}"
     if /usr/bin/x-ui setup-fail2ban; then
         echo -e "${green}Fail2ban setup complete.${plain}"
@@ -967,6 +974,21 @@ _install_xui_service_unit() {
     return 0
 }
 
+# Older tags predate some of these files (x-ui.rc arrived in v2.8.4). Serving
+# main's copy against an old binary is the mismatch this pinning exists to
+# prevent, so probe before the old install is removed and refuse the tag.
+require_repo_files() {
+    local ref="$1" name status
+    shift
+    [[ "${ref}" == "main" ]] && return 0
+    for name in "$@"; do
+        status=$(${curl_bin} -sIL --retry 3 --connect-timeout 15 -o /dev/null -w '%{http_code}' "https://raw.githubusercontent.com/MHSanaei/3x-ui/${ref}/${name}")
+        if [[ "${status}" != "200" ]]; then
+            _fail "ERROR: ${name} is not available for ${ref} (HTTP ${status}). Update to a release that ships it, or to 'dev-latest'. The current installation is untouched."
+        fi
+    done
+}
+
 update_x-ui() {
     cd ${xui_folder%/x-ui}/
 
@@ -993,6 +1015,17 @@ update_x-ui() {
         fi
     fi
     echo -e "Got x-ui latest version: ${tag_version}, beginning the installation..."
+    # x-ui.sh, x-ui.rc and the unit files must come from the same release as
+    # the binary; only the rolling dev build tracks main.
+    script_ref="${tag_version}"
+    if [[ "${tag_version}" == "dev-latest" ]]; then
+        script_ref="main"
+    fi
+    # The unit files are only fetched when the release tarball lacks them, so
+    # they are checked at that point instead of here.
+    local required_files=("x-ui.sh")
+    [[ $release == "alpine" ]] && required_files+=("x-ui.rc")
+    require_repo_files "${script_ref}" "${required_files[@]}"
     ${curl_bin} -fLRo ${xui_folder}-linux-$(arch).tar.gz https://github.com/MHSanaei/3x-ui/releases/download/${tag_version}/x-ui-linux-$(arch).tar.gz 2> /dev/null
     if [[ $? -ne 0 ]]; then
         _fail "ERROR: Failed to download x-ui, please be sure that your server can access GitHub"
@@ -1001,6 +1034,28 @@ update_x-ui() {
         rm ${xui_folder}-linux-$(arch).tar.gz -f > /dev/null 2>&1
         _fail "ERROR: Downloaded x-ui release archive is empty, please be sure that your server can access GitHub"
     fi
+    # Releases publish <asset>.sha256 next to each archive. A mismatch or a
+    # failed sidecar download aborts the update; only a 404 (releases
+    # predating the sidecar) is tolerated with a warning.
+    archive="${xui_folder}-linux-$(arch).tar.gz"
+    rm -f "${archive}.sha256"
+    sidecar_code=$(${curl_bin} -sL --retry 3 --retry-delay 3 --connect-timeout 15 --max-time 60 -o "${archive}.sha256" -w '%{http_code}' "https://github.com/MHSanaei/3x-ui/releases/download/${tag_version}/x-ui-linux-$(arch).tar.gz.sha256" 2> /dev/null)
+    if [[ "${sidecar_code}" == "200" ]]; then
+        expected_sha256=$(awk 'NR == 1 {print $1}' "${archive}.sha256")
+        actual_sha256=$(sha256sum "${archive}" | awk '{print $1}')
+        rm -f "${archive}.sha256"
+        if [[ ! "${expected_sha256}" =~ ^[0-9a-f]{64}$ || "${expected_sha256}" != "${actual_sha256}" ]]; then
+            rm -f "${archive}"
+            _fail "ERROR: Checksum mismatch for $(basename "${archive}"): expected ${expected_sha256:-<none>}, got ${actual_sha256}"
+        fi
+        echo -e "${green}Checksum verified: ${actual_sha256}${plain}"
+    elif [[ "${sidecar_code}" == "404" ]]; then
+        rm -f "${archive}.sha256"
+        echo -e "${yellow}No checksum published for this release, skipping verification${plain}"
+    else
+        rm -f "${archive}.sha256" "${archive}"
+        _fail "ERROR: Failed to download the checksum for x-ui-linux-$(arch).tar.gz (HTTP ${sidecar_code})"
+    fi
 
     if [[ -e ${xui_folder}/ ]]; then
         echo -e "${green}Stopping x-ui...${plain}"
@@ -1086,7 +1141,7 @@ update_x-ui() {
     echo -e "${green}Downloading and installing x-ui.sh script...${plain}"
     local xui_script_temp="/usr/bin/x-ui-temp.$$"
     rm -f "${xui_script_temp}"
-    ${curl_bin} -fLRo "${xui_script_temp}" https://raw.githubusercontent.com/MHSanaei/3x-ui/main/x-ui.sh > /dev/null 2>&1
+    ${curl_bin} -fLRo "${xui_script_temp}" "https://raw.githubusercontent.com/MHSanaei/3x-ui/${script_ref}/x-ui.sh" > /dev/null 2>&1
     if [[ $? -ne 0 ]]; then
         rm -f "${xui_script_temp}"
         _fail "ERROR: Failed to download x-ui.sh script, please be sure that your server can access GitHub"
@@ -1117,7 +1172,7 @@ update_x-ui() {
         echo -e "${green}Downloading and installing startup unit x-ui.rc...${plain}"
         xui_rc_temp="/etc/init.d/x-ui.tmp.$$"
         rm -f "${xui_rc_temp}"
-        ${curl_bin} -fLRo "${xui_rc_temp}" https://raw.githubusercontent.com/MHSanaei/3x-ui/main/x-ui.rc > /dev/null 2>&1
+        ${curl_bin} -fLRo "${xui_rc_temp}" "https://raw.githubusercontent.com/MHSanaei/3x-ui/${script_ref}/x-ui.rc" > /dev/null 2>&1
         if [[ $? -ne 0 ]]; then
             rm -f "${xui_rc_temp}"
             _fail "ERROR: Failed to download startup unit x-ui.rc, please be sure that your server can access GitHub"
@@ -1176,18 +1231,18 @@ update_x-ui() {
                 echo -e "${yellow}Service files not found in tar.gz, downloading from GitHub...${plain}"
                 case "${release}" in
                     ubuntu | debian | armbian)
-                        service_unit_url="https://raw.githubusercontent.com/MHSanaei/3x-ui/main/x-ui.service.debian"
+                        service_unit_url="https://raw.githubusercontent.com/MHSanaei/3x-ui/${script_ref}/x-ui.service.debian"
                         ;;
                     arch | manjaro | parch)
-                        service_unit_url="https://raw.githubusercontent.com/MHSanaei/3x-ui/main/x-ui.service.arch"
+                        service_unit_url="https://raw.githubusercontent.com/MHSanaei/3x-ui/${script_ref}/x-ui.service.arch"
                         ;;
                     *)
-                        service_unit_url="https://raw.githubusercontent.com/MHSanaei/3x-ui/main/x-ui.service.rhel"
+                        service_unit_url="https://raw.githubusercontent.com/MHSanaei/3x-ui/${script_ref}/x-ui.service.rhel"
                         ;;
                 esac
 
                 if ! _install_xui_service_unit "$service_unit_url" "true"; then
-                    echo -e "${red}Failed to install x-ui.service from GitHub${plain}"
+                    echo -e "${red}Failed to install x-ui.service from GitHub (${script_ref}) -- the release tarball did not ship one either${plain}"
                     exit 1
                 fi
             fi

+ 23 - 5
x-ui.sh

@@ -208,6 +208,19 @@ replace_xui_script() {
     return 0
 }
 
+# The menu must match the installed panel, so update it from that release's
+# tag; fall back to main only when no script is published for the version.
+installed_script_url() {
+    local ver
+    ver=$("${xui_folder}/x-ui" -v 2> /dev/null | tr -d '[:space:]')
+    if [[ "$ver" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] && curl -fsIL -o /dev/null "https://raw.githubusercontent.com/MHSanaei/3x-ui/v${ver}/x-ui.sh"; then
+        echo "https://raw.githubusercontent.com/MHSanaei/3x-ui/v${ver}/x-ui.sh"
+    else
+        echo -e "${yellow}No x-ui.sh published for the installed version (${ver:-unknown}), using main${plain}" >&2
+        echo "https://raw.githubusercontent.com/MHSanaei/3x-ui/main/x-ui.sh"
+    fi
+}
+
 update_menu() {
     echo -e "${yellow}Updating Menu${plain}"
     confirm "This function will update the menu to the latest changes." "y"
@@ -219,7 +232,7 @@ update_menu() {
         return 0
     fi
 
-    if replace_xui_script "https://raw.githubusercontent.com/MHSanaei/3x-ui/main/x-ui.sh" "false"; then
+    if replace_xui_script "$(installed_script_url)" "false"; then
         chmod +x ${xui_folder}/x-ui.sh
         echo -e "${green}Update successful. The panel has automatically restarted.${plain}"
         exit 0
@@ -836,7 +849,7 @@ enable_bbr() {
 }
 
 update_shell() {
-    if replace_xui_script "https://github.com/MHSanaei/3x-ui/raw/main/x-ui.sh" "true"; then
+    if replace_xui_script "$(installed_script_url)" "true"; then
         LOGI "Upgrade script succeeded, Please rerun the script"
         before_show_menu
     else
@@ -2492,9 +2505,14 @@ create_iplimit_jails() {
     # Uncomment 'allowipv6 = auto' in fail2ban.conf
     sed -i 's/#allowipv6 = auto/allowipv6 = auto/g' /etc/fail2ban/fail2ban.conf
 
-    # On Debian 12+ and Ubuntu 22.04+ fail2ban's default backend should be changed to systemd
-    if [[ ( "${release}" == "debian" && ${os_version} -ge 12 ) || ( "${release}" == "ubuntu" && ${os_version} -ge 2200 ) ]]; then
-        sed -i '0,/action =/s/backend = auto/backend = systemd/' /etc/fail2ban/jail.conf
+    # Debian 12+ / Ubuntu 22.04+ log sshd to the journal only; a jail.d override
+    # survives package upgrades. Only the stock 'backend = auto' is overridden.
+    if [[ ( "${release}" == "debian" && ${os_version} -ge 12 ) || ( "${release}" == "ubuntu" && ${os_version} -ge 2200 ) ]] &&
+        sed -n '0,/action =/p' /etc/fail2ban/jail.conf | grep -q '^backend = auto'; then
+        cat << EOF > /etc/fail2ban/jail.d/3x-ipl-backend.conf
+[DEFAULT]
+backend = systemd
+EOF
     fi
 
     cat << EOF > /etc/fail2ban/jail.d/3x-ipl.conf

Някои файлове не бяха показани, защото твърде много файлове са промени