9 Commity a5e68f410f ... acf3603dc8

Autor SHA1 Wiadomość Data
  Sanaei acf3603dc8 refactor(ci): review pull requests with one senior-engineer role 3 godzin temu
  DuQi 47d2303334 fix(inbounds): reject missing TLS certificates before saving (#6429) 4 godzin temu
  Rouzbeh† 9f76a66dcf feat(sub): add dummy info node and status configs for subscriptions (#6412) 4 godzin temu
  Gleb Gudkov b8597314f8 docs(api): mark collection responses nullable (#6430) 4 godzin temu
  YoungReckless4 3cd3836d77 fix(amneziawg): account for S4 junk in the default tunnel MTU (#6376) 5 godzin temu
  Amirmohammad Sadat Shokouhi 5a63d5d468 fix(mtproto): use hosts for public share links (#6369) 6 godzin temu
  ilyusha d2ac3b4d7a fix(cli): let -getApiToken name the token it regenerates (#6405) 7 godzin temu
  Sanaei 2d151d7648 Update deps and simplify parsing 7 godzin temu
  Sanaei 2ec6c73613 feat(xray): update xray-core to v26.9.8 and adapt panel 8 godzin temu
100 zmienionych plików z 4061 dodań i 682 usunięć
  1. 2 3
      .github/claude/repo-context.md
  2. 0 81
      .github/claude/review-job.md
  3. 101 59
      .github/workflows/claude-pr-review.yml
  4. 2 2
      .github/workflows/release.yml
  5. 1 1
      DockerInit.sh
  6. 45 37
      REVIEW.md
  7. 148 0
      api_token_cli_test.go
  8. 14 14
      docs/content/docs/en/reference/api/clients.mdx
  9. 34 2
      docs/public/openapi.json
  10. 218 248
      frontend/package-lock.json
  11. 8 8
      frontend/package.json
  12. 34 2
      frontend/public/openapi.json
  13. 7 1
      frontend/scripts/build-openapi.mjs
  14. 6 0
      frontend/src/generated/examples.ts
  15. 24 0
      frontend/src/generated/schemas.ts
  16. 6 0
      frontend/src/generated/types.ts
  17. 6 0
      frontend/src/generated/zod.ts
  18. 50 1
      frontend/src/lib/hosts/host-link.ts
  19. 15 0
      frontend/src/lib/xray/amneziawg-obfuscation.ts
  20. 54 0
      frontend/src/lib/xray/forms/transport/FinalMaskForm.tsx
  21. 2 8
      frontend/src/lib/xray/inbound-form-adapter.ts
  22. 2 3
      frontend/src/lib/xray/inbound-link.ts
  23. 21 4
      frontend/src/lib/xray/outbound-form-adapter.ts
  24. 4 0
      frontend/src/models/setting.ts
  25. 5 2
      frontend/src/pages/api-docs/endpoints.ts
  26. 3 3
      frontend/src/pages/clients/ClientFormModal.tsx
  27. 2 1
      frontend/src/pages/clients/amneziawgConfig.ts
  28. 42 9
      frontend/src/pages/inbounds/InboundsPage.tsx
  29. 43 31
      frontend/src/pages/inbounds/form/InboundFormModal.tsx
  30. 6 0
      frontend/src/pages/inbounds/form/formatValidationError.ts
  31. 7 0
      frontend/src/pages/inbounds/form/protocols/hysteria.tsx
  32. 1 1
      frontend/src/pages/inbounds/form/security/reality.tsx
  33. 14 2
      frontend/src/pages/inbounds/info/InboundInfoModal.tsx
  34. 2 0
      frontend/src/pages/inbounds/info/types.ts
  35. 18 4
      frontend/src/pages/inbounds/qr/QrCodeModal.tsx
  36. 35 0
      frontend/src/pages/settings/SubscriptionGeneralTab.tsx
  37. 24 10
      frontend/src/pages/xray/outbounds/protocols/blackhole.tsx
  38. 3 0
      frontend/src/pages/xray/outbounds/protocols/wireguard.tsx
  39. 7 0
      frontend/src/pages/xray/outbounds/transport/hysteria.tsx
  40. 57 3
      frontend/src/schemas/forms/inbound-form.ts
  41. 2 0
      frontend/src/schemas/forms/outbound-form.ts
  42. 6 6
      frontend/src/schemas/protocols/outbound/blackhole.ts
  43. 13 8
      frontend/src/schemas/protocols/security/index.ts
  44. 24 1
      frontend/src/schemas/protocols/security/tls.ts
  45. 4 0
      frontend/src/schemas/protocols/stream/finalmask.ts
  46. 1 0
      frontend/src/schemas/protocols/stream/hysteria.ts
  47. 1 0
      frontend/src/schemas/routing.ts
  48. 3 0
      frontend/src/schemas/setting.ts
  49. 17 0
      frontend/src/test/__snapshots__/finalmask.test.ts.snap
  50. 11 0
      frontend/src/test/__snapshots__/rule.test.ts.snap
  51. 76 0
      frontend/src/test/amneziawg-conf-parity.test.ts
  52. 9 0
      frontend/src/test/format-validation-error.test.ts
  53. 12 0
      frontend/src/test/golden/fixtures/finalmask/quic-params-flags.json
  54. 5 0
      frontend/src/test/golden/fixtures/rule/local-os.json
  55. 68 1
      frontend/src/test/host-link.test.ts
  56. 57 0
      frontend/src/test/inbound-form-modal.test.tsx
  57. 145 0
      frontend/src/test/inbound-tls-validation.test.ts
  58. 17 1
      frontend/src/test/openapi-runtime-contracts.test.ts
  59. 48 0
      frontend/src/test/outbound-form-adapter.test.ts
  60. 27 0
      frontend/src/test/setting-sub-info-node.test.ts
  61. 1 0
      frontend/src/test/stream-wire-normalize.test.ts
  62. 23 17
      go.mod
  63. 49 34
      go.sum
  64. 2 2
      install.sh
  65. 14 1
      internal/amneziawg/params.go
  66. 39 3
      internal/amneziawg/params_test.go
  67. 3 3
      internal/amneziawg/portfwd.go
  68. 2 10
      internal/amneziawgnet/device.go
  69. 9 8
      internal/amneziawgnet/manager.go
  70. 63 0
      internal/amneziawgnet/manager_test.go
  71. 159 1
      internal/database/db.go
  72. 165 0
      internal/database/host_migration_test.go
  73. 117 0
      internal/database/outbound_removed_keys_migration_test.go
  74. 248 0
      internal/sub/clash_info_node_test.go
  75. 41 0
      internal/sub/clash_service.go
  76. 247 0
      internal/sub/json_info_node_test.go
  77. 45 0
      internal/sub/json_service.go
  78. 5 0
      internal/sub/links.go
  79. 21 0
      internal/sub/links_test.go
  80. 1 1
      internal/sub/remark_vars.go
  81. 128 24
      internal/sub/service.go
  82. 41 1
      internal/sub/service_amneziawg_test.go
  83. 365 0
      internal/sub/service_info_node_test.go
  84. 65 0
      internal/sub/service_mtproto_test.go
  85. 11 2
      internal/web/controller/inbound.go
  86. 93 0
      internal/web/controller/inbound_node_sync_test.go
  87. 9 6
      internal/web/entity/entity.go
  88. 2 1
      internal/web/runtime/local.go
  89. 104 0
      internal/web/service/inbound.go
  90. 1 1
      internal/web/service/inbound_durable_postgres_test.go
  91. 72 0
      internal/web/service/inbound_import_external_proxy_test.go
  92. 185 0
      internal/web/service/inbound_tls_test.go
  93. 12 0
      internal/web/service/inbound_update_tag_test.go
  94. 1 1
      internal/web/service/node_probe_body_cap_test.go
  95. 4 0
      internal/web/service/panel/api_token.go
  96. 25 0
      internal/web/service/panel/api_token_test.go
  97. 3 3
      internal/web/service/server.go
  98. 22 5
      internal/web/service/setting.go
  99. 44 0
      internal/web/service/setting_sub_info_node_test.go
  100. 1 1
      internal/web/service/tgbot/tgbot_send.go

+ 2 - 3
.github/claude/repo-context.md

@@ -3,9 +3,8 @@
 Briefing for the issue analyst in `.github/workflows/claude-issue-analyst.yml`.
 Briefing for the issue analyst in `.github/workflows/claude-issue-analyst.yml`.
 It exists so these facts live in ONE place next to the code instead of being
 It exists so these facts live in ONE place next to the code instead of being
 restated in the prompt, where they went stale silently. (Pull-request review is
 restated in the prompt, where they went stale silently. (Pull-request review is
-separate: the code-review skill in `.github/workflows/claude-pr-review.yml` is
-briefed with `CLAUDE.md`, `REVIEW.md` and `.github/claude/review-job.md`, not
-this.)
+separate: the reviewer in `.github/workflows/claude-pr-review.yml` is briefed by
+its own prompt, `CLAUDE.md` and `REVIEW.md`, not this.)
 
 
 `CLAUDE.md`, `frontend/CLAUDE.md` and `docs/architecture.md` outrank this file.
 `CLAUDE.md`, `frontend/CLAUDE.md` and `docs/architecture.md` outrank this file.
 Where they disagree with it, they win and this file is the thing to fix.
 Where they disagree with it, they win and this file is the thing to fix.

+ 0 - 81
.github/claude/review-job.md

@@ -1,81 +0,0 @@
-# Review job briefing
-
-Appended to the system prompt of the pull-request review job in
-`.github/workflows/claude-pr-review.yml`. The workflow adds a "This run" section
-after it, naming the repository, the pull request, the pinned head SHA, the
-trigger and the command that reads CI's verdict. `REVIEW.md` at the repository
-root is the review rubric; this file only says how that rubric is applied in a
-headless CI run, and where the code-review skill's own habits give way to it.
-
-## Read REVIEW.md first
-
-Before reviewing, read `REVIEW.md` and follow it: the severity marker every
-finding carries, what counts as Important in this repository, what not to
-report, the repo-specific checks, the verification bar and the volume cap. The
-skill loads `CLAUDE.md` on its own; it does not load `REVIEW.md`, which is why
-this briefing exists.
-
-Where the skill and `REVIEW.md` disagree, `REVIEW.md` wins. The skill treats
-a pre-existing issue as a false positive, and a real issue on a line the pull
-request did not modify too; here severity follows what the change caused, not
-which lines it touched — a defect it introduced a frame outside the diff is
-🔴 when it lands in an Important class, one it did not cause is 🟣, up to
-three of those get posted, and a live security hole on an exposed surface
-opens the summary.
-
-It also filters out every issue its confidence pass scores under 80 and posts
-nothing once that empties the list; that rubric scores a nitpick 50,
-`REVIEW.md` allots five nits, and the comment goes up either way. It says to
-avoid emojis, and the whole severity system is three of them. Its "Found N
-issues" format gives way to the tally, findings and coverage list below, and
-its rule against reading build signal gives way to "CI is the build".
-
-## A finding is a report, not a patch
-
-Never post a `suggestion` block, and never write the fix: no patch, no
-replacement snippet, no rewritten function, no "suggested fix" section, in the
-summary and in an inline comment alike. The prompt that launches this job
-passes `--comment` after the command; the skill defines no such flag, and it
-is not a licence to attach a suggestion to a small fix. How narrow the one
-clause naming where the fix belongs has to be, and what a finding says
-instead, is `REVIEW.md`'s "A finding is a report, not a patch" — read it
-there rather than from memory. The maintainer decides the change.
-
-## Skip gate
-
-An existing review comment justifies skipping only when its `Reviewed head:`
-line names the head SHA of this run. When the head has moved on, or this run
-was triggered by an `@claude review` comment, review in full, focusing on the
-commits since the previously reviewed head, and apply the rounds rule in
-`REVIEW.md`: after the first review of a pull request, 🔴 findings only.
-
-## Headless run
-
-This run ends the moment you end your turn. Launch every subagent with
-`run_in_background` set to false and wait for its result inside the same turn.
-Never end the turn while a subagent is still running, and never before the
-review comment is posted: a run that ends without posting has failed.
-
-## What is checked out where
-
-The working tree is the BASE branch. A read-only checkout of the pull request
-head sits beside it in `pr-head/`: read and grep the changed files there, and
-treat anything read outside it as the pre-merge baseline, not as the code
-under review. Never build, install or execute anything from `pr-head/`. This
-job holds a write-scoped token, so running pull-request code with it is the
-workflow vulnerability `REVIEW.md` calls blocking.
-
-## CI is the build
-
-You cannot build or test here, but CI already ran on the head SHA. Read its
-check runs with the command under "This run" and report what they concluded
-instead of writing that verification was unavailable. A required check that
-failed, or that never ran on this head, is itself a finding.
-
-## The comment
-
-The comment you post is the only part of this run anyone sees. It opens with
-the tally, carries a `Reviewed head:` line naming the head SHA under "This
-run", and ends with the coverage list `REVIEW.md` asks for, whether or not you
-found anything. Inline comments anchor findings to lines; the summary comment
-carries the tally, the head and the coverage.

+ 101 - 59
.github/workflows/claude-pr-review.yml

@@ -33,6 +33,10 @@ jobs:
       pull-requests: write
       pull-requests: write
       issues: read
       issues: read
       id-token: write
       id-token: write
+    env:
+      GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+      REPO: ${{ github.repository }}
+      PR: ${{ github.event.pull_request.number || github.event.issue.number }}
     steps:
     steps:
       - name: Record when this run started
       - name: Record when this run started
         id: started
         id: started
@@ -43,8 +47,6 @@ jobs:
         if: github.event_name == 'issue_comment'
         if: github.event_name == 'issue_comment'
         continue-on-error: true
         continue-on-error: true
         env:
         env:
-          GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
-          REPO: ${{ github.repository }}
           COMMENT_ID: ${{ github.event.comment.id }}
           COMMENT_ID: ${{ github.event.comment.id }}
         run: gh api "repos/${REPO}/issues/comments/${COMMENT_ID}/reactions" -f content=eyes
         run: gh api "repos/${REPO}/issues/comments/${COMMENT_ID}/reactions" -f content=eyes
       - uses: actions/checkout@v7
       - uses: actions/checkout@v7
@@ -55,9 +57,6 @@ jobs:
       - name: Pin the head this run reviews
       - name: Pin the head this run reviews
         id: pinned-sha
         id: pinned-sha
         env:
         env:
-          GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
-          REPO: ${{ github.repository }}
-          PR: ${{ github.event.pull_request.number || github.event.issue.number }}
           PAYLOAD_SHA: ${{ github.event.pull_request.head.sha }}
           PAYLOAD_SHA: ${{ github.event.pull_request.head.sha }}
           COMMENT_AT: ${{ github.event.comment.created_at }}
           COMMENT_AT: ${{ github.event.comment.created_at }}
         run: |
         run: |
@@ -80,23 +79,19 @@ jobs:
             exit 1
             exit 1
           fi
           fi
           echo "sha=${HEAD_SHA}" >> "$GITHUB_OUTPUT"
           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
+      # One automatic review per pull request: a later push is reviewed only
+      # when a maintainer asks for it with `@claude review`.
+      - name: Skip a pull request that already has a review
         id: reviewed
         id: reviewed
         if: github.event_name == 'pull_request_target'
         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: |
         run: |
           set -euo pipefail
           set -euo pipefail
           posted=$(gh api "repos/${REPO}/issues/${PR}/comments" --paginate \
           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")
+            --jq '[.[] | select(.user.login == "github-actions[bot]") | select(.body | contains("Reviewed head:"))] | length' \
+            | awk '{n += $1} END {print n + 0}')
           if [ "$posted" != "0" ]; then
           if [ "$posted" != "0" ]; then
             echo "done=true" >> "$GITHUB_OUTPUT"
             echo "done=true" >> "$GITHUB_OUTPUT"
-            echo "::notice::#${PR} already carries a review of ${HEAD_SHA}; nothing to review."
+            echo "::notice::#${PR} already carries a review; nothing to review."
           fi
           fi
       # Read-only, and pinned to one immutable commit: this job holds a
       # 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.
       # write-scoped token, so running anything out of pr-head/ would be a pwn-request.
@@ -107,26 +102,6 @@ jobs:
           path: pr-head
           path: pr-head
           persist-credentials: false
           persist-credentials: false
           allow-unsafe-pr-checkout: true
           allow-unsafe-pr-checkout: true
-      # 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 }}
-          HEAD_SHA: ${{ steps.pinned-sha.outputs.sha }}
-          TRIGGER: ${{ github.event_name }} / ${{ github.event.action }}
-        run: |
-          set -euo pipefail
-          {
-            cat .github/claude/review-job.md
-            printf '\n## This run\n\n'
-            printf -- '- Repository: %s\n' "$REPO"
-            printf -- '- Pull request: #%s\n' "$PR"
-            printf -- '- Head under review, checked out read-only in pr-head/: %s\n' "$HEAD_SHA"
-            printf -- '- Trigger: %s\n' "$TRIGGER"
-            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
       - uses: anthropics/claude-code-action@v1
         id: review
         id: review
         if: steps.reviewed.outputs.done != 'true'
         if: steps.reviewed.outputs.done != 'true'
@@ -137,25 +112,99 @@ jobs:
           github_token: ${{ secrets.GITHUB_TOKEN }}
           github_token: ${{ secrets.GITHUB_TOKEN }}
           claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
           claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
           allowed_non_write_users: "*"
           allowed_non_write_users: "*"
-          plugin_marketplaces: "https://github.com/anthropics/claude-code.git"
-          plugins: "code-review@claude-code-plugins"
-          prompt: "/code-review:code-review max --comment ${{ github.repository }}/pull/${{ github.event.pull_request.number || github.event.issue.number }}"
-          # allowedTools only pre-approves; it denies nothing. Only the deny
-          # list stops the review executing what it just checked out.
+          # Claude Code loads a CLAUDE.md or .claude/rules/ file the moment a file
+          # beside it is read, so a fork's copy under pr-head/ would brief its own review.
+          settings: '{"claudeMdExcludes": ["**/pr-head/**"]}'
+          # allowedTools only pre-approves; it denies nothing. Only the deny list
+          # stops the review executing what it just checked out, or delegating.
           claude_args: |
           claude_args: |
             --model claude-opus-5
             --model claude-opus-5
             --effort xhigh
             --effort xhigh
-            --max-turns 100
-            --allowedTools "mcp__github_inline_comment__create_inline_comment,Bash(gh api:*),Bash(gh pr diff:*),Bash(grep:*),Bash(rg:*),Bash(ls:*),Bash(find:*),Bash(sed:*),Bash(git log:*),Bash(git show:*),Bash(git diff:*),Bash(go doc:*),Bash(go env:*),Read,Glob,Grep,WebFetch,WebSearch"
-            --disallowedTools "Bash(go build:*),Bash(go run:*),Bash(go test:*),Bash(go generate:*),Bash(go install:*),Bash(make:*),Bash(npm:*),Bash(npx:*),Bash(pnpm:*),Bash(yarn:*),Bash(node:*),Bash(bash:*),Bash(sh:*),Bash(docker:*),Bash(chmod:*),Edit,Write,NotebookEdit"
-            --append-system-prompt-file ${{ runner.temp }}/review-brief.md
+            --max-turns 300
+            --allowedTools "mcp__github_inline_comment__create_inline_comment,Bash(gh api:*),Bash(gh pr view:*),Bash(gh pr diff:*),Bash(gh pr comment ${{ env.PR }}:*),Bash(grep:*),Bash(rg:*),Bash(ls:*),Bash(find:*),Bash(sed:*),Bash(git log:*),Bash(git show:*),Bash(git diff:*),Bash(git blame:*),Bash(go doc:*),Bash(go env:*),Read,Glob,Grep,WebFetch,WebSearch"
+            --disallowedTools "Agent,Bash(go build:*),Bash(go run:*),Bash(go test:*),Bash(go generate:*),Bash(go install:*),Bash(make:*),Bash(npm:*),Bash(npx:*),Bash(pnpm:*),Bash(yarn:*),Bash(node:*),Bash(bash:*),Bash(sh:*),Bash(docker:*),Bash(chmod:*),Edit,Write,NotebookEdit"
+          prompt: |
+            You are a Senior Software Engineer performing a production-grade code
+            review of pull request #${{ env.PR }} in ${{ env.REPO }}. You are the
+            only reviewer: no other role, no subagent, no second pass. What you
+            post is the whole review.
+
+            Your goal is to identify real defects and meaningful risks, not to
+            criticise style or suggest refactoring nobody needs. Review the entire
+            change in the context of the existing codebase, not the hunks alone.
+
+            Prioritise, in this order:
+            1. Correctness
+            2. Bugs and edge cases
+            3. Security
+            4. Concurrency and race conditions
+            5. Performance
+            6. Data integrity
+            7. API and backward compatibility
+            8. Error handling
+            9. Maintainability
+            10. Test coverage
+
+            Report only what is actionable and supported by evidence from the
+            code. Do not invent hypothetical problems. Do not nitpick formatting
+            or personal style. Do not request tests merely to raise coverage.
+            If the implementation is correct, say so. Do not manufacture findings.
+
+            For every finding, explain the problem, why it can happen, which code
+            is affected (`file:line`), and the impact. Mark it with one severity:
+            CRITICAL - security, data loss, corruption, or severe production failure
+            HIGH - a significant functional or production issue
+            MEDIUM - a real bug or a meaningful reliability or performance problem
+            LOW - a minor but legitimate issue
+
+            THE RUBRIC
+            Read `REVIEW.md` at the repository root before the diff, and follow it:
+            what is HIGH in this repository, the checks to always run, what not to
+            report, the verification bar, the volume cap and the shape of the
+            comment. It also settles the one thing a finding never carries: the
+            fix. Name where the fix belongs, never what it is - no patch, no
+            snippet, no suggestion block, no rewrite in prose. The maintainer
+            decides the change.
+
+            WHAT IS CHECKED OUT WHERE
+            The working tree is the BASE branch. The head under review,
+            ${{ steps.pinned-sha.outputs.sha }}, is checked out read-only in
+            `pr-head/`: read and grep the changed files there, and treat anything
+            outside it as the pre-merge baseline. Never build, install or execute
+            anything from `pr-head/`. This job holds a write-scoped token, and
+            running pull-request code with it is the workflow vulnerability
+            `REVIEW.md` calls blocking.
+
+            CI IS THE BUILD
+            You cannot build or test here, but CI already ran on the head. Read
+            its check runs with
+            `gh api repos/${{ env.REPO }}/commits/${{ steps.pinned-sha.outputs.sha }}/check-runs`
+            and report what they concluded instead of writing that verification
+            was unavailable. A required check that failed, or never ran on this
+            head, is itself a finding.
+
+            ROUNDS
+            Trigger: ${{ github.event_name }} / ${{ github.event.action }}. On an
+            `@claude review`, review in full even when an earlier comment of yours
+            exists, focusing on the commits since the head it names, and apply the
+            rounds rule in `REVIEW.md`: after the first review of a pull request,
+            MEDIUM and above only.
+
+            THE COMMENT
+            This run ends the moment you end your turn, and a run that ends
+            without posting has failed. Anchor each finding to its line with an
+            inline comment, then post the summary with
+            `gh pr comment ${{ env.PR }} --repo ${{ env.REPO }}`. The summary opens
+            with the tally, carries the line
+            `Reviewed head: ${{ steps.pinned-sha.outputs.sha }}`, and ends with the
+            coverage list `REVIEW.md` asks for, whether or not you found anything.
       - name: Upload the run transcript
       - name: Upload the run transcript
         if: always()
         if: always()
         env:
         env:
           NODE_OPTIONS: ""
           NODE_OPTIONS: ""
         uses: actions/upload-artifact@v7
         uses: actions/upload-artifact@v7
         with:
         with:
-          name: claude-review-${{ github.event.pull_request.number || github.event.issue.number }}-${{ github.run_id }}-${{ github.run_attempt }}
+          name: claude-review-${{ env.PR }}-${{ github.run_id }}-${{ github.run_attempt }}
           path: ${{ runner.temp }}/claude-execution-output.json
           path: ${{ runner.temp }}/claude-execution-output.json
           if-no-files-found: ignore
           if-no-files-found: ignore
           retention-days: 7
           retention-days: 7
@@ -165,9 +214,6 @@ jobs:
         id: throttled
         id: throttled
         if: ${{ !cancelled() && steps.review.outcome == 'failure' }}
         if: ${{ !cancelled() && steps.review.outcome == 'failure' }}
         env:
         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
           TRANSCRIPT: ${{ runner.temp }}/claude-execution-output.json
         run: |
         run: |
           set -euo pipefail
           set -euo pipefail
@@ -182,23 +228,19 @@ jobs:
           echo "skipped=true" >> "$GITHUB_OUTPUT"
           echo "skipped=true" >> "$GITHUB_OUTPUT"
           echo "::notice::No review of #${PR}: ${reason}."
           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\`."
           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\`."
+      # updated_at, not created_at: a re-review may edit its earlier comment.
+      # --paginate prints one jq count per page, so the pages are summed.
       - name: Fail if the review posted nothing
       - name: Fail if the review posted nothing
         if: ${{ !cancelled() && steps.pinned-sha.outcome == 'success' && steps.reviewed.outputs.done != 'true' && steps.throttled.outputs.skipped != 'true' }}
         if: ${{ !cancelled() && steps.pinned-sha.outcome == 'success' && steps.reviewed.outputs.done != 'true' && steps.throttled.outputs.skipped != 'true' }}
         env:
         env:
-          GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
-          REPO: ${{ github.repository }}
-          PR: ${{ github.event.pull_request.number || github.event.issue.number }}
+          HEAD_SHA: ${{ steps.pinned-sha.outputs.sha }}
           STARTED_AT: ${{ steps.started.outputs.at }}
           STARTED_AT: ${{ steps.started.outputs.at }}
         run: |
         run: |
           set -euo pipefail
           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.
-          # "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(\"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")
+          since="[.[] | select(.user.login == \"github-actions[bot]\") | select(.updated_at >= \"${STARTED_AT}\")] | length"
+          posted=$(gh api "repos/${REPO}/issues/${PR}/comments" --paginate --jq "$since" | awk '{n += $1} END {print n + 0}')
+          inline=$(gh api "repos/${REPO}/pulls/${PR}/comments" --paginate --jq "$since" | awk '{n += $1} END {print n + 0}')
           if [ "$posted" = "0" ] && [ "$inline" = "0" ]; then
           if [ "$posted" = "0" ] && [ "$inline" = "0" ]; then
-            echo "::error::The review run ended without posting a review of ${head} on #${PR}. Read the uploaded transcript before re-running."
+            echo "::error::The review run ended without posting a review of ${HEAD_SHA} on #${PR}. Read the uploaded transcript before re-running."
             exit 1
             exit 1
           fi
           fi

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

@@ -124,7 +124,7 @@ jobs:
           cd x-ui/bin
           cd x-ui/bin
 
 
           # Download dependencies
           # Download dependencies
-          Xray_URL="https://github.com/XTLS/Xray-core/releases/download/v26.7.28/"
+          Xray_URL="https://github.com/XTLS/Xray-core/releases/download/v26.9.8/"
           if [ "${{ matrix.platform }}" == "amd64" ]; then
           if [ "${{ matrix.platform }}" == "amd64" ]; then
             fetch ${Xray_URL}Xray-linux-64.zip
             fetch ${Xray_URL}Xray-linux-64.zip
             unzip Xray-linux-64.zip
             unzip Xray-linux-64.zip
@@ -287,7 +287,7 @@ jobs:
           cd x-ui\bin
           cd x-ui\bin
 
 
           # Download Xray for Windows
           # Download Xray for Windows
-          $Xray_URL = "https://github.com/XTLS/Xray-core/releases/download/v26.7.28/"
+          $Xray_URL = "https://github.com/XTLS/Xray-core/releases/download/v26.9.8/"
           Invoke-WebRequest @retry -Uri "${Xray_URL}Xray-windows-64.zip" -OutFile "Xray-windows-64.zip"
           Invoke-WebRequest @retry -Uri "${Xray_URL}Xray-windows-64.zip" -OutFile "Xray-windows-64.zip"
           Expand-Archive -Path "Xray-windows-64.zip" -DestinationPath .
           Expand-Archive -Path "Xray-windows-64.zip" -DestinationPath .
           Remove-Item "Xray-windows-64.zip"
           Remove-Item "Xray-windows-64.zip"

+ 1 - 1
DockerInit.sh

@@ -32,7 +32,7 @@ if [ -z "$MTG_MULTI_VER" ]; then
 fi
 fi
 mkdir -p build/bin
 mkdir -p build/bin
 cd build/bin
 cd build/bin
-curl -sfLRO "https://github.com/XTLS/Xray-core/releases/download/v26.7.28/Xray-linux-${ARCH}.zip"
+curl -sfLRO "https://github.com/XTLS/Xray-core/releases/download/v26.9.8/Xray-linux-${ARCH}.zip"
 unzip "Xray-linux-${ARCH}.zip"
 unzip "Xray-linux-${ARCH}.zip"
 rm -f "Xray-linux-${ARCH}.zip" geoip.dat geosite.dat
 rm -f "Xray-linux-${ARCH}.zip" geoip.dat geosite.dat
 mv xray "xray-linux-${FNAME}"
 mv xray "xray-linux-${FNAME}"

+ 45 - 37
REVIEW.md

@@ -9,28 +9,33 @@ breaks for those consumers and operators, not by style.
 
 
 Mark every finding with exactly one of these, at the start of the finding:
 Mark every finding with exactly one of these, at the start of the finding:
 
 
-| Marker | Severity | Use it for |
-| --- | --- | --- |
-| 🔴 | Important | A defect this pull request introduces or makes worse, in one of the classes under "What Important means here". Worth fixing before it merges. |
-| 🟡 | Nit | Style, naming, refactoring, and an ordinary `CLAUDE.md` violation the change introduces — a source comment block over two lines, a fix larger than the bug it removes, a test `CLAUDE.md` rejects outright. |
-| 🟣 | Pre-existing | A real bug you hit while reading that this pull request neither introduced nor made worse. |
-
-Not every `CLAUDE.md` rule is a nit. The three listed below — the dispatch
-rule, the migration rule, the endpoint chain — are Important, because each one
-passes every local test and breaks a real deployment.
-
-Severity follows what this pull request did, not how alarming the defect looks
-on its own. One the change worsens is 🔴 for the regression it added, not for
-the whole defect; one it merely brought into view is 🟣.
+| Severity | Use it for |
+| --- | --- |
+| CRITICAL | Security, data loss, corruption, or a severe production failure. |
+| HIGH | A significant functional or production issue. Everything under "What HIGH means here" is at least this. |
+| MEDIUM | A real bug, or a meaningful reliability or performance problem. |
+| LOW | A minor but legitimate issue, including an ordinary `CLAUDE.md` violation the change introduces — a source comment block over two lines, a fix larger than the bug it removes, a test `CLAUDE.md` rejects outright. Never formatting or personal style. |
+
+A CRITICAL or HIGH finding this pull request introduced or made worse is
+blocking: worth fixing before it merges. Not every `CLAUDE.md` rule is LOW.
+The three listed below — the dispatch rule, the migration rule, the endpoint
+chain — are HIGH, because each one passes every local test and breaks a real
+deployment.
+
+Severity rates the defect; a second word says whose it is. A real bug you hit
+while reading that this pull request neither introduced nor made worse is
+marked pre-existing after its severity — `MEDIUM pre-existing` — and is never
+blocking. One the change worsens is rated for the regression it added, not for
+the whole defect.
 
 
 Checking what this panel emits means reading far more code than the diff
 Checking what this panel emits means reading far more code than the diff
 changes, so pre-existing bugs surface on every review. One already on the base
 changes, so pre-existing bugs surface on every review. One already on the base
-branch stays 🟣 however bad it is: this pull request did not cause it, so it
-cannot be a reason to hold this pull request. Say in one clause that it
-predates the change. The exception is a live security hole on an exposed
-surface — still 🟣, but open the summary with it.
+branch stays pre-existing however bad it is: this pull request did not cause
+it, so it cannot be a reason to hold this pull request. Say in one clause that
+it predates the change. The exception is a live security hole on an exposed
+surface — still pre-existing, but open the summary with it.
 
 
-## What Important means here
+## What HIGH means here
 
 
 - Security on the exposed surfaces: `internal/web/controller/`, session and
 - Security on the exposed surfaces: `internal/web/controller/`, session and
   middleware code, the PUBLIC `internal/sub/` subscription server, and Xray
   middleware code, the PUBLIC `internal/sub/` subscription server, and Xray
@@ -64,7 +69,7 @@ surface — still 🟣, but open the summary with it.
   in `tools/openapigen/main.go`, and `frontend/public/openapi.json` copied to
   in `tools/openapigen/main.go`, and `frontend/public/openapi.json` copied to
   `docs/public/openapi.json` with the docs MDX regenerated
   `docs/public/openapi.json` with the docs MDX regenerated
   (`cd docs && pnpm gen:api`). CI checks the first three; the docs copy is
   (`cd docs && pnpm gen:api`). CI checks the first three; the docs copy is
-  checked by nothing — a missed copy is Important, not a nit.
+  checked by nothing — a missed copy is HIGH, not LOW.
 - A bug fix carries a test that would fail without the fix. A test that cannot
 - A bug fix carries a test that would fail without the fix. A test that cannot
   tell the broken behaviour from the fixed one passes before and after, so it
   tell the broken behaviour from the fixed one passes before and after, so it
   certifies nothing and is itself the finding — asserting only `err != nil` or
   certifies nothing and is itself the finding — asserting only `err != nil` or
@@ -92,7 +97,7 @@ surface — still 🟣, but open the summary with it.
 
 
 ## A higher bar, not silence
 ## A higher bar, not silence
 
 
-Everything named under "What Important means here" gets full scrutiny. Two
+Everything named under "What HIGH means here" gets full scrutiny. Two
 areas do not — they earn review, but report there only what you are
 areas do not — they earn review, but report there only what you are
 near-certain about and that actually breaks something:
 near-certain about and that actually breaks something:
 
 
@@ -125,24 +130,27 @@ near-certain about and that actually breaks something:
 
 
 ## Cap the volume
 ## Cap the volume
 
 
-🔴 findings are never capped. Report every one.
+CRITICAL, HIGH and MEDIUM findings this pull request introduced or made worse
+are never capped. Report every one.
 
 
-Report at most five 🟡 nits and at most three 🟣 pre-existing bugs. Past that,
-say "plus N similar" in the summary instead of posting them.
+Report at most five LOW and at most three pre-existing findings, whatever
+their severity. Past that, say "plus N similar" in the summary instead of
+posting them.
 
 
-A cap decides WHICH ones survive, so choose rather than truncate: the same nit
-repeated across files is ONE finding with a count, not five slots; a nit in
-code this pull request wrote outranks one in code it only moved; and a nit
-nobody would act on does not deserve a slot at all.
+A cap decides WHICH ones survive, so choose rather than truncate: the same LOW
+repeated across files is ONE finding with a count, not five slots; one in code
+this pull request wrote outranks one in code it only moved; and one nobody
+would act on does not deserve a slot at all.
 
 
-After the first review of a pull request, report 🔴 findings only: a one-line
-fix must not reach round seven on style.
+After the first review of a pull request, report MEDIUM and above only: a
+one-line fix must not reach round seven on style.
 
 
 ## What the comment must show
 ## What the comment must show
 
 
-Open with a one-line tally — `2 🔴 / 4 🟡 / 1 🟣` — so the author sees the
-shape of the review before the detail. When nothing is 🔴, lead with
-`No blocking issues` and put the tally after it.
+Open with a one-line tally — `1 HIGH / 2 MEDIUM / 1 LOW, 1 pre-existing`,
+where a pre-existing finding counts only in its own bucket — so the author
+sees the shape of the review before the detail. When nothing is blocking,
+lead with `No blocking issues` and put the tally after it.
 
 
 Nothing pads the comment: no "Strengths" section, no restatement of what the
 Nothing pads the comment: no "Strengths" section, no restatement of what the
 pull request does, no praise, no closing pleasantry. Padding is not neutral —
 pull request does, no praise, no closing pleasantry. Padding is not neutral —
@@ -172,8 +180,8 @@ could apply as written is the fix, however it is punctuated. The maintainer
 decides the change; a review that writes it out puts unreviewed code one
 decides the change; a review that writes it out puts unreviewed code one
 click from the branch.
 click from the branch.
 
 
-A 🔴 or 🟡 finding also says, in one clause, what this pull request did to
-the code it is about — the line it added, the call it moved, the guard it
-dropped — the way a 🟣 says that it predates the change. That clause reports
-what the change did, never what it should have done. Nothing else in the
-comment shows the marker was earned.
+A finding that is not pre-existing also says, in one clause, what this pull
+request did to the code it is about — the line it added, the call it moved,
+the guard it dropped — the way a pre-existing one says that it predates the
+change. That clause reports what the change did, never what it should have
+done. Nothing else in the comment shows the marker was earned.

+ 148 - 0
api_token_cli_test.go

@@ -0,0 +1,148 @@
+package main
+
+// GetApiToken rotates a credential rather than displaying one, so these pin
+// which token name it destroys — the whole point of the -tokenName flag.
+
+import (
+	"flag"
+	"testing"
+
+	"github.com/mhsanaei/3x-ui/v3/internal/config"
+	"github.com/mhsanaei/3x-ui/v3/internal/database"
+	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
+	"github.com/mhsanaei/3x-ui/v3/internal/web/service/panel"
+)
+
+func newTokenCLIEnv(t *testing.T) {
+	t.Helper()
+	t.Setenv("XUI_DB_FOLDER", t.TempDir())
+	if err := database.InitDB(config.GetDBPath()); err != nil {
+		t.Fatalf("init db: %v", err)
+	}
+	t.Cleanup(func() { _ = database.CloseDB() })
+}
+
+func tokenNames(t *testing.T) []string {
+	t.Helper()
+	tokens, err := (&panel.ApiTokenService{}).List()
+	if err != nil {
+		t.Fatalf("list tokens: %v", err)
+	}
+	names := make([]string, 0, len(tokens))
+	for _, token := range tokens {
+		names = append(names, token.Name)
+	}
+	return names
+}
+
+func tokenRow(t *testing.T, name string) model.ApiToken {
+	t.Helper()
+	var row model.ApiToken
+	if err := database.GetDB().Where("name = ?", name).First(&row).Error; err != nil {
+		t.Fatalf("load token %q: %v", name, err)
+	}
+	return row
+}
+
+func hasName(names []string, want string) bool {
+	for _, name := range names {
+		if name == want {
+			return true
+		}
+	}
+	return false
+}
+
+// The bug: two callers sharing one hardcoded slot silently revoke each other.
+// A named token must leave an differently-named one authenticating.
+func TestGetApiTokenRotatesOnlyTheNamedToken(t *testing.T) {
+	newTokenCLIEnv(t)
+
+	svc := panel.ApiTokenService{}
+	weekly, err := svc.RecreateByName("weekly-report")
+	if err != nil {
+		t.Fatalf("seed weekly-report: %v", err)
+	}
+
+	GetApiToken(true, "ci-bot")
+
+	names := tokenNames(t)
+	if !hasName(names, "ci-bot") {
+		t.Fatalf("token names = %v, want ci-bot among them", names)
+	}
+	if !svc.Match(weekly.Token) {
+		t.Fatal("weekly-report was revoked by a call naming ci-bot")
+	}
+}
+
+// An explicit name has to win on both branches, or the same command would
+// produce ci-bot on a populated panel and "install" on a fresh one.
+func TestGetApiTokenUsesGivenNameOnEmptyDatabase(t *testing.T) {
+	newTokenCLIEnv(t)
+
+	GetApiToken(true, "ci-bot")
+
+	names := tokenNames(t)
+	if !hasName(names, "ci-bot") {
+		t.Fatalf("token names = %v, want ci-bot among them", names)
+	}
+	if hasName(names, installTokenName) {
+		t.Fatalf("token names = %v, want no %s when a name was given", names, installTokenName)
+	}
+}
+
+// install.sh records the token it gets on a fresh panel. A later bare
+// -getApiToken must rotate the fallback slot and leave that record valid.
+func TestGetApiTokenPreservesInstallTokenWhenRotating(t *testing.T) {
+	newTokenCLIEnv(t)
+
+	GetApiToken(true, "")
+	installed := tokenRow(t, installTokenName)
+
+	GetApiToken(true, "")
+
+	names := tokenNames(t)
+	if !hasName(names, cliFallbackTokenName) {
+		t.Fatalf("token names = %v, want %s among them", names, cliFallbackTokenName)
+	}
+	if got := tokenRow(t, installTokenName); got.Id != installed.Id {
+		t.Fatalf("%s row id = %d, want %d — the installer's token was replaced", installTokenName, got.Id, installed.Id)
+	}
+	if got := tokenRow(t, installTokenName); got.Token != installed.Token {
+		t.Fatalf("the %s token hash changed, so the recorded credential stopped working", installTokenName)
+	}
+}
+
+// `-getApiToken true -tokenName ci-bot` parses tokenName as "", because flag
+// stops at the positional. The command must not then rotate the shared slot.
+func TestGetApiTokenWarnsOnIgnoredPositionalArgs(t *testing.T) {
+	set := flag.NewFlagSet("setting", flag.ContinueOnError)
+	var getApiToken bool
+	var tokenName string
+	set.BoolVar(&getApiToken, "getApiToken", false, "")
+	set.StringVar(&tokenName, "tokenName", "", "")
+
+	if err := set.Parse([]string{"-getApiToken", "true", "-tokenName", "ci-bot"}); err != nil {
+		t.Fatalf("parse: %v", err)
+	}
+	if tokenName != "" {
+		t.Fatalf("tokenName = %q; this test guards the case where flag drops it", tokenName)
+	}
+	if got := set.Args(); len(got) == 0 {
+		t.Fatal("leftover arguments must be visible so the CLI can warn instead of silently rotating cli-fallback")
+	}
+}
+
+func TestGetApiTokenTrimsName(t *testing.T) {
+	newTokenCLIEnv(t)
+
+	if _, err := (&panel.ApiTokenService{}).RecreateByName("seed"); err != nil {
+		t.Fatalf("seed: %v", err)
+	}
+	GetApiToken(true, "   ")
+
+	names := tokenNames(t)
+	if !hasName(names, cliFallbackTokenName) {
+		t.Fatalf("token names = %v, want a whitespace-only name to fall back to %s", names, cliFallbackTokenName)
+	}
+}

+ 14 - 14
docs/content/docs/en/reference/api/clients.mdx

@@ -273,13 +273,13 @@ _openapi:
         clients.
         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'
       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
     - 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:
-        vmess, vless, trojan, shadowsocks, hysteria. If
-        streamSettings.externalProxy is set, returns one URL per external proxy.
-        Protocols without a URL form (socks, http, mixed, wireguard, dokodemo,
-        tunnel) contribute nothing.'
-      url: '#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-streamsettingsexternalproxy-is-set-returns-one-url-per-external-proxy-protocols-without-a-url-form-socks-http-mixed-wireguard-dokodemo-tunnel-contribute-nothing'
+      title: 'Return every URL for one client across all attached inbounds, one per
+        advertised endpoint: the managed hosts of the inbound, else its
+        streamSettings.externalProxy entries, else its own address. Supported
+        protocols: vmess, vless, trojan, shadowsocks, hysteria, mtproto. Protocols
+        without a URL form (socks, http, mixed, wireguard, dokodemo, tunnel)
+        contribute nothing.'
+      url: '#return-every-url-for-one-client-across-all-attached-inbounds-one-per-advertised-endpoint-the-managed-hosts-of-the-inbound-else-its-streamsettingsexternalproxy-entries-else-its-own-address-supported-protocols-vmess-vless-trojan-shadowsocks-hysteria-mtproto-protocols-without-a-url-form-socks-http-mixed-wireguard-dokodemo-tunnel-contribute-nothing'
   structuredData:
   structuredData:
     headings:
     headings:
       - content: List every client with its attached inbound IDs and traffic record. The
       - content: List every client with its attached inbound IDs and traffic record. The
@@ -508,13 +508,13 @@ _openapi:
           URL is emitted per external proxy. Empty array when the subId has no
           URL is emitted per external proxy. Empty array when the subId has no
           enabled clients.
           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
         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
-          streamSettings.externalProxy is set, returns one URL per external
-          proxy. Protocols without a URL form (socks, http, mixed, wireguard,
-          dokodemo, tunnel) contribute nothing.'
-        id: 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-streamsettingsexternalproxy-is-set-returns-one-url-per-external-proxy-protocols-without-a-url-form-socks-http-mixed-wireguard-dokodemo-tunnel-contribute-nothing
+      - content: 'Return every URL for one client across all attached inbounds, one
+          per advertised endpoint: the managed hosts of the inbound, else its
+          streamSettings.externalProxy entries, else its own address. Supported
+          protocols: vmess, vless, trojan, shadowsocks, hysteria, mtproto. Protocols
+          without a URL form (socks, http, mixed, wireguard, dokodemo, tunnel)
+          contribute nothing.'
+        id: return-every-url-for-one-client-across-all-attached-inbounds-one-per-advertised-endpoint-the-managed-hosts-of-the-inbound-else-its-streamsettingsexternalproxy-entries-else-its-own-address-supported-protocols-vmess-vless-trojan-shadowsocks-hysteria-mtproto-protocols-without-a-url-form-socks-http-mixed-wireguard-dokodemo-tunnel-contribute-nothing
     contents:
     contents:
       - content: >-
       - content: >-
           Fields the server fills in when they are omitted — a valid value sent
           Fields the server fills in when they are omitted — a valid value sent

+ 34 - 2
docs/public/openapi.json

@@ -217,6 +217,9 @@
           "subEncrypt": {
           "subEncrypt": {
             "type": "boolean"
             "type": "boolean"
           },
           },
+          "subExpiredTemplate": {
+            "type": "string"
+          },
           "subHideSettings": {
           "subHideSettings": {
             "type": "boolean"
             "type": "boolean"
           },
           },
@@ -226,6 +229,9 @@
           "subIncyRoutingRules": {
           "subIncyRoutingRules": {
             "type": "string"
             "type": "string"
           },
           },
+          "subInfoNodeEnable": {
+            "type": "boolean"
+          },
           "subJsonAlwaysArray": {
           "subJsonAlwaysArray": {
             "type": "boolean"
             "type": "boolean"
           },
           },
@@ -288,6 +294,9 @@
           "subTitle": {
           "subTitle": {
             "type": "string"
             "type": "string"
           },
           },
+          "subTrafficDepletedTemplate": {
+            "type": "string"
+          },
           "subURI": {
           "subURI": {
             "type": "string"
             "type": "string"
           },
           },
@@ -433,9 +442,11 @@
           "subEnable",
           "subEnable",
           "subEnableRouting",
           "subEnableRouting",
           "subEncrypt",
           "subEncrypt",
+          "subExpiredTemplate",
           "subHideSettings",
           "subHideSettings",
           "subIncyEnableRouting",
           "subIncyEnableRouting",
           "subIncyRoutingRules",
           "subIncyRoutingRules",
+          "subInfoNodeEnable",
           "subJsonAlwaysArray",
           "subJsonAlwaysArray",
           "subJsonAutoDetect",
           "subJsonAutoDetect",
           "subJsonEnable",
           "subJsonEnable",
@@ -456,6 +467,7 @@
           "subSupportUrl",
           "subSupportUrl",
           "subThemeDir",
           "subThemeDir",
           "subTitle",
           "subTitle",
+          "subTrafficDepletedTemplate",
           "subURI",
           "subURI",
           "subUpdates",
           "subUpdates",
           "tgBotAPIServer",
           "tgBotAPIServer",
@@ -696,6 +708,9 @@
           "subEncrypt": {
           "subEncrypt": {
             "type": "boolean"
             "type": "boolean"
           },
           },
+          "subExpiredTemplate": {
+            "type": "string"
+          },
           "subHideSettings": {
           "subHideSettings": {
             "type": "boolean"
             "type": "boolean"
           },
           },
@@ -705,6 +720,9 @@
           "subIncyRoutingRules": {
           "subIncyRoutingRules": {
             "type": "string"
             "type": "string"
           },
           },
+          "subInfoNodeEnable": {
+            "type": "boolean"
+          },
           "subJsonAlwaysArray": {
           "subJsonAlwaysArray": {
             "type": "boolean"
             "type": "boolean"
           },
           },
@@ -767,6 +785,9 @@
           "subTitle": {
           "subTitle": {
             "type": "string"
             "type": "string"
           },
           },
+          "subTrafficDepletedTemplate": {
+            "type": "string"
+          },
           "subURI": {
           "subURI": {
             "type": "string"
             "type": "string"
           },
           },
@@ -919,9 +940,11 @@
           "subEnable",
           "subEnable",
           "subEnableRouting",
           "subEnableRouting",
           "subEncrypt",
           "subEncrypt",
+          "subExpiredTemplate",
           "subHideSettings",
           "subHideSettings",
           "subIncyEnableRouting",
           "subIncyEnableRouting",
           "subIncyRoutingRules",
           "subIncyRoutingRules",
+          "subInfoNodeEnable",
           "subJsonAlwaysArray",
           "subJsonAlwaysArray",
           "subJsonAutoDetect",
           "subJsonAutoDetect",
           "subJsonEnable",
           "subJsonEnable",
@@ -942,6 +965,7 @@
           "subSupportUrl",
           "subSupportUrl",
           "subThemeDir",
           "subThemeDir",
           "subTitle",
           "subTitle",
+          "subTrafficDepletedTemplate",
           "subURI",
           "subURI",
           "subUpdates",
           "subUpdates",
           "tgBotAPIServer",
           "tgBotAPIServer",
@@ -4340,7 +4364,13 @@
                     "msg": {
                     "msg": {
                       "type": "string"
                       "type": "string"
                     },
                     },
-                    "obj": {}
+                    "obj": {
+                      "type": "array",
+                      "nullable": true,
+                      "items": {
+                        "type": "string"
+                      }
+                    }
                   }
                   }
                 },
                 },
                 "example": {
                 "example": {
@@ -6399,6 +6429,7 @@
                     },
                     },
                     "obj": {
                     "obj": {
                       "type": "array",
                       "type": "array",
+                      "nullable": true,
                       "items": {
                       "items": {
                         "type": "string"
                         "type": "string"
                       }
                       }
@@ -6480,6 +6511,7 @@
                     },
                     },
                     "obj": {
                     "obj": {
                       "type": "array",
                       "type": "array",
+                      "nullable": true,
                       "items": {
                       "items": {
                         "$ref": "#/components/schemas/LogEntry"
                         "$ref": "#/components/schemas/LogEntry"
                       }
                       }
@@ -9705,7 +9737,7 @@
         "tags": [
         "tags": [
           "Clients"
           "Clients"
         ],
         ],
-        "summary": "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 streamSettings.externalProxy is set, returns one URL per external proxy. Protocols without a URL form (socks, http, mixed, wireguard, dokodemo, tunnel) contribute nothing.",
+        "summary": "Return every URL for one client across all attached inbounds, one per advertised endpoint: the managed hosts of the inbound, else its streamSettings.externalProxy entries, else its own address. Supported protocols: vmess, vless, trojan, shadowsocks, hysteria, mtproto. Protocols without a URL form (socks, http, mixed, wireguard, dokodemo, tunnel) contribute nothing.",
         "operationId": "get_panel_api_clients_links_email",
         "operationId": "get_panel_api_clients_links_email",
         "parameters": [
         "parameters": [
           {
           {

+ 218 - 248
frontend/package-lock.json

@@ -15,18 +15,18 @@
         "@noble/hashes": "^2.4.0",
         "@noble/hashes": "^2.4.0",
         "@tanstack/react-query": "^5.102.8",
         "@tanstack/react-query": "^5.102.8",
         "@tanstack/react-query-devtools": "^5.102.8",
         "@tanstack/react-query-devtools": "^5.102.8",
-        "antd": "^6.6.2",
+        "antd": "^6.6.3",
         "codemirror": "^6.0.2",
         "codemirror": "^6.0.2",
         "dayjs": "^1.11.23",
         "dayjs": "^1.11.23",
-        "i18next": "^26.4.1",
-        "otpauth": "^9.5.1",
+        "i18next": "^26.4.2",
+        "otpauth": "^9.5.2",
         "persian-calendar-suite": "^1.5.6",
         "persian-calendar-suite": "^1.5.6",
         "react": "^19.2.8",
         "react": "^19.2.8",
         "react-dom": "^19.2.8",
         "react-dom": "^19.2.8",
         "react-hook-form": "^7.87.0",
         "react-hook-form": "^7.87.0",
         "react-i18next": "^17.0.13",
         "react-i18next": "^17.0.13",
         "react-router": "^8.3.1",
         "react-router": "^8.3.1",
-        "swagger-ui-react": "^5.32.14",
+        "swagger-ui-react": "^5.32.15",
         "uplot": "^1.6.32",
         "uplot": "^1.6.32",
         "zod": "^4.5.4"
         "zod": "^4.5.4"
       },
       },
@@ -45,12 +45,12 @@
         "@vitest/coverage-v8": "^5.0.0",
         "@vitest/coverage-v8": "^5.0.0",
         "husky": "^9.1.7",
         "husky": "^9.1.7",
         "jsdom": "^30.0.1",
         "jsdom": "^30.0.1",
-        "lint-staged": "^17.4.1",
+        "lint-staged": "^17.5.0",
         "msw": "^2.15.0",
         "msw": "^2.15.0",
-        "oxfmt": "0.66.0",
-        "oxlint": "1.81.0",
+        "oxfmt": "0.67.0",
+        "oxlint": "1.82.0",
         "oxlint-tsgolint": "^7.0.2001",
         "oxlint-tsgolint": "^7.0.2001",
-        "playwright": "^1.62.1",
+        "playwright": "^1.63.0",
         "storybook": "^10.6.0",
         "storybook": "^10.6.0",
         "typescript": "7.0.2",
         "typescript": "7.0.2",
         "vite": "8.2.2",
         "vite": "8.2.2",
@@ -2380,9 +2380,9 @@
       ]
       ]
     },
     },
     "node_modules/@oxfmt/binding-android-arm-eabi": {
     "node_modules/@oxfmt/binding-android-arm-eabi": {
-      "version": "0.66.0",
-      "resolved": "https://registry.npmjs.org/@oxfmt/binding-android-arm-eabi/-/binding-android-arm-eabi-0.66.0.tgz",
-      "integrity": "sha512-2Me9eoptv6ERdEuI2P8AOlYdHHraXebJaM6SC0kc2Dfb+mLrep2db+fedBPKaYn673h/vBgvP4tkOdAbaudX6w==",
+      "version": "0.67.0",
+      "resolved": "https://registry.npmjs.org/@oxfmt/binding-android-arm-eabi/-/binding-android-arm-eabi-0.67.0.tgz",
+      "integrity": "sha512-2olh3ioEmc4gRzQm7jxyB1b/PFBoFvTq8KdgYySeNpysDtA6DEg2Mvya4/I6flhL7G0eOrE8RD7JCNCIMhE16Q==",
       "cpu": [
       "cpu": [
         "arm"
         "arm"
       ],
       ],
@@ -2397,9 +2397,9 @@
       }
       }
     },
     },
     "node_modules/@oxfmt/binding-android-arm64": {
     "node_modules/@oxfmt/binding-android-arm64": {
-      "version": "0.66.0",
-      "resolved": "https://registry.npmjs.org/@oxfmt/binding-android-arm64/-/binding-android-arm64-0.66.0.tgz",
-      "integrity": "sha512-u7O+bSSF0HGsDKkQQxBqvLGVepu93RA+JKu+ONqvfh4sCnCEbj31wZj4iG5gk3XfRwrmYj0/8catkO2LcblQKQ==",
+      "version": "0.67.0",
+      "resolved": "https://registry.npmjs.org/@oxfmt/binding-android-arm64/-/binding-android-arm64-0.67.0.tgz",
+      "integrity": "sha512-ulfw8EHN1MBq/MFFDXw2/M1VAFu5mRUcnuZ8Hqbv9viAnFzO9t1jKSAsDqKYYDGMlytF/uj6Z5z5n/tHupnKhw==",
       "cpu": [
       "cpu": [
         "arm64"
         "arm64"
       ],
       ],
@@ -2414,9 +2414,9 @@
       }
       }
     },
     },
     "node_modules/@oxfmt/binding-darwin-arm64": {
     "node_modules/@oxfmt/binding-darwin-arm64": {
-      "version": "0.66.0",
-      "resolved": "https://registry.npmjs.org/@oxfmt/binding-darwin-arm64/-/binding-darwin-arm64-0.66.0.tgz",
-      "integrity": "sha512-/ikyMIVjX/sdo7KtjxoEsSUosfPzveVhT9RWMx9yGqFDKFJ89JAEKuEeLBmurDjrkb4w8tOnAdSO3SBaplY3bw==",
+      "version": "0.67.0",
+      "resolved": "https://registry.npmjs.org/@oxfmt/binding-darwin-arm64/-/binding-darwin-arm64-0.67.0.tgz",
+      "integrity": "sha512-MfONZx/O2o9M5v2jDFol556G9+A+P9xCuJ4DZ+qhE+RnaCdoscy6Eu5nq1dbuNxhwdJyZ6kLI7fnG9mwEeOeGg==",
       "cpu": [
       "cpu": [
         "arm64"
         "arm64"
       ],
       ],
@@ -2431,9 +2431,9 @@
       }
       }
     },
     },
     "node_modules/@oxfmt/binding-darwin-x64": {
     "node_modules/@oxfmt/binding-darwin-x64": {
-      "version": "0.66.0",
-      "resolved": "https://registry.npmjs.org/@oxfmt/binding-darwin-x64/-/binding-darwin-x64-0.66.0.tgz",
-      "integrity": "sha512-q5xUsKeFqawa9NXa6ZGXWimFV19m8MogKPdTaSVDAAk2EQKBmBZRDeluwcl1p8ty/OFc9s9888OKEh3xfPVH0g==",
+      "version": "0.67.0",
+      "resolved": "https://registry.npmjs.org/@oxfmt/binding-darwin-x64/-/binding-darwin-x64-0.67.0.tgz",
+      "integrity": "sha512-CYnIx5LvFVJnyJcCqwH2jxMKjFjqo5678MPjdmNFoSGMhlOvZ/xRZqvhDcolKrXc8fezW3AKh+C4wyoFuWOSSg==",
       "cpu": [
       "cpu": [
         "x64"
         "x64"
       ],
       ],
@@ -2448,9 +2448,9 @@
       }
       }
     },
     },
     "node_modules/@oxfmt/binding-freebsd-x64": {
     "node_modules/@oxfmt/binding-freebsd-x64": {
-      "version": "0.66.0",
-      "resolved": "https://registry.npmjs.org/@oxfmt/binding-freebsd-x64/-/binding-freebsd-x64-0.66.0.tgz",
-      "integrity": "sha512-CR+x4VzMY0pRXLK/xFQ/RzsSFkP5t2Z2mef0QY6OP/rTRcMUoMLCOM62/3Fp/t0K+UDoBKxvMyeb6D0zPMjleA==",
+      "version": "0.67.0",
+      "resolved": "https://registry.npmjs.org/@oxfmt/binding-freebsd-x64/-/binding-freebsd-x64-0.67.0.tgz",
+      "integrity": "sha512-7/iF1orvIS9mxhKUqnmtMgm+OrSQ5acPwuvdQrm6ECgqbwPmC+Pw9cdke3sNfVN6pT2hbJ58+jP8BCThl5HXOg==",
       "cpu": [
       "cpu": [
         "x64"
         "x64"
       ],
       ],
@@ -2465,9 +2465,9 @@
       }
       }
     },
     },
     "node_modules/@oxfmt/binding-linux-arm-gnueabihf": {
     "node_modules/@oxfmt/binding-linux-arm-gnueabihf": {
-      "version": "0.66.0",
-      "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-0.66.0.tgz",
-      "integrity": "sha512-ZEYmO/LbH9tTQCADILHGZE4GeOXOAj2VzedHkASNwjmwlwtutJCLpCJbIs37wRGTFgWRoEcD72jpMX+IBJUGjQ==",
+      "version": "0.67.0",
+      "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-0.67.0.tgz",
+      "integrity": "sha512-yy+OGys07IZOpOmYPZoObKyUQLkfxeQqeCypk+1jaZd8HGo77hzvU1Jg8X3+W75o+9lszOjBfg0nkGtlwYywXw==",
       "cpu": [
       "cpu": [
         "arm"
         "arm"
       ],
       ],
@@ -2482,9 +2482,9 @@
       }
       }
     },
     },
     "node_modules/@oxfmt/binding-linux-arm-musleabihf": {
     "node_modules/@oxfmt/binding-linux-arm-musleabihf": {
-      "version": "0.66.0",
-      "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-0.66.0.tgz",
-      "integrity": "sha512-hNtR9/oU0CeTkq7JnRkmBQwqe17v2ZaAMLC4VcN7IIOWeRyWDk0knSPWS9iiLmtbZ2RRBBtsG01jQgkZmKCJeQ==",
+      "version": "0.67.0",
+      "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-0.67.0.tgz",
+      "integrity": "sha512-wPIeeigXgJpwNw3wydYRt3U9iN9Y/ejpOZuYL9IA7igxWs7LIQMOkhKxTumRvy6dIv0iXKk3RTw3Vmjg0i+2sg==",
       "cpu": [
       "cpu": [
         "arm"
         "arm"
       ],
       ],
@@ -2499,9 +2499,9 @@
       }
       }
     },
     },
     "node_modules/@oxfmt/binding-linux-arm64-gnu": {
     "node_modules/@oxfmt/binding-linux-arm64-gnu": {
-      "version": "0.66.0",
-      "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-0.66.0.tgz",
-      "integrity": "sha512-uwOVQ8i6I1LT/+eDzfsgrrcZp8Fn6NPVUPn8fF5gdFGekFf0PddF+LEuwsD0/pbNUcKZhDj2rQ5UpITh9gF4iQ==",
+      "version": "0.67.0",
+      "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-0.67.0.tgz",
+      "integrity": "sha512-0+XNxcdbkTfxdcD4qW6Ci9n+mBNJ8xTBumnxKvKBmRFOdx0Wf8/KiHjCJayooXmYkqRpRVd98Q5egvzx5BLSgQ==",
       "cpu": [
       "cpu": [
         "arm64"
         "arm64"
       ],
       ],
@@ -2519,9 +2519,9 @@
       }
       }
     },
     },
     "node_modules/@oxfmt/binding-linux-arm64-musl": {
     "node_modules/@oxfmt/binding-linux-arm64-musl": {
-      "version": "0.66.0",
-      "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-arm64-musl/-/binding-linux-arm64-musl-0.66.0.tgz",
-      "integrity": "sha512-tTkF2Dmx4nGAjmBlZb+UtTGqR/EK4ZrW9qBfzte07a9XWqzoGGKzpFFlyNDhQe+Uwql94+ReCTeNbhOXscw1Dg==",
+      "version": "0.67.0",
+      "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-arm64-musl/-/binding-linux-arm64-musl-0.67.0.tgz",
+      "integrity": "sha512-I75LKPJyNOYUzkqAiAMIE31+Ye7xtQXZdoty1IXn4B+bw5Zpmez5wfG19ejGpNnS/BzQ7LFS+7jxuTPb+vHiZw==",
       "cpu": [
       "cpu": [
         "arm64"
         "arm64"
       ],
       ],
@@ -2539,9 +2539,9 @@
       }
       }
     },
     },
     "node_modules/@oxfmt/binding-linux-ppc64-gnu": {
     "node_modules/@oxfmt/binding-linux-ppc64-gnu": {
-      "version": "0.66.0",
-      "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-0.66.0.tgz",
-      "integrity": "sha512-F3cKHUav4yXOHn6GFnwpBhSYsJOYKKf9eqO/9jlEuqPxNw9zb98E9ZFct79gcg8pibUGkbveEu9WDlmXJpDzKw==",
+      "version": "0.67.0",
+      "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-0.67.0.tgz",
+      "integrity": "sha512-c2M5iRpe1QMZSRE/UvZoPdXBWb5Ic/ycvOyNiKCqPwQ/OyOKIMiJs02ynlNnjb7ZZJnRXYLmGcohoINOcwDK3w==",
       "cpu": [
       "cpu": [
         "ppc64"
         "ppc64"
       ],
       ],
@@ -2559,9 +2559,9 @@
       }
       }
     },
     },
     "node_modules/@oxfmt/binding-linux-riscv64-gnu": {
     "node_modules/@oxfmt/binding-linux-riscv64-gnu": {
-      "version": "0.66.0",
-      "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-0.66.0.tgz",
-      "integrity": "sha512-K5fDaNZfDyQMYA/3qL21bqyN0X9T15LLwwbFPt2aHc94+ZG7bh0vZEsy2y7NlRnjjHFSwN+Hzg6ldJtbOriH4Q==",
+      "version": "0.67.0",
+      "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-0.67.0.tgz",
+      "integrity": "sha512-dQzzYlV24Udhfm5ECuSdgqRvFJU/CGHzcYYEO3dLM6W6+CHiBFrq9OjIllkdCcPhsoSQ8o223Dja84MOSzed9A==",
       "cpu": [
       "cpu": [
         "riscv64"
         "riscv64"
       ],
       ],
@@ -2579,9 +2579,9 @@
       }
       }
     },
     },
     "node_modules/@oxfmt/binding-linux-riscv64-musl": {
     "node_modules/@oxfmt/binding-linux-riscv64-musl": {
-      "version": "0.66.0",
-      "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-0.66.0.tgz",
-      "integrity": "sha512-44Yc+I+qOmTElRcEhm5hUKIUJEQIOugymz4ua4tB0Wox7tGAfIbjzmXz/HDAtw1Ij6gmBwZlzh4hc9679RhWeA==",
+      "version": "0.67.0",
+      "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-0.67.0.tgz",
+      "integrity": "sha512-rFNq1CgX4qMJANOq42LkAs90JE80GpiaEohAV2qn/gT2hGjQTW1zBO5zQBxArI4926pM1OSzo3CN0tBszGBIaA==",
       "cpu": [
       "cpu": [
         "riscv64"
         "riscv64"
       ],
       ],
@@ -2599,9 +2599,9 @@
       }
       }
     },
     },
     "node_modules/@oxfmt/binding-linux-s390x-gnu": {
     "node_modules/@oxfmt/binding-linux-s390x-gnu": {
-      "version": "0.66.0",
-      "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-0.66.0.tgz",
-      "integrity": "sha512-1e29Eg9hEj2kRBB19M0seIehPbbXHCk35GvImjDvb79rjjYjXCRmtbUNHJcgoktZAMIzXrTbxDBKmTc1V4bg3A==",
+      "version": "0.67.0",
+      "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-0.67.0.tgz",
+      "integrity": "sha512-Sky6rEdz2o5IGq01lPhS12yEvDdChVEcaYrcLHkveh4Fx0qPjljE/Iul6SX/bRMl6lNc8J7J/mDQdzgBdA++Pg==",
       "cpu": [
       "cpu": [
         "s390x"
         "s390x"
       ],
       ],
@@ -2619,9 +2619,9 @@
       }
       }
     },
     },
     "node_modules/@oxfmt/binding-linux-x64-gnu": {
     "node_modules/@oxfmt/binding-linux-x64-gnu": {
-      "version": "0.66.0",
-      "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-x64-gnu/-/binding-linux-x64-gnu-0.66.0.tgz",
-      "integrity": "sha512-vODY1UQo10gngn0+D4xHKU84F1Twm1LqrzV4SqPXvmQKSd87paehvZ6jqA5wKs6XQrlWul9clYMDVHcoW9CPMA==",
+      "version": "0.67.0",
+      "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-x64-gnu/-/binding-linux-x64-gnu-0.67.0.tgz",
+      "integrity": "sha512-vPXmlNORV8AZq2Ocxh07pxwMjfENUWCV/eZArnao0qC3NO/hDeTVkQvee7SJJUbIiF5PZbBa4kYmaXnu7Rk58w==",
       "cpu": [
       "cpu": [
         "x64"
         "x64"
       ],
       ],
@@ -2639,9 +2639,9 @@
       }
       }
     },
     },
     "node_modules/@oxfmt/binding-linux-x64-musl": {
     "node_modules/@oxfmt/binding-linux-x64-musl": {
-      "version": "0.66.0",
-      "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-x64-musl/-/binding-linux-x64-musl-0.66.0.tgz",
-      "integrity": "sha512-YDzXx2JsT4+HL4MdkVrYjO55NS5lUKNm8rLC4ZPou8+seu0v0jhecSh+ufoO6+xEa8gccEezMlI2WHJi4ApUgw==",
+      "version": "0.67.0",
+      "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-x64-musl/-/binding-linux-x64-musl-0.67.0.tgz",
+      "integrity": "sha512-x/WAtFqYtVr3vZ9ni8nr4kn9whSitg8fOljq/pZzBpxopRdY1BMLZCZkrbIbaBcYkm46qGbqVea2FCWmtQ2P9w==",
       "cpu": [
       "cpu": [
         "x64"
         "x64"
       ],
       ],
@@ -2659,9 +2659,9 @@
       }
       }
     },
     },
     "node_modules/@oxfmt/binding-openharmony-arm64": {
     "node_modules/@oxfmt/binding-openharmony-arm64": {
-      "version": "0.66.0",
-      "resolved": "https://registry.npmjs.org/@oxfmt/binding-openharmony-arm64/-/binding-openharmony-arm64-0.66.0.tgz",
-      "integrity": "sha512-mJjUYd8lj0+j4JkYyEM+5qKBf1Rnrpgjn/SVYKJhicVDqLz566ooa7Fs8zflPqt+dnZDV7X054rVIQX6ZcQNlQ==",
+      "version": "0.67.0",
+      "resolved": "https://registry.npmjs.org/@oxfmt/binding-openharmony-arm64/-/binding-openharmony-arm64-0.67.0.tgz",
+      "integrity": "sha512-eRw9Neh4/aA6i+q/R3WU1gGQINhVM0J4fXIm6t27caOamkr/37uAkp1IdBx4zlJH97hmXR63z/q9n5c5dN7MzA==",
       "cpu": [
       "cpu": [
         "arm64"
         "arm64"
       ],
       ],
@@ -2676,9 +2676,9 @@
       }
       }
     },
     },
     "node_modules/@oxfmt/binding-win32-arm64-msvc": {
     "node_modules/@oxfmt/binding-win32-arm64-msvc": {
-      "version": "0.66.0",
-      "resolved": "https://registry.npmjs.org/@oxfmt/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-0.66.0.tgz",
-      "integrity": "sha512-soV+0vESv7e5ntCHWC61x4gg8OSak6IHHnWsZmHrJFlvMj2AK+kmldErCNkVkrvc1Ts2/++rJXn+IuAb2WMXhw==",
+      "version": "0.67.0",
+      "resolved": "https://registry.npmjs.org/@oxfmt/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-0.67.0.tgz",
+      "integrity": "sha512-YIMvb+sGNYN2uc6+QK2HLPeEKM2vl7QZ5onQzpAJRb6pnf0DwUFP5R8tdS9R0l8hdUil2gu4Uxd0Yxrop0iT4w==",
       "cpu": [
       "cpu": [
         "arm64"
         "arm64"
       ],
       ],
@@ -2693,9 +2693,9 @@
       }
       }
     },
     },
     "node_modules/@oxfmt/binding-win32-ia32-msvc": {
     "node_modules/@oxfmt/binding-win32-ia32-msvc": {
-      "version": "0.66.0",
-      "resolved": "https://registry.npmjs.org/@oxfmt/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-0.66.0.tgz",
-      "integrity": "sha512-YCPi23uRIEYuIKTZohAkKbPFpujQ5QBuUM5iDv+UqbCmTPAkaFsxjsSuB8xlBpRT0G7eP/4HMF+cPDSqHtOD9A==",
+      "version": "0.67.0",
+      "resolved": "https://registry.npmjs.org/@oxfmt/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-0.67.0.tgz",
+      "integrity": "sha512-LzmU9MyACPzwNDIK0ItMedHPz735Ug7ELWguxo4/kuy6zWuDoeglOAEFCY8jLg0PzRpFO3hDyLFe2Gu2eFDeGA==",
       "cpu": [
       "cpu": [
         "ia32"
         "ia32"
       ],
       ],
@@ -2710,9 +2710,9 @@
       }
       }
     },
     },
     "node_modules/@oxfmt/binding-win32-x64-msvc": {
     "node_modules/@oxfmt/binding-win32-x64-msvc": {
-      "version": "0.66.0",
-      "resolved": "https://registry.npmjs.org/@oxfmt/binding-win32-x64-msvc/-/binding-win32-x64-msvc-0.66.0.tgz",
-      "integrity": "sha512-bwTQcv/JVRPkOqQtMF0X7vpvpncDQiBcXHxZ9S2hR12Hlo8bvBdUR5x5XnxzDZ3kM0qoZw1rv7KaD66Ly+pFWA==",
+      "version": "0.67.0",
+      "resolved": "https://registry.npmjs.org/@oxfmt/binding-win32-x64-msvc/-/binding-win32-x64-msvc-0.67.0.tgz",
+      "integrity": "sha512-sbQOIDNLUEeVZcAJcSL5VURn7kfjvilPviody4Yl5n8lQCDtUm+C9oHTTwZS/m4d/Z6Vv3jNEiAofH932NPPCg==",
       "cpu": [
       "cpu": [
         "x64"
         "x64"
       ],
       ],
@@ -2811,9 +2811,9 @@
       ]
       ]
     },
     },
     "node_modules/@oxlint/binding-android-arm-eabi": {
     "node_modules/@oxlint/binding-android-arm-eabi": {
-      "version": "1.81.0",
-      "resolved": "https://registry.npmjs.org/@oxlint/binding-android-arm-eabi/-/binding-android-arm-eabi-1.81.0.tgz",
-      "integrity": "sha512-IcCRsXiedJoJopY6mpZUBEeVFsUrutmrG7dZ87zMuKJlhg70Ora9bBl1WcCxZQtyI10YpnVdEso5oCg7YcfSHw==",
+      "version": "1.82.0",
+      "resolved": "https://registry.npmjs.org/@oxlint/binding-android-arm-eabi/-/binding-android-arm-eabi-1.82.0.tgz",
+      "integrity": "sha512-a3LB+C5Dsj5b/qtmG/mv5WrzuiXEpg1KF5nXWcEvaoN5TYAqkIvxPOwTPp3Jy/FoGpRo8zsTFhMElMXfeoOEzA==",
       "cpu": [
       "cpu": [
         "arm"
         "arm"
       ],
       ],
@@ -2828,9 +2828,9 @@
       }
       }
     },
     },
     "node_modules/@oxlint/binding-android-arm64": {
     "node_modules/@oxlint/binding-android-arm64": {
-      "version": "1.81.0",
-      "resolved": "https://registry.npmjs.org/@oxlint/binding-android-arm64/-/binding-android-arm64-1.81.0.tgz",
-      "integrity": "sha512-GRrIPyTGVhx3L3h+0T5xT2A0jFAcdPv4+IfuXpGDLIdl6XeYhgg/zw72A5ILZoUgRqZuM8F1y+V/gfDriXSxzQ==",
+      "version": "1.82.0",
+      "resolved": "https://registry.npmjs.org/@oxlint/binding-android-arm64/-/binding-android-arm64-1.82.0.tgz",
+      "integrity": "sha512-OBlhRgNqFblGpGenno/aqOfJLOkQ2B8Ig3iDAalfn0H8hJGZKXPeexCRTDm6uwv6YUjSA9Xnwt1y/Bgj5ZH8uw==",
       "cpu": [
       "cpu": [
         "arm64"
         "arm64"
       ],
       ],
@@ -2845,9 +2845,9 @@
       }
       }
     },
     },
     "node_modules/@oxlint/binding-darwin-arm64": {
     "node_modules/@oxlint/binding-darwin-arm64": {
-      "version": "1.81.0",
-      "resolved": "https://registry.npmjs.org/@oxlint/binding-darwin-arm64/-/binding-darwin-arm64-1.81.0.tgz",
-      "integrity": "sha512-qNQ9tXRgLuKbqSV1S2h9h4KPHjbovO7RRR2/enUOtHzTkFZ7B9X5zqqHJua8dRyc7dBy7Aoyq5pqTSLFVcAzGQ==",
+      "version": "1.82.0",
+      "resolved": "https://registry.npmjs.org/@oxlint/binding-darwin-arm64/-/binding-darwin-arm64-1.82.0.tgz",
+      "integrity": "sha512-dsopxqtY5ZdyT9uLHyGt1SyiLop6hi7hWI3PKpePodkRQOkLaCm+OE4fR9CAz9qdfjiFO8531tX/QDyP/psjFg==",
       "cpu": [
       "cpu": [
         "arm64"
         "arm64"
       ],
       ],
@@ -2862,9 +2862,9 @@
       }
       }
     },
     },
     "node_modules/@oxlint/binding-darwin-x64": {
     "node_modules/@oxlint/binding-darwin-x64": {
-      "version": "1.81.0",
-      "resolved": "https://registry.npmjs.org/@oxlint/binding-darwin-x64/-/binding-darwin-x64-1.81.0.tgz",
-      "integrity": "sha512-q0QTm32jWga2Gv4j7IaVZN0jYMi9UV73sWVgFtDA4iIfqwMCLLZ3ve+9KwfYtsaKZSgQhmPaogeZWqDZpcY1Pw==",
+      "version": "1.82.0",
+      "resolved": "https://registry.npmjs.org/@oxlint/binding-darwin-x64/-/binding-darwin-x64-1.82.0.tgz",
+      "integrity": "sha512-94Lu0SgTClKColU66g1VDuigV3HkcbkJBnTtZjGYfE8UPugaWDgKrm2icjC6HJVUYler2OXaHP/X0TBy8+CowQ==",
       "cpu": [
       "cpu": [
         "x64"
         "x64"
       ],
       ],
@@ -2879,9 +2879,9 @@
       }
       }
     },
     },
     "node_modules/@oxlint/binding-freebsd-x64": {
     "node_modules/@oxlint/binding-freebsd-x64": {
-      "version": "1.81.0",
-      "resolved": "https://registry.npmjs.org/@oxlint/binding-freebsd-x64/-/binding-freebsd-x64-1.81.0.tgz",
-      "integrity": "sha512-/+8wVWDXEC7wHVAhOc59Fw/SkMc1arLkFD8iQCaSsmzenK1X4doFqquL9H1wrtGUzaiycVqkf/sSpcILK6W1UA==",
+      "version": "1.82.0",
+      "resolved": "https://registry.npmjs.org/@oxlint/binding-freebsd-x64/-/binding-freebsd-x64-1.82.0.tgz",
+      "integrity": "sha512-hne/V06ewhh1i0w8+l7GDNROAGCGPmyFuOwiP7YTRu0JycyStJ4785dmF8xU5p0uUwt2emvIF9vc7Xjis+cJ0g==",
       "cpu": [
       "cpu": [
         "x64"
         "x64"
       ],
       ],
@@ -2896,9 +2896,9 @@
       }
       }
     },
     },
     "node_modules/@oxlint/binding-linux-arm-gnueabihf": {
     "node_modules/@oxlint/binding-linux-arm-gnueabihf": {
-      "version": "1.81.0",
-      "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.81.0.tgz",
-      "integrity": "sha512-4xt422FEgioRq9hAL4Tq7fujGUWnc8z1BJ+Oi8RN8vB8axaP+sdK6a2xdlcQCCYnJg9QMuMFS0AucuIFx/EacA==",
+      "version": "1.82.0",
+      "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.82.0.tgz",
+      "integrity": "sha512-aWY2xtbZf1LneW9Qsv/n2Sp8gOu74JrlQzEtj4coHX2SHFrCfhmAumaU+sI/A5nr+yoTRTSmI/pL2s6ADlNSkw==",
       "cpu": [
       "cpu": [
         "arm"
         "arm"
       ],
       ],
@@ -2913,9 +2913,9 @@
       }
       }
     },
     },
     "node_modules/@oxlint/binding-linux-arm-musleabihf": {
     "node_modules/@oxlint/binding-linux-arm-musleabihf": {
-      "version": "1.81.0",
-      "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-1.81.0.tgz",
-      "integrity": "sha512-u3vna8KdGplH4DRCW9K54D68fcMo7IxVrkCJWwXnIhwtBdnDnYrmzOUA/XjmBlPpcLsgw9Z5BNdY4za9+Dj+MQ==",
+      "version": "1.82.0",
+      "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-1.82.0.tgz",
+      "integrity": "sha512-Fe+TtXCXMh/5f7kWlZ2VAwsMumZWtraFlKVk1NJlL52/beGwfDE7ov+/8gVirHzWokzGu7X65hSPq0ucPDskWQ==",
       "cpu": [
       "cpu": [
         "arm"
         "arm"
       ],
       ],
@@ -2930,9 +2930,9 @@
       }
       }
     },
     },
     "node_modules/@oxlint/binding-linux-arm64-gnu": {
     "node_modules/@oxlint/binding-linux-arm64-gnu": {
-      "version": "1.81.0",
-      "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.81.0.tgz",
-      "integrity": "sha512-3j9k+gsYsE7nv71GWotXsqsa2l9/aJenD7dVHNt/CBvsb0SgRjSMnHFeP59IXUAl1wvVFhqGl2wJNMwWU3UBlA==",
+      "version": "1.82.0",
+      "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.82.0.tgz",
+      "integrity": "sha512-6azCZ6OJudlvipNttXCCQcyeFfcJ/NvUZdSN1z8elo73kCHtyQC7WTiUcSjWYvJ1jaq9KDUyMAoAS/vNzhBomA==",
       "cpu": [
       "cpu": [
         "arm64"
         "arm64"
       ],
       ],
@@ -2950,9 +2950,9 @@
       }
       }
     },
     },
     "node_modules/@oxlint/binding-linux-arm64-musl": {
     "node_modules/@oxlint/binding-linux-arm64-musl": {
-      "version": "1.81.0",
-      "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.81.0.tgz",
-      "integrity": "sha512-k5iAp3dNxW0/uDCBY+WSm8jKB2szu7SkEQZdgRRpDXvuDd69vvDcqhB3A/pWCfCwXyenjNjFn9Td1fVoyAc+Yg==",
+      "version": "1.82.0",
+      "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.82.0.tgz",
+      "integrity": "sha512-PLEaSD8IAIIlwW4dwOd9YaxuxeOpwiXL4J24rcnE4iNtyM5j9Q9/3+gti08oXpx0u2ygNjRDx9xjWWpQonuJEw==",
       "cpu": [
       "cpu": [
         "arm64"
         "arm64"
       ],
       ],
@@ -2970,9 +2970,9 @@
       }
       }
     },
     },
     "node_modules/@oxlint/binding-linux-ppc64-gnu": {
     "node_modules/@oxlint/binding-linux-ppc64-gnu": {
-      "version": "1.81.0",
-      "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.81.0.tgz",
-      "integrity": "sha512-TFqLja3uYmVSte6nof9GWrex9Z8WgdZrNiLC6Te5rXGDqXB2y4j/26iFhwosXiAFqDhE9JJVuuCkDKLwptTn1g==",
+      "version": "1.82.0",
+      "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.82.0.tgz",
+      "integrity": "sha512-D94em/BwknNTn4vqxjHh5wb2oL566eFhArabqKIr0cNZMHOJuiraFp1A8tXpH05bbE5tqwEfLXTI0MWEGtn3Dw==",
       "cpu": [
       "cpu": [
         "ppc64"
         "ppc64"
       ],
       ],
@@ -2990,9 +2990,9 @@
       }
       }
     },
     },
     "node_modules/@oxlint/binding-linux-riscv64-gnu": {
     "node_modules/@oxlint/binding-linux-riscv64-gnu": {
-      "version": "1.81.0",
-      "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-1.81.0.tgz",
-      "integrity": "sha512-UEcySvGS0NOVo7h7n7CYyJL9+6gFAh7Zc/ToDXVScFvzHSTIxtzkMVU30rmQ6+nQ1LF+UdiRDdJajpDu+OylLg==",
+      "version": "1.82.0",
+      "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-1.82.0.tgz",
+      "integrity": "sha512-MOprxBaoYU2D4VgxXCl3ghydThWtx7Um1lL51kGYNeQ5Al7WzsH7/tqGdNtbLrIWnjq3bsm13+nz/gRIxjrOXw==",
       "cpu": [
       "cpu": [
         "riscv64"
         "riscv64"
       ],
       ],
@@ -3010,9 +3010,9 @@
       }
       }
     },
     },
     "node_modules/@oxlint/binding-linux-riscv64-musl": {
     "node_modules/@oxlint/binding-linux-riscv64-musl": {
-      "version": "1.81.0",
-      "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-1.81.0.tgz",
-      "integrity": "sha512-H+diDbhD00+wI1IRP8Kz88x/lat+DgtoBJzoTthS16xkTJGNaEkfb8gzmd1rzc/2uDQQMl7GNl+JFUacVeWxIA==",
+      "version": "1.82.0",
+      "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-1.82.0.tgz",
+      "integrity": "sha512-5h55QsfJ/luDXZzC20k6SNOY1Az+dCP9WvntKtcUWh2JhckAdwApY2ZusaBTwLENnReXU+A2fJtSrYvZJNKNPg==",
       "cpu": [
       "cpu": [
         "riscv64"
         "riscv64"
       ],
       ],
@@ -3030,9 +3030,9 @@
       }
       }
     },
     },
     "node_modules/@oxlint/binding-linux-s390x-gnu": {
     "node_modules/@oxlint/binding-linux-s390x-gnu": {
-      "version": "1.81.0",
-      "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.81.0.tgz",
-      "integrity": "sha512-8znJ/5TekjOKg1j1Acho4PJMdiAHLtlcXuWEiipOhAMV6rQcXdmDdXCbheyDczN6TjBwiNfjcP81k4AthrKRzw==",
+      "version": "1.82.0",
+      "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.82.0.tgz",
+      "integrity": "sha512-IE8NJNLlHr0CaXyGJPGVn0eTkUyoj1I2UfA8x7I4PSOYKsQ/6btVC7Pywrj5onk0cMH25r6Z38SoN3AvE5Zuog==",
       "cpu": [
       "cpu": [
         "s390x"
         "s390x"
       ],
       ],
@@ -3050,9 +3050,9 @@
       }
       }
     },
     },
     "node_modules/@oxlint/binding-linux-x64-gnu": {
     "node_modules/@oxlint/binding-linux-x64-gnu": {
-      "version": "1.81.0",
-      "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.81.0.tgz",
-      "integrity": "sha512-Q2Wj70yFsvn5QjlmifFzbj4H+kJy53bwqc41o1fzoM7MpLV1NIbhg/LpWXRfC6KOkSAdUx1Wd8VJsdPmhp/HRA==",
+      "version": "1.82.0",
+      "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.82.0.tgz",
+      "integrity": "sha512-XUUUxaBo9XKl+J1B9EmP1cTGQPddzeURvoGkfwh/94PGnbW+hBprDljneoI2M1jzC1bzrIV3ihc7iM9UXl8+tg==",
       "cpu": [
       "cpu": [
         "x64"
         "x64"
       ],
       ],
@@ -3070,9 +3070,9 @@
       }
       }
     },
     },
     "node_modules/@oxlint/binding-linux-x64-musl": {
     "node_modules/@oxlint/binding-linux-x64-musl": {
-      "version": "1.81.0",
-      "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-x64-musl/-/binding-linux-x64-musl-1.81.0.tgz",
-      "integrity": "sha512-cPInHp/ddEe5qkyK2IiyQ8Q3Mp2oLLEhhsGgTK2oZx4L6+llGam1H1yBvJZ7qHfOXj8N3hxBS8sj4tO+gtFlIg==",
+      "version": "1.82.0",
+      "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-x64-musl/-/binding-linux-x64-musl-1.82.0.tgz",
+      "integrity": "sha512-SWLSFulX9TDuH6yvbPYp4+VNn6jkkIvvI+KiujDM5rWBRHEfkesCC/pCneIIUr6ovkxZ5fRtpi2v5Cz5FrMJZg==",
       "cpu": [
       "cpu": [
         "x64"
         "x64"
       ],
       ],
@@ -3090,9 +3090,9 @@
       }
       }
     },
     },
     "node_modules/@oxlint/binding-openharmony-arm64": {
     "node_modules/@oxlint/binding-openharmony-arm64": {
-      "version": "1.81.0",
-      "resolved": "https://registry.npmjs.org/@oxlint/binding-openharmony-arm64/-/binding-openharmony-arm64-1.81.0.tgz",
-      "integrity": "sha512-0CQxSX4ajqm07AHBf5U33qQzXKdd7wtq/oTL/7vpY6RNNuxrRi8W4bqUV1Jyu/vj+9KmxQyDhxfeVX1nQL6kfg==",
+      "version": "1.82.0",
+      "resolved": "https://registry.npmjs.org/@oxlint/binding-openharmony-arm64/-/binding-openharmony-arm64-1.82.0.tgz",
+      "integrity": "sha512-BQy35f6ZUdNr9a6c7B7orxQTcLjByGT2z3WAgmRovpRwmPYAaJ+NTplmMzhdjdJ4qSchfMNZy/Ukg+qRg6zseQ==",
       "cpu": [
       "cpu": [
         "arm64"
         "arm64"
       ],
       ],
@@ -3107,9 +3107,9 @@
       }
       }
     },
     },
     "node_modules/@oxlint/binding-win32-arm64-msvc": {
     "node_modules/@oxlint/binding-win32-arm64-msvc": {
-      "version": "1.81.0",
-      "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.81.0.tgz",
-      "integrity": "sha512-l0hbeISm9673hVrrQU8j/p2M7YH9Ouoj7p7E/QM55NTrKVLP+P3PF8hLu+OY+x0VtGRW+ggiQKZqmdYps9H+TA==",
+      "version": "1.82.0",
+      "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.82.0.tgz",
+      "integrity": "sha512-V4QhSTg5gctZue8RJjsGi7NpQPThr/p1/HfmiMC5kfe1KFEup9SQRVub4A6kijQjdHfxj7bLL1KO3QO7/5bwMQ==",
       "cpu": [
       "cpu": [
         "arm64"
         "arm64"
       ],
       ],
@@ -3124,9 +3124,9 @@
       }
       }
     },
     },
     "node_modules/@oxlint/binding-win32-ia32-msvc": {
     "node_modules/@oxlint/binding-win32-ia32-msvc": {
-      "version": "1.81.0",
-      "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-1.81.0.tgz",
-      "integrity": "sha512-ksqPP5jbFXcYreEQ7zdJh06rJQBymCTyGRCdaXjfcf2aG4f8KxUWY5wcgYHmaTK+FJ4bPG5sUAdOX+6trnH1JA==",
+      "version": "1.82.0",
+      "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-1.82.0.tgz",
+      "integrity": "sha512-TUSCLaKB2yktpFAJ/r3HAUYsaV/3DT7JS4iNKyoh3a9YNwD0UG7Ezh4D8m23654vQcU6P/RQrCAjRPKe4peP/A==",
       "cpu": [
       "cpu": [
         "ia32"
         "ia32"
       ],
       ],
@@ -3141,9 +3141,9 @@
       }
       }
     },
     },
     "node_modules/@oxlint/binding-win32-x64-msvc": {
     "node_modules/@oxlint/binding-win32-x64-msvc": {
-      "version": "1.81.0",
-      "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.81.0.tgz",
-      "integrity": "sha512-IZuUCwGw9emG5JtCp+fYGB+Z4OWEoeEcM8R5BA1pYw63/ieYFVdcU2ylxTpHbVHSenZnsYE+ZZ20uHAJszQ4cA==",
+      "version": "1.82.0",
+      "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.82.0.tgz",
+      "integrity": "sha512-VTVoRIWJTb+wvUX8EYoPArfFH02whuR10goFXE/LHRRX33ajRrFgqbcONXZMiF4C5rnattfkm87HqYn8jb8hmQ==",
       "cpu": [
       "cpu": [
         "x64"
         "x64"
       ],
       ],
@@ -3504,15 +3504,15 @@
       }
       }
     },
     },
     "node_modules/@rc-component/picker": {
     "node_modules/@rc-component/picker": {
-      "version": "1.12.0",
-      "resolved": "https://registry.npmjs.org/@rc-component/picker/-/picker-1.12.0.tgz",
-      "integrity": "sha512-0FZGgDiDZFMm2hcfGv4jyjm7yWIj2MiwfeTzLL0vWkO+8cCSKdiM9XhWpkn1aAsoI2EwUy48Oz2cGfvL8ZKrfw==",
+      "version": "1.12.2",
+      "resolved": "https://registry.npmjs.org/@rc-component/picker/-/picker-1.12.2.tgz",
+      "integrity": "sha512-ZB1A+tzTQkgtR4flPrAMvxIAOelIIOOOsp22fU2Nad6PbZcJacnyGJUJqUH7ZS9mwxeUwurf1pLMKXrVsK1tlA==",
       "license": "MIT",
       "license": "MIT",
       "dependencies": {
       "dependencies": {
         "@rc-component/overflow": "^1.0.0",
         "@rc-component/overflow": "^1.0.0",
         "@rc-component/resize-observer": "^1.0.0",
         "@rc-component/resize-observer": "^1.0.0",
         "@rc-component/trigger": "^3.6.15",
         "@rc-component/trigger": "^3.6.15",
-        "@rc-component/util": "^1.11.1",
+        "@rc-component/util": "^1.13.0",
         "clsx": "^2.1.1"
         "clsx": "^2.1.1"
       },
       },
       "engines": {
       "engines": {
@@ -3847,9 +3847,9 @@
       }
       }
     },
     },
     "node_modules/@rc-component/util": {
     "node_modules/@rc-component/util": {
-      "version": "1.12.0",
-      "resolved": "https://registry.npmjs.org/@rc-component/util/-/util-1.12.0.tgz",
-      "integrity": "sha512-AEjPL8JVdohIITaiXokyjL9WQ6tKWWjAYK9QU16tGNE9JaQABBQy+hA4H2Lup5MgXy9yY3iLrbZJheuU13hTdQ==",
+      "version": "1.13.0",
+      "resolved": "https://registry.npmjs.org/@rc-component/util/-/util-1.13.0.tgz",
+      "integrity": "sha512-IXf2zBfZrbQGT+G8LxnoICDBGnMHIPIiq5HCRn3EDfKUx28x1rnbZBj46ULJf4sM8ImTG1SepqfNBdxZziqQ4w==",
       "license": "MIT",
       "license": "MIT",
       "dependencies": {
       "dependencies": {
         "is-mobile": "^5.0.0",
         "is-mobile": "^5.0.0",
@@ -6146,16 +6146,16 @@
       }
       }
     },
     },
     "node_modules/antd": {
     "node_modules/antd": {
-      "version": "6.6.2",
-      "resolved": "https://registry.npmjs.org/antd/-/antd-6.6.2.tgz",
-      "integrity": "sha512-aTOPWXsqfWrlSiI0a1qR9UWR4jBCTJ8FNZfZmYQP9/aGGLJSrw9/c5uE+4vSVSt3riQlIPAwR3BcAgEHmE7GMg==",
+      "version": "6.6.3",
+      "resolved": "https://registry.npmjs.org/antd/-/antd-6.6.3.tgz",
+      "integrity": "sha512-USnOxcOhWEQc65pHapSiCrZOijWef783XnqYZv8fuYIcYVoFxNxl6TsnsUhmoJyZheD/XwAx9CyO+hlyekDCQA==",
       "license": "MIT",
       "license": "MIT",
       "dependencies": {
       "dependencies": {
         "@ant-design/colors": "^8.0.1",
         "@ant-design/colors": "^8.0.1",
         "@ant-design/cssinjs": "^2.1.2",
         "@ant-design/cssinjs": "^2.1.2",
         "@ant-design/cssinjs-utils": "^2.1.2",
         "@ant-design/cssinjs-utils": "^2.1.2",
         "@ant-design/fast-color": "^3.0.1",
         "@ant-design/fast-color": "^3.0.1",
-        "@ant-design/icons": "^6.3.2",
+        "@ant-design/icons": "^6.3.4",
         "@ant-design/react-slick": "~2.0.0",
         "@ant-design/react-slick": "~2.0.0",
         "@babel/runtime": "^7.29.2",
         "@babel/runtime": "^7.29.2",
         "@rc-component/cascader": "~1.22.0",
         "@rc-component/cascader": "~1.22.0",
@@ -6176,7 +6176,7 @@
         "@rc-component/mutate-observer": "^2.0.1",
         "@rc-component/mutate-observer": "^2.0.1",
         "@rc-component/notification": "~2.0.8",
         "@rc-component/notification": "~2.0.8",
         "@rc-component/pagination": "~1.4.0",
         "@rc-component/pagination": "~1.4.0",
-        "@rc-component/picker": "~1.12.0",
+        "@rc-component/picker": "~1.12.2",
         "@rc-component/progress": "~1.0.3",
         "@rc-component/progress": "~1.0.3",
         "@rc-component/qrcode": "~2.0.0",
         "@rc-component/qrcode": "~2.0.0",
         "@rc-component/rate": "~1.0.1",
         "@rc-component/rate": "~1.0.1",
@@ -6194,7 +6194,7 @@
         "@rc-component/tree-select": "~1.16.1",
         "@rc-component/tree-select": "~1.16.1",
         "@rc-component/trigger": "^3.10.1",
         "@rc-component/trigger": "^3.10.1",
         "@rc-component/upload": "~1.1.1",
         "@rc-component/upload": "~1.1.1",
-        "@rc-component/util": "^1.12.0",
+        "@rc-component/util": "^1.13.0",
         "clsx": "^2.1.1",
         "clsx": "^2.1.1",
         "dayjs": "^1.11.11",
         "dayjs": "^1.11.11",
         "scroll-into-view-if-needed": "^3.1.0",
         "scroll-into-view-if-needed": "^3.1.0",
@@ -7343,21 +7343,6 @@
         "node": ">=0.4.x"
         "node": ">=0.4.x"
       }
       }
     },
     },
-    "node_modules/fsevents": {
-      "version": "2.3.2",
-      "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
-      "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
-      "dev": true,
-      "hasInstallScript": true,
-      "license": "MIT",
-      "optional": true,
-      "os": [
-        "darwin"
-      ],
-      "engines": {
-        "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
-      }
-    },
     "node_modules/function-bind": {
     "node_modules/function-bind": {
       "version": "1.1.2",
       "version": "1.1.2",
       "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
       "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
@@ -7623,9 +7608,9 @@
       }
       }
     },
     },
     "node_modules/i18next": {
     "node_modules/i18next": {
-      "version": "26.4.1",
-      "resolved": "https://registry.npmjs.org/i18next/-/i18next-26.4.1.tgz",
-      "integrity": "sha512-9YbX5E6gd1H+yaOSX3izCsPj5iWXyH7X4oC+iuHHJJw8AeHglzOK5SJf+1CHxaYKYigcea+8jvzSxqYx46YvyA==",
+      "version": "26.4.2",
+      "resolved": "https://registry.npmjs.org/i18next/-/i18next-26.4.2.tgz",
+      "integrity": "sha512-RX+R0VLg13IbvRuJSxnqykUFS9vQZTl8wYpWPCIUDWVrSGjsQywB5Y+pjzrkboxGAuYfJZVH1InFTdgBdxq6ug==",
       "funding": [
       "funding": [
         {
         {
           "type": "individual",
           "type": "individual",
@@ -7671,9 +7656,9 @@
       "license": "BSD-3-Clause"
       "license": "BSD-3-Clause"
     },
     },
     "node_modules/immutable": {
     "node_modules/immutable": {
-      "version": "4.3.9",
-      "resolved": "https://registry.npmjs.org/immutable/-/immutable-4.3.9.tgz",
-      "integrity": "sha512-ObHy4YN7ycwZOUCLI1/6svfyAFu7vL8RhAvVu/bh/RZW9EPlOyDaQ9jDQWCtdqzaXUjgXZCW1migtHE7YI7UGQ==",
+      "version": "5.1.9",
+      "resolved": "https://registry.npmjs.org/immutable/-/immutable-5.1.9.tgz",
+      "integrity": "sha512-m8nVez3rwrgmWxtLMt1ZYXB2Lv7OKYn/disyxAlSDYAlKSlFoPPfIAmAM/M5xqL4m4C/wAPw7S2/CNaUii1Hxg==",
       "license": "MIT"
       "license": "MIT"
     },
     },
     "node_modules/indent-string": {
     "node_modules/indent-string": {
@@ -8266,15 +8251,15 @@
       }
       }
     },
     },
     "node_modules/lint-staged": {
     "node_modules/lint-staged": {
-      "version": "17.4.1",
-      "resolved": "https://registry.npmjs.org/lint-staged/-/lint-staged-17.4.1.tgz",
-      "integrity": "sha512-FmJeudcalbSfg1du+JCfvi5vS6Qt08KgbfLWiHinbef+2JJwUZwAWVoaO1AcJVUTWPfk0t30PMQNwPAeCzYQ+Q==",
+      "version": "17.5.0",
+      "resolved": "https://registry.npmjs.org/lint-staged/-/lint-staged-17.5.0.tgz",
+      "integrity": "sha512-ah2qsNtvKP1+Ak4rAvEEIvcXDLjjbr/xmxCvlequxqEOFYk0qINcZcRxD3Ic8wRn7/oQ8+wC9s0DfDrdMVCRFg==",
       "dev": true,
       "dev": true,
       "license": "MIT",
       "license": "MIT",
       "dependencies": {
       "dependencies": {
         "picomatch": "^4.0.7",
         "picomatch": "^4.0.7",
         "string-argv": "^0.3.2",
         "string-argv": "^0.3.2",
-        "tinyexec": "^1.3.0"
+        "tinyexec": "^1.3.1"
       },
       },
       "bin": {
       "bin": {
         "lint-staged": "bin/lint-staged.js"
         "lint-staged": "bin/lint-staged.js"
@@ -8289,6 +8274,16 @@
         "yaml": "^2.9.0"
         "yaml": "^2.9.0"
       }
       }
     },
     },
+    "node_modules/lint-staged/node_modules/tinyexec": {
+      "version": "1.3.1",
+      "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.3.1.tgz",
+      "integrity": "sha512-GCvB3aoys96IuDFBMcTB46JOR6mdMtAToqwiW8JlWhsoh1mhHi/xn9ss/Dg7N555GiJyEt2qzoG/NHCwM6h1EA==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=18"
+      }
+    },
     "node_modules/lodash": {
     "node_modules/lodash": {
       "version": "4.18.1",
       "version": "4.18.1",
       "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz",
       "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz",
@@ -8674,29 +8669,17 @@
       }
       }
     },
     },
     "node_modules/otpauth": {
     "node_modules/otpauth": {
-      "version": "9.5.1",
-      "resolved": "https://registry.npmjs.org/otpauth/-/otpauth-9.5.1.tgz",
-      "integrity": "sha512-fJmDAHc8wImfqqqOXIlBvT1dEKrZK0Cmb2VEgScpNTolCz0PHh6ExUZGv4sLtOsWNaHCQlD+rRqaPgnoxFoZjQ==",
+      "version": "9.5.2",
+      "resolved": "https://registry.npmjs.org/otpauth/-/otpauth-9.5.2.tgz",
+      "integrity": "sha512-GQ5emWR/x1tcExT62IBT0UfO95wZzJZyxYOJOGVeQF47SYEN9vmh0vISvDZaNMuFJRG+IaWCKtfm+t9Bfoal6w==",
       "license": "MIT",
       "license": "MIT",
       "dependencies": {
       "dependencies": {
-        "@noble/hashes": "2.2.0"
+        "@noble/hashes": "2.4.0"
       },
       },
       "funding": {
       "funding": {
         "url": "https://github.com/hectorm/otpauth?sponsor=1"
         "url": "https://github.com/hectorm/otpauth?sponsor=1"
       }
       }
     },
     },
-    "node_modules/otpauth/node_modules/@noble/hashes": {
-      "version": "2.2.0",
-      "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.2.0.tgz",
-      "integrity": "sha512-IYqDGiTXab6FniAgnSdZwgWbomxpy9FtYvLKs7wCUs2a8RkITG+DFGO1DM9cr+E3/RgADRpFjrKVaJ1z6sjtEg==",
-      "license": "MIT",
-      "engines": {
-        "node": ">= 20.19.0"
-      },
-      "funding": {
-        "url": "https://paulmillr.com/funding/"
-      }
-    },
     "node_modules/outvariant": {
     "node_modules/outvariant": {
       "version": "1.4.3",
       "version": "1.4.3",
       "resolved": "https://registry.npmjs.org/outvariant/-/outvariant-1.4.3.tgz",
       "resolved": "https://registry.npmjs.org/outvariant/-/outvariant-1.4.3.tgz",
@@ -8774,13 +8757,13 @@
       }
       }
     },
     },
     "node_modules/oxfmt": {
     "node_modules/oxfmt": {
-      "version": "0.66.0",
-      "resolved": "https://registry.npmjs.org/oxfmt/-/oxfmt-0.66.0.tgz",
-      "integrity": "sha512-FfvqR8RFtV6JJpRrpkfqyVCQ7HDvZ/VriWFx7veftCgL1B5ZO9qNr+1rvPieycMQnNfVG0PWyJQiy7p0hq1I5w==",
+      "version": "0.67.0",
+      "resolved": "https://registry.npmjs.org/oxfmt/-/oxfmt-0.67.0.tgz",
+      "integrity": "sha512-vV7sSiPsaO0mSxdoUdayipVDFPzW/UQ+hrezEHa20+Tx1dnMdZLSRHMT0PdS67FFbhd74M1n08asW21aLGeCrA==",
       "dev": true,
       "dev": true,
       "license": "MIT",
       "license": "MIT",
       "dependencies": {
       "dependencies": {
-        "tinypool": "2.1.0"
+        "tinypool": "2.1.2"
       },
       },
       "bin": {
       "bin": {
         "oxfmt": "bin/oxfmt"
         "oxfmt": "bin/oxfmt"
@@ -8792,25 +8775,25 @@
         "url": "https://github.com/sponsors/oxc-project"
         "url": "https://github.com/sponsors/oxc-project"
       },
       },
       "optionalDependencies": {
       "optionalDependencies": {
-        "@oxfmt/binding-android-arm-eabi": "0.66.0",
-        "@oxfmt/binding-android-arm64": "0.66.0",
-        "@oxfmt/binding-darwin-arm64": "0.66.0",
-        "@oxfmt/binding-darwin-x64": "0.66.0",
-        "@oxfmt/binding-freebsd-x64": "0.66.0",
-        "@oxfmt/binding-linux-arm-gnueabihf": "0.66.0",
-        "@oxfmt/binding-linux-arm-musleabihf": "0.66.0",
-        "@oxfmt/binding-linux-arm64-gnu": "0.66.0",
-        "@oxfmt/binding-linux-arm64-musl": "0.66.0",
-        "@oxfmt/binding-linux-ppc64-gnu": "0.66.0",
-        "@oxfmt/binding-linux-riscv64-gnu": "0.66.0",
-        "@oxfmt/binding-linux-riscv64-musl": "0.66.0",
-        "@oxfmt/binding-linux-s390x-gnu": "0.66.0",
-        "@oxfmt/binding-linux-x64-gnu": "0.66.0",
-        "@oxfmt/binding-linux-x64-musl": "0.66.0",
-        "@oxfmt/binding-openharmony-arm64": "0.66.0",
-        "@oxfmt/binding-win32-arm64-msvc": "0.66.0",
-        "@oxfmt/binding-win32-ia32-msvc": "0.66.0",
-        "@oxfmt/binding-win32-x64-msvc": "0.66.0"
+        "@oxfmt/binding-android-arm-eabi": "0.67.0",
+        "@oxfmt/binding-android-arm64": "0.67.0",
+        "@oxfmt/binding-darwin-arm64": "0.67.0",
+        "@oxfmt/binding-darwin-x64": "0.67.0",
+        "@oxfmt/binding-freebsd-x64": "0.67.0",
+        "@oxfmt/binding-linux-arm-gnueabihf": "0.67.0",
+        "@oxfmt/binding-linux-arm-musleabihf": "0.67.0",
+        "@oxfmt/binding-linux-arm64-gnu": "0.67.0",
+        "@oxfmt/binding-linux-arm64-musl": "0.67.0",
+        "@oxfmt/binding-linux-ppc64-gnu": "0.67.0",
+        "@oxfmt/binding-linux-riscv64-gnu": "0.67.0",
+        "@oxfmt/binding-linux-riscv64-musl": "0.67.0",
+        "@oxfmt/binding-linux-s390x-gnu": "0.67.0",
+        "@oxfmt/binding-linux-x64-gnu": "0.67.0",
+        "@oxfmt/binding-linux-x64-musl": "0.67.0",
+        "@oxfmt/binding-openharmony-arm64": "0.67.0",
+        "@oxfmt/binding-win32-arm64-msvc": "0.67.0",
+        "@oxfmt/binding-win32-ia32-msvc": "0.67.0",
+        "@oxfmt/binding-win32-x64-msvc": "0.67.0"
       },
       },
       "peerDependencies": {
       "peerDependencies": {
         "svelte": "^5.0.0",
         "svelte": "^5.0.0",
@@ -8826,9 +8809,9 @@
       }
       }
     },
     },
     "node_modules/oxlint": {
     "node_modules/oxlint": {
-      "version": "1.81.0",
-      "resolved": "https://registry.npmjs.org/oxlint/-/oxlint-1.81.0.tgz",
-      "integrity": "sha512-HyrJYqeoOCL0iqaLEzGewGT48ZX99P3hxYh8udAF9RGGIghSamkXE4ClUyBpEDNqasamThgmlPbuMOe7SAZmHg==",
+      "version": "1.82.0",
+      "resolved": "https://registry.npmjs.org/oxlint/-/oxlint-1.82.0.tgz",
+      "integrity": "sha512-+iFM1BGw1ntYJt3QngbJmjbrGxPaKMUADOXOijpWGnYcBPq8YZnQftSS1C+pVcDYy9YxqDVJKQqQkTazTQMboQ==",
       "dev": true,
       "dev": true,
       "license": "MIT",
       "license": "MIT",
       "bin": {
       "bin": {
@@ -8841,25 +8824,25 @@
         "url": "https://github.com/sponsors/oxc-project"
         "url": "https://github.com/sponsors/oxc-project"
       },
       },
       "optionalDependencies": {
       "optionalDependencies": {
-        "@oxlint/binding-android-arm-eabi": "1.81.0",
-        "@oxlint/binding-android-arm64": "1.81.0",
-        "@oxlint/binding-darwin-arm64": "1.81.0",
-        "@oxlint/binding-darwin-x64": "1.81.0",
-        "@oxlint/binding-freebsd-x64": "1.81.0",
-        "@oxlint/binding-linux-arm-gnueabihf": "1.81.0",
-        "@oxlint/binding-linux-arm-musleabihf": "1.81.0",
-        "@oxlint/binding-linux-arm64-gnu": "1.81.0",
-        "@oxlint/binding-linux-arm64-musl": "1.81.0",
-        "@oxlint/binding-linux-ppc64-gnu": "1.81.0",
-        "@oxlint/binding-linux-riscv64-gnu": "1.81.0",
-        "@oxlint/binding-linux-riscv64-musl": "1.81.0",
-        "@oxlint/binding-linux-s390x-gnu": "1.81.0",
-        "@oxlint/binding-linux-x64-gnu": "1.81.0",
-        "@oxlint/binding-linux-x64-musl": "1.81.0",
-        "@oxlint/binding-openharmony-arm64": "1.81.0",
-        "@oxlint/binding-win32-arm64-msvc": "1.81.0",
-        "@oxlint/binding-win32-ia32-msvc": "1.81.0",
-        "@oxlint/binding-win32-x64-msvc": "1.81.0"
+        "@oxlint/binding-android-arm-eabi": "1.82.0",
+        "@oxlint/binding-android-arm64": "1.82.0",
+        "@oxlint/binding-darwin-arm64": "1.82.0",
+        "@oxlint/binding-darwin-x64": "1.82.0",
+        "@oxlint/binding-freebsd-x64": "1.82.0",
+        "@oxlint/binding-linux-arm-gnueabihf": "1.82.0",
+        "@oxlint/binding-linux-arm-musleabihf": "1.82.0",
+        "@oxlint/binding-linux-arm64-gnu": "1.82.0",
+        "@oxlint/binding-linux-arm64-musl": "1.82.0",
+        "@oxlint/binding-linux-ppc64-gnu": "1.82.0",
+        "@oxlint/binding-linux-riscv64-gnu": "1.82.0",
+        "@oxlint/binding-linux-riscv64-musl": "1.82.0",
+        "@oxlint/binding-linux-s390x-gnu": "1.82.0",
+        "@oxlint/binding-linux-x64-gnu": "1.82.0",
+        "@oxlint/binding-linux-x64-musl": "1.82.0",
+        "@oxlint/binding-openharmony-arm64": "1.82.0",
+        "@oxlint/binding-win32-arm64-msvc": "1.82.0",
+        "@oxlint/binding-win32-ia32-msvc": "1.82.0",
+        "@oxlint/binding-win32-x64-msvc": "1.82.0"
       },
       },
       "peerDependencies": {
       "peerDependencies": {
         "oxlint-tsgolint": ">=7.0.2001",
         "oxlint-tsgolint": ">=7.0.2001",
@@ -9008,28 +8991,25 @@
       }
       }
     },
     },
     "node_modules/playwright": {
     "node_modules/playwright": {
-      "version": "1.62.1",
-      "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.62.1.tgz",
-      "integrity": "sha512-0M+L3LAD8/nm554LOla9Ayx0j0tmFZ0FBcoQ7F1VuVHpM/XpiC8RcDzBQB8W5+hA8L22THxELzeF+2WcUzvcLg==",
+      "version": "1.63.0",
+      "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.63.0.tgz",
+      "integrity": "sha512-+7ziBLidS4NaNCdt57SUDT+wYmmd5fmiQejUic/kb+YsYSCPyOOE9sebzMjNmQrsnNpDJqd4WHvV/8lfKfUDUg==",
       "dev": true,
       "dev": true,
       "license": "Apache-2.0",
       "license": "Apache-2.0",
       "dependencies": {
       "dependencies": {
-        "playwright-core": "1.62.1"
+        "playwright-core": "1.63.0"
       },
       },
       "bin": {
       "bin": {
         "playwright": "cli.js"
         "playwright": "cli.js"
       },
       },
       "engines": {
       "engines": {
         "node": ">=20"
         "node": ">=20"
-      },
-      "optionalDependencies": {
-        "fsevents": "2.3.2"
       }
       }
     },
     },
     "node_modules/playwright-core": {
     "node_modules/playwright-core": {
-      "version": "1.62.1",
-      "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.62.1.tgz",
-      "integrity": "sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw==",
+      "version": "1.63.0",
+      "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.63.0.tgz",
+      "integrity": "sha512-rYCsBF/M5HjUch52bbtVONEFjv6Xu8sm8h72dNlR5bzIE1fvC/bxgspzkjSfU+MweEMmPM8KJebG6nnyxo5mCg==",
       "dev": true,
       "dev": true,
       "license": "Apache-2.0",
       "license": "Apache-2.0",
       "bin": {
       "bin": {
@@ -9492,15 +9472,6 @@
       "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==",
       "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==",
       "license": "MIT"
       "license": "MIT"
     },
     },
-    "node_modules/redux-immutable": {
-      "version": "4.0.0",
-      "resolved": "https://registry.npmjs.org/redux-immutable/-/redux-immutable-4.0.0.tgz",
-      "integrity": "sha512-SchSn/DWfGb3oAejd+1hhHx01xUoxY+V7TeK0BKqpkLKiQPVFf7DYzEaKmrEVxsWxielKfSK9/Xq66YyxgR1cg==",
-      "license": "BSD-3-Clause",
-      "peerDependencies": {
-        "immutable": "^3.8.1 || ^4.0.0-rc.1"
-      }
-    },
     "node_modules/refractor": {
     "node_modules/refractor": {
       "version": "5.0.0",
       "version": "5.0.0",
       "resolved": "https://registry.npmjs.org/refractor/-/refractor-5.0.0.tgz",
       "resolved": "https://registry.npmjs.org/refractor/-/refractor-5.0.0.tgz",
@@ -10129,9 +10100,9 @@
       }
       }
     },
     },
     "node_modules/swagger-ui-react": {
     "node_modules/swagger-ui-react": {
-      "version": "5.32.14",
-      "resolved": "https://registry.npmjs.org/swagger-ui-react/-/swagger-ui-react-5.32.14.tgz",
-      "integrity": "sha512-6LAVBeC78DplbJ7kutm/YeBYo22nPzGOca4bIZAvQG4w2eSetnYDdazaUfY0qzQUlg/H90HnYZX3rg67EmENOw==",
+      "version": "5.32.15",
+      "resolved": "https://registry.npmjs.org/swagger-ui-react/-/swagger-ui-react-5.32.15.tgz",
+      "integrity": "sha512-QyCcnHLE2KGE+mMZNSwMABU9jV70OUtSIs6UrYgTXzG2eUSm75CqumNWuwD3QBn3S4F7FhmDNPb3di9kAvcFAw==",
       "license": "Apache-2.0",
       "license": "Apache-2.0",
       "dependencies": {
       "dependencies": {
         "@babel/runtime-corejs3": "^7.27.1",
         "@babel/runtime-corejs3": "^7.27.1",
@@ -10143,7 +10114,7 @@
         "deep-extend": "0.6.0",
         "deep-extend": "0.6.0",
         "dompurify": "^3.4.13",
         "dompurify": "^3.4.13",
         "ieee754": "^1.2.1",
         "ieee754": "^1.2.1",
-        "immutable": "^4.3.9",
+        "immutable": "^5.1.9",
         "js-file-download": "^0.4.12",
         "js-file-download": "^0.4.12",
         "js-yaml": "=4.3.1",
         "js-yaml": "=4.3.1",
         "lodash": "^4.18.1",
         "lodash": "^4.18.1",
@@ -10158,7 +10129,6 @@
         "react-redux": "^9.3.0",
         "react-redux": "^9.3.0",
         "react-syntax-highlighter": "^16.0.0",
         "react-syntax-highlighter": "^16.0.0",
         "redux": "^5.0.1",
         "redux": "^5.0.1",
-        "redux-immutable": "^4.0.0",
         "remarkable": "^2.0.1",
         "remarkable": "^2.0.1",
         "reselect": "^5.1.1",
         "reselect": "^5.1.1",
         "serialize-error": "^8.1.0",
         "serialize-error": "^8.1.0",
@@ -10248,9 +10218,9 @@
       }
       }
     },
     },
     "node_modules/tinypool": {
     "node_modules/tinypool": {
-      "version": "2.1.0",
-      "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-2.1.0.tgz",
-      "integrity": "sha512-Pugqs6M0m7Lv1I7FtxN4aoyToKg1C4tu+/381vH35y8oENM/Ai7f7C4StcoK4/+BSw9ebcS8jRiVrORFKCALLw==",
+      "version": "2.1.2",
+      "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-2.1.2.tgz",
+      "integrity": "sha512-9YodfrxS9g9IbFr/KOjE5bAeJ0p61n3bW6mqvy0jtoeKd1kTW1Cxm0oulm6KX2lyM9Gl6WIe8nEbY7LWv5ZJww==",
       "dev": true,
       "dev": true,
       "license": "MIT",
       "license": "MIT",
       "engines": {
       "engines": {

+ 8 - 8
frontend/package.json

@@ -41,18 +41,18 @@
     "@noble/hashes": "^2.4.0",
     "@noble/hashes": "^2.4.0",
     "@tanstack/react-query": "^5.102.8",
     "@tanstack/react-query": "^5.102.8",
     "@tanstack/react-query-devtools": "^5.102.8",
     "@tanstack/react-query-devtools": "^5.102.8",
-    "antd": "^6.6.2",
+    "antd": "^6.6.3",
     "codemirror": "^6.0.2",
     "codemirror": "^6.0.2",
     "dayjs": "^1.11.23",
     "dayjs": "^1.11.23",
-    "i18next": "^26.4.1",
-    "otpauth": "^9.5.1",
+    "i18next": "^26.4.2",
+    "otpauth": "^9.5.2",
     "persian-calendar-suite": "^1.5.6",
     "persian-calendar-suite": "^1.5.6",
     "react": "^19.2.8",
     "react": "^19.2.8",
     "react-dom": "^19.2.8",
     "react-dom": "^19.2.8",
     "react-hook-form": "^7.87.0",
     "react-hook-form": "^7.87.0",
     "react-i18next": "^17.0.13",
     "react-i18next": "^17.0.13",
     "react-router": "^8.3.1",
     "react-router": "^8.3.1",
-    "swagger-ui-react": "^5.32.14",
+    "swagger-ui-react": "^5.32.15",
     "uplot": "^1.6.32",
     "uplot": "^1.6.32",
     "zod": "^4.5.4"
     "zod": "^4.5.4"
   },
   },
@@ -71,12 +71,12 @@
     "@vitest/coverage-v8": "^5.0.0",
     "@vitest/coverage-v8": "^5.0.0",
     "husky": "^9.1.7",
     "husky": "^9.1.7",
     "jsdom": "^30.0.1",
     "jsdom": "^30.0.1",
-    "lint-staged": "^17.4.1",
+    "lint-staged": "^17.5.0",
     "msw": "^2.15.0",
     "msw": "^2.15.0",
-    "oxfmt": "0.66.0",
-    "oxlint": "1.81.0",
+    "oxfmt": "0.67.0",
+    "oxlint": "1.82.0",
     "oxlint-tsgolint": "^7.0.2001",
     "oxlint-tsgolint": "^7.0.2001",
-    "playwright": "^1.62.1",
+    "playwright": "^1.63.0",
     "storybook": "^10.6.0",
     "storybook": "^10.6.0",
     "typescript": "7.0.2",
     "typescript": "7.0.2",
     "vite": "8.2.2",
     "vite": "8.2.2",

+ 34 - 2
frontend/public/openapi.json

@@ -217,6 +217,9 @@
           "subEncrypt": {
           "subEncrypt": {
             "type": "boolean"
             "type": "boolean"
           },
           },
+          "subExpiredTemplate": {
+            "type": "string"
+          },
           "subHideSettings": {
           "subHideSettings": {
             "type": "boolean"
             "type": "boolean"
           },
           },
@@ -226,6 +229,9 @@
           "subIncyRoutingRules": {
           "subIncyRoutingRules": {
             "type": "string"
             "type": "string"
           },
           },
+          "subInfoNodeEnable": {
+            "type": "boolean"
+          },
           "subJsonAlwaysArray": {
           "subJsonAlwaysArray": {
             "type": "boolean"
             "type": "boolean"
           },
           },
@@ -288,6 +294,9 @@
           "subTitle": {
           "subTitle": {
             "type": "string"
             "type": "string"
           },
           },
+          "subTrafficDepletedTemplate": {
+            "type": "string"
+          },
           "subURI": {
           "subURI": {
             "type": "string"
             "type": "string"
           },
           },
@@ -433,9 +442,11 @@
           "subEnable",
           "subEnable",
           "subEnableRouting",
           "subEnableRouting",
           "subEncrypt",
           "subEncrypt",
+          "subExpiredTemplate",
           "subHideSettings",
           "subHideSettings",
           "subIncyEnableRouting",
           "subIncyEnableRouting",
           "subIncyRoutingRules",
           "subIncyRoutingRules",
+          "subInfoNodeEnable",
           "subJsonAlwaysArray",
           "subJsonAlwaysArray",
           "subJsonAutoDetect",
           "subJsonAutoDetect",
           "subJsonEnable",
           "subJsonEnable",
@@ -456,6 +467,7 @@
           "subSupportUrl",
           "subSupportUrl",
           "subThemeDir",
           "subThemeDir",
           "subTitle",
           "subTitle",
+          "subTrafficDepletedTemplate",
           "subURI",
           "subURI",
           "subUpdates",
           "subUpdates",
           "tgBotAPIServer",
           "tgBotAPIServer",
@@ -696,6 +708,9 @@
           "subEncrypt": {
           "subEncrypt": {
             "type": "boolean"
             "type": "boolean"
           },
           },
+          "subExpiredTemplate": {
+            "type": "string"
+          },
           "subHideSettings": {
           "subHideSettings": {
             "type": "boolean"
             "type": "boolean"
           },
           },
@@ -705,6 +720,9 @@
           "subIncyRoutingRules": {
           "subIncyRoutingRules": {
             "type": "string"
             "type": "string"
           },
           },
+          "subInfoNodeEnable": {
+            "type": "boolean"
+          },
           "subJsonAlwaysArray": {
           "subJsonAlwaysArray": {
             "type": "boolean"
             "type": "boolean"
           },
           },
@@ -767,6 +785,9 @@
           "subTitle": {
           "subTitle": {
             "type": "string"
             "type": "string"
           },
           },
+          "subTrafficDepletedTemplate": {
+            "type": "string"
+          },
           "subURI": {
           "subURI": {
             "type": "string"
             "type": "string"
           },
           },
@@ -919,9 +940,11 @@
           "subEnable",
           "subEnable",
           "subEnableRouting",
           "subEnableRouting",
           "subEncrypt",
           "subEncrypt",
+          "subExpiredTemplate",
           "subHideSettings",
           "subHideSettings",
           "subIncyEnableRouting",
           "subIncyEnableRouting",
           "subIncyRoutingRules",
           "subIncyRoutingRules",
+          "subInfoNodeEnable",
           "subJsonAlwaysArray",
           "subJsonAlwaysArray",
           "subJsonAutoDetect",
           "subJsonAutoDetect",
           "subJsonEnable",
           "subJsonEnable",
@@ -942,6 +965,7 @@
           "subSupportUrl",
           "subSupportUrl",
           "subThemeDir",
           "subThemeDir",
           "subTitle",
           "subTitle",
+          "subTrafficDepletedTemplate",
           "subURI",
           "subURI",
           "subUpdates",
           "subUpdates",
           "tgBotAPIServer",
           "tgBotAPIServer",
@@ -4340,7 +4364,13 @@
                     "msg": {
                     "msg": {
                       "type": "string"
                       "type": "string"
                     },
                     },
-                    "obj": {}
+                    "obj": {
+                      "type": "array",
+                      "nullable": true,
+                      "items": {
+                        "type": "string"
+                      }
+                    }
                   }
                   }
                 },
                 },
                 "example": {
                 "example": {
@@ -6399,6 +6429,7 @@
                     },
                     },
                     "obj": {
                     "obj": {
                       "type": "array",
                       "type": "array",
+                      "nullable": true,
                       "items": {
                       "items": {
                         "type": "string"
                         "type": "string"
                       }
                       }
@@ -6480,6 +6511,7 @@
                     },
                     },
                     "obj": {
                     "obj": {
                       "type": "array",
                       "type": "array",
+                      "nullable": true,
                       "items": {
                       "items": {
                         "$ref": "#/components/schemas/LogEntry"
                         "$ref": "#/components/schemas/LogEntry"
                       }
                       }
@@ -9705,7 +9737,7 @@
         "tags": [
         "tags": [
           "Clients"
           "Clients"
         ],
         ],
-        "summary": "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 streamSettings.externalProxy is set, returns one URL per external proxy. Protocols without a URL form (socks, http, mixed, wireguard, dokodemo, tunnel) contribute nothing.",
+        "summary": "Return every URL for one client across all attached inbounds, one per advertised endpoint: the managed hosts of the inbound, else its streamSettings.externalProxy entries, else its own address. Supported protocols: vmess, vless, trojan, shadowsocks, hysteria, mtproto. Protocols without a URL form (socks, http, mixed, wireguard, dokodemo, tunnel) contribute nothing.",
         "operationId": "get_panel_api_clients_links_email",
         "operationId": "get_panel_api_clients_links_email",
         "parameters": [
         "parameters": [
           {
           {

+ 7 - 1
frontend/scripts/build-openapi.mjs

@@ -245,7 +245,13 @@ function buildOperation(ep, tag) {
       );
       );
     }
     }
     const ref = { $ref: `#/components/schemas/${ep.responseSchema}` };
     const ref = { $ref: `#/components/schemas/${ep.responseSchema}` };
-    objSchema = ep.responseSchemaArray ? { type: 'array', items: ref } : ref;
+    objSchema = ep.responseSchemaArray
+      ? {
+          type: 'array',
+          ...(ep.responseSchemaArrayNullable ? { nullable: true } : {}),
+          items: ref,
+        }
+      : ref;
     if (successExample === undefined) {
     if (successExample === undefined) {
       successExample = { success: true, obj: ep.responseSchemaArray ? [obj] : obj };
       successExample = { success: true, obj: ep.responseSchemaArray ? [obj] : obj };
     }
     }

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

@@ -58,9 +58,11 @@ export const EXAMPLES: Record<string, unknown> = {
     "subEnable": false,
     "subEnable": false,
     "subEnableRouting": false,
     "subEnableRouting": false,
     "subEncrypt": false,
     "subEncrypt": false,
+    "subExpiredTemplate": "",
     "subHideSettings": false,
     "subHideSettings": false,
     "subIncyEnableRouting": false,
     "subIncyEnableRouting": false,
     "subIncyRoutingRules": "",
     "subIncyRoutingRules": "",
+    "subInfoNodeEnable": false,
     "subJsonAlwaysArray": false,
     "subJsonAlwaysArray": false,
     "subJsonAutoDetect": false,
     "subJsonAutoDetect": false,
     "subJsonEnable": false,
     "subJsonEnable": false,
@@ -81,6 +83,7 @@ export const EXAMPLES: Record<string, unknown> = {
     "subSupportUrl": "",
     "subSupportUrl": "",
     "subThemeDir": "",
     "subThemeDir": "",
     "subTitle": "",
     "subTitle": "",
+    "subTrafficDepletedTemplate": "",
     "subURI": "",
     "subURI": "",
     "subUpdates": 0,
     "subUpdates": 0,
     "tgBotAPIServer": "",
     "tgBotAPIServer": "",
@@ -172,9 +175,11 @@ export const EXAMPLES: Record<string, unknown> = {
     "subEnable": false,
     "subEnable": false,
     "subEnableRouting": false,
     "subEnableRouting": false,
     "subEncrypt": false,
     "subEncrypt": false,
+    "subExpiredTemplate": "",
     "subHideSettings": false,
     "subHideSettings": false,
     "subIncyEnableRouting": false,
     "subIncyEnableRouting": false,
     "subIncyRoutingRules": "",
     "subIncyRoutingRules": "",
+    "subInfoNodeEnable": false,
     "subJsonAlwaysArray": false,
     "subJsonAlwaysArray": false,
     "subJsonAutoDetect": false,
     "subJsonAutoDetect": false,
     "subJsonEnable": false,
     "subJsonEnable": false,
@@ -195,6 +200,7 @@ export const EXAMPLES: Record<string, unknown> = {
     "subSupportUrl": "",
     "subSupportUrl": "",
     "subThemeDir": "",
     "subThemeDir": "",
     "subTitle": "",
     "subTitle": "",
+    "subTrafficDepletedTemplate": "",
     "subURI": "",
     "subURI": "",
     "subUpdates": 0,
     "subUpdates": 0,
     "tgBotAPIServer": "",
     "tgBotAPIServer": "",

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

@@ -191,6 +191,9 @@ export const SCHEMAS: Record<string, unknown> = {
       "subEncrypt": {
       "subEncrypt": {
         "type": "boolean"
         "type": "boolean"
       },
       },
+      "subExpiredTemplate": {
+        "type": "string"
+      },
       "subHideSettings": {
       "subHideSettings": {
         "type": "boolean"
         "type": "boolean"
       },
       },
@@ -200,6 +203,9 @@ export const SCHEMAS: Record<string, unknown> = {
       "subIncyRoutingRules": {
       "subIncyRoutingRules": {
         "type": "string"
         "type": "string"
       },
       },
+      "subInfoNodeEnable": {
+        "type": "boolean"
+      },
       "subJsonAlwaysArray": {
       "subJsonAlwaysArray": {
         "type": "boolean"
         "type": "boolean"
       },
       },
@@ -262,6 +268,9 @@ export const SCHEMAS: Record<string, unknown> = {
       "subTitle": {
       "subTitle": {
         "type": "string"
         "type": "string"
       },
       },
+      "subTrafficDepletedTemplate": {
+        "type": "string"
+      },
       "subURI": {
       "subURI": {
         "type": "string"
         "type": "string"
       },
       },
@@ -407,9 +416,11 @@ export const SCHEMAS: Record<string, unknown> = {
       "subEnable",
       "subEnable",
       "subEnableRouting",
       "subEnableRouting",
       "subEncrypt",
       "subEncrypt",
+      "subExpiredTemplate",
       "subHideSettings",
       "subHideSettings",
       "subIncyEnableRouting",
       "subIncyEnableRouting",
       "subIncyRoutingRules",
       "subIncyRoutingRules",
+      "subInfoNodeEnable",
       "subJsonAlwaysArray",
       "subJsonAlwaysArray",
       "subJsonAutoDetect",
       "subJsonAutoDetect",
       "subJsonEnable",
       "subJsonEnable",
@@ -430,6 +441,7 @@ export const SCHEMAS: Record<string, unknown> = {
       "subSupportUrl",
       "subSupportUrl",
       "subThemeDir",
       "subThemeDir",
       "subTitle",
       "subTitle",
+      "subTrafficDepletedTemplate",
       "subURI",
       "subURI",
       "subUpdates",
       "subUpdates",
       "tgBotAPIServer",
       "tgBotAPIServer",
@@ -670,6 +682,9 @@ export const SCHEMAS: Record<string, unknown> = {
       "subEncrypt": {
       "subEncrypt": {
         "type": "boolean"
         "type": "boolean"
       },
       },
+      "subExpiredTemplate": {
+        "type": "string"
+      },
       "subHideSettings": {
       "subHideSettings": {
         "type": "boolean"
         "type": "boolean"
       },
       },
@@ -679,6 +694,9 @@ export const SCHEMAS: Record<string, unknown> = {
       "subIncyRoutingRules": {
       "subIncyRoutingRules": {
         "type": "string"
         "type": "string"
       },
       },
+      "subInfoNodeEnable": {
+        "type": "boolean"
+      },
       "subJsonAlwaysArray": {
       "subJsonAlwaysArray": {
         "type": "boolean"
         "type": "boolean"
       },
       },
@@ -741,6 +759,9 @@ export const SCHEMAS: Record<string, unknown> = {
       "subTitle": {
       "subTitle": {
         "type": "string"
         "type": "string"
       },
       },
+      "subTrafficDepletedTemplate": {
+        "type": "string"
+      },
       "subURI": {
       "subURI": {
         "type": "string"
         "type": "string"
       },
       },
@@ -893,9 +914,11 @@ export const SCHEMAS: Record<string, unknown> = {
       "subEnable",
       "subEnable",
       "subEnableRouting",
       "subEnableRouting",
       "subEncrypt",
       "subEncrypt",
+      "subExpiredTemplate",
       "subHideSettings",
       "subHideSettings",
       "subIncyEnableRouting",
       "subIncyEnableRouting",
       "subIncyRoutingRules",
       "subIncyRoutingRules",
+      "subInfoNodeEnable",
       "subJsonAlwaysArray",
       "subJsonAlwaysArray",
       "subJsonAutoDetect",
       "subJsonAutoDetect",
       "subJsonEnable",
       "subJsonEnable",
@@ -916,6 +939,7 @@ export const SCHEMAS: Record<string, unknown> = {
       "subSupportUrl",
       "subSupportUrl",
       "subThemeDir",
       "subThemeDir",
       "subTitle",
       "subTitle",
+      "subTrafficDepletedTemplate",
       "subURI",
       "subURI",
       "subUpdates",
       "subUpdates",
       "tgBotAPIServer",
       "tgBotAPIServer",

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

@@ -66,9 +66,11 @@ export interface AllSetting {
   subEnable: boolean;
   subEnable: boolean;
   subEnableRouting: boolean;
   subEnableRouting: boolean;
   subEncrypt: boolean;
   subEncrypt: boolean;
+  subExpiredTemplate: string;
   subHideSettings: boolean;
   subHideSettings: boolean;
   subIncyEnableRouting: boolean;
   subIncyEnableRouting: boolean;
   subIncyRoutingRules: string;
   subIncyRoutingRules: string;
+  subInfoNodeEnable: boolean;
   subJsonAlwaysArray: boolean;
   subJsonAlwaysArray: boolean;
   subJsonAutoDetect: boolean;
   subJsonAutoDetect: boolean;
   subJsonEnable: boolean;
   subJsonEnable: boolean;
@@ -89,6 +91,7 @@ export interface AllSetting {
   subSupportUrl: string;
   subSupportUrl: string;
   subThemeDir: string;
   subThemeDir: string;
   subTitle: string;
   subTitle: string;
+  subTrafficDepletedTemplate: string;
   subURI: string;
   subURI: string;
   subUpdates: number;
   subUpdates: number;
   tgBotAPIServer: string;
   tgBotAPIServer: string;
@@ -181,9 +184,11 @@ export interface AllSettingView {
   subEnable: boolean;
   subEnable: boolean;
   subEnableRouting: boolean;
   subEnableRouting: boolean;
   subEncrypt: boolean;
   subEncrypt: boolean;
+  subExpiredTemplate: string;
   subHideSettings: boolean;
   subHideSettings: boolean;
   subIncyEnableRouting: boolean;
   subIncyEnableRouting: boolean;
   subIncyRoutingRules: string;
   subIncyRoutingRules: string;
+  subInfoNodeEnable: boolean;
   subJsonAlwaysArray: boolean;
   subJsonAlwaysArray: boolean;
   subJsonAutoDetect: boolean;
   subJsonAutoDetect: boolean;
   subJsonEnable: boolean;
   subJsonEnable: boolean;
@@ -204,6 +209,7 @@ export interface AllSettingView {
   subSupportUrl: string;
   subSupportUrl: string;
   subThemeDir: string;
   subThemeDir: string;
   subTitle: string;
   subTitle: string;
+  subTrafficDepletedTemplate: string;
   subURI: string;
   subURI: string;
   subUpdates: number;
   subUpdates: number;
   tgBotAPIServer: string;
   tgBotAPIServer: string;

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

@@ -82,9 +82,11 @@ export const AllSettingSchema = z.object({
   subEnable: z.boolean(),
   subEnable: z.boolean(),
   subEnableRouting: z.boolean(),
   subEnableRouting: z.boolean(),
   subEncrypt: z.boolean(),
   subEncrypt: z.boolean(),
+  subExpiredTemplate: z.string(),
   subHideSettings: z.boolean(),
   subHideSettings: z.boolean(),
   subIncyEnableRouting: z.boolean(),
   subIncyEnableRouting: z.boolean(),
   subIncyRoutingRules: z.string(),
   subIncyRoutingRules: z.string(),
+  subInfoNodeEnable: z.boolean(),
   subJsonAlwaysArray: z.boolean(),
   subJsonAlwaysArray: z.boolean(),
   subJsonAutoDetect: z.boolean(),
   subJsonAutoDetect: z.boolean(),
   subJsonEnable: z.boolean(),
   subJsonEnable: z.boolean(),
@@ -105,6 +107,7 @@ export const AllSettingSchema = z.object({
   subSupportUrl: z.string(),
   subSupportUrl: z.string(),
   subThemeDir: z.string(),
   subThemeDir: z.string(),
   subTitle: z.string(),
   subTitle: z.string(),
+  subTrafficDepletedTemplate: z.string(),
   subURI: z.string(),
   subURI: z.string(),
   subUpdates: z.number().int().min(0).max(525600),
   subUpdates: z.number().int().min(0).max(525600),
   tgBotAPIServer: z.string(),
   tgBotAPIServer: z.string(),
@@ -198,9 +201,11 @@ export const AllSettingViewSchema = z.object({
   subEnable: z.boolean(),
   subEnable: z.boolean(),
   subEnableRouting: z.boolean(),
   subEnableRouting: z.boolean(),
   subEncrypt: z.boolean(),
   subEncrypt: z.boolean(),
+  subExpiredTemplate: z.string(),
   subHideSettings: z.boolean(),
   subHideSettings: z.boolean(),
   subIncyEnableRouting: z.boolean(),
   subIncyEnableRouting: z.boolean(),
   subIncyRoutingRules: z.string(),
   subIncyRoutingRules: z.string(),
+  subInfoNodeEnable: z.boolean(),
   subJsonAlwaysArray: z.boolean(),
   subJsonAlwaysArray: z.boolean(),
   subJsonAutoDetect: z.boolean(),
   subJsonAutoDetect: z.boolean(),
   subJsonEnable: z.boolean(),
   subJsonEnable: z.boolean(),
@@ -221,6 +226,7 @@ export const AllSettingViewSchema = z.object({
   subSupportUrl: z.string(),
   subSupportUrl: z.string(),
   subThemeDir: z.string(),
   subThemeDir: z.string(),
   subTitle: z.string(),
   subTitle: z.string(),
+  subTrafficDepletedTemplate: z.string(),
   subURI: z.string(),
   subURI: z.string(),
   subUpdates: z.number().int().min(0).max(525600),
   subUpdates: z.number().int().min(0).max(525600),
   tgBotAPIServer: z.string(),
   tgBotAPIServer: z.string(),

+ 50 - 1
frontend/src/lib/hosts/host-link.ts

@@ -1,5 +1,7 @@
 import type { ExternalProxyEntry } from '@/schemas/protocols/stream/external-proxy';
 import type { ExternalProxyEntry } from '@/schemas/protocols/stream/external-proxy';
-import type { HostFormValues } from '@/schemas/api/host';
+import type { HostFormValues, HostRecord } from '@/schemas/api/host';
+import type { Inbound } from '@/schemas/api/inbound';
+import { resolveAddr } from '@/lib/xray/inbound-link';
 
 
 // The subset of a host that affects its share link. Mirrors the fields the
 // The subset of a host that affects its share link. Mirrors the fields the
 // backend's hostToExternalProxyMap reads.
 // backend's hostToExternalProxyMap reads.
@@ -54,3 +56,50 @@ export function hostToExternalProxyEntry(host: HostLinkInput): ExternalProxyEntr
     vlessRoute: host.vlessRoute || undefined,
     vlessRoute: host.vlessRoute || undefined,
   };
   };
 }
 }
+
+function splitAdvertisedHost(value: string, inboundPort: number): [string, number] {
+  const host = value.trim();
+  if (host.startsWith('[')) {
+    const close = host.indexOf(']');
+    if (close > 0) {
+      const port = host.slice(close + 1).match(/^:(\d+)$/)?.[1];
+      return [host.slice(1, close), port ? Number(port) : inboundPort];
+    }
+  }
+  const match = host.match(/^([^:]*):(\d+)$/);
+  return match ? [match[1], Number(match[2])] : [host, inboundPort];
+}
+
+export function withMtprotoHostEndpoints(
+  inbound: Inbound,
+  inboundId: number,
+  records: HostRecord[],
+  hostOverride: string,
+  fallbackHostname: string,
+): Inbound {
+  if (inbound.protocol !== 'mtproto') return inbound;
+  const endpoints: ExternalProxyEntry[] = [];
+  for (const record of records) {
+    if (
+      record.isDisabled ||
+      !record.inboundIds.includes(inboundId) ||
+      record.excludeFromSubTypes?.includes('raw')
+    ) {
+      continue;
+    }
+    for (const value of record.hosts) {
+      const [dest, port] = splitAdvertisedHost(value, inbound.port);
+      endpoints.push({
+        forceTls: 'same',
+        dest: dest || resolveAddr(inbound, hostOverride, fallbackHostname),
+        port,
+        remark: record.remark || '',
+      });
+    }
+  }
+  if (endpoints.length === 0) return inbound;
+  return {
+    ...inbound,
+    streamSettings: { ...inbound.streamSettings, externalProxy: endpoints },
+  } as Inbound;
+}

+ 15 - 0
frontend/src/lib/xray/amneziawg-obfuscation.ts

@@ -40,6 +40,21 @@ export type AwgObfuscation = Pick<
 
 
 const randInt = (min: number, max: number) => min + Math.floor(Math.random() * (max - min + 1));
 const randInt = (min: number, max: number) => min + Math.floor(Math.random() * (max - min + 1));
 
 
+// WireGuard's usual tunnel MTU on a 1500-byte host link.
+export const DEFAULT_MTU = 1420;
+
+/** Floor for the S4-adjusted default, so a large s4 cannot shrink the tunnel
+ * below what clients reliably tolerate. */
+export const MIN_MTU = 1280;
+
+// s4 junk is prepended to every transport packet and never clamped to the MTU,
+// so a plain 1420 tunnel fragments once s4 passes 20. Mirrors Go's EffectiveMTU.
+export function effectiveMtu(configuredMtu: number | undefined, s4: number | undefined): number {
+  if (configuredMtu && configuredMtu > 0) return configuredMtu;
+  const junk = Math.max(s4 ?? 0, 0);
+  return Math.max(DEFAULT_MTU - junk, MIN_MTU);
+}
+
 /*
 /*
  * base64 of 32 crypto-grade random bytes — the exact HeaderProtectionKey
  * base64 of 32 crypto-grade random bytes — the exact HeaderProtectionKey
  * shape amneziawg-tools parses and the Go backend validates.
  * shape amneziawg-tools parses and the Go backend validates.

+ 54 - 0
frontend/src/lib/xray/forms/transport/FinalMaskForm.tsx

@@ -994,6 +994,36 @@ function UdpMaskItem({
                     placeholder="host:port"
                     placeholder="host:port"
                   />
                   />
                 </Form.Item>
                 </Form.Item>
+                <Form.Item label="IP Mode" name={[fieldName, 'settings', 'ipMode']}>
+                  <Select
+                    allowClear
+                    placeholder="dual"
+                    options={[
+                      { value: 'dual', label: 'Dual' },
+                      { value: 'v4', label: 'IPv4' },
+                      { value: 'v6', label: 'IPv6' },
+                    ]}
+                  />
+                </Form.Item>
+                <Form.Item
+                  label="Port Mapping (UPnP / NAT-PMP)"
+                  name={[fieldName, 'settings', 'portMapping', 'enabled']}
+                  valuePropName="checked"
+                >
+                  <Switch />
+                </Form.Item>
+                <Form.Item
+                  label="Mapping Timeout (s)"
+                  name={[fieldName, 'settings', 'portMapping', 'timeout']}
+                >
+                  <InputNumber min={0} placeholder="10 = default" />
+                </Form.Item>
+                <Form.Item
+                  label="Mapping Lifetime (s)"
+                  name={[fieldName, 'settings', 'portMapping', 'lifetime']}
+                >
+                  <InputNumber min={0} placeholder="600 = default" />
+                </Form.Item>
                 <Divider plain style={{ margin: '8px 0' }}>
                 <Divider plain style={{ margin: '8px 0' }}>
                   TLS (optional)
                   TLS (optional)
                 </Divider>
                 </Divider>
@@ -1443,6 +1473,13 @@ function QuicParamsForm({ base, form }: { base: (string | number)[]; form: FormI
           <Form.Item label="Brutal Down" name={[...base, 'brutalDown']}>
           <Form.Item label="Brutal Down" name={[...base, 'brutalDown']}>
             <Input placeholder="e.g. 100 mbps" />
             <Input placeholder="e.g. 100 mbps" />
           </Form.Item>
           </Form.Item>
+          <Form.Item
+            label="Brutal Disable Loss Comp"
+            name={[...base, 'brutalDisableLossCompensation']}
+            valuePropName="checked"
+          >
+            <Switch />
+          </Form.Item>
         </>
         </>
       )}
       )}
 
 
@@ -1478,6 +1515,23 @@ function QuicParamsForm({ base, form }: { base: (string | number)[]; form: FormI
       >
       >
         <Switch />
         <Switch />
       </Form.Item>
       </Form.Item>
+      <Form.Item
+        label="Disable Chrome Parrot"
+        name={[...base, 'disableChromeParrot']}
+        valuePropName="checked"
+      >
+        <Switch />
+      </Form.Item>
+      <Form.Item label="Disable GSO" name={[...base, 'disableGSO']} valuePropName="checked">
+        <Switch />
+      </Form.Item>
+      <Form.Item
+        label="Disable Stateless Reset"
+        name={[...base, 'disableStatelessReset']}
+        valuePropName="checked"
+      >
+        <Switch />
+      </Form.Item>
 
 
       <Form.Item label="Max Incoming Streams" name={[...base, 'maxIncomingStreams']}>
       <Form.Item label="Max Incoming Streams" name={[...base, 'maxIncomingStreams']}>
         <InputNumber min={8} placeholder="1024 = default" />
         <InputNumber min={8} placeholder="1024 = default" />

+ 2 - 8
frontend/src/lib/xray/inbound-form-adapter.ts

@@ -19,6 +19,7 @@ import type { Sniffing } from '@/schemas/primitives';
 import type { z } from 'zod';
 import type { z } from 'zod';
 import { normalizeStreamSettingsForWire } from '@/lib/xray/stream-wire-normalize';
 import { normalizeStreamSettingsForWire } from '@/lib/xray/stream-wire-normalize';
 import { canEnableSniffing } from '@/lib/xray/protocol-capabilities';
 import { canEnableSniffing } from '@/lib/xray/protocol-capabilities';
+import { tlsCertUsesFiles } from '@/schemas/protocols/security/tls';
 import { SockoptStreamSettingsSchema } from '@/schemas/protocols/stream/sockopt';
 import { SockoptStreamSettingsSchema } from '@/schemas/protocols/stream/sockopt';
 import { XHttpStreamSettingsSchema, XHttpXmuxSchema } from '@/schemas/protocols/stream/xhttp';
 import { XHttpStreamSettingsSchema, XHttpXmuxSchema } from '@/schemas/protocols/stream/xhttp';
 
 
@@ -152,14 +153,7 @@ function tlsCerts(stream: Record<string, unknown>): Record<string, unknown>[] {
 }
 }
 
 
 function synthesizeTlsCertUseFile(stream: Record<string, unknown>): void {
 function synthesizeTlsCertUseFile(stream: Record<string, unknown>): void {
-  for (const c of tlsCerts(stream)) {
-    if (typeof c.useFile === 'boolean') continue;
-    const hasFile = !!c.certificateFile || !!c.keyFile;
-    const hasInline =
-      (Array.isArray(c.certificate) && c.certificate.length > 0) ||
-      (Array.isArray(c.key) && c.key.length > 0);
-    c.useFile = hasFile || !hasInline;
-  }
+  for (const c of tlsCerts(stream)) c.useFile = tlsCertUsesFiles(c);
 }
 }
 
 
 function stripTlsCertUseFile(stream: Record<string, unknown>): void {
 function stripTlsCertUseFile(stream: Record<string, unknown>): void {

+ 2 - 3
frontend/src/lib/xray/inbound-link.ts

@@ -1,4 +1,5 @@
 import { Base64, Wireguard } from '@/utils';
 import { Base64, Wireguard } from '@/utils';
+import { effectiveMtu } from '@/lib/xray/amneziawg-obfuscation';
 
 
 import type { Inbound } from '@/schemas/api/inbound';
 import type { Inbound } from '@/schemas/api/inbound';
 import type { AmneziawgInboundSettings } from '@/schemas/protocols/inbound/amneziawg';
 import type { AmneziawgInboundSettings } from '@/schemas/protocols/inbound/amneziawg';
@@ -985,9 +986,7 @@ export function genAmneziaWGConfig(input: GenAmneziaWGLinkInput): string {
   txt += `Address = ${(client.allowedIPs ?? []).join(', ')}\n`;
   txt += `Address = ${(client.allowedIPs ?? []).join(', ')}\n`;
   const dns = [server.primaryDns, server.secondaryDns].filter((v) => !!v && v.trim() !== '');
   const dns = [server.primaryDns, server.secondaryDns].filter((v) => !!v && v.trim() !== '');
   if (dns.length > 0) txt += `DNS = ${dns.join(', ')}\n`;
   if (dns.length > 0) txt += `DNS = ${dns.join(', ')}\n`;
-  if (typeof server.mtu === 'number' && server.mtu > 0) {
-    txt += `MTU = ${server.mtu}\n`;
-  }
+  txt += `MTU = ${effectiveMtu(server.mtu, server.s4)}\n`;
   txt += `Jc = ${server.jc}\n`;
   txt += `Jc = ${server.jc}\n`;
   txt += `Jmin = ${server.jmin}\n`;
   txt += `Jmin = ${server.jmin}\n`;
   txt += `Jmax = ${server.jmax}\n`;
   txt += `Jmax = ${server.jmax}\n`;

+ 21 - 4
frontend/src/lib/xray/outbound-form-adapter.ts

@@ -6,6 +6,7 @@ import type { Sniffing, SniffingDest } from '@/schemas/primitives';
 import type { OutboundDomainStrategy } from '@/schemas/protocols/outbound';
 import type { OutboundDomainStrategy } from '@/schemas/protocols/outbound';
 
 
 import type {
 import type {
+  BlackholeOutboundFormSettings,
   DnsOutboundFormSettings,
   DnsOutboundFormSettings,
   DnsRuleForm,
   DnsRuleForm,
   FreedomFinalRuleForm,
   FreedomFinalRuleForm,
@@ -243,6 +244,9 @@ function wireguardFromWire(raw: Raw): WireguardOutboundFormSettings {
       return (allowed.includes(s) ? s : '') as WireguardOutboundFormSettings['domainStrategy'];
       return (allowed.includes(s) ? s : '') as WireguardOutboundFormSettings['domainStrategy'];
     })(),
     })(),
     reserved: reservedArr.join(','),
     reserved: reservedArr.join(','),
+    remoteDNS: asArray(raw.remoteDNS)
+      .map((x) => asString(x))
+      .join(','),
     peers,
     peers,
     noKernelTun: asBool(raw.noKernelTun),
     noKernelTun: asBool(raw.noKernelTun),
   };
   };
@@ -322,10 +326,13 @@ function freedomFromWire(raw: Raw): FreedomOutboundFormSettings {
   };
   };
 }
 }
 
 
-function blackholeFromWire(raw: Raw) {
+function blackholeFromWire(raw: Raw): BlackholeOutboundFormSettings {
   const response = asObject(raw.response);
   const response = asObject(raw.response);
   const t = asString(response.type);
   const t = asString(response.type);
-  return { type: (t === 'none' || t === 'http' ? t : '') as '' | 'none' | 'http' };
+  return {
+    type: t === 'none' || t === 'http' || t === 'custom' ? t : '',
+    customResponseData: asString(response.customResponseData),
+  };
 }
 }
 
 
 function dnsRuleFromWire(raw: unknown): DnsRuleForm {
 function dnsRuleFromWire(raw: unknown): DnsRuleForm {
@@ -585,6 +592,12 @@ function wireguardToWire(s: WireguardOutboundFormSettings) {
           .map((x) => Number(x.trim()))
           .map((x) => Number(x.trim()))
           .filter((n) => Number.isFinite(n))
           .filter((n) => Number.isFinite(n))
       : undefined,
       : undefined,
+    remoteDNS: s.remoteDNS
+      ? s.remoteDNS
+          .split(',')
+          .map((x) => x.trim())
+          .filter(Boolean)
+      : undefined,
     peers: s.peers.map((p) => ({
     peers: s.peers.map((p) => ({
       publicKey: p.publicKey,
       publicKey: p.publicKey,
       preSharedKey: p.psk.length > 0 ? p.psk : undefined,
       preSharedKey: p.psk.length > 0 ? p.psk : undefined,
@@ -631,8 +644,12 @@ function freedomToWire(s: FreedomOutboundFormSettings) {
   };
   };
 }
 }
 
 
-function blackholeToWire(s: { type: '' | 'none' | 'http' }) {
-  return { response: s.type ? { type: s.type } : undefined };
+function blackholeToWire(s: BlackholeOutboundFormSettings) {
+  if (!s.type) return { response: undefined };
+  if (s.type === 'custom') {
+    return { response: { type: s.type, customResponseData: s.customResponseData } };
+  }
+  return { response: { type: s.type } };
 }
 }
 
 
 function dnsRuleToWire(r: DnsRuleForm) {
 function dnsRuleToWire(r: DnsRuleForm) {

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

@@ -16,6 +16,10 @@ export class AllSetting {
   trafficDiff = 0;
   trafficDiff = 0;
   remarkTemplate = '{{INBOUND}}-{{EMAIL}}|📊{{TRAFFIC_LEFT}}|⏳{{DAYS_LEFT}}D';
   remarkTemplate = '{{INBOUND}}-{{EMAIL}}|📊{{TRAFFIC_LEFT}}|⏳{{DAYS_LEFT}}D';
   subShowIdentityOnAllLinks = false;
   subShowIdentityOnAllLinks = false;
+  subInfoNodeEnable = false;
+  subExpiredTemplate = '⛔ {{EMAIL}} | Expired: {{EXPIRE_DATE}}';
+  subTrafficDepletedTemplate =
+    '🚫 {{EMAIL}} | Traffic Depleted | {{TRAFFIC_USED}}/{{TRAFFIC_TOTAL}}';
   datepicker: 'gregorian' | 'jalalian' = 'gregorian';
   datepicker: 'gregorian' | 'jalalian' = 'gregorian';
   tgBotEnable = false;
   tgBotEnable = false;
   tgBotToken = '';
   tgBotToken = '';

+ 5 - 2
frontend/src/pages/api-docs/endpoints.ts

@@ -46,6 +46,7 @@ export interface Endpoint {
   bodyRequiredOneOf?: string[];
   bodyRequiredOneOf?: string[];
   responseSchema?: string;
   responseSchema?: string;
   responseSchemaArray?: boolean;
   responseSchemaArray?: boolean;
+  responseSchemaArrayNullable?: boolean;
   responseObjectSchema?: Record<string, unknown>;
   responseObjectSchema?: Record<string, unknown>;
   responses?: Record<string, Record<string, unknown>>;
   responses?: Record<string, Record<string, unknown>>;
   security?: readonly Record<string, readonly string[]>[];
   security?: readonly Record<string, readonly string[]>[];
@@ -265,6 +266,7 @@ export const sections: readonly Section[] = [
       {
       {
         method: 'GET',
         method: 'GET',
         path: '/panel/api/inbounds/allLinks',
         path: '/panel/api/inbounds/allLinks',
+        responseObjectSchema: { type: 'array', nullable: true, items: { type: 'string' } },
         summary:
         summary:
           'Return every protocol URL (vless://, vmess://, trojan://, ss://, hysteria://, mtproto) across all inbounds and all of their clients. Links are rendered through the subscription engine, so the configured remark template (name-only display part) is applied per client — the same output the client info/QR pages use. Protocols without a URL form (socks, http, mixed, wireguard, dokodemo, tunnel) contribute nothing. Used by the panel’s "Export all inbound links" action.',
           'Return every protocol URL (vless://, vmess://, trojan://, ss://, hysteria://, mtproto) across all inbounds and all of their clients. Links are rendered through the subscription engine, so the configured remark template (name-only display part) is applied per client — the same output the client info/QR pages use. Protocols without a URL form (socks, http, mixed, wireguard, dokodemo, tunnel) contribute nothing. Used by the panel’s "Export all inbound links" action.',
         response:
         response:
@@ -709,7 +711,7 @@ export const sections: readonly Section[] = [
           },
           },
         ],
         ],
         body: 'level=info&syslog=false',
         body: 'level=info&syslog=false',
-        responseObjectSchema: { type: 'array', items: { type: 'string' } },
+        responseObjectSchema: { type: 'array', nullable: true, items: { type: 'string' } },
         response:
         response:
           '{\n  "success": true,\n  "obj": [\n    "2025/01/01 12:00:00 [INFO] Server started",\n    "2025/01/01 12:00:01 [INFO] Xray is running"\n  ]\n}',
           '{\n  "success": true,\n  "obj": [\n    "2025/01/01 12:00:00 [INFO] Server started",\n    "2025/01/01 12:00:01 [INFO] Xray is running"\n  ]\n}',
       },
       },
@@ -751,6 +753,7 @@ export const sections: readonly Section[] = [
         body: 'filter=error&showDirect=false&showBlocked=true&showProxy=true',
         body: 'filter=error&showDirect=false&showBlocked=true&showProxy=true',
         responseSchema: 'LogEntry',
         responseSchema: 'LogEntry',
         responseSchemaArray: true,
         responseSchemaArray: true,
+        responseSchemaArrayNullable: true,
       },
       },
       {
       {
         method: 'POST',
         method: 'POST',
@@ -1561,7 +1564,7 @@ export const sections: readonly Section[] = [
         method: 'GET',
         method: 'GET',
         path: '/panel/api/clients/links/:email',
         path: '/panel/api/clients/links/:email',
         summary:
         summary:
-          '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 streamSettings.externalProxy is set, returns one URL per external proxy. Protocols without a URL form (socks, http, mixed, wireguard, dokodemo, tunnel) contribute nothing.',
+          'Return every URL for one client across all attached inbounds, one per advertised endpoint: the managed hosts of the inbound, else its streamSettings.externalProxy entries, else its own address. Supported protocols: vmess, vless, trojan, shadowsocks, hysteria, mtproto. Protocols without a URL form (socks, http, mixed, wireguard, dokodemo, tunnel) contribute nothing.',
         params: [
         params: [
           { name: 'email', in: 'path', type: 'string', desc: 'Client email (unique identifier).' },
           { name: 'email', in: 'path', type: 'string', desc: 'Client email (unique identifier).' },
         ],
         ],

+ 3 - 3
frontend/src/pages/clients/ClientFormModal.tsx

@@ -849,7 +849,7 @@ export default function ClientFormModal({
                             </Space.Compact>
                             </Space.Compact>
                           </Form.Item>
                           </Form.Item>
                         </Col>
                         </Col>
-                        <Col xs={24} md={6}>
+                        <Col xs={24} md={12}>
                           <FormField
                           <FormField
                             name="totalGB"
                             name="totalGB"
                             label={t('pages.clients.totalGB')}
                             label={t('pages.clients.totalGB')}
@@ -859,7 +859,7 @@ export default function ClientFormModal({
                             <InputNumber min={0} step={1} style={{ width: '100%' }} />
                             <InputNumber min={0} step={1} style={{ width: '100%' }} />
                           </FormField>
                           </FormField>
                         </Col>
                         </Col>
-                        <Col xs={24} md={6}>
+                        <Col xs={24} md={12}>
                           <Form.Item
                           <Form.Item
                             label={t('pages.clients.limitIp')}
                             label={t('pages.clients.limitIp')}
                             tooltip={t('pages.clients.limitIpDesc')}
                             tooltip={t('pages.clients.limitIpDesc')}
@@ -894,7 +894,7 @@ export default function ClientFormModal({
                             </Tooltip>
                             </Tooltip>
                           </Form.Item>
                           </Form.Item>
                         </Col>
                         </Col>
-                        <Col xs={24} md={6}>
+                        <Col xs={24} md={12}>
                           <Form.Item
                           <Form.Item
                             label={t('pages.clients.limitHwid')}
                             label={t('pages.clients.limitHwid')}
                             tooltip={t('pages.clients.limitHwidDesc')}
                             tooltip={t('pages.clients.limitHwidDesc')}

+ 2 - 1
frontend/src/pages/clients/amneziawgConfig.ts

@@ -1,5 +1,6 @@
 import { formatInboundLabel } from '@/lib/inbounds/label';
 import { formatInboundLabel } from '@/lib/inbounds/label';
 import { preferPublicHost, resolveShareHost } from '@/lib/xray/inbound-link';
 import { preferPublicHost, resolveShareHost } from '@/lib/xray/inbound-link';
+import { effectiveMtu } from '@/lib/xray/amneziawg-obfuscation';
 import type { ClientRecord, InboundOption } from '@/hooks/useClients';
 import type { ClientRecord, InboundOption } from '@/hooks/useClients';
 
 
 // AmneziaWG clients are wire-identical to WireGuard clients (same
 // AmneziaWG clients are wire-identical to WireGuard clients (same
@@ -65,7 +66,7 @@ export function buildAmneziaWGClientConfig(
   const dnsParts = [server?.primaryDns, server?.secondaryDns].filter((v) => !!v && v.trim() !== '');
   const dnsParts = [server?.primaryDns, server?.secondaryDns].filter((v) => !!v && v.trim() !== '');
   const lines = ['[Interface]', `PrivateKey = ${privateKey}`, `Address = ${address}`];
   const lines = ['[Interface]', `PrivateKey = ${privateKey}`, `Address = ${address}`];
   if (dnsParts.length > 0) lines.push(`DNS = ${dnsParts.join(', ')}`);
   if (dnsParts.length > 0) lines.push(`DNS = ${dnsParts.join(', ')}`);
-  if (server?.mtu && server.mtu > 0) lines.push(`MTU = ${server.mtu}`);
+  lines.push(`MTU = ${effectiveMtu(server?.mtu, server?.s4)}`);
 
 
   // AmneziaWG obfuscation parameters — must match the server's values.
   // AmneziaWG obfuscation parameters — must match the server's values.
   lines.push(`Jc = ${server?.jc ?? 5}`);
   lines.push(`Jc = ${server?.jc ?? 5}`);

+ 42 - 9
frontend/src/pages/inbounds/InboundsPage.tsx

@@ -38,6 +38,8 @@ import { useTheme } from '@/hooks/useTheme';
 import { useMediaQuery } from '@/hooks/useMediaQuery';
 import { useMediaQuery } from '@/hooks/useMediaQuery';
 import { useWebSocket } from '@/hooks/useWebSocket';
 import { useWebSocket } from '@/hooks/useWebSocket';
 import { useNodesQuery } from '@/api/queries/useNodesQuery';
 import { useNodesQuery } from '@/api/queries/useNodesQuery';
+import { useHostsQuery } from '@/api/queries/useHostsQuery';
+import { withMtprotoHostEndpoints } from '@/lib/hosts/host-link';
 import AppSidebar from '@/layouts/AppSidebar';
 import AppSidebar from '@/layouts/AppSidebar';
 const TextModal = lazy(() => import('@/components/feedback/TextModal'));
 const TextModal = lazy(() => import('@/components/feedback/TextModal'));
 import type { TextModalTab } from '@/components/feedback/TextModal';
 import type { TextModalTab } from '@/components/feedback/TextModal';
@@ -112,6 +114,16 @@ export default function InboundsPage() {
   }, [messageApi]);
   }, [messageApi]);
 
 
   const { nodes: nodesList, fetched: nodesFetched } = useNodesQuery();
   const { nodes: nodesList, fetched: nodesFetched } = useNodesQuery();
+  // MTProto share links are generated from this list, so an empty one must mean
+  // "no hosts" and not "not loaded yet" — the gate below waits for it.
+  const {
+    hosts,
+    fetched: hostsFetched,
+    fetchError: hostsFetchError,
+    refetch: refetchHosts,
+  } = useHostsQuery();
+  // A background refetch that fails while rows are still cached is not fatal.
+  const hostsError = hosts.length > 0 ? '' : hostsFetchError;
   const nodesById = useMemo(() => {
   const nodesById = useMemo(() => {
     const map = new Map<number, ReturnType<typeof useNodesQuery>['nodes'][number]>();
     const map = new Map<number, ReturnType<typeof useNodesQuery>['nodes'][number]>();
     for (const n of nodesList || []) map.set(n.id, n);
     for (const n of nodesList || []) map.set(n.id, n);
@@ -325,11 +337,19 @@ export default function InboundsPage() {
   const exportInboundLinks = useCallback(
   const exportInboundLinks = useCallback(
     (dbInbound: DBInbound) => {
     (dbInbound: DBInbound) => {
       const projected = checkFallback(dbInbound);
       const projected = checkFallback(dbInbound);
+      const hostOverride = hostOverrideFor(dbInbound);
+      const fallbackHostname = preferPublicHost(window.location.hostname, subSettings.publicHost);
       const genInput = {
       const genInput = {
-        inbound: inboundFromDb(projected),
+        inbound: withMtprotoHostEndpoints(
+          inboundFromDb(projected),
+          dbInbound.id,
+          hosts,
+          hostOverride,
+          fallbackHostname,
+        ),
         remark: projected.remark,
         remark: projected.remark,
-        hostOverride: hostOverrideFor(dbInbound),
-        fallbackHostname: preferPublicHost(window.location.hostname, subSettings.publicHost),
+        hostOverride,
+        fallbackHostname,
       };
       };
       const content = genInboundLinks(genInput);
       const content = genInboundLinks(genInput);
       const tabs: TextModalTab[] | undefined = projected.isWireguard
       const tabs: TextModalTab[] | undefined = projected.isWireguard
@@ -358,7 +378,7 @@ export default function InboundsPage() {
         tabs,
         tabs,
       });
       });
     },
     },
-    [checkFallback, hostOverrideFor, subSettings.publicHost, openText, t],
+    [checkFallback, hostOverrideFor, hosts, subSettings.publicHost, openText, t],
   );
   );
 
 
   const exportInboundClipboard = useCallback(
   const exportInboundClipboard = useCallback(
@@ -708,16 +728,27 @@ export default function InboundsPage() {
 
 
         <Layout className="content-shell">
         <Layout className="content-shell">
           <Layout.Content id="content-layout" className="content-area">
           <Layout.Content id="content-layout" className="content-area">
-            <Spin spinning={!fetched} delay={200} description={t('loading')} size="large">
-              {!fetched ? (
+            <Spin
+              spinning={!fetched || !hostsFetched}
+              delay={200}
+              description={t('loading')}
+              size="large"
+            >
+              {!fetched || !hostsFetched ? (
                 <div className="loading-spacer" />
                 <div className="loading-spacer" />
-              ) : fetchError ? (
+              ) : fetchError || hostsError ? (
                 <Result
                 <Result
                   status="error"
                   status="error"
                   title={t('somethingWentWrong')}
                   title={t('somethingWentWrong')}
-                  subTitle={fetchError}
+                  subTitle={fetchError || hostsError}
                   extra={
                   extra={
-                    <Button type="primary" onClick={refresh}>
+                    <Button
+                      type="primary"
+                      onClick={() => {
+                        void refresh();
+                        void refetchHosts();
+                      }}
+                    >
                       {t('refresh')}
                       {t('refresh')}
                     </Button>
                     </Button>
                   }
                   }
@@ -809,6 +840,7 @@ export default function InboundsPage() {
             ipLimitEnable={ipLimitEnable}
             ipLimitEnable={ipLimitEnable}
             tgBotEnable={tgBotEnable}
             tgBotEnable={tgBotEnable}
             subSettings={subSettings}
             subSettings={subSettings}
+            hosts={hosts}
             lastOnlineMap={lastOnlineMap}
             lastOnlineMap={lastOnlineMap}
             nodeAddress={infoNodeAddress}
             nodeAddress={infoNodeAddress}
           />
           />
@@ -821,6 +853,7 @@ export default function InboundsPage() {
             client={null}
             client={null}
             nodeAddress={qrNodeAddress}
             nodeAddress={qrNodeAddress}
             subSettings={subSettings}
             subSettings={subSettings}
+            hosts={hosts}
           />
           />
         </LazyMount>
         </LazyMount>
         <LazyMount when={attachOpen}>
         <LazyMount when={attachOpen}>

+ 43 - 31
frontend/src/pages/inbounds/form/InboundFormModal.tsx

@@ -490,8 +490,14 @@ export default function InboundFormModal({
    */
    */
   useEffect(() => {
   useEffect(() => {
     if (!open) return;
     if (!open) return;
-    if (!availableNodesFetched || !protocol) return;
+    if (!protocol) return;
     const current = getV('shareAddrStrategy') as InboundFormValues['shareAddrStrategy'] | undefined;
     const current = getV('shareAddrStrategy') as InboundFormValues['shareAddrStrategy'] | undefined;
+    if (protocol === Protocols.MTPROTO) {
+      if (current !== 'listen') setV('shareAddrStrategy', 'listen');
+      if (getV('shareAddr')) setV('shareAddr', '');
+      return;
+    }
+    if (!availableNodesFetched) return;
     if (!nodeShareOptionAvailable && (current ?? 'node') === 'node') {
     if (!nodeShareOptionAvailable && (current ?? 'node') === 'node') {
       setV('shareAddrStrategy', 'listen');
       setV('shareAddrStrategy', 'listen');
     }
     }
@@ -559,6 +565,7 @@ export default function InboundFormModal({
     const parsed = InboundFormSchema.safeParse(values);
     const parsed = InboundFormSchema.safeParse(values);
     if (!parsed.success) {
     if (!parsed.success) {
       const issues = parsed.error.issues;
       const issues = parsed.error.issues;
+      setActiveTab(tabForValidationPath(issues[0].path));
       messageApi.error(formatInboundValidation(issues, values, t));
       messageApi.error(formatInboundValidation(issues, values, t));
       console.error(
       console.error(
         '[InboundFormModal] schema validation failed:',
         '[InboundFormModal] schema validation failed:',
@@ -643,37 +650,42 @@ export default function InboundFormModal({
         <Input placeholder={t('pages.inbounds.monitorDesc')} />
         <Input placeholder={t('pages.inbounds.monitorDesc')} />
       </FormField>
       </FormField>
 
 
-      <FormField
-        name="shareAddrStrategy"
-        label={labelWithHint(
-          t('pages.inbounds.form.shareAddrStrategy'),
-          t('pages.inbounds.form.shareAddrStrategyHelp'),
-        )}
-      >
-        <Select
-          options={SHARE_ADDR_STRATEGIES.filter(
-            (strategy) => strategy !== 'node' || nodeShareOptionAvailable,
-          ).map((strategy) => ({
-            value: strategy,
-            label: t(`pages.inbounds.form.shareAddrStrategyOptions.${strategy}`),
-          }))}
-        />
-      </FormField>
-
-      {shareAddrStrategy === 'custom' && (
-        <FormField
-          name="shareAddr"
-          label={labelWithHint(
-            t('pages.inbounds.form.shareAddr'),
-            t('pages.inbounds.form.shareAddrHelp'),
+      {protocol !== Protocols.MTPROTO && (
+        <>
+          <FormField
+            name="shareAddrStrategy"
+            label={labelWithHint(
+              t('pages.inbounds.form.shareAddrStrategy'),
+              t('pages.inbounds.form.shareAddrStrategyHelp'),
+            )}
+          >
+            <Select
+              options={SHARE_ADDR_STRATEGIES.filter(
+                (strategy) => strategy !== 'node' || nodeShareOptionAvailable,
+              ).map((strategy) => ({
+                value: strategy,
+                label: t(`pages.inbounds.form.shareAddrStrategyOptions.${strategy}`),
+              }))}
+            />
+          </FormField>
+
+          {shareAddrStrategy === 'custom' && (
+            <FormField
+              name="shareAddr"
+              label={labelWithHint(
+                t('pages.inbounds.form.shareAddr'),
+                t('pages.inbounds.form.shareAddrHelp'),
+              )}
+              rules={{
+                validate: (value) =>
+                  isValidShareAddrInput(String(value ?? '')) ||
+                  t('pages.inbounds.form.shareAddrHelp'),
+              }}
+            >
+              <Input placeholder="edge.example.com" />
+            </FormField>
           )}
           )}
-          rules={{
-            validate: (value) =>
-              isValidShareAddrInput(String(value ?? '')) || t('pages.inbounds.form.shareAddrHelp'),
-          }}
-        >
-          <Input placeholder="edge.example.com" />
-        </FormField>
+        </>
       )}
       )}
 
 
       <FormField
       <FormField

+ 6 - 0
frontend/src/pages/inbounds/form/formatValidationError.ts

@@ -18,6 +18,12 @@ export function formatInboundIssue(issue: IssueLike, values: unknown, t: TFuncti
   const path = Array.isArray(issue?.path) ? issue.path : [];
   const path = Array.isArray(issue?.path) ? issue.path : [];
   const reason = t(issue?.message, { defaultValue: issue?.message });
   const reason = t(issue?.message, { defaultValue: issue?.message });
 
 
+  if (path[0] === 'streamSettings' && path[1] === 'tlsSettings' && path[2] === 'certificates') {
+    return typeof path[3] === 'number'
+      ? t('pages.inbounds.toasts.invalidCertificate', { index: path[3] + 1, reason })
+      : reason;
+  }
+
   if (path[0] === 'settings' && path[1] === 'clients' && typeof path[2] === 'number') {
   if (path[0] === 'settings' && path[1] === 'clients' && typeof path[2] === 'number') {
     const index = path[2];
     const index = path[2];
     const clients = (values as { settings?: { clients?: ClientLike[] } })?.settings?.clients;
     const clients = (values as { settings?: { clients?: ClientLike[] } })?.settings?.clients;

+ 7 - 0
frontend/src/pages/inbounds/form/protocols/hysteria.tsx

@@ -78,6 +78,13 @@ export default function HysteriaFields() {
               >
               >
                 <Switch />
                 <Switch />
               </FormField>
               </FormField>
+              <FormField
+                label={t('pages.inbounds.form.xForwarded')}
+                name={[...MASQ_PATH, 'xForwarded']}
+                valueProp="checked"
+              >
+                <Switch />
+              </FormField>
               <FormField
               <FormField
                 label={t('pages.inbounds.form.skipTlsVerify')}
                 label={t('pages.inbounds.form.skipTlsVerify')}
                 name={[...MASQ_PATH, 'insecure']}
                 name={[...MASQ_PATH, 'insecure']}

+ 1 - 1
frontend/src/pages/inbounds/form/security/reality.tsx

@@ -189,7 +189,7 @@ export default function RealityForm({
           },
           },
         }}
         }}
       >
       >
-        <Input placeholder="26.3.27" />
+        <Input placeholder="x.y.z" />
       </FormField>
       </FormField>
       <FormField
       <FormField
         name={['streamSettings', 'realitySettings', 'maxClientVer']}
         name={['streamSettings', 'realitySettings', 'maxClientVer']}

+ 14 - 2
frontend/src/pages/inbounds/info/InboundInfoModal.tsx

@@ -17,6 +17,7 @@ import {
   preferPublicHost,
   preferPublicHost,
 } from '@/lib/xray/inbound-link';
 } from '@/lib/xray/inbound-link';
 import { inboundFromDb } from '@/lib/xray/inbound-from-db';
 import { inboundFromDb } from '@/lib/xray/inbound-from-db';
+import { withMtprotoHostEndpoints } from '@/lib/hosts/host-link';
 
 
 import {
 import {
   buildInboundInfo,
   buildInboundInfo,
@@ -29,6 +30,8 @@ import {
 import type { ClientSetting, ClientStats, InboundInfo, InboundInfoModalProps } from './types';
 import type { ClientSetting, ClientStats, InboundInfo, InboundInfoModalProps } from './types';
 import './InboundInfoModal.css';
 import './InboundInfoModal.css';
 
 
+const EMPTY_HOSTS: NonNullable<InboundInfoModalProps['hosts']> = [];
+
 export default function InboundInfoModal({
 export default function InboundInfoModal({
   open,
   open,
   onClose,
   onClose,
@@ -40,6 +43,7 @@ export default function InboundInfoModal({
   tgBotEnable = false,
   tgBotEnable = false,
   nodeAddress = '',
   nodeAddress = '',
   subSettings,
   subSettings,
+  hosts = EMPTY_HOSTS,
   lastOnlineMap = {},
   lastOnlineMap = {},
 }: InboundInfoModalProps) {
 }: InboundInfoModalProps) {
   const { t } = useTranslation();
   const { t } = useTranslation();
@@ -110,6 +114,7 @@ export default function InboundInfoModal({
     clientIndex: typeof clientIndex;
     clientIndex: typeof clientIndex;
     nodeAddress: typeof nodeAddress;
     nodeAddress: typeof nodeAddress;
     subSettings: typeof subSettings;
     subSettings: typeof subSettings;
+    hosts: typeof hosts;
     ipLimitEnable: typeof ipLimitEnable;
     ipLimitEnable: typeof ipLimitEnable;
   } | null>(null);
   } | null>(null);
   if (
   if (
@@ -120,9 +125,10 @@ export default function InboundInfoModal({
       syncedProps.clientIndex !== clientIndex ||
       syncedProps.clientIndex !== clientIndex ||
       syncedProps.nodeAddress !== nodeAddress ||
       syncedProps.nodeAddress !== nodeAddress ||
       syncedProps.subSettings !== subSettings ||
       syncedProps.subSettings !== subSettings ||
+      syncedProps.hosts !== hosts ||
       syncedProps.ipLimitEnable !== ipLimitEnable)
       syncedProps.ipLimitEnable !== ipLimitEnable)
   ) {
   ) {
-    setSyncedProps({ dbInbound, clientIndex, nodeAddress, subSettings, ipLimitEnable });
+    setSyncedProps({ dbInbound, clientIndex, nodeAddress, subSettings, hosts, ipLimitEnable });
     const info = buildInboundInfo(dbInbound);
     const info = buildInboundInfo(dbInbound);
     setInbound(info);
     setInbound(info);
     setActiveTab(info.clients.length > 0 ? 'client' : 'inbound');
     setActiveTab(info.clients.length > 0 ? 'client' : 'inbound');
@@ -135,11 +141,17 @@ export default function InboundInfoModal({
       : null;
       : null;
     setClientStats(stats);
     setClientStats(stats);
 
 
-    const inboundForLinks = inboundFromDb(dbInbound);
     const fallbackHostname = preferPublicHost(
     const fallbackHostname = preferPublicHost(
       window.location.hostname,
       window.location.hostname,
       subSettings?.publicHost ?? '',
       subSettings?.publicHost ?? '',
     );
     );
+    const inboundForLinks = withMtprotoHostEndpoints(
+      inboundFromDb(dbInbound),
+      dbInbound.id,
+      hosts,
+      nodeAddress,
+      fallbackHostname,
+    );
     if (info.protocol === Protocols.WIREGUARD) {
     if (info.protocol === Protocols.WIREGUARD) {
       setWireguardConfigs(
       setWireguardConfigs(
         genWireguardConfigs({
         genWireguardConfigs({

+ 2 - 0
frontend/src/pages/inbounds/info/types.ts

@@ -1,4 +1,5 @@
 import type { SubSettings } from '../useInbounds';
 import type { SubSettings } from '../useInbounds';
+import type { HostRecord } from '@/schemas/api/host';
 
 
 export interface ClientStats {
 export interface ClientStats {
   email: string;
   email: string;
@@ -82,5 +83,6 @@ export interface InboundInfoModalProps {
   tgBotEnable?: boolean;
   tgBotEnable?: boolean;
   nodeAddress?: string;
   nodeAddress?: string;
   subSettings?: SubSettings;
   subSettings?: SubSettings;
+  hosts?: HostRecord[];
   lastOnlineMap?: Record<string, number>;
   lastOnlineMap?: Record<string, number>;
 }
 }

+ 18 - 4
frontend/src/pages/inbounds/qr/QrCodeModal.tsx

@@ -14,6 +14,8 @@ import {
   preferPublicHost,
   preferPublicHost,
 } from '@/lib/xray/inbound-link';
 } from '@/lib/xray/inbound-link';
 import { inboundFromDb, type DbInboundLike } from '@/lib/xray/inbound-from-db';
 import { inboundFromDb, type DbInboundLike } from '@/lib/xray/inbound-from-db';
+import { withMtprotoHostEndpoints } from '@/lib/hosts/host-link';
+import type { HostRecord } from '@/schemas/api/host';
 import QrPanel from './QrPanel';
 import QrPanel from './QrPanel';
 import type { SubSettings } from '../useInbounds';
 import type { SubSettings } from '../useInbounds';
 
 
@@ -26,10 +28,11 @@ interface ClientSetting {
 interface QrCodeModalProps {
 interface QrCodeModalProps {
   open: boolean;
   open: boolean;
   onClose: () => void;
   onClose: () => void;
-  dbInbound: (DbInboundLike & { remark?: string }) | null;
+  dbInbound: (DbInboundLike & { id: number; remark?: string }) | null;
   client?: ClientSetting | null;
   client?: ClientSetting | null;
   nodeAddress?: string;
   nodeAddress?: string;
   subSettings?: SubSettings;
   subSettings?: SubSettings;
+  hosts?: HostRecord[];
 }
 }
 
 
 interface QrItem {
 interface QrItem {
@@ -40,6 +43,8 @@ interface QrItem {
   showQr?: boolean;
   showQr?: boolean;
 }
 }
 
 
+const EMPTY_HOSTS: HostRecord[] = [];
+
 export default function QrCodeModal({
 export default function QrCodeModal({
   open,
   open,
   onClose,
   onClose,
@@ -47,6 +52,7 @@ export default function QrCodeModal({
   client = null,
   client = null,
   nodeAddress = '',
   nodeAddress = '',
   subSettings,
   subSettings,
+  hosts = EMPTY_HOSTS,
 }: QrCodeModalProps) {
 }: QrCodeModalProps) {
   const { t } = useTranslation();
   const { t } = useTranslation();
   const [links, setLinks] = useState<{ remark?: string; link: string }[]>([]);
   const [links, setLinks] = useState<{ remark?: string; link: string }[]>([]);
@@ -65,6 +71,7 @@ export default function QrCodeModal({
     client: typeof client;
     client: typeof client;
     nodeAddress: typeof nodeAddress;
     nodeAddress: typeof nodeAddress;
     subSettings: typeof subSettings;
     subSettings: typeof subSettings;
+    hosts: typeof hosts;
   } | null>(null);
   } | null>(null);
   if (
   if (
     open &&
     open &&
@@ -73,14 +80,21 @@ export default function QrCodeModal({
       syncedProps.dbInbound !== dbInbound ||
       syncedProps.dbInbound !== dbInbound ||
       syncedProps.client !== client ||
       syncedProps.client !== client ||
       syncedProps.nodeAddress !== nodeAddress ||
       syncedProps.nodeAddress !== nodeAddress ||
-      syncedProps.subSettings !== subSettings)
+      syncedProps.subSettings !== subSettings ||
+      syncedProps.hosts !== hosts)
   ) {
   ) {
-    setSyncedProps({ dbInbound, client, nodeAddress, subSettings });
-    const inbound = inboundFromDb(dbInbound);
+    setSyncedProps({ dbInbound, client, nodeAddress, subSettings, hosts });
     const fallbackHostname = preferPublicHost(
     const fallbackHostname = preferPublicHost(
       window.location.hostname,
       window.location.hostname,
       subSettings?.publicHost ?? '',
       subSettings?.publicHost ?? '',
     );
     );
+    const inbound = withMtprotoHostEndpoints(
+      inboundFromDb(dbInbound),
+      dbInbound.id,
+      hosts,
+      nodeAddress,
+      fallbackHostname,
+    );
     if (inbound.protocol === Protocols.WIREGUARD) {
     if (inbound.protocol === Protocols.WIREGUARD) {
       const peerRemark = client?.email
       const peerRemark = client?.email
         ? `${dbInbound.remark}-${client.email}`
         ? `${dbInbound.remark}-${client.email}`

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

@@ -182,6 +182,41 @@ export default function SubscriptionGeneralTab({
                 />
                 />
               </SettingListItem>
               </SettingListItem>
 
 
+              <SettingListItem
+                paddings="small"
+                title={t('pages.settings.subInfoNodeEnable')}
+                description={t('pages.settings.subInfoNodeEnableDesc')}
+              >
+                <Switch
+                  checked={allSetting.subInfoNodeEnable}
+                  onChange={(v) => updateSetting({ subInfoNodeEnable: v })}
+                />
+              </SettingListItem>
+
+              <SettingListItem
+                paddings="small"
+                title={t('pages.settings.subExpiredTemplate')}
+                description={t('pages.settings.subExpiredTemplateDesc')}
+              >
+                <RemarkTemplateField
+                  value={allSetting.subExpiredTemplate}
+                  onChange={(v) => updateSetting({ subExpiredTemplate: v })}
+                  maxLength={256}
+                />
+              </SettingListItem>
+
+              <SettingListItem
+                paddings="small"
+                title={t('pages.settings.subTrafficDepletedTemplate')}
+                description={t('pages.settings.subTrafficDepletedTemplateDesc')}
+              >
+                <RemarkTemplateField
+                  value={allSetting.subTrafficDepletedTemplate}
+                  onChange={(v) => updateSetting({ subTrafficDepletedTemplate: v })}
+                  maxLength={256}
+                />
+              </SettingListItem>
+
               <SettingListItem
               <SettingListItem
                 paddings="small"
                 paddings="small"
                 title={t('pages.settings.subUpdates')}
                 title={t('pages.settings.subUpdates')}

+ 24 - 10
frontend/src/pages/xray/outbounds/protocols/blackhole.tsx

@@ -1,19 +1,33 @@
 import { useTranslation } from 'react-i18next';
 import { useTranslation } from 'react-i18next';
-import { Select } from 'antd';
+import { Input, Select } from 'antd';
+import { useFormContext, useWatch } from 'react-hook-form';
 
 
 import { FormField } from '@/components/form/rhf';
 import { FormField } from '@/components/form/rhf';
 
 
 export default function BlackholeFields() {
 export default function BlackholeFields() {
   const { t } = useTranslation();
   const { t } = useTranslation();
+  const { control } = useFormContext();
+  const type = useWatch({ control, name: 'settings.type' }) as string | undefined;
   return (
   return (
-    <FormField label={t('pages.xray.outboundForm.responseType')} name={['settings', 'type']}>
-      <Select
-        options={[
-          { value: '', label: '(empty)' },
-          { value: 'none', label: 'none' },
-          { value: 'http', label: 'http' },
-        ]}
-      />
-    </FormField>
+    <>
+      <FormField label={t('pages.xray.outboundForm.responseType')} name={['settings', 'type']}>
+        <Select
+          options={[
+            { value: '', label: '(empty)' },
+            { value: 'none', label: 'none' },
+            { value: 'http', label: 'http' },
+            { value: 'custom', label: 'custom' },
+          ]}
+        />
+      </FormField>
+      {type === 'custom' && (
+        <FormField
+          label={t('pages.xray.outboundForm.customResponseData')}
+          name={['settings', 'customResponseData']}
+        >
+          <Input.TextArea rows={3} placeholder="SFRUUC8xLjEgNDAzIEZvcmJpZGRlbg0KDQo=" />
+        </FormField>
+      )}
+    </>
   );
   );
 }
 }

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

@@ -99,6 +99,9 @@ export default function WireguardFields() {
       <FormField label={t('pages.xray.outboundForm.reserved')} name={['settings', 'reserved']}>
       <FormField label={t('pages.xray.outboundForm.reserved')} name={['settings', 'reserved']}>
         <Input placeholder="comma-separated bytes, e.g. 1,2,3" />
         <Input placeholder="comma-separated bytes, e.g. 1,2,3" />
       </FormField>
       </FormField>
+      <FormField label={t('pages.xray.outboundForm.remoteDNS')} name={['settings', 'remoteDNS']}>
+        <Input placeholder="comma-separated, e.g. 1.1.1.1,2606:4700:4700::1111" />
+      </FormField>
       <Form.Item label={t('pages.inbounds.form.peers')}>
       <Form.Item label={t('pages.inbounds.form.peers')}>
         <Button
         <Button
           size="small"
           size="small"

+ 7 - 0
frontend/src/pages/xray/outbounds/transport/hysteria.tsx

@@ -79,6 +79,13 @@ export default function HysteriaForm() {
               >
               >
                 <Switch />
                 <Switch />
               </FormField>
               </FormField>
+              <FormField
+                label={t('pages.inbounds.form.xForwarded')}
+                name={[...MASQ, 'xForwarded']}
+                valueProp="checked"
+              >
+                <Switch />
+              </FormField>
               <FormField
               <FormField
                 label={t('pages.inbounds.form.skipTlsVerify')}
                 label={t('pages.inbounds.form.skipTlsVerify')}
                 name={[...MASQ, 'insecure']}
                 name={[...MASQ, 'insecure']}

+ 57 - 3
frontend/src/schemas/forms/inbound-form.ts

@@ -2,11 +2,65 @@ import { z } from 'zod';
 
 
 import { InboundPortSchema, SniffingSchema } from '@/schemas/primitives';
 import { InboundPortSchema, SniffingSchema } from '@/schemas/primitives';
 import { InboundSettingsSchema } from '@/schemas/protocols/inbound';
 import { InboundSettingsSchema } from '@/schemas/protocols/inbound';
-import { SecuritySettingsSchema } from '@/schemas/protocols/security';
+import {
+  TlsCertInlineSchema,
+  TlsStreamSettingsSchema,
+  securitySettingsSchemaFor,
+  tlsCertUsesFiles,
+} from '@/schemas/protocols/security';
 import { NetworkSettingsSchema, StreamExtrasSchema } from '@/schemas/protocols/stream';
 import { NetworkSettingsSchema, StreamExtrasSchema } from '@/schemas/protocols/stream';
 
 
-export const InboundStreamFormSchema =
-  NetworkSettingsSchema.and(SecuritySettingsSchema).and(StreamExtrasSchema);
+// Inbound certificates must follow the selected editor mode. The shared wire
+// union also serves outbound TLS, where a client certificate is optional.
+const InboundTlsCertFieldsSchema = TlsCertInlineSchema.extend({
+  useFile: z.boolean().optional(),
+  certificateFile: z.string().default(''),
+  keyFile: z.string().default(''),
+  certificate: z.array(z.string()).default([]),
+  key: z.array(z.string()).default([]),
+});
+
+const InboundTlsCertSchema = InboundTlsCertFieldsSchema.superRefine((cert, ctx) => {
+  const useFile = tlsCertUsesFiles(cert);
+  const hasCertificate = useFile
+    ? cert.certificateFile.trim() !== ''
+    : cert.certificate.some((line) => line.trim() !== '');
+  const hasKey = useFile ? cert.keyFile.trim() !== '' : cert.key.some((line) => line.trim() !== '');
+  if (!hasCertificate) {
+    ctx.addIssue({
+      code: 'custom',
+      path: [useFile ? 'certificateFile' : 'certificate'],
+      message: 'pages.inbounds.form.tlsCertificateRequired',
+    });
+  }
+  if (cert.usage !== 'verify' && !hasKey) {
+    ctx.addIssue({
+      code: 'custom',
+      path: [useFile ? 'keyFile' : 'key'],
+      message: 'pages.inbounds.form.tlsPrivateKeyRequired',
+    });
+  }
+}).transform((cert) => {
+  const { useFile: _useFile, certificateFile, keyFile, certificate, key, ...settings } = cert;
+  return tlsCertUsesFiles(cert)
+    ? { ...settings, certificateFile, keyFile }
+    : { ...settings, certificate, key };
+});
+
+const InboundTlsSettingsSchema = TlsStreamSettingsSchema.extend({
+  certificates: z
+    .array(InboundTlsCertSchema)
+    .default([])
+    .refine((certificates) => certificates.some((cert) => cert.usage !== 'verify'), {
+      message: 'pages.inbounds.form.tlsServerCertificateRequired',
+    }),
+});
+
+const InboundSecuritySettingsSchema = securitySettingsSchemaFor(InboundTlsSettingsSchema);
+
+export const InboundStreamFormSchema = NetworkSettingsSchema.and(InboundSecuritySettingsSchema).and(
+  StreamExtrasSchema,
+);
 export type InboundStreamFormValues = z.infer<typeof InboundStreamFormSchema>;
 export type InboundStreamFormValues = z.infer<typeof InboundStreamFormSchema>;
 
 
 export const TrafficResetSchema = z.enum(['never', 'hourly', 'daily', 'weekly', 'monthly']);
 export const TrafficResetSchema = z.enum(['never', 'hourly', 'daily', 'weekly', 'monthly']);

+ 2 - 0
frontend/src/schemas/forms/outbound-form.ts

@@ -106,6 +106,7 @@ export const WireguardOutboundFormSettingsSchema = z.object({
   address: z.string().default(''),
   address: z.string().default(''),
   domainStrategy: z.union([WireguardDomainStrategySchema, z.literal('')]).default(''),
   domainStrategy: z.union([WireguardDomainStrategySchema, z.literal('')]).default(''),
   reserved: z.string().default(''),
   reserved: z.string().default(''),
+  remoteDNS: z.string().default(''),
   peers: z.array(WireguardOutboundFormPeerSchema).default([]),
   peers: z.array(WireguardOutboundFormPeerSchema).default([]),
   noKernelTun: z.boolean().default(false),
   noKernelTun: z.boolean().default(false),
 });
 });
@@ -152,6 +153,7 @@ export type FreedomOutboundFormSettings = z.infer<typeof FreedomOutboundFormSett
 // adapter wraps as { response: { type } } on the wire and omits when empty.
 // adapter wraps as { response: { type } } on the wire and omits when empty.
 export const BlackholeOutboundFormSettingsSchema = z.object({
 export const BlackholeOutboundFormSettingsSchema = z.object({
   type: z.union([BlackholeResponseTypeSchema, z.literal('')]).default(''),
   type: z.union([BlackholeResponseTypeSchema, z.literal('')]).default(''),
+  customResponseData: z.string().default(''),
 });
 });
 export type BlackholeOutboundFormSettings = z.infer<typeof BlackholeOutboundFormSettingsSchema>;
 export type BlackholeOutboundFormSettings = z.infer<typeof BlackholeOutboundFormSettingsSchema>;
 
 

+ 6 - 6
frontend/src/schemas/protocols/outbound/blackhole.ts

@@ -1,13 +1,13 @@
 import { z } from 'zod';
 import { z } from 'zod';
 
 
-export const BlackholeResponseTypeSchema = z.enum(['none', 'http']);
+export const BlackholeResponseTypeSchema = z.enum(['none', 'http', 'custom']);
 export type BlackholeResponseType = z.infer<typeof BlackholeResponseTypeSchema>;
 export type BlackholeResponseType = z.infer<typeof BlackholeResponseTypeSchema>;
 
 
-// Blackhole drops traffic. `response.type` is the only knob — when set, Xray
-// returns the canned 403 HTTP response before closing; when omitted it
-// silently drops. The panel stores it as { response: { type } } or omits the
-// whole `response` key when type is empty.
+// `response.type` picks Xray's reply before closing: none (silent), http
+// (canned 403) or custom (base64 customResponseData). Omitted when empty.
 export const BlackholeOutboundSettingsSchema = z.object({
 export const BlackholeOutboundSettingsSchema = z.object({
-  response: z.object({ type: BlackholeResponseTypeSchema }).optional(),
+  response: z
+    .object({ type: BlackholeResponseTypeSchema, customResponseData: z.string().optional() })
+    .optional(),
 });
 });
 export type BlackholeOutboundSettings = z.infer<typeof BlackholeOutboundSettingsSchema>;
 export type BlackholeOutboundSettings = z.infer<typeof BlackholeOutboundSettingsSchema>;

+ 13 - 8
frontend/src/schemas/protocols/security/index.ts

@@ -21,12 +21,17 @@ export type Security = z.infer<typeof SecuritySchema>;
 // transportless branch accepts that shape, mirroring NetworkSettingsSchema's
 // transportless branch accepts that shape, mirroring NetworkSettingsSchema's
 // `network: never().optional()` handling. A present-but-invalid security
 // `network: never().optional()` handling. A present-but-invalid security
 // still fails both branches so a typo can't slip through.
 // still fails both branches so a typo can't slip through.
-export const SecuritySettingsSchema = z.union([
-  z.discriminatedUnion('security', [
-    z.object({ security: z.literal('none') }),
-    z.object({ security: z.literal('tls'), tlsSettings: TlsStreamSettingsSchema }),
-    z.object({ security: z.literal('reality'), realitySettings: RealityStreamSettingsSchema }),
-  ]),
-  z.object({ security: z.never().optional() }),
-]);
+
+// The inbound form swaps in a stricter tlsSettings; every other branch is shared.
+export function securitySettingsSchemaFor<T extends z.ZodType>(tlsSettings: T) {
+  return z.union([
+    z.discriminatedUnion('security', [
+      z.object({ security: z.literal('none') }),
+      z.object({ security: z.literal('tls'), tlsSettings }),
+      z.object({ security: z.literal('reality'), realitySettings: RealityStreamSettingsSchema }),
+    ]),
+    z.object({ security: z.never().optional() }),
+  ]);
+}
+export const SecuritySettingsSchema = securitySettingsSchemaFor(TlsStreamSettingsSchema);
 export type SecuritySettings = z.infer<typeof SecuritySettingsSchema>;
 export type SecuritySettings = z.infer<typeof SecuritySettingsSchema>;

+ 24 - 1
frontend/src/schemas/protocols/security/tls.ts

@@ -52,9 +52,32 @@ export const TlsCertInlineSchema = z.object({
   usage: TlsCertUsageSchema.default('encipherment'),
   usage: TlsCertUsageSchema.default('encipherment'),
   buildChain: z.boolean().default(false),
   buildChain: z.boolean().default(false),
 });
 });
-export const TlsCertSchema = z.union([TlsCertFileSchema, TlsCertInlineSchema]);
+export const TlsCertSchema = z.union([
+  TlsCertFileSchema,
+  TlsCertInlineSchema,
+  // Verification CAs contain only public certificates. Their omitted private
+  // keys must survive reading a saved inbound for details and share links.
+  TlsCertFileSchema.extend({ usage: z.literal('verify'), keyFile: z.string().optional() }),
+  TlsCertInlineSchema.extend({ usage: z.literal('verify'), key: z.array(z.string()).optional() }),
+]);
 export type TlsCert = z.infer<typeof TlsCertSchema>;
 export type TlsCert = z.infer<typeof TlsCertSchema>;
 
 
+// A stored certificate predates the panel's `useFile` toggle when the boolean is
+// absent; infer the editor mode from whichever half of the credential is filled.
+export function tlsCertUsesFiles(cert: {
+  useFile?: unknown;
+  certificateFile?: unknown;
+  keyFile?: unknown;
+  certificate?: unknown;
+  key?: unknown;
+}): boolean {
+  if (typeof cert.useFile === 'boolean') return cert.useFile;
+  const hasInline =
+    (Array.isArray(cert.certificate) && cert.certificate.length > 0) ||
+    (Array.isArray(cert.key) && cert.key.length > 0);
+  return !!cert.certificateFile || !!cert.keyFile || !hasInline;
+}
+
 export const TlsClientSettingsSchema = z.object({
 export const TlsClientSettingsSchema = z.object({
   // '' = None. Hysteria rejects uTLS fingerprints, and a chrome default
   // '' = None. Hysteria rejects uTLS fingerprints, and a chrome default
   // silently flipped the form's None back to chrome on every save.
   // silently flipped the form's None back to chrome on every save.

+ 4 - 0
frontend/src/schemas/protocols/stream/finalmask.ts

@@ -61,6 +61,7 @@ export const QuicParamsSchema = z.object({
   debug: z.boolean().optional(),
   debug: z.boolean().optional(),
   brutalUp: z.string().optional(),
   brutalUp: z.string().optional(),
   brutalDown: z.string().optional(),
   brutalDown: z.string().optional(),
+  brutalDisableLossCompensation: z.boolean().optional(),
   udpHop: QuicUdpHopSchema.optional(),
   udpHop: QuicUdpHopSchema.optional(),
   initStreamReceiveWindow: z.number().int().min(0).optional(),
   initStreamReceiveWindow: z.number().int().min(0).optional(),
   maxStreamReceiveWindow: z.number().int().min(0).optional(),
   maxStreamReceiveWindow: z.number().int().min(0).optional(),
@@ -69,7 +70,10 @@ export const QuicParamsSchema = z.object({
   maxIdleTimeout: z.number().int().min(4).max(120).optional(),
   maxIdleTimeout: z.number().int().min(4).max(120).optional(),
   keepAlivePeriod: z.number().int().min(2).max(60).optional(),
   keepAlivePeriod: z.number().int().min(2).max(60).optional(),
   disablePathMTUDiscovery: z.boolean().optional(),
   disablePathMTUDiscovery: z.boolean().optional(),
+  disableChromeParrot: z.boolean().optional(),
+  disableGSO: z.boolean().optional(),
   maxIncomingStreams: z.number().int().min(8).optional(),
   maxIncomingStreams: z.number().int().min(8).optional(),
+  disableStatelessReset: z.boolean().optional(),
 });
 });
 export type QuicParams = z.infer<typeof QuicParamsSchema>;
 export type QuicParams = z.infer<typeof QuicParamsSchema>;
 
 

+ 1 - 0
frontend/src/schemas/protocols/stream/hysteria.ts

@@ -15,6 +15,7 @@ export const HysteriaMasqueradeSchema = z.object({
   dir: z.string().default(''),
   dir: z.string().default(''),
   url: z.string().default(''),
   url: z.string().default(''),
   rewriteHost: z.boolean().default(false),
   rewriteHost: z.boolean().default(false),
+  xForwarded: z.boolean().optional(),
   insecure: z.boolean().default(false),
   insecure: z.boolean().default(false),
   content: z.string().default(''),
   content: z.string().default(''),
   headers: z.record(z.string(), z.string()).default({}),
   headers: z.record(z.string(), z.string()).default({}),

+ 1 - 0
frontend/src/schemas/routing.ts

@@ -30,6 +30,7 @@ export const RuleObjectSchema = z.object({
   protocol: z.array(z.string()).optional(),
   protocol: z.array(z.string()).optional(),
   attrs: z.record(z.string(), z.string()).optional(),
   attrs: z.record(z.string(), z.string()).optional(),
   process: z.array(z.string()).optional(),
   process: z.array(z.string()).optional(),
+  localOS: z.array(z.string()).optional(),
   outboundTag: z.string().optional(),
   outboundTag: z.string().optional(),
   balancerTag: z.string().optional(),
   balancerTag: z.string().optional(),
   ruleTag: z.string().optional(),
   ruleTag: z.string().optional(),

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

@@ -21,6 +21,9 @@ export const AllSettingSchema = z
     trafficDiff: nonNegativeInt.max(100).optional(),
     trafficDiff: nonNegativeInt.max(100).optional(),
     remarkTemplate: z.string().optional(),
     remarkTemplate: z.string().optional(),
     subShowIdentityOnAllLinks: z.boolean().optional(),
     subShowIdentityOnAllLinks: z.boolean().optional(),
+    subInfoNodeEnable: z.boolean().optional(),
+    subExpiredTemplate: z.string().optional(),
+    subTrafficDepletedTemplate: z.string().optional(),
     datepicker: z.enum(['gregorian', 'jalalian']).optional(),
     datepicker: z.enum(['gregorian', 'jalalian']).optional(),
     tgBotEnable: z.boolean().optional(),
     tgBotEnable: z.boolean().optional(),
     tgBotToken: z.string().optional(),
     tgBotToken: z.string().optional(),

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

@@ -62,6 +62,23 @@ exports[`FinalMaskStreamSettingsSchema fixtures > parses quic-params byte-stably
 }
 }
 `;
 `;
 
 
+exports[`FinalMaskStreamSettingsSchema fixtures > parses quic-params-flags byte-stably 1`] = `
+{
+  "quicParams": {
+    "brutalDisableLossCompensation": true,
+    "brutalDown": "100 mbps",
+    "brutalUp": "60 mbps",
+    "congestion": "brutal",
+    "disableChromeParrot": true,
+    "disableGSO": true,
+    "disablePathMTUDiscovery": false,
+    "disableStatelessReset": true,
+  },
+  "tcp": [],
+  "udp": [],
+}
+`;
+
 exports[`FinalMaskStreamSettingsSchema fixtures > parses realm-tls byte-stably 1`] = `
 exports[`FinalMaskStreamSettingsSchema fixtures > parses realm-tls byte-stably 1`] = `
 {
 {
   "tcp": [],
   "tcp": [],

+ 11 - 0
frontend/src/test/__snapshots__/rule.test.ts.snap

@@ -74,6 +74,17 @@ exports[`RuleObjectSchema fixtures > parses full byte-stably 1`] = `
 }
 }
 `;
 `;
 
 
+exports[`RuleObjectSchema fixtures > parses local-os byte-stably 1`] = `
+{
+  "localOS": [
+    "linux",
+    "darwin",
+  ],
+  "outboundTag": "direct",
+  "type": "field",
+}
+`;
+
 exports[`RuleObjectSchema fixtures > parses minimal byte-stably 1`] = `
 exports[`RuleObjectSchema fixtures > parses minimal byte-stably 1`] = `
 {
 {
   "outboundTag": "direct",
   "outboundTag": "direct",

+ 76 - 0
frontend/src/test/amneziawg-conf-parity.test.ts

@@ -113,3 +113,79 @@ describe('AmneziaWG .conf emitters agree on the peer block', () => {
     ).toEqual(want);
     ).toEqual(want);
   });
   });
 });
 });
+
+// s4 junk is prepended to every transport packet and never clamped to the MTU,
+// so both emitters must write the same S4-aware value the server interface uses.
+describe('AmneziaWG .conf emitters agree on MTU', () => {
+  function build(mtu: number | undefined, s4: number) {
+    const settings = {
+      server: {
+        publicKey: 'serverPubKey==',
+        primaryDns: '8.8.8.8',
+        secondaryDns: '',
+        mtu,
+        jc: 4,
+        jmin: 40,
+        jmax: 100,
+        s1: 30,
+        s2: 90,
+        s3: 0,
+        s4,
+        h1: '',
+        h2: '',
+        h3: '',
+        h4: '',
+      },
+      clients: [{ email: 'peer-1', privateKey: 'clientPrivKey==', allowedIPs: ['10.8.1.2/32'] }],
+    } as unknown as AmneziawgInboundSettings;
+
+    const link = genAmneziaWGConfig({
+      settings,
+      address: 'awg.example.test',
+      port: 51820,
+      remark: 'awg-peer-1',
+      peerIndex: 0,
+    });
+    const download = buildAmneziaWGClientConfig(
+      {
+        email: 'peer-1',
+        privateKey: 'clientPrivKey==',
+        allowedIPs: '10.8.1.2/32',
+      } as unknown as ClientRecord,
+      {
+        id: 1,
+        tag: 'awg-1',
+        remark: 'awg',
+        protocol: 'amneziawg',
+        port: 51820,
+        awgServer: settings.server,
+      } as unknown as InboundOption,
+      'awg.example.test',
+    );
+    return { link, download };
+  }
+
+  function mtuLine(conf: string): string | undefined {
+    return conf.split('\n').find((l) => l.startsWith('MTU = '));
+  }
+
+  it('always emits an MTU, even when the inbound has none set', () => {
+    const { link, download } = build(undefined, 27);
+    // 1420 - 27: without this the client stays on its own 1420 default and
+    // fragments every full-size packet it sends.
+    expect(mtuLine(link)).toBe('MTU = 1393');
+    expect(mtuLine(download)).toBe('MTU = 1393');
+  });
+
+  it('keeps an explicit MTU untouched', () => {
+    const { link, download } = build(1380, 27);
+    expect(mtuLine(link)).toBe('MTU = 1380');
+    expect(mtuLine(download)).toBe('MTU = 1380');
+  });
+
+  it('falls back to the plain default when there is no s4', () => {
+    const { link, download } = build(undefined, 0);
+    expect(mtuLine(link)).toBe('MTU = 1420');
+    expect(mtuLine(download)).toBe('MTU = 1420');
+  });
+});

+ 9 - 0
frontend/src/test/format-validation-error.test.ts

@@ -12,6 +12,7 @@ const templates: Record<string, string> = {
   'pages.inbounds.toasts.invalidClientField': 'Client {client}: {field} — {reason}',
   'pages.inbounds.toasts.invalidClientField': 'Client {client}: {field} — {reason}',
   'pages.inbounds.toasts.invalidField': '{field} — {reason}',
   'pages.inbounds.toasts.invalidField': '{field} — {reason}',
   'pages.inbounds.toasts.moreIssues': '{message}  (+{count} more)',
   'pages.inbounds.toasts.moreIssues': '{message}  (+{count} more)',
+  'pages.inbounds.toasts.invalidCertificate': 'TLS certificate {index}: {reason}',
   clients: 'clients',
   clients: 'clients',
 };
 };
 
 
@@ -59,6 +60,14 @@ describe('formatInboundValidation', () => {
     expect(formatInboundIssue(issue, {}, t)).toBe('port — Invalid input');
     expect(formatInboundIssue(issue, {}, t)).toBe('port — Invalid input');
   });
   });
 
 
+  it('identifies the certificate by its displayed row number', () => {
+    const issue = {
+      path: ['streamSettings', 'tlsSettings', 'certificates', 1, 'keyFile'],
+      message: 'Private key is required',
+    };
+    expect(formatInboundIssue(issue, {}, t)).toBe('TLS certificate 2: Private key is required');
+  });
+
   it('appends a count when several fields fail', () => {
   it('appends a count when several fields fail', () => {
     const issues = [
     const issues = [
       { path: ['settings', 'clients', 0, 'tgId'], message: 'Invalid input' },
       { path: ['settings', 'clients', 0, 'tgId'], message: 'Invalid input' },

+ 12 - 0
frontend/src/test/golden/fixtures/finalmask/quic-params-flags.json

@@ -0,0 +1,12 @@
+{
+  "quicParams": {
+    "congestion": "brutal",
+    "brutalUp": "60 mbps",
+    "brutalDown": "100 mbps",
+    "brutalDisableLossCompensation": true,
+    "disablePathMTUDiscovery": false,
+    "disableChromeParrot": true,
+    "disableGSO": true,
+    "disableStatelessReset": true
+  }
+}

+ 5 - 0
frontend/src/test/golden/fixtures/rule/local-os.json

@@ -0,0 +1,5 @@
+{
+  "type": "field",
+  "localOS": ["linux", "darwin"],
+  "outboundTag": "direct"
+}

+ 68 - 1
frontend/src/test/host-link.test.ts

@@ -1,7 +1,8 @@
 /// <reference types="vite/client" />
 /// <reference types="vite/client" />
 import { describe, expect, it } from 'vitest';
 import { describe, expect, it } from 'vitest';
 
 
-import { hostToExternalProxyEntry } from '@/lib/hosts/host-link';
+import { hostToExternalProxyEntry, withMtprotoHostEndpoints } from '@/lib/hosts/host-link';
+import { inboundFromDb } from '@/lib/xray/inbound-from-db';
 
 
 describe('hostToExternalProxyEntry', () => {
 describe('hostToExternalProxyEntry', () => {
   const base = {
   const base = {
@@ -60,3 +61,69 @@ describe('hostToExternalProxyEntry', () => {
     expect(hostToExternalProxyEntry({ ...base, vlessRoute: '' }).vlessRoute).toBeUndefined();
     expect(hostToExternalProxyEntry({ ...base, vlessRoute: '' }).vlessRoute).toBeUndefined();
   });
   });
 });
 });
+
+describe('withMtprotoHostEndpoints', () => {
+  const inbound = inboundFromDb({
+    protocol: 'mtproto',
+    port: 4060,
+    listen: '127.0.0.1',
+    settings: { clients: [] },
+    streamSettings: {},
+    sniffing: {},
+  });
+
+  it('projects enabled raw Hosts onto MTProto share endpoints', () => {
+    const got = withMtprotoHostEndpoints(
+      inbound,
+      7,
+      [
+        {
+          groupId: 'public',
+          inboundIds: [7],
+          hosts: ['proxy.example.com:443', '[2001:db8::1]'],
+          port: 443,
+          remark: 'public',
+        },
+      ],
+      '',
+      'panel.example.com',
+    );
+    expect(got.streamSettings?.externalProxy).toEqual([
+      { forceTls: 'same', dest: 'proxy.example.com', port: 443, remark: 'public' },
+      { forceTls: 'same', dest: '2001:db8::1', port: 4060, remark: 'public' },
+    ]);
+  });
+
+  it('inherits the inbound address for a port-only Host', () => {
+    const got = withMtprotoHostEndpoints(
+      inbound,
+      7,
+      [{ groupId: 'port-only', inboundIds: [7], hosts: [':8443'], port: 8443 }],
+      '',
+      'panel.example.com',
+    );
+    expect(got.streamSettings?.externalProxy).toEqual([
+      { forceTls: 'same', dest: 'panel.example.com', port: 8443, remark: '' },
+    ]);
+  });
+
+  it('ignores disabled, excluded and unrelated Hosts', () => {
+    const got = withMtprotoHostEndpoints(
+      inbound,
+      7,
+      [
+        { groupId: 'disabled', inboundIds: [7], hosts: ['a.example.com:443'], isDisabled: true },
+        {
+          groupId: 'excluded',
+          inboundIds: [7],
+          hosts: ['b.example.com:443'],
+          excludeFromSubTypes: ['raw'],
+        },
+        { groupId: 'other', inboundIds: [8], hosts: ['c.example.com:443'] },
+      ],
+      '',
+      'panel.example.com',
+    );
+    expect(got).toBe(inbound);
+  });
+});

+ 57 - 0
frontend/src/test/inbound-form-modal.test.tsx

@@ -167,6 +167,41 @@ describe('InboundFormModal', () => {
     expect((shareAddrInput as HTMLInputElement).value).toBe('edge.example.test');
     expect((shareAddrInput as HTMLInputElement).value).toBe('edge.example.test');
   });
   });
 
 
+  it('uses Hosts instead of showing the custom share address fields for MTProto', async () => {
+    renderWithProviders(
+      <InboundFormModal
+        open
+        mode="edit"
+        dbInbound={
+          new DBInbound({
+            id: 2,
+            port: 4060,
+            listen: '',
+            protocol: 'mtproto',
+            remark: 'proxy',
+            enable: true,
+            settings: { clients: [] },
+            streamSettings: {},
+            sniffing: { enabled: false },
+            nodeId: null,
+            shareAddrStrategy: 'custom',
+            shareAddr: 'proxy.example.test',
+          })
+        }
+        dbInbounds={[]}
+        availableNodes={[]}
+        onClose={() => {}}
+        onSaved={() => {}}
+      />,
+    );
+
+    await act(async () => {
+      await new Promise((resolve) => setTimeout(resolve, 0));
+    });
+    expect(fieldLabels()).not.toContain('Share address strategy');
+    expect(screen.queryByDisplayValue('proxy.example.test')).toBeNull();
+  });
+
   it('keeps the persisted node share strategy through the nodes-loading race (#5375)', async () => {
   it('keeps the persisted node share strategy through the nodes-loading race (#5375)', async () => {
     const node = { id: 1, name: 'arm2', enable: true, status: 'online' } as never;
     const node = { id: 1, name: 'arm2', enable: true, status: 'online' } as never;
     const buildInbound = () =>
     const buildInbound = () =>
@@ -240,6 +275,28 @@ describe('InboundFormModal', () => {
     expect(post).not.toHaveBeenCalled();
     expect(post).not.toHaveBeenCalled();
   });
   });
 
 
+  it('blocks adding TLS without a certificate and directs the user to Security', async () => {
+    const post = vi.mocked(HttpUtil.post);
+    post.mockClear();
+    messageError.mockClear();
+    renderModal();
+
+    fireEvent.click(screen.getByRole('tab', { name: 'Security' }));
+    fireEvent.click(screen.getByRole('radio', { name: 'TLS' }));
+    fireEvent.click(screen.getByRole('tab', { name: 'Basics' }));
+    fireEvent.click(primaryButton());
+
+    await waitFor(() => {
+      expect(screen.getByRole('tab', { name: 'Security' }).getAttribute('aria-selected')).toBe(
+        'true',
+      );
+      expect(messageError).toHaveBeenCalledWith(
+        expect.stringContaining('TLS certificate 1: Import a TLS certificate'),
+      );
+    });
+    expect(post).not.toHaveBeenCalled();
+  });
+
   it('submits a valid clone-like Reality inbound', async () => {
   it('submits a valid clone-like Reality inbound', async () => {
     const post = vi.mocked(HttpUtil.post);
     const post = vi.mocked(HttpUtil.post);
     post.mockClear();
     post.mockClear();

+ 145 - 0
frontend/src/test/inbound-tls-validation.test.ts

@@ -0,0 +1,145 @@
+import { describe, expect, it } from 'vitest';
+
+import { InboundFormSchema, InboundStreamFormSchema } from '@/schemas/forms/inbound-form';
+import { TlsCertSchema, TlsStreamSettingsSchema } from '@/schemas/protocols/security';
+import { createTlsSettingsWithDefaultCert } from '@/lib/xray/inbound-tls-defaults';
+import { formValuesToWirePayload } from '@/lib/xray/inbound-form-adapter';
+import { inboundFromDb } from '@/lib/xray/inbound-from-db';
+
+const fileCert = { certificateFile: '/cert/server.pem', keyFile: '/cert/server.key' };
+const inlineCert = { certificate: ['certificate content'], key: ['private key content'] };
+
+function parseCertificates(certificates?: unknown[]) {
+  return InboundFormSchema.safeParse({
+    port: 443,
+    protocol: 'vless',
+    settings: { clients: [] },
+    streamSettings: {
+      network: 'tcp',
+      tcpSettings: {},
+      security: 'tls',
+      tlsSettings: { certificates },
+    },
+  });
+}
+
+describe('inbound TLS certificate validation', () => {
+  it('rejects the empty certificate seeded by the TLS editor with a useful field error', () => {
+    const result = parseCertificates(createTlsSettingsWithDefaultCert().certificates as unknown[]);
+    expect(result.success).toBe(false);
+    if (result.success) return;
+    expect(result.error.issues[0]).toMatchObject({
+      path: ['streamSettings', 'tlsSettings', 'certificates', 0, 'certificateFile'],
+      message: 'pages.inbounds.form.tlsCertificateRequired',
+    });
+  });
+
+  it.each([
+    ['missing certificates', undefined],
+    ['empty certificates', []],
+    ['empty row', [{}]],
+    ['blank paths', [{ certificateFile: '  ', keyFile: '\t' }]],
+    ['certificate path only', [{ certificateFile: fileCert.certificateFile }]],
+    ['private key path only', [{ keyFile: fileCert.keyFile }]],
+    ['empty content', [{ useFile: false, certificate: [], key: [] }]],
+    ['blank content', [{ useFile: false, certificate: [' ', '\n'], key: ['\t'] }]],
+    ['certificate content only', [{ useFile: false, certificate: inlineCert.certificate }]],
+    ['private key content only', [{ useFile: false, key: inlineCert.key }]],
+    ['empty file mode with stale inline content', [{ useFile: true, ...inlineCert }]],
+    ['empty content mode with stale file paths', [{ useFile: false, ...fileCert }]],
+    ['valid certificate followed by an empty row', [fileCert, {}]],
+    ['verify certificate only', [{ certificateFile: '/ca.pem', usage: 'verify' }]],
+    ['issue certificate without its key', [{ certificate: ['CA'], usage: 'issue' }]],
+    ['empty verify certificate alongside server certificate', [fileCert, { usage: 'verify' }]],
+  ])('rejects %s', (_name, certificates) => {
+    expect(parseCertificates(certificates as unknown[] | undefined).success).toBe(false);
+  });
+
+  it.each([
+    ['file certificate', [fileCert]],
+    ['inline certificate', [inlineCert]],
+    ['explicit file mode', [{ useFile: true, ...fileCert }]],
+    ['explicit inline mode', [{ useFile: false, ...inlineCert }]],
+    ['multiple certificates', [fileCert, inlineCert]],
+    ['issuing CA with its key', [{ ...inlineCert, usage: 'issue' }]],
+    [
+      'file verification CA without a key',
+      [fileCert, { certificateFile: '/ca.pem', usage: 'verify' }],
+    ],
+    [
+      'inline verification CA without a key',
+      [inlineCert, { certificate: ['CA'], usage: 'verify' }],
+    ],
+  ])('accepts %s', (_name, certificates) => {
+    expect(parseCertificates(certificates).success).toBe(true);
+  });
+
+  it.each([true, false])('serializes only the selected mode (useFile=%s)', (useFile) => {
+    const result = parseCertificates([{ useFile, ...fileCert, ...inlineCert }]);
+    expect(result.success).toBe(true);
+    if (!result.success) return;
+    const stream = JSON.parse(formValuesToWirePayload(result.data).streamSettings);
+    const cert = stream.tlsSettings.certificates[0];
+    expect(cert).toMatchObject(useFile ? fileCert : inlineCert);
+    expect(cert).not.toHaveProperty('useFile');
+    expect(cert).not.toHaveProperty(useFile ? 'certificate' : 'certificateFile');
+    expect(cert).not.toHaveProperty(useFile ? 'key' : 'keyFile');
+  });
+
+  it.each([
+    ['file', { certificateFile: '/cert/ca.pem', usage: 'verify' }],
+    ['inline', { certificate: ['CA certificate'], usage: 'verify' }],
+  ])('preserves TLS settings when reading back a %s verification CA without a key', (_mode, ca) => {
+    const tlsSettings = {
+      serverName: 'tls.example.test',
+      alpn: ['h3'],
+      certificates: [fileCert, ca],
+      settings: { fingerprint: 'firefox', pinnedPeerCertSha256: ['test-pin'] },
+    };
+    const values = InboundFormSchema.parse({
+      port: 443,
+      protocol: 'vless',
+      settings: { clients: [] },
+      streamSettings: { network: 'tcp', tcpSettings: {}, security: 'tls', tlsSettings },
+    });
+
+    const restored = inboundFromDb(formValuesToWirePayload(values));
+
+    expect(restored.streamSettings).toMatchObject({ security: 'tls', tlsSettings });
+  });
+
+  it.each([undefined, 'encipherment', 'issue'])(
+    'keeps wire private keys required for usage=%s',
+    (usage) => {
+      expect(TlsCertSchema.safeParse({ certificateFile: '/cert.pem', usage }).success).toBe(false);
+      expect(
+        TlsCertSchema.safeParse({ certificateFile: '/cert.pem', keyFile: '', usage }).success,
+      ).toBe(false);
+      expect(TlsCertSchema.safeParse({ certificate: ['certificate'], usage }).success).toBe(false);
+    },
+  );
+
+  it('applies the same certificate requirement to Hysteria TLS', () => {
+    const stream = {
+      network: 'hysteria',
+      hysteriaSettings: {},
+      security: 'tls',
+      tlsSettings: createTlsSettingsWithDefaultCert(),
+    };
+    expect(InboundStreamFormSchema.safeParse(stream).success).toBe(false);
+    expect(
+      InboundStreamFormSchema.safeParse({ ...stream, tlsSettings: { certificates: [fileCert] } })
+        .success,
+    ).toBe(true);
+  });
+
+  it('keeps Reality, unsecured, transportless and outbound TLS certificate-free', () => {
+    for (const security of [{ security: 'reality', realitySettings: {} }, { security: 'none' }]) {
+      expect(
+        InboundStreamFormSchema.safeParse({ network: 'tcp', tcpSettings: {}, ...security }).success,
+      ).toBe(true);
+    }
+    expect(InboundStreamFormSchema.safeParse({}).success).toBe(true);
+    expect(TlsStreamSettingsSchema.safeParse({}).success).toBe(true);
+  });
+});

+ 17 - 1
frontend/src/test/openapi-runtime-contracts.test.ts

@@ -1,6 +1,7 @@
 import { describe, expect, it } from 'vitest';
 import { describe, expect, it } from 'vitest';
 
 
 import { buildSpec } from '../../scripts/build-openapi.mjs';
 import { buildSpec } from '../../scripts/build-openapi.mjs';
+import { EXAMPLES } from '../generated/examples';
 
 
 interface OpenApiSchema {
 interface OpenApiSchema {
   $ref?: string;
   $ref?: string;
@@ -31,7 +32,7 @@ interface OpenApiOperation {
   responses: Record<
   responses: Record<
     string,
     string,
     {
     {
-      content?: Record<string, { schema: OpenApiSchema }>;
+      content?: Record<string, { schema: OpenApiSchema; example?: unknown }>;
     }
     }
   >;
   >;
   security?: Record<string, never[]>[];
   security?: Record<string, never[]>[];
@@ -127,15 +128,30 @@ describe('generated OpenAPI runtime contracts', () => {
     });
     });
   });
   });
 
 
+  it('documents all inbound links as a nullable string array', () => {
+    expect(responseObjectSchema('/panel/api/inbounds/allLinks')).toEqual({
+      type: 'array',
+      nullable: true,
+      items: { type: 'string' },
+    });
+  });
+
   it('uses the runtime REST response schemas', () => {
   it('uses the runtime REST response schemas', () => {
     expect(responseObjectSchema('/panel/api/server/logs/{count}', 'post')).toEqual({
     expect(responseObjectSchema('/panel/api/server/logs/{count}', 'post')).toEqual({
       type: 'array',
       type: 'array',
+      nullable: true,
       items: { type: 'string' },
       items: { type: 'string' },
     });
     });
     expect(responseObjectSchema('/panel/api/server/xraylogs/{count}', 'post')).toEqual({
     expect(responseObjectSchema('/panel/api/server/xraylogs/{count}', 'post')).toEqual({
       type: 'array',
       type: 'array',
+      nullable: true,
       items: { $ref: '#/components/schemas/LogEntry' },
       items: { $ref: '#/components/schemas/LogEntry' },
     });
     });
+    expect(
+      operation('/panel/api/server/xraylogs/{count}', 'post').responses['200'].content?.[
+        'application/json'
+      ].example,
+    ).toEqual({ success: true, obj: [EXAMPLES.LogEntry] });
     expect(responseObjectSchema('/panel/api/server/getNewUUID')).toEqual({
     expect(responseObjectSchema('/panel/api/server/getNewUUID')).toEqual({
       $ref: '#/components/schemas/NewUUIDResponse',
       $ref: '#/components/schemas/NewUUIDResponse',
     });
     });

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

@@ -264,6 +264,54 @@ describe('outbound-form-adapter: round-trip', () => {
     expect(withType.settings).toEqual({ response: { type: 'http' } });
     expect(withType.settings).toEqual({ response: { type: 'http' } });
   });
   });
 
 
+  it('blackhole carries customResponseData only for the custom response type', () => {
+    const custom = formValuesToWirePayload(
+      rawOutboundToFormValues({
+        protocol: 'blackhole',
+        settings: { response: { type: 'custom', customResponseData: 'SFRUUC8xLjEgNDAz' } },
+      }),
+    );
+    expect(custom.settings).toEqual({
+      response: { type: 'custom', customResponseData: 'SFRUUC8xLjEgNDAz' },
+    });
+
+    const http = formValuesToWirePayload(
+      rawOutboundToFormValues({
+        protocol: 'blackhole',
+        settings: { response: { type: 'http', customResponseData: 'ignored' } },
+      }),
+    );
+    expect(http.settings).toEqual({ response: { type: 'http' } });
+  });
+
+  it('wireguard csv-joins remoteDNS on read and splits it on write', () => {
+    const wire = {
+      protocol: 'wireguard',
+      settings: {
+        secretKey: 'YFVmTVCBsLxXJCe4i+jK8PgD3S6vUqfZ4Zl0JVNDfHA=',
+        remoteDNS: ['1.1.1.1', '2606:4700:4700::1111'],
+        peers: [{ publicKey: 'pk', endpoint: 'wg.example.com:51820' }],
+      },
+    };
+    const form = rawOutboundToFormValues(wire);
+    if (form.protocol === 'wireguard') {
+      expect(form.settings.remoteDNS).toBe('1.1.1.1,2606:4700:4700::1111');
+    }
+    const back = formValuesToWirePayload(form);
+    expect((back.settings as { remoteDNS?: string[] }).remoteDNS).toEqual([
+      '1.1.1.1',
+      '2606:4700:4700::1111',
+    ]);
+
+    const unset = formValuesToWirePayload(
+      rawOutboundToFormValues({
+        protocol: 'wireguard',
+        settings: { secretKey: wire.settings.secretKey, peers: wire.settings.peers },
+      }),
+    );
+    expect((unset.settings as { remoteDNS?: string[] }).remoteDNS).toBeUndefined();
+  });
+
   it('dns rules normalize qType numeric strings, split domains, carry rCode', () => {
   it('dns rules normalize qType numeric strings, split domains, carry rCode', () => {
     const wire = {
     const wire = {
       protocol: 'dns',
       protocol: 'dns',

+ 27 - 0
frontend/src/test/setting-sub-info-node.test.ts

@@ -0,0 +1,27 @@
+import { describe, it, expect } from 'vitest';
+import { AllSettingSchema } from '@/schemas/setting';
+import { AllSetting } from '@/models/setting';
+
+describe('subInfoNode settings', () => {
+  it('defaults on AllSetting', () => {
+    const s = new AllSetting();
+    expect(s.subInfoNodeEnable).toBe(false);
+    expect(s.subExpiredTemplate).toBe('⛔ {{EMAIL}} | Expired: {{EXPIRE_DATE}}');
+    expect(s.subTrafficDepletedTemplate).toBe(
+      '🚫 {{EMAIL}} | Traffic Depleted | {{TRAFFIC_USED}}/{{TRAFFIC_TOTAL}}',
+    );
+  });
+
+  it('accepts valid values in the settings schema', () => {
+    const r = AllSettingSchema.safeParse({
+      subInfoNodeEnable: true,
+      subExpiredTemplate: 'custom expired',
+      subTrafficDepletedTemplate: 'custom depleted',
+    });
+    expect(r.success).toBe(true);
+  });
+
+  it('rejects invalid types', () => {
+    expect(AllSettingSchema.safeParse({ subInfoNodeEnable: 'true' }).success).toBe(false);
+  });
+});

+ 1 - 0
frontend/src/test/stream-wire-normalize.test.ts

@@ -488,6 +488,7 @@ describe('inbound formValuesToWirePayload integration', () => {
         },
         },
         tlsSettings: {
         tlsSettings: {
           alpn: ['h3'],
           alpn: ['h3'],
+          certificates: [{ certificateFile: '/cert/server.pem', keyFile: '/cert/server.key' }],
           settings: {
           settings: {
             fingerprint: '',
             fingerprint: '',
           },
           },

+ 23 - 17
go.mod

@@ -4,18 +4,18 @@ go 1.27.1
 
 
 require (
 require (
 	github.com/amnezia-vpn/amneziawg-go/v3 v3.1.20260828
 	github.com/amnezia-vpn/amneziawg-go/v3 v3.1.20260828
-	github.com/gin-contrib/gzip v1.2.6
-	github.com/gin-contrib/sessions v1.1.0
+	github.com/gin-contrib/gzip v1.2.7
+	github.com/gin-contrib/sessions v1.1.1
 	github.com/gin-gonic/gin v1.12.0
 	github.com/gin-gonic/gin v1.12.0
 	github.com/go-ldap/ldap/v3 v3.4.14
 	github.com/go-ldap/ldap/v3 v3.4.14
-	github.com/go-playground/validator/v10 v10.30.3
+	github.com/go-playground/validator/v10 v10.30.4
 	github.com/goccy/go-json v0.10.6
 	github.com/goccy/go-json v0.10.6
 	github.com/goccy/go-yaml v1.19.2
 	github.com/goccy/go-yaml v1.19.2
 	github.com/google/uuid v1.6.0
 	github.com/google/uuid v1.6.0
 	github.com/gorilla/websocket v1.5.3
 	github.com/gorilla/websocket v1.5.3
 	github.com/joho/godotenv v1.5.1
 	github.com/joho/godotenv v1.5.1
 	github.com/klauspost/compress v1.20.0
 	github.com/klauspost/compress v1.20.0
-	github.com/mattn/go-sqlite3 v1.14.50
+	github.com/mattn/go-sqlite3 v1.14.52
 	github.com/mymmrac/telego v1.12.1
 	github.com/mymmrac/telego v1.12.1
 	github.com/nicksnyder/go-i18n/v2 v2.6.1
 	github.com/nicksnyder/go-i18n/v2 v2.6.1
 	github.com/op/go-logging v0.0.0-20160315200505-970db520ece7
 	github.com/op/go-logging v0.0.0-20160315200505-970db520ece7
@@ -23,13 +23,13 @@ require (
 	github.com/robfig/cron/v3 v3.0.1
 	github.com/robfig/cron/v3 v3.0.1
 	github.com/shirou/gopsutil/v4 v4.26.8
 	github.com/shirou/gopsutil/v4 v4.26.8
 	github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e
 	github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e
-	github.com/valyala/fasthttp v1.73.0
+	github.com/valyala/fasthttp v1.74.0
 	github.com/xlzd/gotp v0.1.0
 	github.com/xlzd/gotp v0.1.0
-	github.com/xtls/xray-core v1.260327.1-0.20260728075948-5ca6f4b7d4dc
+	github.com/xtls/xray-core v1.260327.1-0.20260908094724-37ceb8b4b65e
 	go.uber.org/atomic v1.11.0
 	go.uber.org/atomic v1.11.0
 	golang.org/x/crypto v0.56.0
 	golang.org/x/crypto v0.56.0
 	golang.org/x/net v0.58.0
 	golang.org/x/net v0.58.0
-	golang.org/x/sys v0.47.0
+	golang.org/x/sys v0.48.0
 	golang.org/x/text v0.41.0
 	golang.org/x/text v0.41.0
 	google.golang.org/grpc v1.83.2
 	google.golang.org/grpc v1.83.2
 	google.golang.org/protobuf v1.36.12
 	google.golang.org/protobuf v1.36.12
@@ -44,7 +44,7 @@ require (
 require (
 require (
 	github.com/Azure/go-ntlmssp v0.1.1 // indirect
 	github.com/Azure/go-ntlmssp v0.1.1 // indirect
 	github.com/andybalholm/brotli v1.2.3 // indirect
 	github.com/andybalholm/brotli v1.2.3 // indirect
-	github.com/apernet/quic-go v0.59.1-0.20260425001925-6c6cc9bcb716 // indirect
+	github.com/apernet/quic-go v0.61.1-0.20260806010916-184d081eef3e // indirect
 	github.com/bytedance/gopkg v0.1.4 // indirect
 	github.com/bytedance/gopkg v0.1.4 // indirect
 	github.com/bytedance/sonic v1.15.3 // indirect
 	github.com/bytedance/sonic v1.15.3 // indirect
 	github.com/bytedance/sonic/loader v0.5.2 // indirect
 	github.com/bytedance/sonic/loader v0.5.2 // indirect
@@ -52,7 +52,7 @@ require (
 	github.com/cloudwego/base64x v0.1.7 // indirect
 	github.com/cloudwego/base64x v0.1.7 // indirect
 	github.com/ebitengine/purego v0.11.0 // indirect
 	github.com/ebitengine/purego v0.11.0 // indirect
 	github.com/gabriel-vasile/mimetype v1.4.15 // indirect
 	github.com/gabriel-vasile/mimetype v1.4.15 // indirect
-	github.com/gin-contrib/sse v1.1.1 // indirect
+	github.com/gin-contrib/sse v1.1.2 // indirect
 	github.com/go-asn1-ber/asn1-ber v1.5.8 // indirect
 	github.com/go-asn1-ber/asn1-ber v1.5.8 // indirect
 	github.com/go-ole/go-ole v1.3.0 // indirect
 	github.com/go-ole/go-ole v1.3.0 // indirect
 	github.com/go-playground/locales v0.14.1 // indirect
 	github.com/go-playground/locales v0.14.1 // indirect
@@ -62,21 +62,27 @@ require (
 	github.com/gorilla/securecookie v1.1.2 // indirect
 	github.com/gorilla/securecookie v1.1.2 // indirect
 	github.com/gorilla/sessions v1.4.0 // indirect
 	github.com/gorilla/sessions v1.4.0 // indirect
 	github.com/grbit/go-json v0.11.0 // indirect
 	github.com/grbit/go-json v0.11.0 // indirect
+	github.com/huin/goupnp v1.3.0 // indirect
 	github.com/jackc/pgpassfile v1.0.0 // indirect
 	github.com/jackc/pgpassfile v1.0.0 // indirect
 	github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
 	github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
-	github.com/jackc/pgx/v5 v5.10.0 // indirect
+	github.com/jackc/pgx/v5 v5.11.0 // indirect
 	github.com/jackc/puddle/v2 v2.2.2 // indirect
 	github.com/jackc/puddle/v2 v2.2.2 // indirect
+	github.com/jackpal/go-nat-pmp v1.1.0 // indirect
 	github.com/jinzhu/inflection v1.0.0 // indirect
 	github.com/jinzhu/inflection v1.0.0 // indirect
 	github.com/jinzhu/now v1.1.5 // indirect
 	github.com/jinzhu/now v1.1.5 // indirect
 	github.com/json-iterator/go v1.1.12 // indirect
 	github.com/json-iterator/go v1.1.12 // indirect
 	github.com/juju/ratelimit v1.0.2 // indirect
 	github.com/juju/ratelimit v1.0.2 // indirect
 	github.com/klauspost/cpuid/v2 v2.4.0 // indirect
 	github.com/klauspost/cpuid/v2 v2.4.0 // indirect
+	github.com/koron/go-ssdp v0.9.1 // indirect
 	github.com/leodido/go-urn v1.5.0 // indirect
 	github.com/leodido/go-urn v1.5.0 // indirect
+	github.com/libp2p/go-nat v1.0.1-0.20250821073202-01afc089f138 // indirect
+	github.com/libp2p/go-netroute v0.4.0 // indirect
 	github.com/lufia/plan9stats v0.0.0-20260802145828-341c2f0c90b5 // indirect
 	github.com/lufia/plan9stats v0.0.0-20260802145828-341c2f0c90b5 // indirect
 	github.com/mattn/go-isatty v0.0.24 // indirect
 	github.com/mattn/go-isatty v0.0.24 // indirect
 	github.com/miekg/dns v1.1.73 // indirect
 	github.com/miekg/dns v1.1.73 // indirect
 	github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
 	github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
 	github.com/modern-go/reflect2 v1.0.2 // indirect
 	github.com/modern-go/reflect2 v1.0.2 // indirect
+	github.com/molecule-man/go-brrr v1.0.1 // indirect
 	github.com/pelletier/go-toml/v2 v2.4.3 // indirect
 	github.com/pelletier/go-toml/v2 v2.4.3 // indirect
 	github.com/pion/dtls/v3 v3.1.8 // indirect
 	github.com/pion/dtls/v3 v3.1.8 // indirect
 	github.com/pion/logging v0.2.4 // indirect
 	github.com/pion/logging v0.2.4 // indirect
@@ -87,7 +93,7 @@ require (
 	github.com/quic-go/qpack v0.6.0 // indirect
 	github.com/quic-go/qpack v0.6.0 // indirect
 	github.com/quic-go/quic-go v0.62.0 // indirect
 	github.com/quic-go/quic-go v0.62.0 // indirect
 	github.com/rogpeppe/go-internal v1.15.0 // indirect
 	github.com/rogpeppe/go-internal v1.15.0 // indirect
-	github.com/sagernet/sing v0.9.0 // indirect
+	github.com/sagernet/sing v0.9.3 // indirect
 	github.com/sagernet/sing-shadowsocks v0.2.9 // indirect
 	github.com/sagernet/sing-shadowsocks v0.2.9 // indirect
 	github.com/tklauser/go-sysconf v0.4.0 // indirect
 	github.com/tklauser/go-sysconf v0.4.0 // indirect
 	github.com/tklauser/numcpus v0.12.0 // indirect
 	github.com/tklauser/numcpus v0.12.0 // indirect
@@ -98,17 +104,17 @@ require (
 	github.com/vishvananda/netlink v1.3.1 // indirect
 	github.com/vishvananda/netlink v1.3.1 // indirect
 	github.com/vishvananda/netns v0.0.5 // indirect
 	github.com/vishvananda/netns v0.0.5 // indirect
 	github.com/wlynxg/anet v0.0.5 // indirect
 	github.com/wlynxg/anet v0.0.5 // indirect
-	github.com/xtls/reality v0.0.0-20260322125925-9234c772ba8f // indirect
+	github.com/xtls/reality v0.0.0-20260908062103-8cdf7bf9c7f0 // indirect
 	github.com/yusufpapurcu/wmi v1.2.4 // indirect
 	github.com/yusufpapurcu/wmi v1.2.4 // indirect
-	go.mongodb.org/mongo-driver/v2 v2.8.2 // indirect
+	go.mongodb.org/mongo-driver/v2 v2.9.0 // indirect
 	go4.org/netipx v0.0.0-20260823151212-3075585bcbeb // indirect
 	go4.org/netipx v0.0.0-20260823151212-3075585bcbeb // indirect
-	golang.org/x/arch v0.30.0 // indirect
+	golang.org/x/arch v0.31.0 // indirect
 	golang.org/x/exp v0.0.0-20260824195058-e88cd73687aa // indirect
 	golang.org/x/exp v0.0.0-20260824195058-e88cd73687aa // indirect
-	golang.org/x/sync v0.22.0 // indirect
-	golang.org/x/time v0.15.0 // indirect
+	golang.org/x/sync v0.23.0 // indirect
+	golang.org/x/time v0.16.0 // indirect
 	golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 // indirect
 	golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 // indirect
 	golang.zx2c4.com/wireguard v0.0.0-20260522210424-ecfc5a8d5446 // indirect
 	golang.zx2c4.com/wireguard v0.0.0-20260522210424-ecfc5a8d5446 // indirect
 	golang.zx2c4.com/wireguard/windows v1.0.1 // indirect
 	golang.zx2c4.com/wireguard/windows v1.0.1 // indirect
-	google.golang.org/genproto/googleapis/rpc v0.0.0-20260831171406-18b4a7587f8a // indirect
+	google.golang.org/genproto/googleapis/rpc v0.0.0-20260908043556-f8649ddbbfe6 // indirect
 	lukechampine.com/blake3 v1.4.1 // indirect
 	lukechampine.com/blake3 v1.4.1 // indirect
 )
 )

+ 49 - 34
go.sum

@@ -8,8 +8,8 @@ github.com/amnezia-vpn/amneziawg-go/v3 v3.1.20260828 h1:D8d8gGvwXcTxUIsE4z6F6vjy
 github.com/amnezia-vpn/amneziawg-go/v3 v3.1.20260828/go.mod h1:YoPc6qcOZqD7TXZ1xpedD8Sx3aSKsxN05ZqEFmXDNHk=
 github.com/amnezia-vpn/amneziawg-go/v3 v3.1.20260828/go.mod h1:YoPc6qcOZqD7TXZ1xpedD8Sx3aSKsxN05ZqEFmXDNHk=
 github.com/andybalholm/brotli v1.2.3 h1:8H1qwOkl2LPfjf3YezB90JnCliZb6SInJ/OJkEbA5NQ=
 github.com/andybalholm/brotli v1.2.3 h1:8H1qwOkl2LPfjf3YezB90JnCliZb6SInJ/OJkEbA5NQ=
 github.com/andybalholm/brotli v1.2.3/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
 github.com/andybalholm/brotli v1.2.3/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
-github.com/apernet/quic-go v0.59.1-0.20260425001925-6c6cc9bcb716 h1:J1O+xpLuJWkdYbw5JPGwBqIHs2J8tiEP7Py9lPqkN2I=
-github.com/apernet/quic-go v0.59.1-0.20260425001925-6c6cc9bcb716/go.mod h1:Npbg8qBtAZlsAB3FWmqwlVh5jtVG6a4DlYsOylUpvzA=
+github.com/apernet/quic-go v0.61.1-0.20260806010916-184d081eef3e h1:5mgtR5gwIgBKMiGI1QdXldZZ+SNor06Nbu1wCBulQBg=
+github.com/apernet/quic-go v0.61.1-0.20260806010916-184d081eef3e/go.mod h1:x7qxEvX6MCVtDuBKHj3E+88+BtrbEMuAL5qGUKItjW8=
 github.com/bytedance/gopkg v0.1.4 h1:oZnQwnX82KAIWb7033bEwtxvTqXcYMxDBaQxo5JJHWM=
 github.com/bytedance/gopkg v0.1.4 h1:oZnQwnX82KAIWb7033bEwtxvTqXcYMxDBaQxo5JJHWM=
 github.com/bytedance/gopkg v0.1.4/go.mod h1:v1zWfPm21Fb+OsyXN2VAHdL6TBb2L88anLQgdyje6R4=
 github.com/bytedance/gopkg v0.1.4/go.mod h1:v1zWfPm21Fb+OsyXN2VAHdL6TBb2L88anLQgdyje6R4=
 github.com/bytedance/sonic v1.15.3 h1:P3akjLPBtV/i6bHC6LbcLjY3KuoOvfiqF8wFHeP5IhY=
 github.com/bytedance/sonic v1.15.3 h1:P3akjLPBtV/i6bHC6LbcLjY3KuoOvfiqF8wFHeP5IhY=
@@ -31,12 +31,12 @@ github.com/gabriel-vasile/mimetype v1.4.15 h1:05iP/CYtZ/w455R/KZM6rZ5ieAdh99UPtd
 github.com/gabriel-vasile/mimetype v1.4.15/go.mod h1:azpTcoLcDZRNgFou5j+APrqQx9HqVPWa6ijYQIIVswQ=
 github.com/gabriel-vasile/mimetype v1.4.15/go.mod h1:azpTcoLcDZRNgFou5j+APrqQx9HqVPWa6ijYQIIVswQ=
 github.com/ghodss/yaml v1.0.1-0.20220118164431-d8423dcdf344 h1:Arcl6UOIS/kgO2nW3A65HN+7CMjSDP/gofXL4CZt1V4=
 github.com/ghodss/yaml v1.0.1-0.20220118164431-d8423dcdf344 h1:Arcl6UOIS/kgO2nW3A65HN+7CMjSDP/gofXL4CZt1V4=
 github.com/ghodss/yaml v1.0.1-0.20220118164431-d8423dcdf344/go.mod h1:GIjDIg/heH5DOkXY3YJ/wNhfHsQHoXGjl8G8amsYQ1I=
 github.com/ghodss/yaml v1.0.1-0.20220118164431-d8423dcdf344/go.mod h1:GIjDIg/heH5DOkXY3YJ/wNhfHsQHoXGjl8G8amsYQ1I=
-github.com/gin-contrib/gzip v1.2.6 h1:OtN8DplD5DNZCSLAnQ5HxRkD2qZ5VU+JhOrcfJrcRvg=
-github.com/gin-contrib/gzip v1.2.6/go.mod h1:BQy8/+JApnRjAVUplSGZiVtD2k8GmIE2e9rYu/hLzzU=
-github.com/gin-contrib/sessions v1.1.0 h1:00mhHfNEGF5sP2fwxa98aRqj1FOJdL6IkR86n2hOiBo=
-github.com/gin-contrib/sessions v1.1.0/go.mod h1:TyYZDIs6qCQg2SOoYPgMT9pAkmZceVNEJMcv5qbIy60=
-github.com/gin-contrib/sse v1.1.1 h1:uGYpNwTacv5R68bSGMapo62iLTRa9l5zxGCps4hK6ko=
-github.com/gin-contrib/sse v1.1.1/go.mod h1:QXzuVkA0YO7o/gun03UI1Q+FTI8ZV/n5t03kIQAI89s=
+github.com/gin-contrib/gzip v1.2.7 h1:eQYOd81DpSU24TYYYNPzATrl7Hv3hGyzQilt3fGkxoc=
+github.com/gin-contrib/gzip v1.2.7/go.mod h1:mfl5NDloGODrP2QryKtW37zsWrLkJkp/Y3iHTw0ZDy8=
+github.com/gin-contrib/sessions v1.1.1 h1:/3ORwmsZ0h/Kcc9fekFrSSc6QXEifMscBLcS8lyQfqQ=
+github.com/gin-contrib/sessions v1.1.1/go.mod h1:h8ilnqzj7fPELlu4/rbFcZW/0SNYMXhm4X/KQv0i1G8=
+github.com/gin-contrib/sse v1.1.2 h1:MU2fgl1RrdYTMcgJLtz2kJF+vPg3xrqaaKfUUU18tCo=
+github.com/gin-contrib/sse v1.1.2/go.mod h1:QXzuVkA0YO7o/gun03UI1Q+FTI8ZV/n5t03kIQAI89s=
 github.com/gin-gonic/gin v1.12.0 h1:b3YAbrZtnf8N//yjKeU2+MQsh2mY5htkZidOM7O0wG8=
 github.com/gin-gonic/gin v1.12.0 h1:b3YAbrZtnf8N//yjKeU2+MQsh2mY5htkZidOM7O0wG8=
 github.com/gin-gonic/gin v1.12.0/go.mod h1:VxccKfsSllpKshkBWgVgRniFFAzFb9csfngsqANjnLc=
 github.com/gin-gonic/gin v1.12.0/go.mod h1:VxccKfsSllpKshkBWgVgRniFFAzFb9csfngsqANjnLc=
 github.com/go-asn1-ber/asn1-ber v1.5.8 h1:H9AZkK22UOmfX8J84ubyaZxKJZ3FMHVwn8swoMML7iQ=
 github.com/go-asn1-ber/asn1-ber v1.5.8 h1:H9AZkK22UOmfX8J84ubyaZxKJZ3FMHVwn8swoMML7iQ=
@@ -56,8 +56,8 @@ github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/o
 github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
 github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
 github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
 github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
 github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
 github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
-github.com/go-playground/validator/v10 v10.30.3 h1:4MU6YkEwx7GbcPJOZxrtbu+QfF3pJLJuaYTeAH0DYy8=
-github.com/go-playground/validator/v10 v10.30.3/go.mod h1:4Axh7oCNGcoGkqLoE4YWt6n20mcEIsPRlB7vPk3lpyc=
+github.com/go-playground/validator/v10 v10.30.4 h1:9Rcod2ZPO6mOEG6b4GqyoHE/H6//Ze0RuhOo1hT1x0w=
+github.com/go-playground/validator/v10 v10.30.4/go.mod h1:numpT+RPLE91R9oYWMY/R9zRgJBewr3IXHko4OISPpk=
 github.com/goccy/go-json v0.10.6 h1:p8HrPJzOakx/mn/bQtjgNjdTcN+/S6FcG2CTtQOrHVU=
 github.com/goccy/go-json v0.10.6 h1:p8HrPJzOakx/mn/bQtjgNjdTcN+/S6FcG2CTtQOrHVU=
 github.com/goccy/go-json v0.10.6/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
 github.com/goccy/go-json v0.10.6/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
 github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM=
 github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM=
@@ -87,14 +87,18 @@ github.com/grbit/go-json v0.11.0 h1:bAbyMdYrYl/OjYsSqLH99N2DyQ291mHy726Mx+sYrnc=
 github.com/grbit/go-json v0.11.0/go.mod h1:IYpHsdybQ386+6g3VE6AXQ3uTGa5mquBme5/ZWmtzek=
 github.com/grbit/go-json v0.11.0/go.mod h1:IYpHsdybQ386+6g3VE6AXQ3uTGa5mquBme5/ZWmtzek=
 github.com/hashicorp/go-uuid v1.0.3 h1:2gKiV6YVmrJ1i2CKKa9obLvRieoRGviZFL26PcT/Co8=
 github.com/hashicorp/go-uuid v1.0.3 h1:2gKiV6YVmrJ1i2CKKa9obLvRieoRGviZFL26PcT/Co8=
 github.com/hashicorp/go-uuid v1.0.3/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
 github.com/hashicorp/go-uuid v1.0.3/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
+github.com/huin/goupnp v1.3.0 h1:UvLUlWDNpoUdYzb2TCn+MuTWtcjXKSza2n6CBdQ0xXc=
+github.com/huin/goupnp v1.3.0/go.mod h1:gnGPsThkYa7bFi/KWmEysQRf48l2dvR5bxr2OFckNX8=
 github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
 github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
 github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
 github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
 github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
 github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
 github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
 github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
-github.com/jackc/pgx/v5 v5.10.0 h1:VhSvgU2jSli8o3AqIEOTJr7rZwAEUVo4E4XhR94Zfr0=
-github.com/jackc/pgx/v5 v5.10.0/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4=
+github.com/jackc/pgx/v5 v5.11.0 h1:IzBBtyK9AHqf98cctWFifYSci2hgQR/cd56wB4p+ogg=
+github.com/jackc/pgx/v5 v5.11.0/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4=
 github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
 github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
 github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
 github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
+github.com/jackpal/go-nat-pmp v1.1.0 h1:UInMLPV1VQdP860ggNiz0YxGvJH/bWzxL099y+1EdCs=
+github.com/jackpal/go-nat-pmp v1.1.0/go.mod h1:m9o4DK1wHA4h2pPpErD5vwzWLf91tJcfNQ3QyUIbh5A=
 github.com/jcmturner/aescts/v2 v2.0.0 h1:9YKLH6ey7H4eDBXW8khjYslgyqG2xZikXP0EQFKrle8=
 github.com/jcmturner/aescts/v2 v2.0.0 h1:9YKLH6ey7H4eDBXW8khjYslgyqG2xZikXP0EQFKrle8=
 github.com/jcmturner/aescts/v2 v2.0.0/go.mod h1:AiaICIRyfYg35RUkr8yESTqvSy7csK90qZ5xfvvsoNs=
 github.com/jcmturner/aescts/v2 v2.0.0/go.mod h1:AiaICIRyfYg35RUkr8yESTqvSy7csK90qZ5xfvvsoNs=
 github.com/jcmturner/dnsutils/v2 v2.0.0 h1:lltnkeZGL0wILNvrNiVCR6Ro5PGU/SeBvVO/8c/iPbo=
 github.com/jcmturner/dnsutils/v2 v2.0.0 h1:lltnkeZGL0wILNvrNiVCR6Ro5PGU/SeBvVO/8c/iPbo=
@@ -121,18 +125,24 @@ github.com/klauspost/compress v1.20.0 h1:a3C1ke2ohxFymNlb2HWAHjDeKCI90scRskErZkR
 github.com/klauspost/compress v1.20.0/go.mod h1:LUdAzn7YLVvxLpc7y3V1m40wESHTgc1422pwwBSKYuI=
 github.com/klauspost/compress v1.20.0/go.mod h1:LUdAzn7YLVvxLpc7y3V1m40wESHTgc1422pwwBSKYuI=
 github.com/klauspost/cpuid/v2 v2.4.0 h1:S6Hrbc7+ywsr0r+RLapfGBHfyefhCTwEh3A0tV913Dw=
 github.com/klauspost/cpuid/v2 v2.4.0 h1:S6Hrbc7+ywsr0r+RLapfGBHfyefhCTwEh3A0tV913Dw=
 github.com/klauspost/cpuid/v2 v2.4.0/go.mod h1:19jmZ9mjzoF//ddRSUsv0zfBTJWh3QJh9FNxZTMrGxU=
 github.com/klauspost/cpuid/v2 v2.4.0/go.mod h1:19jmZ9mjzoF//ddRSUsv0zfBTJWh3QJh9FNxZTMrGxU=
+github.com/koron/go-ssdp v0.9.1 h1:zvxbAAuJftJIZ8Jh8mda+LI7V92hYZf/sKprmOxpxwA=
+github.com/koron/go-ssdp v0.9.1/go.mod h1:C43c047jWkDaeg9YuZlSh/QGqOieuWV6dbhWi/jcaLk=
 github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
 github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
 github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
 github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
 github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
 github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
 github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
 github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
 github.com/leodido/go-urn v1.5.0 h1:pLqT2kq1zpHW/1D18QMjMpdtX7cekxqtJJjg5ANyWw0=
 github.com/leodido/go-urn v1.5.0 h1:pLqT2kq1zpHW/1D18QMjMpdtX7cekxqtJJjg5ANyWw0=
 github.com/leodido/go-urn v1.5.0/go.mod h1:9BORnCDhdPBJNDEX+w1bJisa8yOKYi116VeO96s4ifE=
 github.com/leodido/go-urn v1.5.0/go.mod h1:9BORnCDhdPBJNDEX+w1bJisa8yOKYi116VeO96s4ifE=
+github.com/libp2p/go-nat v1.0.1-0.20250821073202-01afc089f138 h1:YohuNPT/1k3VcThCQlBZ43PCPWPfMRS1zcxWBF2SLK8=
+github.com/libp2p/go-nat v1.0.1-0.20250821073202-01afc089f138/go.mod h1:TXQg5tfSy+bUjnhT5728j5j/MBj7keIYqqZ1+8k/ui8=
+github.com/libp2p/go-netroute v0.4.0 h1:sZZx9hyANYUx9PZyqcgE/E1GUG3iEtTZHUEvdtXT7/Q=
+github.com/libp2p/go-netroute v0.4.0/go.mod h1:Nkd5ShYgSMS5MUKy/MU2T57xFoOKvvLR92Lic48LEyA=
 github.com/lufia/plan9stats v0.0.0-20260802145828-341c2f0c90b5 h1:eveIIGn4BGM3qknO74omf6HYr30/exH+eVUTuAgwjZ0=
 github.com/lufia/plan9stats v0.0.0-20260802145828-341c2f0c90b5 h1:eveIIGn4BGM3qknO74omf6HYr30/exH+eVUTuAgwjZ0=
 github.com/lufia/plan9stats v0.0.0-20260802145828-341c2f0c90b5/go.mod h1:autxFIvghDt3jPTLoqZ9OZ7s9qTGNAWmYCjVFWPX/zg=
 github.com/lufia/plan9stats v0.0.0-20260802145828-341c2f0c90b5/go.mod h1:autxFIvghDt3jPTLoqZ9OZ7s9qTGNAWmYCjVFWPX/zg=
 github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI=
 github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI=
 github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A=
 github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A=
-github.com/mattn/go-sqlite3 v1.14.50 h1:dmdFvo1XG4MPzA4IkAmE9upVz/Nj31uRoM5+jC8hYbY=
-github.com/mattn/go-sqlite3 v1.14.50/go.mod h1:6JTjA44L93a0QCyJef5YvlPoKXntQPjzWv5gtm9sB6w=
+github.com/mattn/go-sqlite3 v1.14.52 h1:wVbm2Qnf4OXkqhBTSPuCRZDRnxfbVrrmiCEroVdog8U=
+github.com/mattn/go-sqlite3 v1.14.52/go.mod h1:6JTjA44L93a0QCyJef5YvlPoKXntQPjzWv5gtm9sB6w=
 github.com/miekg/dns v1.1.73 h1:uhT8nJxmTrPJYClxVxTCX+CVn6qnzSiybRk72Z6DgrE=
 github.com/miekg/dns v1.1.73 h1:uhT8nJxmTrPJYClxVxTCX+CVn6qnzSiybRk72Z6DgrE=
 github.com/miekg/dns v1.1.73/go.mod h1:RW2Obtfd5NZHvOFe3zYG0W8koWOQtAzyHaLo8vASBuQ=
 github.com/miekg/dns v1.1.73/go.mod h1:RW2Obtfd5NZHvOFe3zYG0W8koWOQtAzyHaLo8vASBuQ=
 github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
 github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
@@ -140,6 +150,8 @@ github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w
 github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
 github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
 github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
 github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
 github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
 github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
+github.com/molecule-man/go-brrr v1.0.1 h1:cEjgx8hgNw6UGdhQ94SPDbPkKuRbkUcxBO3IzbGpA/o=
+github.com/molecule-man/go-brrr v1.0.1/go.mod h1:7ybW6/7gA3oKY45jOfVNjSJDtrr6ea4tzbsTkjmQDC4=
 github.com/mymmrac/telego v1.12.1 h1:yx1T5pPSNsU3BjLR7jnfY0D4dtL9caH58Y9e8uzjR88=
 github.com/mymmrac/telego v1.12.1 h1:yx1T5pPSNsU3BjLR7jnfY0D4dtL9caH58Y9e8uzjR88=
 github.com/mymmrac/telego v1.12.1/go.mod h1:K4z3Z3Qr6AA8yEjSry3JGScu506NlLl1O4Gqascmop4=
 github.com/mymmrac/telego v1.12.1/go.mod h1:K4z3Z3Qr6AA8yEjSry3JGScu506NlLl1O4Gqascmop4=
 github.com/nicksnyder/go-i18n/v2 v2.6.1 h1:JDEJraFsQE17Dut9HFDHzCoAWGEQJom5s0TRd17NIEQ=
 github.com/nicksnyder/go-i18n/v2 v2.6.1 h1:JDEJraFsQE17Dut9HFDHzCoAWGEQJom5s0TRd17NIEQ=
@@ -175,8 +187,8 @@ github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs=
 github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro=
 github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro=
 github.com/rogpeppe/go-internal v1.15.0 h1:D0RCU5rMAp+SpgkiNdrjfJ+LX4J1M32V2NeCY7EJ6hc=
 github.com/rogpeppe/go-internal v1.15.0 h1:D0RCU5rMAp+SpgkiNdrjfJ+LX4J1M32V2NeCY7EJ6hc=
 github.com/rogpeppe/go-internal v1.15.0/go.mod h1:DrUVZyrJU+txYW5/1kwtXQSMFio52ZOxX7yM1VHvnxs=
 github.com/rogpeppe/go-internal v1.15.0/go.mod h1:DrUVZyrJU+txYW5/1kwtXQSMFio52ZOxX7yM1VHvnxs=
-github.com/sagernet/sing v0.9.0 h1:NQvJxtYEl+2uIh/Bkxf5cqAZfZuFCQKQe2z85Pbfxag=
-github.com/sagernet/sing v0.9.0/go.mod h1:K3Owt3xPhHugvlnlPPxZJ/exXdaJfEPOTNorGk4AXjo=
+github.com/sagernet/sing v0.9.3 h1:66QWoMcD24KrQ4PqiaNNWApogJaf6LoozcW2RPtJ7KE=
+github.com/sagernet/sing v0.9.3/go.mod h1:K3Owt3xPhHugvlnlPPxZJ/exXdaJfEPOTNorGk4AXjo=
 github.com/sagernet/sing-shadowsocks v0.2.9 h1:Paep5zCszRKsEn8587O0MnhFWKJwDW1Y4zOYYlIxMkM=
 github.com/sagernet/sing-shadowsocks v0.2.9 h1:Paep5zCszRKsEn8587O0MnhFWKJwDW1Y4zOYYlIxMkM=
 github.com/sagernet/sing-shadowsocks v0.2.9/go.mod h1:TE/Z6401Pi8tgr0nBZcM/xawAI6u3F6TTbz4nH/qw+8=
 github.com/sagernet/sing-shadowsocks v0.2.9/go.mod h1:TE/Z6401Pi8tgr0nBZcM/xawAI6u3F6TTbz4nH/qw+8=
 github.com/shirou/gopsutil/v4 v4.26.8 h1:YQMTF/1J50B5+Y0vlo1eDRf5DoR7Gk69hY+8wjYkQeo=
 github.com/shirou/gopsutil/v4 v4.26.8 h1:YQMTF/1J50B5+Y0vlo1eDRf5DoR7Gk69hY+8wjYkQeo=
@@ -205,8 +217,8 @@ github.com/ugorji/go/codec v1.3.2 h1:zkEASHHyEClGeURfgNT9PJZVfAbs9oEX9QXggwWNJbc
 github.com/ugorji/go/codec v1.3.2/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
 github.com/ugorji/go/codec v1.3.2/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
 github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
 github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
 github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
 github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
-github.com/valyala/fasthttp v1.73.0 h1:ocTOORnBWtJ+P8t/6wAjdkchMzdfHmWx2VD/DPbgZ7s=
-github.com/valyala/fasthttp v1.73.0/go.mod h1:EtXQDHaR+5P18p8wqDRFpUhxr108Ga9mXvVJXHRrN2k=
+github.com/valyala/fasthttp v1.74.0 h1:wMS9fnO2QTALozYx5pId2Vi7ZwU/epUkY8i/KPWCHoU=
+github.com/valyala/fasthttp v1.74.0/go.mod h1:3ARmLamUcw7ElxVtC8PXaGzQ6VEuvnetlkrwIklQBSE=
 github.com/valyala/fastjson v1.6.10 h1:/yjJg8jaVQdYR3arGxPE2X5z89xrlhS0eGXdv+ADTh4=
 github.com/valyala/fastjson v1.6.10 h1:/yjJg8jaVQdYR3arGxPE2X5z89xrlhS0eGXdv+ADTh4=
 github.com/valyala/fastjson v1.6.10/go.mod h1:e6FubmQouUNP73jtMLmcbxS6ydWIpOfhz34TSfO3JaE=
 github.com/valyala/fastjson v1.6.10/go.mod h1:e6FubmQouUNP73jtMLmcbxS6ydWIpOfhz34TSfO3JaE=
 github.com/vishvananda/netlink v1.3.1 h1:3AEMt62VKqz90r0tmNhog0r/PpWKmrEShJU0wJW6bV0=
 github.com/vishvananda/netlink v1.3.1 h1:3AEMt62VKqz90r0tmNhog0r/PpWKmrEShJU0wJW6bV0=
@@ -217,16 +229,16 @@ github.com/wlynxg/anet v0.0.5 h1:J3VJGi1gvo0JwZ/P1/Yc/8p63SoW98B5dHkYDmpgvvU=
 github.com/wlynxg/anet v0.0.5/go.mod h1:eay5PRQr7fIVAMbTbchTnO9gG65Hg/uYGdc7mguHxoA=
 github.com/wlynxg/anet v0.0.5/go.mod h1:eay5PRQr7fIVAMbTbchTnO9gG65Hg/uYGdc7mguHxoA=
 github.com/xlzd/gotp v0.1.0 h1:37blvlKCh38s+fkem+fFh7sMnceltoIEBYTVXyoa5Po=
 github.com/xlzd/gotp v0.1.0 h1:37blvlKCh38s+fkem+fFh7sMnceltoIEBYTVXyoa5Po=
 github.com/xlzd/gotp v0.1.0/go.mod h1:ndLJ3JKzi3xLmUProq4LLxCuECL93dG9WASNLpHz8qg=
 github.com/xlzd/gotp v0.1.0/go.mod h1:ndLJ3JKzi3xLmUProq4LLxCuECL93dG9WASNLpHz8qg=
-github.com/xtls/reality v0.0.0-20260322125925-9234c772ba8f h1:iy2JRioxmUpoJ3SzbFPyTxHZMbR/rSHP7dOOgYaq1O8=
-github.com/xtls/reality v0.0.0-20260322125925-9234c772ba8f/go.mod h1:DsJblcWDGt76+FVqBVwbwRhxyyNJsGV48gJLch0OOWI=
-github.com/xtls/xray-core v1.260327.1-0.20260728075948-5ca6f4b7d4dc h1:fkOkmgHWbF2Q8MdV9VxrsyxRz4OndcrUXUkh1ANBTg0=
-github.com/xtls/xray-core v1.260327.1-0.20260728075948-5ca6f4b7d4dc/go.mod h1:wukQoBGnQ6GaLTGuKwv8rCTgf80QxPj+6iznDZHQEWo=
+github.com/xtls/reality v0.0.0-20260908062103-8cdf7bf9c7f0 h1:rb+fKQFhz+5I2PPuQsNYxI5mUU840XWYtRF0ZBjvkws=
+github.com/xtls/reality v0.0.0-20260908062103-8cdf7bf9c7f0/go.mod h1:DsJblcWDGt76+FVqBVwbwRhxyyNJsGV48gJLch0OOWI=
+github.com/xtls/xray-core v1.260327.1-0.20260908094724-37ceb8b4b65e h1:yQS0pPlOBi8y6bHNXbqx9AYito2pAXgItwKK7Waqaw8=
+github.com/xtls/xray-core v1.260327.1-0.20260908094724-37ceb8b4b65e/go.mod h1:G9OqFEPNkwNoxywvEkdBncyfeytCzC31CBazgy3d9ic=
 github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
 github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
 github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
 github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
 github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0=
 github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0=
 github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
 github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
-go.mongodb.org/mongo-driver/v2 v2.8.2 h1:b6o2m7zL8g2URuO8urBedAylxojybKXNZTxgkOcl+2w=
-go.mongodb.org/mongo-driver/v2 v2.8.2/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0=
+go.mongodb.org/mongo-driver/v2 v2.9.0 h1:e2mQdOmbkiYz+dj3faM7lVDwl7WdnRD+g5VicafMhL0=
+go.mongodb.org/mongo-driver/v2 v2.9.0/go.mod h1:SHKN0IWkKmEVGHLjXnni6s4wPKX4v86FTgOeJJFuXcA=
 go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
 go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
 go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
 go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
 go.opentelemetry.io/otel v1.44.0 h1:JjwHmHpA4iZ3wBxluu2fbbE7j4kqlE8jXyAyPXH7HqU=
 go.opentelemetry.io/otel v1.44.0 h1:JjwHmHpA4iZ3wBxluu2fbbE7j4kqlE8jXyAyPXH7HqU=
@@ -247,27 +259,30 @@ go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw=
 go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg=
 go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg=
 go4.org/netipx v0.0.0-20260823151212-3075585bcbeb h1:XBM4hvfwGAttkkiTIFfeigdfcL1xIfdKXqFdgiHGtDs=
 go4.org/netipx v0.0.0-20260823151212-3075585bcbeb h1:XBM4hvfwGAttkkiTIFfeigdfcL1xIfdKXqFdgiHGtDs=
 go4.org/netipx v0.0.0-20260823151212-3075585bcbeb/go.mod h1:PLyyIXexvUFg3Owu6p/WfdlivPbZJsZdgWZlrGope/Y=
 go4.org/netipx v0.0.0-20260823151212-3075585bcbeb/go.mod h1:PLyyIXexvUFg3Owu6p/WfdlivPbZJsZdgWZlrGope/Y=
-golang.org/x/arch v0.30.0 h1:sB9h+1gRGa2+LauFSV0tm8bK1J2yo1bx6/Uyi/P6DTU=
-golang.org/x/arch v0.30.0/go.mod h1:0X+GdSIP+kL5wPmpK7sdkEVTt2XoYP0cSjQSbZBwOi8=
+golang.org/x/arch v0.31.0 h1:22MlEb14/O/EPCYHFxsDdv5TuLD5dMjT5e2QeJw4ULk=
+golang.org/x/arch v0.31.0/go.mod h1:KcJSod3cqT2dKcjBxqTyGfbumNikqU9p5tHJinPJnuY=
 golang.org/x/crypto v0.56.0 h1:GUh5Ii4J5jtcseSMiRqr1jXCNHoxjeV9Fmekc2oLy6Y=
 golang.org/x/crypto v0.56.0 h1:GUh5Ii4J5jtcseSMiRqr1jXCNHoxjeV9Fmekc2oLy6Y=
 golang.org/x/crypto v0.56.0/go.mod h1:OMW5y6CY9l38uPLmxU6l6pwcXp1obtLo3e6gT7gQR2I=
 golang.org/x/crypto v0.56.0/go.mod h1:OMW5y6CY9l38uPLmxU6l6pwcXp1obtLo3e6gT7gQR2I=
 golang.org/x/exp v0.0.0-20260824195058-e88cd73687aa h1:QSyA8ishJCyT21kER9KwNt0b7BM3iRK4x9QXhjN5Fdk=
 golang.org/x/exp v0.0.0-20260824195058-e88cd73687aa h1:QSyA8ishJCyT21kER9KwNt0b7BM3iRK4x9QXhjN5Fdk=
 golang.org/x/exp v0.0.0-20260824195058-e88cd73687aa/go.mod h1:zeBbvyFKDaLwa7CH/zI8KXt7gTl14SF7sO08Pl5jBCM=
 golang.org/x/exp v0.0.0-20260824195058-e88cd73687aa/go.mod h1:zeBbvyFKDaLwa7CH/zI8KXt7gTl14SF7sO08Pl5jBCM=
 golang.org/x/net v0.58.0 h1:ynWG7rqYi4ccpTEuPZ2QGWHktVEM9DMCj9yzDE0Q7To=
 golang.org/x/net v0.58.0 h1:ynWG7rqYi4ccpTEuPZ2QGWHktVEM9DMCj9yzDE0Q7To=
 golang.org/x/net v0.58.0/go.mod h1:YwCddHnFlT7eLQqVprV19OnhLGtc5xOKgE0RyqgfWAU=
 golang.org/x/net v0.58.0/go.mod h1:YwCddHnFlT7eLQqVprV19OnhLGtc5xOKgE0RyqgfWAU=
-golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
-golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
+golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
+golang.org/x/sync v0.23.0 h1:KameEIfc1IkluZyXWLn39Wd4tURc6GbCiISGiZm2bQk=
+golang.org/x/sync v0.23.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0=
 golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.10.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.10.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
-golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
+golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo=
+golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
 golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8=
 golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8=
 golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M=
 golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M=
-golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
-golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
+golang.org/x/time v0.16.0 h1:vMb6ptszcQMkcwiRTAuNNU50gom6++Q/6gY2hDM6VDE=
+golang.org/x/time v0.16.0/go.mod h1:rVKOqvZeKvrDKTQiAHJ7wmwP0RzleSphoEA9RcdLA0s=
+golang.org/x/tools v0.49.0 h1:3NI7VXzL9+1WZD52Dx2ttoPwD5DWrFGpl9mFZDlmisI=
+golang.org/x/tools v0.49.0/go.mod h1:SJNXV9DBKT0UbdttsQjbfJlAE/q+y36++zo3uL3N0Oo=
 golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 h1:B82qJJgjvYKsXS9jeunTOisW56dUokqW/FOteYJJ/yg=
 golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 h1:B82qJJgjvYKsXS9jeunTOisW56dUokqW/FOteYJJ/yg=
 golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2/go.mod h1:deeaetjYA+DHMHg+sMSMI58GrEteJUUzzw7en6TJQcI=
 golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2/go.mod h1:deeaetjYA+DHMHg+sMSMI58GrEteJUUzzw7en6TJQcI=
 golang.zx2c4.com/wireguard v0.0.0-20260522210424-ecfc5a8d5446 h1:cqHQ3AycTHvM2R7ikgyX57D+XvtcSnGylsLkOVhta/w=
 golang.zx2c4.com/wireguard v0.0.0-20260522210424-ecfc5a8d5446 h1:cqHQ3AycTHvM2R7ikgyX57D+XvtcSnGylsLkOVhta/w=
@@ -276,8 +291,8 @@ golang.zx2c4.com/wireguard/windows v1.0.1 h1:eOxiDVbywPC+ZQqvdCK7x+ZwWXKbYv50TtH
 golang.zx2c4.com/wireguard/windows v1.0.1/go.mod h1:+fbT3FFdX4zzYDLwJh5+HPEcNN/3HyNdzhNSVsQM+zs=
 golang.zx2c4.com/wireguard/windows v1.0.1/go.mod h1:+fbT3FFdX4zzYDLwJh5+HPEcNN/3HyNdzhNSVsQM+zs=
 gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4=
 gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4=
 gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E=
 gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E=
-google.golang.org/genproto/googleapis/rpc v0.0.0-20260831171406-18b4a7587f8a h1:3Dnd1cDaZlB68lziofO+bJXpjOy8UfRv8Unt+yH8tQ4=
-google.golang.org/genproto/googleapis/rpc v0.0.0-20260831171406-18b4a7587f8a/go.mod h1:DjtHYE8FKJLivXcBEjGwndXfIC23G0VpXiXKqG179uA=
+google.golang.org/genproto/googleapis/rpc v0.0.0-20260908043556-f8649ddbbfe6 h1:ieEbjQ6lzbvntOXUB9nMx9uH+yIU/HbgkNDjnk/mJuk=
+google.golang.org/genproto/googleapis/rpc v0.0.0-20260908043556-f8649ddbbfe6/go.mod h1:DjtHYE8FKJLivXcBEjGwndXfIC23G0VpXiXKqG179uA=
 google.golang.org/grpc v1.83.2 h1:EManeRomTObA0BU7I8vXgg/78uE5MJ9M8B39EX2WscU=
 google.golang.org/grpc v1.83.2 h1:EManeRomTObA0BU7I8vXgg/78uE5MJ9M8B39EX2WscU=
 google.golang.org/grpc v1.83.2/go.mod h1:YPI1hK3kDked6iHvgX3tR0y+nX/qpMFKhPgFsokw1S8=
 google.golang.org/grpc v1.83.2/go.mod h1:YPI1hK3kDked6iHvgX3tR0y+nX/qpMFKhPgFsokw1S8=
 google.golang.org/protobuf v1.36.12 h1:pJOKDDOyeXErUroCihFAd5LQuwXBSpVnKGrj5o/fwxc=
 google.golang.org/protobuf v1.36.12 h1:pJOKDDOyeXErUroCihFAd5LQuwXBSpVnKGrj5o/fwxc=

+ 2 - 2
install.sh

@@ -1228,7 +1228,7 @@ EOF
             prompt_and_setup_ssl "${config_port}" "${config_webBasePath}" "${server_ip}"
             prompt_and_setup_ssl "${config_port}" "${config_webBasePath}" "${server_ip}"
 
 
             # Retrieve the API token for display
             # Retrieve the API token for display
-            local config_apiToken=$(${xui_folder}/x-ui setting -getApiToken true | grep -Eo 'apiToken: .+' | awk '{print $2}')
+            local config_apiToken=$(${xui_folder}/x-ui setting -getApiToken | grep -Eo 'apiToken: .+' | awk '{print $2}')
 
 
             # Display final credentials and access information
             # Display final credentials and access information
             echo ""
             echo ""
@@ -1322,7 +1322,7 @@ EOF
 
 
             # Persist a machine-parseable credentials file for cloud-init / MOTD.
             # Persist a machine-parseable credentials file for cloud-init / MOTD.
             local config_apiToken
             local config_apiToken
-            config_apiToken=$(${xui_folder}/x-ui setting -getApiToken true | grep -Eo 'apiToken: .+' | awk '{print $2}')
+            config_apiToken=$(${xui_folder}/x-ui setting -getApiToken | grep -Eo 'apiToken: .+' | awk '{print $2}')
             : "${SSL_SCHEME:=https}"
             : "${SSL_SCHEME:=https}"
             : "${SSL_HOST:=${server_ip}}"
             : "${SSL_HOST:=${server_ip}}"
             write_install_result "${config_username}" "${config_password}" "${existing_port}" \
             write_install_result "${config_username}" "${config_password}" "${existing_port}" \

+ 14 - 1
internal/amneziawg/params.go

@@ -33,6 +33,19 @@ func randInt(min, max int) int {
 	return min + int(n.Int64())
 	return min + int(n.Int64())
 }
 }
 
 
+// DefaultMTU is WireGuard/AmneziaWG's usual tunnel MTU on a 1500-byte host
+// link, before AmneziaWG's own S4 transport junk is prepended.
+const DefaultMTU = 1420
+
+// EffectiveMTU is the admin's value when set, else DefaultMTU minus S4: s4 junk
+// is prepended to every transport packet and never clamped against the MTU.
+func EffectiveMTU(configuredMTU, s4 int) int {
+	if configuredMTU > 0 {
+		return configuredMTU
+	}
+	return max(DefaultMTU-max(s4, 0), 1280)
+}
+
 // GenerateObfuscation31 produces a randomized AmneziaWG 3.1 parameter set: a
 // GenerateObfuscation31 produces a randomized AmneziaWG 3.1 parameter set: a
 // static value gets profiled by DPI, defeating the point.
 // static value gets profiled by DPI, defeating the point.
 func GenerateObfuscation31() Obfuscation31 {
 func GenerateObfuscation31() Obfuscation31 {
@@ -113,7 +126,7 @@ func generateHValues() [4]string {
 	const lo = 5
 	const lo = 5
 	bandSize := (awgHMax - lo + 1) / 4
 	bandSize := (awgHMax - lo + 1) / 4
 	var out [4]string
 	var out [4]string
-	for i := 0; i < 4; i++ {
+	for i := range 4 {
 		bandLo := lo + i*bandSize
 		bandLo := lo + i*bandSize
 		bandHi := bandLo + bandSize - 1
 		bandHi := bandLo + bandSize - 1
 		out[i] = fmt.Sprintf("%d", randInt(bandLo, bandHi))
 		out[i] = fmt.Sprintf("%d", randInt(bandLo, bandHi))

+ 39 - 3
internal/amneziawg/params_test.go

@@ -8,7 +8,7 @@ import (
 )
 )
 
 
 func TestGenerateObfuscation31DefaultRanges(t *testing.T) {
 func TestGenerateObfuscation31DefaultRanges(t *testing.T) {
-	for i := 0; i < 200; i++ {
+	for range 200 {
 		o := GenerateObfuscation31()
 		o := GenerateObfuscation31()
 		if o.Jc < 3 || o.Jc > 6 {
 		if o.Jc < 3 || o.Jc > 6 {
 			t.Fatalf("Jc = %d, want [3,6]", o.Jc)
 			t.Fatalf("Jc = %d, want [3,6]", o.Jc)
@@ -96,7 +96,7 @@ func assertRangeWithin(t *testing.T, name, v string, min, max int64) (lo, hi int
 }
 }
 
 
 func TestGenerateHValuesDistinct(t *testing.T) {
 func TestGenerateHValuesDistinct(t *testing.T) {
-	for i := 0; i < 50; i++ {
+	for range 50 {
 		h := generateHValues()
 		h := generateHValues()
 		var prev int64
 		var prev int64
 		for i, v := range h {
 		for i, v := range h {
@@ -117,7 +117,7 @@ func validObfuscation() Obfuscation31 {
 }
 }
 
 
 func TestValidateObfuscationAcceptsGenerated(t *testing.T) {
 func TestValidateObfuscationAcceptsGenerated(t *testing.T) {
-	for i := 0; i < 50; i++ {
+	for range 50 {
 		if err := ValidateObfuscation(validObfuscation()); err != nil {
 		if err := ValidateObfuscation(validObfuscation()); err != nil {
 			t.Fatalf("generated obfuscation set rejected: %v", err)
 			t.Fatalf("generated obfuscation set rejected: %v", err)
 		}
 		}
@@ -378,6 +378,42 @@ func TestValidateConfigValueRejectsControlCharacters(t *testing.T) {
 	}
 	}
 }
 }
 
 
+// The plain 1420 default left no headroom for s4: it put full-size packets at
+// 1480+S4 on the wire and fragmented every one of them once S4 passed 20.
+func TestEffectiveMTUKeepsFullSizePacketsUnfragmented(t *testing.T) {
+	t.Parallel()
+
+	// 20 IPv4 + 8 UDP + 16 transport header + 16 poly1305 tag.
+	const encapOverhead = 60
+	const hostLinkMTU = 1500
+
+	for s4 := 0; s4 <= 32; s4++ {
+		mtu := EffectiveMTU(0, s4)
+		if wire := mtu + encapOverhead + s4; wire > hostLinkMTU {
+			t.Errorf("s4=%d: MTU %d puts a full-size transport packet at %d bytes on the wire, over the %d-byte host link", s4, mtu, wire, hostLinkMTU)
+		}
+	}
+}
+
+// TestEffectiveMTUPrefersTheAdminsValue: the S4-aware default is a fallback,
+// not an override -- an explicit MTU must survive untouched.
+func TestEffectiveMTUPrefersTheAdminsValue(t *testing.T) {
+	t.Parallel()
+
+	if got := EffectiveMTU(1380, 27); got != 1380 {
+		t.Errorf("EffectiveMTU(1380, 27) = %d, want the configured 1380", got)
+	}
+	if got := EffectiveMTU(0, 27); got != DefaultMTU-27 {
+		t.Errorf("EffectiveMTU(0, 27) = %d, want %d", got, DefaultMTU-27)
+	}
+	if got := EffectiveMTU(0, 0); got != DefaultMTU {
+		t.Errorf("EffectiveMTU(0, 0) = %d, want %d", got, DefaultMTU)
+	}
+	if got := EffectiveMTU(-5, 12); got != DefaultMTU-12 {
+		t.Errorf("a nonsense configured MTU must fall back, got %d", got)
+	}
+}
+
 // TestValidateObfuscationRejectsOutOfRangeJunkAndPadding pins the widths
 // TestValidateObfuscationRejectsOutOfRangeJunkAndPadding pins the widths
 // amneziawg-go's UAPI actually parses: uint32 for jc/jmin/jmax, uint16 for s1-s4.
 // amneziawg-go's UAPI actually parses: uint32 for jc/jmin/jmax, uint16 for s1-s4.
 func TestValidateObfuscationRejectsOutOfRangeJunkAndPadding(t *testing.T) {
 func TestValidateObfuscationRejectsOutOfRangeJunkAndPadding(t *testing.T) {

+ 3 - 3
internal/amneziawg/portfwd.go

@@ -46,9 +46,9 @@ func parseForwardedPorts(input string) []portSpec {
 }
 }
 
 
 func parsePortToken(tok string) (portSpec, bool) {
 func parsePortToken(tok string) (portSpec, bool) {
-	if idx := strings.IndexByte(tok, '-'); idx >= 0 {
-		start, ok1 := parsePortNumber(strings.TrimSpace(tok[:idx]))
-		end, ok2 := parsePortNumber(strings.TrimSpace(tok[idx+1:]))
+	if before, after, ok0 := strings.Cut(tok, "-"); ok0 {
+		start, ok1 := parsePortNumber(strings.TrimSpace(before))
+		end, ok2 := parsePortNumber(strings.TrimSpace(after))
 		if !ok1 || !ok2 || start > end {
 		if !ok1 || !ok2 || start > end {
 			return portSpec{}, false
 			return portSpec{}, false
 		}
 		}

+ 2 - 10
internal/amneziawgnet/device.go

@@ -13,11 +13,6 @@ import (
 	"github.com/mhsanaei/3x-ui/v3/internal/util/wireguard"
 	"github.com/mhsanaei/3x-ui/v3/internal/util/wireguard"
 )
 )
 
 
-// defaultMTU matches internal/amneziawg's own kernel-module interface
-// default -- 1420, WireGuard/AmneziaWG's usual accounting for tunnel
-// encapsulation overhead on a standard 1500-byte-MTU host link.
-const defaultMTU = 1420
-
 // DeviceOptions carries AmneziaWG 3.0's device-wide fields (header
 // DeviceOptions carries AmneziaWG 3.0's device-wide fields (header
 // protection, content padding, and the five session-timing knobs) --
 // protection, content padding, and the five session-timing knobs) --
 // mirrored from amneziawg.Instance's identically named fields by every
 // mirrored from amneziawg.Instance's identically named fields by every
@@ -78,7 +73,7 @@ type Device struct {
 
 
 // NewDevice constructs, configures, and brings up an embedded AmneziaWG
 // NewDevice constructs, configures, and brings up an embedded AmneziaWG
 // interface for inst in one call: a gVisor-backed tun.Device sized to
 // interface for inst in one call: a gVisor-backed tun.Device sized to
-// inst.MTU (or defaultMTU), addressed with inst.Address, configured via
+// amneziawg.EffectiveMTU, addressed with inst.Address, configured via
 // UAPI with inst.Obfuscation, inst.PrivateKey, opts' AWG 3.0 fields, and one
 // UAPI with inst.Obfuscation, inst.PrivateKey, opts' AWG 3.0 fields, and one
 // UAPI peer per inst.Peers entry. It does not attach a forwarder or start
 // UAPI peer per inst.Peers entry. It does not attach a forwarder or start
 // relaying traffic -- that's the caller's job (see AttachTCPForwarder /
 // relaying traffic -- that's the caller's job (see AttachTCPForwarder /
@@ -122,10 +117,7 @@ func newUnconfiguredDevice(inst amneziawg.Instance, opts DeviceOptions) (*Device
 		return nil, fmt.Errorf("amneziawgnet: %w", err)
 		return nil, fmt.Errorf("amneziawgnet: %w", err)
 	}
 	}
 
 
-	mtu := inst.MTU
-	if mtu <= 0 {
-		mtu = defaultMTU
-	}
+	mtu := amneziawg.EffectiveMTU(inst.MTU, inst.Obfuscation.S4)
 
 
 	tun, gstack, err := createNetTUNWithStack(addrs, mtu)
 	tun, gstack, err := createNetTUNWithStack(addrs, mtu)
 	if err != nil {
 	if err != nil {

+ 9 - 8
internal/amneziawgnet/manager.go

@@ -126,9 +126,10 @@ func (m *Manager) Ensure(d Desired) error {
 // tearing down every peer's live handshake/session state on every single
 // tearing down every peer's live handshake/session state on every single
 // reconcile, so no connection could ever survive past one tick); only
 // reconcile, so no connection could ever survive past one tick); only
 // peers/obfuscation/keys/listen_port changed (reconfigure the existing
 // peers/obfuscation/keys/listen_port changed (reconfigure the existing
-// Device in place via IpcSet); or the interface's own address(es)/MTU
-// changed (these are fixed at netstack-construction time, so the only
-// option is closing the old Device and building a fresh one).
+// Device in place via IpcSet); or the interface's own address(es)/effective
+// MTU changed -- S4 counts, the default MTU derives from it (these are fixed
+// at netstack-construction time, so the only option is closing the old
+// Device and building a fresh one).
 func (m *Manager) ensureLocked(d Desired) error {
 func (m *Manager) ensureLocked(d Desired) error {
 	inst, opts := d.Instance, d.Options
 	inst, opts := d.Instance, d.Options
 	if opts.Logger == nil {
 	if opts.Logger == nil {
@@ -256,12 +257,12 @@ func socksRelayForInstance(inst amneziawg.Instance) SocksRelay {
 	}
 	}
 }
 }
 
 
-// addressFingerprint captures the two Instance fields that can't be changed
-// on a running Device via IpcSet alone (they're fixed when the gVisor
-// netstack is built) -- everything else (keys, listen port, obfuscation,
-// AWG 3.0 options, peers) amneziawg-go's own UAPI can hot-reconfigure.
+// addressFingerprint captures what IpcSet can't change on a running Device,
+// fixed when the netstack is built: address, and the S4-derived effective MTU.
 func addressFingerprint(inst amneziawg.Instance) string {
 func addressFingerprint(inst amneziawg.Instance) string {
-	return fmt.Sprintf("%d|%s", inst.MTU, strings.Join(inst.Address, ","))
+	return fmt.Sprintf("%d|%s",
+		amneziawg.EffectiveMTU(inst.MTU, inst.Obfuscation.S4),
+		strings.Join(inst.Address, ","))
 }
 }
 
 
 // Reconcile brings every desired instance's embedded interface up to date
 // Reconcile brings every desired instance's embedded interface up to date

+ 63 - 0
internal/amneziawgnet/manager_test.go

@@ -90,6 +90,69 @@ func TestManagerLifecycle(t *testing.T) {
 	}
 	}
 }
 }
 
 
+// An inbound with no explicit MTU derives it from S4, so an S4-only edit is
+// structural: leave it out of the fingerprint and the netstack keeps the old MTU
+// while every client emitter already advertises the new one.
+func TestEnsureRebuildsWhenS4ChangesTheDerivedMTU(t *testing.T) {
+	priv, pub, err := wireguard.GenerateWireguardKeypair()
+	if err != nil {
+		t.Fatalf("generate keypair: %v", err)
+	}
+
+	tests := []struct {
+		name        string
+		mtu         int
+		wantRebuild bool
+	}{
+		{"derived MTU", 0, true},
+		{"explicit MTU", 1420, false},
+	}
+	for i, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			m := &Manager{ifaces: map[int]*managed{}}
+			defer m.StopAll()
+
+			inst := amneziawg.Instance{
+				Id:            9 + i,
+				InterfaceName: fmt.Sprintf("awgtest%d", 9+i),
+				ListenPort:    58719 + i,
+				PrivateKey:    priv,
+				PublicKey:     pub,
+				Address:       []string{"10.209.0.1/24"},
+				MTU:           tt.mtu,
+				Obfuscation: amneziawg.Obfuscation31{
+					Jc: 4, Jmin: 40, Jmax: 70,
+					S1: 20, S2: 30, S3: 20, S4: 5,
+				},
+			}
+			if err := m.Ensure(Desired{Instance: inst}); err != nil {
+				t.Fatalf("Ensure (create): %v", err)
+			}
+			before, _, ok := m.Lookup(inst.Id)
+			if !ok {
+				t.Fatal("Lookup after create: not found")
+			}
+
+			edited := inst
+			edited.Obfuscation.S4 = 27
+			if err := m.Ensure(Desired{Instance: edited}); err != nil {
+				t.Fatalf("Ensure (S4 changed): %v", err)
+			}
+			after, _, ok := m.Lookup(inst.Id)
+			if !ok {
+				t.Fatal("Lookup after S4 edit: not found")
+			}
+
+			if rebuilt := before != after; rebuilt != tt.wantRebuild {
+				t.Errorf("S4 5->27 rebuilt the Device = %v, want %v (MTU %d -> %d)",
+					rebuilt, tt.wantRebuild,
+					amneziawg.EffectiveMTU(inst.MTU, inst.Obfuscation.S4),
+					amneziawg.EffectiveMTU(edited.MTU, edited.Obfuscation.S4))
+			}
+		})
+	}
+}
+
 func TestManagedUDPHandlerDoesNotWaitForManagerLock(t *testing.T) {
 func TestManagedUDPHandlerDoesNotWaitForManagerLock(t *testing.T) {
 	cur := &managed{udpRelay: NewUDPRelay(SocksRelay{Addr: "invalid"}, nil)}
 	cur := &managed{udpRelay: NewUDPRelay(SocksRelay{Addr: "invalid"}, nil)}
 	cur.peers.Store(NewPeerIndex([]amneziawg.Peer{{
 	cur.peers.Store(NewPeerIndex([]amneziawg.Peer{{

+ 159 - 1
internal/database/db.go

@@ -410,6 +410,57 @@ func seedHostsFromExternalProxy() error {
 	})
 	})
 }
 }
 
 
+func seedMtprotoCustomShareAddrToHosts() error {
+	const seederName = "MtprotoCustomShareAddrToHosts"
+	var count int64
+	if err := db.Model(&model.HistoryOfSeeders{}).Where("seeder_name = ?", seederName).Count(&count).Error; err != nil {
+		return err
+	}
+	if count > 0 {
+		return nil
+	}
+
+	return db.Transaction(func(tx *gorm.DB) error {
+		var inbounds []model.Inbound
+		if err := tx.Where("protocol = ? AND TRIM(COALESCE(share_addr_strategy, '')) = ?", string(model.MTProto), "custom").Find(&inbounds).Error; err != nil {
+			return err
+		}
+		for _, inbound := range inbounds {
+			if err := CreateHostFromMtprotoCustomShareAddr(tx, inbound.Id, inbound.ShareAddr); err != nil {
+				return err
+			}
+			if err := tx.Model(&model.Inbound{}).Where("id = ?", inbound.Id).Updates(map[string]any{
+				"share_addr_strategy": "listen",
+				"share_addr":          "",
+			}).Error; err != nil {
+				return err
+			}
+		}
+		return tx.Create(&model.HistoryOfSeeders{SeederName: seederName}).Error
+	})
+}
+
+func CreateHostFromMtprotoCustomShareAddr(tx *gorm.DB, inboundId int, rawAddress string) error {
+	address := strings.TrimPrefix(strings.TrimSuffix(strings.TrimSpace(rawAddress), "]"), "[")
+	if address == "" {
+		return nil
+	}
+	var sameAddress []model.Host
+	if err := tx.Where("inbound_id = ? AND address = ? AND is_disabled = ?", inboundId, address, false).
+		Find(&sameAddress).Error; err != nil {
+		return err
+	}
+	for _, host := range sameAddress {
+		if !slices.Contains(host.ExcludeFromSubTypes, "raw") {
+			return nil
+		}
+	}
+	return tx.Create(&model.Host{
+		GroupId: random.NumLower(16), InboundId: inboundId,
+		Remark: address, Address: address, Security: "same",
+	}).Error
+}
+
 func seedWireguardPeersToClients() error {
 func seedWireguardPeersToClients() error {
 	var history []string
 	var history []string
 	if err := db.Model(&model.HistoryOfSeeders{}).Pluck("seeder_name", &history).Error; err != nil {
 	if err := db.Model(&model.HistoryOfSeeders{}).Pluck("seeder_name", &history).Error; err != nil {
@@ -1193,7 +1244,7 @@ func runSeeders(isUsersEmpty bool) error {
 	}
 	}
 
 
 	if empty && isUsersEmpty {
 	if empty && isUsersEmpty {
-		seeders := []string{"UserPasswordHash", "ClientsTable", "InboundClientsArrayFix", "InboundClientTgIdFix2", "InboundClientSubIdFix", "FreedomFinalRulesReverseFix", "FreedomFinalRulesPrivateEgressBlock", "InboundRealityFinalmaskTcpStrip", "ApiTokensHash", "LegacyProxySettingsCleanup", "WireguardPeersToClients", "MtprotoSecretsToClients", "NodeInboundsAdopted", "ResetIpLimitNoFail2ban"}
+		seeders := []string{"UserPasswordHash", "ClientsTable", "InboundClientsArrayFix", "InboundClientTgIdFix2", "InboundClientSubIdFix", "FreedomFinalRulesReverseFix", "FreedomFinalRulesPrivateEgressBlock", "InboundRealityFinalmaskTcpStrip", "ApiTokensHash", "LegacyProxySettingsCleanup", "OutboundRemovedKeysFix", "WireguardPeersToClients", "MtprotoSecretsToClients", "NodeInboundsAdopted", "ResetIpLimitNoFail2ban"}
 		for _, name := range seeders {
 		for _, name := range seeders {
 			if err := db.Create(&model.HistoryOfSeeders{SeederName: name}).Error; err != nil {
 			if err := db.Create(&model.HistoryOfSeeders{SeederName: name}).Error; err != nil {
 				return err
 				return err
@@ -1298,6 +1349,12 @@ func runSeeders(isUsersEmpty bool) error {
 		}
 		}
 	}
 	}
 
 
+	if !slices.Contains(seedersHistory, "OutboundRemovedKeysFix") {
+		if err := migrateOutboundRemovedKeys(); err != nil {
+			return err
+		}
+	}
+
 	if !slices.Contains(seedersHistory, "NodeInboundsAdopted") {
 	if !slices.Contains(seedersHistory, "NodeInboundsAdopted") {
 		if err := seedNodeInboundsAdopted(); err != nil {
 		if err := seedNodeInboundsAdopted(); err != nil {
 			return err
 			return err
@@ -1308,6 +1365,10 @@ func runSeeders(isUsersEmpty bool) error {
 		return err
 		return err
 	}
 	}
 
 
+	if err := seedMtprotoCustomShareAddrToHosts(); err != nil {
+		return err
+	}
+
 	if err := resetIpLimitsWithoutFail2ban(); err != nil {
 	if err := resetIpLimitsWithoutFail2ban(); err != nil {
 		return err
 		return err
 	}
 	}
@@ -1484,6 +1545,103 @@ func clearLegacyProxySettings() error {
 	})
 	})
 }
 }
 
 
+func migrateOutboundRemovedKeys() error {
+	var setting model.Setting
+	err := db.Model(model.Setting{}).Where("key = ?", "xrayTemplateConfig").First(&setting).Error
+	if errors.Is(err, gorm.ErrRecordNotFound) {
+		return db.Create(&model.HistoryOfSeeders{SeederName: "OutboundRemovedKeysFix"}).Error
+	}
+	if err != nil {
+		return err
+	}
+
+	updated, changed, rErr := rewriteRemovedOutboundKeys(setting.Value)
+	if rErr != nil {
+		log.Printf("OutboundRemovedKeysFix: skip (invalid xrayTemplateConfig json): %v", rErr)
+		return db.Create(&model.HistoryOfSeeders{SeederName: "OutboundRemovedKeysFix"}).Error
+	}
+
+	return db.Transaction(func(tx *gorm.DB) error {
+		if changed {
+			if err := tx.Model(&model.Setting{}).Where("key = ?", "xrayTemplateConfig").
+				Update("value", updated).Error; err != nil {
+				return err
+			}
+		}
+		return tx.Create(&model.HistoryOfSeeders{SeederName: "OutboundRemovedKeysFix"}).Error
+	})
+}
+
+// rewriteRemovedOutboundKeys moves outbound proxySettings.tag to sockopt.dialerProxy
+// and drops freedom sockopt.addressPortStrategy: xray-core v26.9.8 refuses both.
+func rewriteRemovedOutboundKeys(raw string) (string, bool, error) {
+	if strings.TrimSpace(raw) == "" {
+		return raw, false, nil
+	}
+	var cfg map[string]any
+	if err := json.Unmarshal([]byte(raw), &cfg); err != nil {
+		return raw, false, err
+	}
+	outbounds, ok := cfg["outbounds"].([]any)
+	if !ok {
+		return raw, false, nil
+	}
+	changed := false
+	for _, ob := range outbounds {
+		obj, ok := ob.(map[string]any)
+		if !ok {
+			continue
+		}
+		if proxySettings, present := obj["proxySettings"]; present {
+			ps, _ := proxySettings.(map[string]any)
+			if tag, _ := ps["tag"].(string); tag != "" {
+				sockopt := outboundSockopt(obj, true)
+				if current, _ := sockopt["dialerProxy"].(string); current == "" {
+					sockopt["dialerProxy"] = tag
+				}
+			}
+			delete(obj, "proxySettings")
+			changed = true
+		}
+		if proto, _ := obj["protocol"].(string); proto == "freedom" {
+			if sockopt := outboundSockopt(obj, false); sockopt != nil {
+				if _, present := sockopt["addressPortStrategy"]; present {
+					delete(sockopt, "addressPortStrategy")
+					changed = true
+				}
+			}
+		}
+	}
+	if !changed {
+		return raw, false, nil
+	}
+	out, err := json.MarshalIndent(cfg, "", "  ")
+	if err != nil {
+		return raw, false, err
+	}
+	return string(out), true, nil
+}
+
+func outboundSockopt(obj map[string]any, create bool) map[string]any {
+	stream, _ := obj["streamSettings"].(map[string]any)
+	if stream == nil {
+		if !create {
+			return nil
+		}
+		stream = map[string]any{}
+		obj["streamSettings"] = stream
+	}
+	sockopt, _ := stream["sockopt"].(map[string]any)
+	if sockopt == nil {
+		if !create {
+			return nil
+		}
+		sockopt = map[string]any{}
+		stream["sockopt"] = sockopt
+	}
+	return sockopt
+}
+
 func normalizeSettingPaths() error {
 func normalizeSettingPaths() error {
 	pathKeys := []string{"webBasePath", "subPath", "subJsonPath", "subClashPath"}
 	pathKeys := []string{"webBasePath", "subPath", "subJsonPath", "subClashPath"}
 	var rows []model.Setting
 	var rows []model.Setting

+ 165 - 0
internal/database/host_migration_test.go

@@ -190,3 +190,168 @@ func TestMigrate_Postgres(t *testing.T) {
 		t.Fatalf("pg host count after 2nd run = %d, want 2 (idempotent)", count)
 		t.Fatalf("pg host count after 2nd run = %d, want 2 (idempotent)", count)
 	}
 	}
 }
 }
+
+func TestMigrateMtprotoCustomShareAddrToHosts(t *testing.T) {
+	initMigrateDB(t)
+	ib := &model.Inbound{
+		UserId: 1, Tag: "mtproto-custom", Enable: true, Port: 4060, Protocol: model.MTProto,
+		Remark: "MTProto", Settings: `{"clients":[]}`, StreamSettings: `{}`,
+		ShareAddrStrategy: "custom", ShareAddr: "proxy.example.com",
+	}
+	if err := GetDB().Create(ib).Error; err != nil {
+		t.Fatalf("create inbound: %v", err)
+	}
+
+	if err := seedMtprotoCustomShareAddrToHosts(); err != nil {
+		t.Fatalf("migrate: %v", err)
+	}
+
+	var got model.Inbound
+	if err := GetDB().First(&got, ib.Id).Error; err != nil {
+		t.Fatalf("reload inbound: %v", err)
+	}
+	if got.ShareAddrStrategy != "listen" || got.ShareAddr != "" {
+		t.Fatalf("share fields = (%q, %q), want (listen, empty)", got.ShareAddrStrategy, got.ShareAddr)
+	}
+	var hosts []model.Host
+	if err := GetDB().Where("inbound_id = ?", ib.Id).Find(&hosts).Error; err != nil {
+		t.Fatalf("load hosts: %v", err)
+	}
+	if len(hosts) != 1 || hosts[0].Remark != "proxy.example.com" || hosts[0].Address != "proxy.example.com" || hosts[0].Port != 0 || hosts[0].Security != "same" {
+		t.Fatalf("migrated hosts = %+v", hosts)
+	}
+	if hosts[0].GroupId == "" {
+		t.Fatal("migrated host has an empty group id")
+	}
+
+	if err := seedMtprotoCustomShareAddrToHosts(); err != nil {
+		t.Fatalf("second migrate: %v", err)
+	}
+	var count int64
+	if err := GetDB().Model(&model.Host{}).Where("inbound_id = ?", ib.Id).Count(&count).Error; err != nil {
+		t.Fatalf("count hosts: %v", err)
+	}
+	if count != 1 {
+		t.Fatalf("host count = %d, want 1", count)
+	}
+}
+
+func TestMigrateMtprotoCustomShareAddrWithUnrelatedHost(t *testing.T) {
+	initMigrateDB(t)
+	ib := &model.Inbound{
+		UserId: 1, Tag: "mtproto-host", Enable: true, Port: 4060, Protocol: model.MTProto,
+		Remark: "MTProto", Settings: `{"clients":[]}`, StreamSettings: `{}`,
+		ShareAddrStrategy: "custom", ShareAddr: "old.example.com",
+	}
+	if err := GetDB().Create(ib).Error; err != nil {
+		t.Fatalf("create inbound: %v", err)
+	}
+	existing := &model.Host{
+		GroupId: "existing", InboundId: ib.Id, Remark: "public",
+		Address: "new.example.com", Port: 443, Security: "same", IsDisabled: true,
+	}
+	if err := GetDB().Create(existing).Error; err != nil {
+		t.Fatalf("create host: %v", err)
+	}
+
+	if err := seedMtprotoCustomShareAddrToHosts(); err != nil {
+		t.Fatalf("migrate: %v", err)
+	}
+
+	var hosts []model.Host
+	if err := GetDB().Where("inbound_id = ?", ib.Id).Order("id asc").Find(&hosts).Error; err != nil {
+		t.Fatalf("load hosts: %v", err)
+	}
+	if len(hosts) != 2 || hosts[0].Id != existing.Id {
+		t.Fatalf("hosts = %+v, want the existing host plus the migrated one", hosts)
+	}
+	if hosts[1].Address != "old.example.com" || hosts[1].Port != 0 || hosts[1].IsDisabled {
+		t.Fatalf("migrated host = %+v, want enabled old.example.com on the inbound port", hosts[1])
+	}
+	var got model.Inbound
+	if err := GetDB().First(&got, ib.Id).Error; err != nil {
+		t.Fatalf("reload inbound: %v", err)
+	}
+	if got.ShareAddrStrategy != "listen" || got.ShareAddr != "" {
+		t.Fatalf("share fields = (%q, %q), want (listen, empty)", got.ShareAddrStrategy, got.ShareAddr)
+	}
+}
+
+func TestMigrateMtprotoCustomShareAddrSkipsHostWithSameAddress(t *testing.T) {
+	initMigrateDB(t)
+	ib := &model.Inbound{
+		UserId: 1, Tag: "mtproto-dup", Enable: true, Port: 4060, Protocol: model.MTProto,
+		Remark: "MTProto", Settings: `{"clients":[]}`, StreamSettings: `{}`,
+		ShareAddrStrategy: "custom", ShareAddr: "proxy.example.com",
+	}
+	if err := GetDB().Create(ib).Error; err != nil {
+		t.Fatalf("create inbound: %v", err)
+	}
+	existing := &model.Host{
+		GroupId: "existing", InboundId: ib.Id, Remark: "public",
+		Address: "proxy.example.com", Port: 443, Security: "same",
+	}
+	if err := GetDB().Create(existing).Error; err != nil {
+		t.Fatalf("create host: %v", err)
+	}
+
+	if err := seedMtprotoCustomShareAddrToHosts(); err != nil {
+		t.Fatalf("migrate: %v", err)
+	}
+
+	var hosts []model.Host
+	if err := GetDB().Where("inbound_id = ?", ib.Id).Find(&hosts).Error; err != nil {
+		t.Fatalf("load hosts: %v", err)
+	}
+	if len(hosts) != 1 || hosts[0].Id != existing.Id {
+		t.Fatalf("hosts = %+v, want only the existing host %d", hosts, existing.Id)
+	}
+}
+
+func TestMigrateMtprotoCustomShareAddrWithUnusableSameAddressHost(t *testing.T) {
+	cases := []struct {
+		name     string
+		existing model.Host
+	}{
+		{"disabled", model.Host{IsDisabled: true}},
+		{"excludes_raw", model.Host{ExcludeFromSubTypes: []string{"raw"}}},
+	}
+	for _, c := range cases {
+		t.Run(c.name, func(t *testing.T) {
+			initMigrateDB(t)
+			ib := &model.Inbound{
+				UserId: 1, Tag: "mtproto-" + c.name, Enable: true, Port: 4060, Protocol: model.MTProto,
+				Remark: "MTProto", Settings: `{"clients":[]}`, StreamSettings: `{}`,
+				ShareAddrStrategy: "custom", ShareAddr: "proxy.example.com",
+			}
+			if err := GetDB().Create(ib).Error; err != nil {
+				t.Fatalf("create inbound: %v", err)
+			}
+			existing := c.existing
+			existing.GroupId = "existing"
+			existing.InboundId = ib.Id
+			existing.Remark = "parked"
+			existing.Address = "proxy.example.com"
+			existing.Security = "same"
+			if err := GetDB().Create(&existing).Error; err != nil {
+				t.Fatalf("create host: %v", err)
+			}
+
+			if err := seedMtprotoCustomShareAddrToHosts(); err != nil {
+				t.Fatalf("migrate: %v", err)
+			}
+
+			var hosts []model.Host
+			if err := GetDB().Where("inbound_id = ?", ib.Id).Order("id asc").Find(&hosts).Error; err != nil {
+				t.Fatalf("load hosts: %v", err)
+			}
+			if len(hosts) != 2 {
+				t.Fatalf("hosts = %+v, want the parked host plus a usable one", hosts)
+			}
+			migrated := hosts[1]
+			if migrated.Address != "proxy.example.com" || migrated.IsDisabled || len(migrated.ExcludeFromSubTypes) != 0 {
+				t.Fatalf("migrated host = %+v, want an enabled raw-included proxy.example.com", migrated)
+			}
+		})
+	}
+}

+ 117 - 0
internal/database/outbound_removed_keys_migration_test.go

@@ -0,0 +1,117 @@
+package database
+
+import (
+	"encoding/json"
+	"testing"
+
+	"github.com/mhsanaei/3x-ui/v3/internal/xray"
+)
+
+func TestRewriteRemovedOutboundKeys(t *testing.T) {
+	tests := []struct {
+		name         string
+		raw          string
+		wantChanged  bool
+		wantOutbound map[string]any
+	}{
+		{
+			name:        "proxySettings.tag becomes sockopt.dialerProxy",
+			raw:         `{"outbounds":[{"protocol":"vless","tag":"chain","settings":{},"proxySettings":{"tag":"hop","transportLayer":true}}]}`,
+			wantChanged: true,
+			wantOutbound: map[string]any{
+				"protocol": "vless", "tag": "chain", "settings": map[string]any{},
+				"streamSettings": map[string]any{"sockopt": map[string]any{"dialerProxy": "hop"}},
+			},
+		},
+		{
+			name:        "an existing dialerProxy wins over proxySettings",
+			raw:         `{"outbounds":[{"protocol":"vless","tag":"chain","proxySettings":{"tag":"hop"},"streamSettings":{"network":"tcp","sockopt":{"dialerProxy":"keep"}}}]}`,
+			wantChanged: true,
+			wantOutbound: map[string]any{
+				"protocol": "vless", "tag": "chain",
+				"streamSettings": map[string]any{"network": "tcp", "sockopt": map[string]any{"dialerProxy": "keep"}},
+			},
+		},
+		{
+			name:        "freedom drops sockopt.addressPortStrategy",
+			raw:         `{"outbounds":[{"protocol":"freedom","tag":"direct","streamSettings":{"sockopt":{"addressPortStrategy":"SrvPortOnly","tcpFastOpen":true}}}]}`,
+			wantChanged: true,
+			wantOutbound: map[string]any{
+				"protocol": "freedom", "tag": "direct",
+				"streamSettings": map[string]any{"sockopt": map[string]any{"tcpFastOpen": true}},
+			},
+		},
+		{
+			name:        "addressPortStrategy stays on other protocols",
+			raw:         `{"outbounds":[{"protocol":"vless","tag":"proxy","streamSettings":{"sockopt":{"addressPortStrategy":"SrvPortOnly"}}}]}`,
+			wantChanged: false,
+			wantOutbound: map[string]any{
+				"protocol": "vless", "tag": "proxy",
+				"streamSettings": map[string]any{"sockopt": map[string]any{"addressPortStrategy": "SrvPortOnly"}},
+			},
+		},
+	}
+
+	for _, tc := range tests {
+		t.Run(tc.name, func(t *testing.T) {
+			updated, changed, err := rewriteRemovedOutboundKeys(tc.raw)
+			if err != nil {
+				t.Fatalf("unexpected error: %v", err)
+			}
+			if changed != tc.wantChanged {
+				t.Fatalf("changed = %v, want %v", changed, tc.wantChanged)
+			}
+			var cfg struct {
+				Outbounds []map[string]any `json:"outbounds"`
+			}
+			if err := json.Unmarshal([]byte(updated), &cfg); err != nil {
+				t.Fatalf("rewritten template is not JSON: %v", err)
+			}
+			if len(cfg.Outbounds) != 1 {
+				t.Fatalf("got %d outbounds, want 1", len(cfg.Outbounds))
+			}
+			got, _ := json.Marshal(cfg.Outbounds[0])
+			want, _ := json.Marshal(tc.wantOutbound)
+			if string(got) != string(want) {
+				t.Fatalf("outbound = %s, want %s", got, want)
+			}
+		})
+	}
+}
+
+func TestRewriteRemovedOutboundKeysSatisfiesCore(t *testing.T) {
+	raw := `{"outbounds":[{"protocol":"freedom","tag":"direct","settings":{},"proxySettings":{"tag":"hop"},"streamSettings":{"sockopt":{"addressPortStrategy":"SrvPortOnly"}}}]}`
+	var before struct {
+		Outbounds []json.RawMessage `json:"outbounds"`
+	}
+	if err := json.Unmarshal([]byte(raw), &before); err != nil {
+		t.Fatal(err)
+	}
+	if err := xray.ValidateOutboundConfig(before.Outbounds[0]); err == nil {
+		t.Fatal("expected the vendored core to refuse the legacy outbound")
+	}
+
+	updated, changed, err := rewriteRemovedOutboundKeys(raw)
+	if err != nil || !changed {
+		t.Fatalf("rewrite: changed=%v err=%v", changed, err)
+	}
+	var after struct {
+		Outbounds []json.RawMessage `json:"outbounds"`
+	}
+	if err := json.Unmarshal([]byte(updated), &after); err != nil {
+		t.Fatal(err)
+	}
+	if err := xray.ValidateOutboundConfig(after.Outbounds[0]); err != nil {
+		t.Fatalf("rewritten outbound still refused by xray-core: %v", err)
+	}
+}
+
+func TestRewriteRemovedOutboundKeysInvalidJSON(t *testing.T) {
+	_, changed, err := rewriteRemovedOutboundKeys("{not json")
+	if err == nil {
+		t.Fatal("expected an error for invalid JSON")
+	}
+	if changed {
+		t.Fatal("invalid JSON must not report a change")
+	}
+}

+ 248 - 0
internal/sub/clash_info_node_test.go

@@ -0,0 +1,248 @@
+package sub
+
+import (
+	"fmt"
+	"strings"
+	"testing"
+	"time"
+
+	"github.com/mhsanaei/3x-ui/v3/internal/database"
+	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
+	"github.com/mhsanaei/3x-ui/v3/internal/web/service"
+	"github.com/mhsanaei/3x-ui/v3/internal/xray"
+)
+
+func TestSubClash_InfoNode_Active(t *testing.T) {
+	setupInfoNodeTestDB(t)
+	db := database.GetDB()
+
+	ib := &model.Inbound{
+		Id:             1,
+		UserId:         1,
+		Remark:         "Germany-VLESS",
+		Enable:         true,
+		Port:           443,
+		Protocol:       model.VLESS,
+		Settings:       `{"clients":[{"id":"c1-uuid","email":"[email protected]","subId":"sub-clash","enable":true,"totalGB":10737418240}]}`,
+		StreamSettings: `{"network":"tcp","security":"none"}`,
+	}
+	if err := db.Create(ib).Error; err != nil {
+		t.Fatal(err)
+	}
+	rec := &model.ClientRecord{
+		Id:      1,
+		Email:   "[email protected]",
+		SubID:   "sub-clash",
+		UUID:    "c1-uuid",
+		Enable:  true,
+		TotalGB: 10737418240,
+	}
+	if err := db.Create(rec).Error; err != nil {
+		t.Fatal(err)
+	}
+	if err := db.Create(&model.ClientInbound{InboundId: 1, ClientId: 1}).Error; err != nil {
+		t.Fatal(err)
+	}
+	if err := db.Create(&xray.ClientTraffic{
+		InboundId: 1,
+		Email:     "[email protected]",
+		Up:        1073741824,
+		Down:      1073741824,
+		Total:     10737418240,
+		Enable:    true,
+	}).Error; err != nil {
+		t.Fatal(err)
+	}
+
+	sub := NewSubService("{{EMAIL}}|📊{{TRAFFIC_LEFT}}")
+	sub.subInfoNodeEnable = true
+	clash := NewSubClashService(false, "", sub)
+
+	out, _, err := clash.GetClash("sub-clash", "sub.example.com")
+	if err != nil {
+		t.Fatalf("GetClash: %v", err)
+	}
+
+	if !strings.Contains(out, "type: socks5") || !strings.Contains(out, "server: 127.0.0.1") {
+		t.Fatalf("expected socks5 dummy node in clash YAML, got:\n%s", out)
+	}
+	if !strings.Contains(out, "[email protected]|📊8.00GB") {
+		t.Fatalf("expected expanded remark on dummy proxy, got:\n%s", out)
+	}
+}
+
+func TestSubClash_InfoNode_Expired(t *testing.T) {
+	setupInfoNodeTestDB(t)
+	db := database.GetDB()
+
+	expiredTime := time.Now().Add(-24 * time.Hour).UnixMilli()
+	ib := &model.Inbound{
+		Id:             1,
+		UserId:         1,
+		Remark:         "Germany-VLESS",
+		Enable:         true,
+		Port:           443,
+		Protocol:       model.VLESS,
+		Settings:       fmt.Sprintf(`{"clients":[{"id":"c1-uuid","email":"[email protected]","subId":"sub-clash-exp","enable":true,"expiryTime":%d}]}`, expiredTime),
+		StreamSettings: `{"network":"tcp","security":"none"}`,
+	}
+	if err := db.Create(ib).Error; err != nil {
+		t.Fatal(err)
+	}
+	rec := &model.ClientRecord{
+		Id:         1,
+		Email:      "[email protected]",
+		SubID:      "sub-clash-exp",
+		UUID:       "c1-uuid",
+		Enable:     true,
+		ExpiryTime: expiredTime,
+	}
+	if err := db.Create(rec).Error; err != nil {
+		t.Fatal(err)
+	}
+	if err := db.Create(&model.ClientInbound{InboundId: 1, ClientId: 1}).Error; err != nil {
+		t.Fatal(err)
+	}
+	if err := db.Create(&xray.ClientTraffic{
+		InboundId:  1,
+		Email:      "[email protected]",
+		ExpiryTime: expiredTime,
+		Enable:     true,
+	}).Error; err != nil {
+		t.Fatal(err)
+	}
+
+	sub := NewSubService("{{INBOUND}}")
+	sub.subInfoNodeEnable = true
+	sub.subExpiredTemplate = service.DefaultSubExpiredTemplate
+	clash := NewSubClashService(false, "", sub)
+
+	out, _, err := clash.GetClash("sub-clash-exp", "sub.example.com")
+	if err != nil {
+		t.Fatalf("GetClash: %v", err)
+	}
+
+	if !strings.Contains(out, "type: socks5") || !strings.Contains(out, "server: 127.0.0.1") {
+		t.Fatalf("expected socks5 dummy node in clash YAML, got:\n%s", out)
+	}
+	if !strings.Contains(out, "Expired") || !strings.Contains(out, "[email protected]") {
+		t.Fatalf("expected expired remark in clash YAML, got:\n%s", out)
+	}
+	if strings.Contains(out, "Germany-VLESS") {
+		t.Fatalf("expired subscription must NOT contain working inbound, got:\n%s", out)
+	}
+}
+
+func TestSubClash_InfoNode_Depleted(t *testing.T) {
+	setupInfoNodeTestDB(t)
+	db := database.GetDB()
+
+	ib := &model.Inbound{
+		Id:             1,
+		UserId:         1,
+		Remark:         "Germany-VLESS",
+		Enable:         true,
+		Port:           443,
+		Protocol:       model.VLESS,
+		Settings:       `{"clients":[{"id":"c1-uuid","email":"[email protected]","subId":"sub-clash-dep","enable":true,"totalGB":5368709120}]}`,
+		StreamSettings: `{"network":"tcp","security":"none"}`,
+	}
+	if err := db.Create(ib).Error; err != nil {
+		t.Fatal(err)
+	}
+	rec := &model.ClientRecord{
+		Id:      1,
+		Email:   "[email protected]",
+		SubID:   "sub-clash-dep",
+		UUID:    "c1-uuid",
+		Enable:  true,
+		TotalGB: 5368709120,
+	}
+	if err := db.Create(rec).Error; err != nil {
+		t.Fatal(err)
+	}
+	if err := db.Create(&model.ClientInbound{InboundId: 1, ClientId: 1}).Error; err != nil {
+		t.Fatal(err)
+	}
+	if err := db.Create(&xray.ClientTraffic{
+		InboundId: 1,
+		Email:     "[email protected]",
+		Up:        3221225472,
+		Down:      2147483648,
+		Total:     5368709120,
+		Enable:    true,
+	}).Error; err != nil {
+		t.Fatal(err)
+	}
+
+	sub := NewSubService("{{INBOUND}}")
+	sub.subInfoNodeEnable = true
+	sub.subTrafficDepletedTemplate = service.DefaultSubTrafficDepletedTemplate
+	clash := NewSubClashService(false, "", sub)
+
+	out, _, err := clash.GetClash("sub-clash-dep", "sub.example.com")
+	if err != nil {
+		t.Fatalf("GetClash: %v", err)
+	}
+
+	if !strings.Contains(out, "type: socks5") || !strings.Contains(out, "server: 127.0.0.1") {
+		t.Fatalf("expected socks5 dummy node in clash YAML, got:\n%s", out)
+	}
+	if !strings.Contains(out, "Traffic Depleted") || !strings.Contains(out, "[email protected]") {
+		t.Fatalf("expected depleted remark in clash YAML, got:\n%s", out)
+	}
+	if strings.Contains(out, "Germany-VLESS") {
+		t.Fatalf("depleted subscription must NOT contain working inbound, got:\n%s", out)
+	}
+}
+
+func TestSubClash_InfoNode_ProxyGroupOrder_DoesNotDefaultToDummy(t *testing.T) {
+	setupInfoNodeTestDB(t)
+	db := database.GetDB()
+
+	ib := &model.Inbound{
+		Id:             1,
+		UserId:         1,
+		Remark:         "Germany-VLESS",
+		Enable:         true,
+		Port:           443,
+		Protocol:       model.VLESS,
+		Settings:       `{"clients":[{"id":"c1-uuid","email":"[email protected]","subId":"sub-clash","enable":true,"totalGB":10737418240}]}`,
+		StreamSettings: `{"network":"tcp","security":"none"}`,
+	}
+	if err := db.Create(ib).Error; err != nil {
+		t.Fatal(err)
+	}
+	if err := db.Create(&model.ClientRecord{Id: 1, Email: "[email protected]", SubID: "sub-clash", UUID: "c1-uuid", Enable: true}).Error; err != nil {
+		t.Fatal(err)
+	}
+	if err := db.Create(&model.ClientInbound{InboundId: 1, ClientId: 1}).Error; err != nil {
+		t.Fatal(err)
+	}
+	if err := db.Create(&xray.ClientTraffic{InboundId: 1, Email: "[email protected]", Enable: true}).Error; err != nil {
+		t.Fatal(err)
+	}
+
+	sub := NewSubService("{{EMAIL}}|📊{{TRAFFIC_LEFT}}|{{STATUS}}")
+	sub.subInfoNodeEnable = true
+	clash := NewSubClashService(false, "", sub)
+
+	out, _, err := clash.GetClash("sub-clash", "sub.example.com")
+	if err != nil {
+		t.Fatalf("GetClash: %v", err)
+	}
+
+	// Status token must be rendered as active
+	if !strings.Contains(out, "active") {
+		t.Fatalf("expected active status in clash dummy remark, got:\n%s", out)
+	}
+
+	// In proxy-groups, PROXY select group must NOT have the dummy node as first member
+	// PROXY group proxies should begin with real proxy
+	if strings.Contains(out, "[email protected]|active") && strings.Contains(out, "proxies:\n  - [email protected]|active") {
+		t.Fatalf("PROXY group must NOT contain dummy info node as member, got:\n%s", out)
+	}
+	if !strings.Contains(out, "proxies:\n  - Germany-VLESS\n  - DIRECT") {
+		t.Fatalf("expected real proxy Germany-VLESS in PROXY group, got:\n%s", out)
+	}
+}

+ 41 - 0
internal/sub/clash_service.go

@@ -4,6 +4,7 @@ import (
 	"errors"
 	"errors"
 	"fmt"
 	"fmt"
 	"maps"
 	"maps"
+	"slices"
 	"strings"
 	"strings"
 
 
 	"github.com/goccy/go-json"
 	"github.com/goccy/go-json"
@@ -40,6 +41,7 @@ func (s *SubClashService) GetClash(subId string, host string) (string, string, e
 
 
 	var proxies []map[string]any
 	var proxies []map[string]any
 	var hasInactiveExternal bool
 	var hasInactiveExternal bool
+	var hasEnabledClient bool
 
 
 	seenEmails := make(map[string]struct{})
 	seenEmails := make(map[string]struct{})
 	for _, inbound := range inbounds {
 	for _, inbound := range inbounds {
@@ -52,11 +54,17 @@ func (s *SubClashService) GetClash(subId string, host string) (string, string, e
 			injectExternalProxy(inbound, hostEps)
 			injectExternalProxy(inbound, hostEps)
 		}
 		}
 		for _, client := range clients {
 		for _, client := range clients {
+			if client.Enable {
+				hasEnabledClient = true
+			}
 			seenEmails[client.Email] = struct{}{}
 			seenEmails[client.Email] = struct{}{}
 			proxies = append(proxies, s.getProxies(subReq, inbound, client, host)...)
 			proxies = append(proxies, s.getProxies(subReq, inbound, client, host)...)
 		}
 		}
 	}
 	}
 	for _, ext := range externalLinks {
 	for _, ext := range externalLinks {
+		if ext.Enable {
+			hasEnabledClient = true
+		}
 		if !ext.Active {
 		if !ext.Active {
 			seenEmails[ext.Email] = struct{}{}
 			seenEmails[ext.Email] = struct{}{}
 			hasInactiveExternal = true
 			hasInactiveExternal = true
@@ -82,8 +90,25 @@ func (s *SubClashService) GetClash(subId string, host string) (string, string, e
 	for e := range seenEmails {
 	for e := range seenEmails {
 		emails = append(emails, e)
 		emails = append(emails, e)
 	}
 	}
+	slices.Sort(emails)
 	traffic, _ := subReq.AggregateTrafficByEmails(emails)
 	traffic, _ := subReq.AggregateTrafficByEmails(emails)
+	traffic.Enable = hasEnabledClient
 	header := fmt.Sprintf("upload=%d; download=%d; total=%d; expire=%d", traffic.Up, traffic.Down, traffic.Total, traffic.ExpiryTime/1000)
 	header := fmt.Sprintf("upload=%d; download=%d; total=%d; expire=%d", traffic.Up, traffic.Down, traffic.Total, traffic.ExpiryTime/1000)
+
+	if mode, remark := subReq.resolveInfoNodeRemark(subId, emails, traffic, len(proxies) > 0); mode != infoNodeNone {
+		dummyProxy := map[string]any{
+			"name":   remark,
+			"type":   "socks5",
+			"server": "127.0.0.1",
+			"port":   1080,
+		}
+		if mode == infoNodeExpired || mode == infoNodeDepleted {
+			proxies = []map[string]any{dummyProxy}
+		} else {
+			proxies = append([]map[string]any{dummyProxy}, proxies...)
+		}
+	}
+
 	if len(proxies) == 0 {
 	if len(proxies) == 0 {
 		return "", header, nil
 		return "", header, nil
 	}
 	}
@@ -92,6 +117,9 @@ func (s *SubClashService) GetClash(subId string, host string) (string, string, e
 
 
 	proxyNames := make([]string, 0, len(proxies)+1)
 	proxyNames := make([]string, 0, len(proxies)+1)
 	for _, proxy := range proxies {
 	for _, proxy := range proxies {
+		if isDummyProxy(proxy) && len(proxies) > 1 {
+			continue
+		}
 		if name, ok := proxy["name"].(string); ok && name != "" {
 		if name, ok := proxy["name"].(string); ok && name != "" {
 			proxyNames = append(proxyNames, name)
 			proxyNames = append(proxyNames, name)
 		}
 		}
@@ -152,6 +180,19 @@ func ensureUniqueProxyNames(proxies []map[string]any) {
 	}
 	}
 }
 }
 
 
+func isDummyProxy(proxy map[string]any) bool {
+	typ, _ := proxy["type"].(string)
+	server, _ := proxy["server"].(string)
+	var port int
+	switch p := proxy["port"].(type) {
+	case int:
+		port = p
+	case float64:
+		port = int(p)
+	}
+	return typ == "socks5" && server == "127.0.0.1" && port == 1080
+}
+
 func fallbackProxyName(proxy map[string]any, idx int) string {
 func fallbackProxyName(proxy map[string]any, idx int) string {
 	typ, _ := proxy["type"].(string)
 	typ, _ := proxy["type"].(string)
 	server, _ := proxy["server"].(string)
 	server, _ := proxy["server"].(string)

+ 247 - 0
internal/sub/json_info_node_test.go

@@ -0,0 +1,247 @@
+package sub
+
+import (
+	"fmt"
+	"strings"
+	"testing"
+	"time"
+
+	"github.com/mhsanaei/3x-ui/v3/internal/database"
+	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
+	"github.com/mhsanaei/3x-ui/v3/internal/web/service"
+	"github.com/mhsanaei/3x-ui/v3/internal/xray"
+)
+
+func TestSubJson_InfoNode_Active(t *testing.T) {
+	setupInfoNodeTestDB(t)
+	db := database.GetDB()
+
+	ib := &model.Inbound{
+		Id:             1,
+		UserId:         1,
+		Remark:         "Germany-VLESS",
+		Enable:         true,
+		Port:           443,
+		Protocol:       model.VLESS,
+		Settings:       `{"clients":[{"id":"c1-uuid","email":"[email protected]","subId":"sub-json","enable":true,"totalGB":10737418240}]}`,
+		StreamSettings: `{"network":"tcp","security":"none"}`,
+	}
+	if err := db.Create(ib).Error; err != nil {
+		t.Fatal(err)
+	}
+	rec := &model.ClientRecord{
+		Id:      1,
+		Email:   "[email protected]",
+		SubID:   "sub-json",
+		UUID:    "c1-uuid",
+		Enable:  true,
+		TotalGB: 10737418240,
+	}
+	if err := db.Create(rec).Error; err != nil {
+		t.Fatal(err)
+	}
+	if err := db.Create(&model.ClientInbound{InboundId: 1, ClientId: 1}).Error; err != nil {
+		t.Fatal(err)
+	}
+	if err := db.Create(&xray.ClientTraffic{
+		InboundId: 1,
+		Email:     "[email protected]",
+		Up:        1073741824,
+		Down:      1073741824,
+		Total:     10737418240,
+		Enable:    true,
+	}).Error; err != nil {
+		t.Fatal(err)
+	}
+
+	sub := NewSubService("{{EMAIL}}|📊{{TRAFFIC_LEFT}}")
+	sub.subInfoNodeEnable = true
+	jsonSvc := NewSubJsonService("", "", "", sub)
+
+	out, _, err := jsonSvc.GetJson("sub-json", "sub.example.com", false)
+	if err != nil {
+		t.Fatalf("GetJson: %v", err)
+	}
+
+	if !strings.Contains(out, "[email protected]|📊8.00GB") {
+		t.Fatalf("expected dummy remark in JSON remarks, got:\n%s", out)
+	}
+	if !strings.Contains(out, `"protocol": "socks"`) && !strings.Contains(out, `"protocol":"socks"`) {
+		t.Fatalf("expected socks outbound in JSON, got:\n%s", out)
+	}
+}
+
+func TestSubJson_InfoNode_Expired(t *testing.T) {
+	setupInfoNodeTestDB(t)
+	db := database.GetDB()
+
+	expiredTime := time.Now().Add(-24 * time.Hour).UnixMilli()
+	ib := &model.Inbound{
+		Id:             1,
+		UserId:         1,
+		Remark:         "Germany-VLESS",
+		Enable:         true,
+		Port:           443,
+		Protocol:       model.VLESS,
+		Settings:       fmt.Sprintf(`{"clients":[{"id":"c1-uuid","email":"[email protected]","subId":"sub-json-exp","enable":true,"expiryTime":%d}]}`, expiredTime),
+		StreamSettings: `{"network":"tcp","security":"none"}`,
+	}
+	if err := db.Create(ib).Error; err != nil {
+		t.Fatal(err)
+	}
+	rec := &model.ClientRecord{
+		Id:         1,
+		Email:      "[email protected]",
+		SubID:      "sub-json-exp",
+		UUID:       "c1-uuid",
+		Enable:     true,
+		ExpiryTime: expiredTime,
+	}
+	if err := db.Create(rec).Error; err != nil {
+		t.Fatal(err)
+	}
+	if err := db.Create(&model.ClientInbound{InboundId: 1, ClientId: 1}).Error; err != nil {
+		t.Fatal(err)
+	}
+	if err := db.Create(&xray.ClientTraffic{
+		InboundId:  1,
+		Email:      "[email protected]",
+		ExpiryTime: expiredTime,
+		Enable:     true,
+	}).Error; err != nil {
+		t.Fatal(err)
+	}
+
+	sub := NewSubService("{{INBOUND}}")
+	sub.subInfoNodeEnable = true
+	sub.subExpiredTemplate = service.DefaultSubExpiredTemplate
+	jsonSvc := NewSubJsonService("", "", "", sub)
+
+	out, _, err := jsonSvc.GetJson("sub-json-exp", "sub.example.com", false)
+	if err != nil {
+		t.Fatalf("GetJson: %v", err)
+	}
+
+	if !strings.Contains(out, "Expired") || !strings.Contains(out, "[email protected]") {
+		t.Fatalf("expected expired remark in JSON remarks, got:\n%s", out)
+	}
+	if strings.Contains(out, "Germany-VLESS") {
+		t.Fatalf("expired subscription must NOT contain working inbound in JSON, got:\n%s", out)
+	}
+}
+
+func TestSubJson_InfoNode_Depleted(t *testing.T) {
+	setupInfoNodeTestDB(t)
+	db := database.GetDB()
+
+	ib := &model.Inbound{
+		Id:             1,
+		UserId:         1,
+		Remark:         "Germany-VLESS",
+		Enable:         true,
+		Port:           443,
+		Protocol:       model.VLESS,
+		Settings:       `{"clients":[{"id":"c1-uuid","email":"[email protected]","subId":"sub-json-dep","enable":true,"totalGB":5368709120}]}`,
+		StreamSettings: `{"network":"tcp","security":"none"}`,
+	}
+	if err := db.Create(ib).Error; err != nil {
+		t.Fatal(err)
+	}
+	rec := &model.ClientRecord{
+		Id:      1,
+		Email:   "[email protected]",
+		SubID:   "sub-json-dep",
+		UUID:    "c1-uuid",
+		Enable:  true,
+		TotalGB: 5368709120,
+	}
+	if err := db.Create(rec).Error; err != nil {
+		t.Fatal(err)
+	}
+	if err := db.Create(&model.ClientInbound{InboundId: 1, ClientId: 1}).Error; err != nil {
+		t.Fatal(err)
+	}
+	if err := db.Create(&xray.ClientTraffic{
+		InboundId: 1,
+		Email:     "[email protected]",
+		Up:        3221225472,
+		Down:      2147483648,
+		Total:     5368709120,
+		Enable:    true,
+	}).Error; err != nil {
+		t.Fatal(err)
+	}
+
+	sub := NewSubService("{{INBOUND}}")
+	sub.subInfoNodeEnable = true
+	sub.subTrafficDepletedTemplate = service.DefaultSubTrafficDepletedTemplate
+	jsonSvc := NewSubJsonService("", "", "", sub)
+
+	out, _, err := jsonSvc.GetJson("sub-json-dep", "sub.example.com", false)
+	if err != nil {
+		t.Fatalf("GetJson: %v", err)
+	}
+
+	if !strings.Contains(out, "Traffic Depleted") || !strings.Contains(out, "[email protected]") {
+		t.Fatalf("expected depleted remark in JSON remarks, got:\n%s", out)
+	}
+	if strings.Contains(out, "Germany-VLESS") {
+		t.Fatalf("depleted subscription must NOT contain working inbound in JSON, got:\n%s", out)
+	}
+}
+
+func TestSubJson_InfoNode_StatusActive(t *testing.T) {
+	setupInfoNodeTestDB(t)
+	db := database.GetDB()
+
+	ib := &model.Inbound{
+		Id:             1,
+		UserId:         1,
+		Remark:         "Germany-VLESS",
+		Enable:         true,
+		Port:           443,
+		Protocol:       model.VLESS,
+		Settings:       `{"clients":[{"id":"c1-uuid","email":"[email protected]","subId":"sub-json-status","enable":true,"totalGB":10737418240}]}`,
+		StreamSettings: `{"network":"tcp","security":"none"}`,
+	}
+	if err := db.Create(ib).Error; err != nil {
+		t.Fatal(err)
+	}
+	rec := &model.ClientRecord{
+		Id:      1,
+		Email:   "[email protected]",
+		SubID:   "sub-json-status",
+		UUID:    "c1-uuid",
+		Enable:  true,
+		TotalGB: 10737418240,
+	}
+	if err := db.Create(rec).Error; err != nil {
+		t.Fatal(err)
+	}
+	if err := db.Create(&model.ClientInbound{InboundId: 1, ClientId: 1}).Error; err != nil {
+		t.Fatal(err)
+	}
+	if err := db.Create(&xray.ClientTraffic{
+		InboundId: 1,
+		Email:     "[email protected]",
+		Up:        100,
+		Down:      100,
+		Total:     10737418240,
+		Enable:    true,
+	}).Error; err != nil {
+		t.Fatal(err)
+	}
+
+	sub := NewSubService("{{EMAIL}}|{{STATUS_EMOJI}} {{STATUS}}")
+	sub.subInfoNodeEnable = true
+	jsonSvc := NewSubJsonService("", "", "", sub)
+
+	out, _, err := jsonSvc.GetJson("sub-json-status", "sub.example.com", false)
+	if err != nil {
+		t.Fatalf("GetJson: %v", err)
+	}
+
+	if !strings.Contains(out, "[email protected]|✅ active") {
+		t.Fatalf("expected '[email protected]|✅ active' in JSON remarks, got:\n%s", out)
+	}
+}

+ 45 - 0
internal/sub/json_service.go

@@ -83,6 +83,7 @@ func (s *SubJsonService) GetJson(subId string, host string, alwaysReturnArray bo
 
 
 	var header string
 	var header string
 	var hasInactiveExternal bool
 	var hasInactiveExternal bool
+	var hasEnabledClient bool
 
 
 	seenEmails := make(map[string]struct{})
 	seenEmails := make(map[string]struct{})
 	entries := make([]subConfigEntry, 0, len(inbounds))
 	entries := make([]subConfigEntry, 0, len(inbounds))
@@ -99,6 +100,9 @@ func (s *SubJsonService) GetJson(subId string, host string, alwaysReturnArray bo
 
 
 		var inboundConfigs []json_util.RawMessage
 		var inboundConfigs []json_util.RawMessage
 		for _, client := range clients {
 		for _, client := range clients {
+			if client.Enable {
+				hasEnabledClient = true
+			}
 			seenEmails[client.Email] = struct{}{}
 			seenEmails[client.Email] = struct{}{}
 			inboundConfigs = append(inboundConfigs, s.getConfig(subReq, inbound, client, host)...)
 			inboundConfigs = append(inboundConfigs, s.getConfig(subReq, inbound, client, host)...)
 		}
 		}
@@ -128,6 +132,9 @@ func (s *SubJsonService) GetJson(subId string, host string, alwaysReturnArray bo
 		configArray = append(configArray, entry.configs...)
 		configArray = append(configArray, entry.configs...)
 	}
 	}
 	for _, ext := range externalLinks {
 	for _, ext := range externalLinks {
+		if ext.Enable {
+			hasEnabledClient = true
+		}
 		if !ext.Active {
 		if !ext.Active {
 			seenEmails[ext.Email] = struct{}{}
 			seenEmails[ext.Email] = struct{}{}
 			hasInactiveExternal = true
 			hasInactiveExternal = true
@@ -162,8 +169,20 @@ func (s *SubJsonService) GetJson(subId string, host string, alwaysReturnArray bo
 	for e := range seenEmails {
 	for e := range seenEmails {
 		emails = append(emails, e)
 		emails = append(emails, e)
 	}
 	}
+	slices.Sort(emails)
 	traffic, _ := subReq.AggregateTrafficByEmails(emails)
 	traffic, _ := subReq.AggregateTrafficByEmails(emails)
+	traffic.Enable = hasEnabledClient
 	header = fmt.Sprintf("upload=%d; download=%d; total=%d; expire=%d", traffic.Up, traffic.Down, traffic.Total, traffic.ExpiryTime/1000)
 	header = fmt.Sprintf("upload=%d; download=%d; total=%d; expire=%d", traffic.Up, traffic.Down, traffic.Total, traffic.ExpiryTime/1000)
+
+	if mode, remark := subReq.resolveInfoNodeRemark(subId, emails, traffic, len(configArray) > 0); mode != infoNodeNone {
+		dummyConfig := s.genDummySocksConfig(remark)
+		if mode == infoNodeExpired || mode == infoNodeDepleted {
+			configArray = []json_util.RawMessage{dummyConfig}
+		} else {
+			configArray = append([]json_util.RawMessage{dummyConfig}, configArray...)
+		}
+	}
+
 	if len(configArray) == 0 {
 	if len(configArray) == 0 {
 		return "", header, nil
 		return "", header, nil
 	}
 	}
@@ -962,6 +981,32 @@ func (s *SubJsonService) genWireguard(inbound *model.Inbound, client model.Clien
 	return result
 	return result
 }
 }
 
 
+func (s *SubJsonService) genDummySocksConfig(remark string) json_util.RawMessage {
+	outbound := map[string]any{
+		"protocol": "socks",
+		"tag":      "proxy",
+		"settings": map[string]any{
+			"servers": []any{
+				map[string]any{
+					"address": "127.0.0.1",
+					"port":    1080,
+				},
+			},
+		},
+	}
+	rawOutbound, _ := json.Marshal(outbound)
+	newOutbounds := []json_util.RawMessage{rawOutbound}
+	newOutbounds = append(newOutbounds, s.defaultOutbounds...)
+
+	newConfigJson := make(map[string]any)
+	maps.Copy(newConfigJson, s.configJson)
+	newConfigJson["outbounds"] = newOutbounds
+	newConfigJson["remarks"] = remark
+
+	newConfig, _ := json.MarshalIndent(newConfigJson, "", "  ")
+	return newConfig
+}
+
 func mergeFinalMask(base any, extra map[string]any) map[string]any {
 func mergeFinalMask(base any, extra map[string]any) map[string]any {
 	merged := map[string]any{}
 	merged := map[string]any{}
 	if baseMap, ok := base.(map[string]any); ok {
 	if baseMap, ok := base.(map[string]any); ok {

+ 5 - 0
internal/sub/links.go

@@ -38,6 +38,11 @@ func (p *LinkProvider) SubLinksForSubId(host, subId string) ([]string, error) {
 func (p *LinkProvider) LinksForClient(host string, inbound *model.Inbound, email string) []string {
 func (p *LinkProvider) LinksForClient(host string, inbound *model.Inbound, email string) []string {
 	svc := p.build(host)
 	svc := p.build(host)
 	svc.projectThroughFallbackMaster(inbound)
 	svc.projectThroughFallbackMaster(inbound)
+	if endpoints := svc.hostEndpoints(inbound, "raw"); len(endpoints) > 0 {
+		if client, ok := svc.clientForLink(inbound, email); ok {
+			return splitLinkLines(svc.linkFromHosts(inbound, client, endpoints))
+		}
+	}
 	return splitLinkLines(svc.GetLink(inbound, email))
 	return splitLinkLines(svc.GetLink(inbound, email))
 }
 }
 
 

+ 21 - 0
internal/sub/links_test.go

@@ -2,7 +2,10 @@ package sub
 
 
 import (
 import (
 	"reflect"
 	"reflect"
+	"strings"
 	"testing"
 	"testing"
+
+	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
 )
 )
 
 
 func TestSplitLinkLines(t *testing.T) {
 func TestSplitLinkLines(t *testing.T) {
@@ -38,3 +41,21 @@ func TestSplitLinkLines_WhitespaceOnlyHasNoEntries(t *testing.T) {
 		t.Fatalf("splitLinkLines(whitespace) = %#v, want empty slice", got)
 		t.Fatalf("splitLinkLines(whitespace) = %#v, want empty slice", got)
 	}
 	}
 }
 }
+
+func TestLinksForClient_UsesHostEndpoints(t *testing.T) {
+	seedSubDB(t)
+	inbound := seedSubInbound(t, "s-gate", "gate", 4431, 1, `{"network":"tcp","security":"none"}`)
+	seedHost(t, &model.Host{
+		InboundId: inbound.Id, Remark: "public", Address: "proxy.example.com",
+		Port: 443, Security: "same",
+	})
+
+	links := NewLinkProvider().LinksForClient("req.example.com", inbound, "gate@e")
+
+	if len(links) != 1 {
+		t.Fatalf("links = %d, want 1: %v", len(links), links)
+	}
+	if !strings.Contains(links[0], "proxy.example.com:443") {
+		t.Fatalf("link = %q, want the host endpoint proxy.example.com:443", links[0])
+	}
+}

+ 1 - 1
internal/sub/remark_vars.go

@@ -625,7 +625,7 @@ func (s *SubService) effectiveTemplate(client model.Client) string {
 		s.usageShown = map[string]bool{}
 		s.usageShown = map[string]bool{}
 	}
 	}
 	key := templateInfoKey(client)
 	key := templateInfoKey(client)
-	if s.usageShown[key] {
+	if s.subInfoNodeEnable || s.usageShown[key] {
 		remove := firstLinkOnlyBodyTokens
 		remove := firstLinkOnlyBodyTokens
 		if s.showIdentityOnAllLinks {
 		if s.showIdentityOnAllLinks {
 			remove = usageInfoTokens
 			remove = usageInfoTokens

+ 128 - 24
internal/sub/service.go

@@ -44,10 +44,13 @@ type SubService struct {
 	subscriptionBody bool
 	subscriptionBody bool
 	// usageShown emits info once per subscription identity, including twins.
 	// usageShown emits info once per subscription identity, including twins.
 	// PrepareForRequest resets this per-request state.
 	// PrepareForRequest resets this per-request state.
-	usageShown             map[string]bool
-	showIdentityOnAllLinks bool
-	inboundService         service.InboundService
-	settingService         service.SettingService
+	usageShown                 map[string]bool
+	showIdentityOnAllLinks     bool
+	subInfoNodeEnable          bool
+	subExpiredTemplate         string
+	subTrafficDepletedTemplate string
+	inboundService             service.InboundService
+	settingService             service.SettingService
 	// nodesByID is populated per request from the Node table so
 	// nodesByID is populated per request from the Node table so
 	// resolveInboundAddress can return the node's address for any
 	// resolveInboundAddress can return the node's address for any
 	// inbound whose NodeID is set. Keeps the per-link host derivation
 	// inbound whose NodeID is set. Keeps the per-link host derivation
@@ -197,14 +200,29 @@ func (s *SubService) linkSettings(inbound *model.Inbound) map[string]any {
 // (the date formatter reads datepicker). Loading it only in getSubs left
 // (the date formatter reads datepicker). Loading it only in getSubs left
 // JSON/Clash with the zero value.
 // JSON/Clash with the zero value.
 func (s *SubService) loadRemarkSettings() {
 func (s *SubService) loadRemarkSettings() {
-	var err error
-	s.datepicker, err = s.settingService.GetDatepicker()
-	if err != nil {
+	if s.datepicker == "" {
 		s.datepicker = "gregorian"
 		s.datepicker = "gregorian"
 	}
 	}
-	s.showIdentityOnAllLinks, err = s.settingService.GetSubShowIdentityOnAllLinks()
-	if err != nil {
-		s.showIdentityOnAllLinks = false
+	if s.subExpiredTemplate == "" {
+		s.subExpiredTemplate = service.DefaultSubExpiredTemplate
+	}
+	if s.subTrafficDepletedTemplate == "" {
+		s.subTrafficDepletedTemplate = service.DefaultSubTrafficDepletedTemplate
+	}
+	if datepicker, err := s.settingService.GetDatepicker(); err == nil && datepicker != "" {
+		s.datepicker = datepicker
+	}
+	if enabled, err := s.settingService.GetSubShowIdentityOnAllLinks(); err == nil && enabled {
+		s.showIdentityOnAllLinks = enabled
+	}
+	if enabled, err := s.settingService.GetSubInfoNodeEnable(); err == nil && enabled {
+		s.subInfoNodeEnable = enabled
+	}
+	if tmpl, err := s.settingService.GetSubExpiredTemplate(); err == nil && tmpl != "" {
+		s.subExpiredTemplate = tmpl
+	}
+	if tmpl, err := s.settingService.GetSubTrafficDepletedTemplate(); err == nil && tmpl != "" {
+		s.subTrafficDepletedTemplate = tmpl
 	}
 	}
 }
 }
 
 
@@ -293,6 +311,70 @@ func (s *SubService) GetSubs(subId string, host string) ([]string, []string, int
 	return s.ForRequest(host).getSubs(subId)
 	return s.ForRequest(host).getSubs(subId)
 }
 }
 
 
+type infoNodeMode int
+
+const (
+	infoNodeNone infoNodeMode = iota
+	infoNodeActive
+	infoNodeExpired
+	infoNodeDepleted
+)
+
+func (s *SubService) resolveInfoNodeRemark(subId string, uniqueEmails []string, traffic xray.ClientTraffic, hasEntries bool) (infoNodeMode, string) {
+	if !s.subInfoNodeEnable || !s.subscriptionBody {
+		return infoNodeNone, ""
+	}
+	nowSec := time.Now().Unix()
+	isExpired := traffic.ExpiryTime > 0 && traffic.ExpiryTime/1000 <= nowSec
+	isDepleted := traffic.Total > 0 && (traffic.Up+traffic.Down) >= traffic.Total
+
+	primaryEmail := ""
+	if len(uniqueEmails) > 0 {
+		primaryEmail = uniqueEmails[0]
+	}
+	ctx := remarkContext{
+		client: model.Client{Email: primaryEmail, SubID: subId},
+		stats:  traffic,
+	}
+
+	if isExpired {
+		tmpl := s.subExpiredTemplate
+		if tmpl == "" {
+			tmpl = service.DefaultSubExpiredTemplate
+		}
+		remark := expandRemarkVars(tmpl, ctx)
+		if strings.TrimSpace(remark) == "" {
+			remark = "Expired"
+		}
+		return infoNodeExpired, remark
+	}
+
+	if isDepleted {
+		tmpl := s.subTrafficDepletedTemplate
+		if tmpl == "" {
+			tmpl = service.DefaultSubTrafficDepletedTemplate
+		}
+		remark := expandRemarkVars(tmpl, ctx)
+		if strings.TrimSpace(remark) == "" {
+			remark = "Traffic Depleted"
+		}
+		return infoNodeDepleted, remark
+	}
+
+	if hasEntries {
+		tmpl := s.remarkTemplate
+		if tmpl == "" {
+			tmpl = service.DefaultRemarkTemplate
+		}
+		remark := expandRemarkVars(tmpl, ctx)
+		if strings.TrimSpace(remark) != "" {
+			return infoNodeActive, remark
+		}
+	}
+
+	return infoNodeNone, ""
+}
+
 func (s *SubService) getSubs(subId string) ([]string, []string, int64, xray.ClientTraffic, error) {
 func (s *SubService) getSubs(subId string) ([]string, []string, int64, xray.ClientTraffic, error) {
 	var result []string
 	var result []string
 	var emails []string
 	var emails []string
@@ -360,8 +442,18 @@ func (s *SubService) getSubs(subId string) ([]string, []string, int64, xray.Clie
 	for e := range seenEmails {
 	for e := range seenEmails {
 		uniqueEmails = append(uniqueEmails, e)
 		uniqueEmails = append(uniqueEmails, e)
 	}
 	}
+	slices.Sort(uniqueEmails)
 	traffic, lastOnline := s.AggregateTrafficByEmails(uniqueEmails)
 	traffic, lastOnline := s.AggregateTrafficByEmails(uniqueEmails)
 	traffic.Enable = hasEnabledClient
 	traffic.Enable = hasEnabledClient
+
+	if mode, remark := s.resolveInfoNodeRemark(subId, uniqueEmails, traffic, len(result) > 0); mode != infoNodeNone {
+		dummyLink := fmt.Sprintf("socks://127.0.0.1:1080#%s", strings.ReplaceAll(url.QueryEscape(remark), "+", "%20"))
+		if mode == infoNodeExpired || mode == infoNodeDepleted {
+			return []string{dummyLink}, emails, lastOnline, traffic, nil
+		}
+		result = append([]string{dummyLink}, result...)
+	}
+
 	return result, emails, lastOnline, traffic, nil
 	return result, emails, lastOnline, traffic, nil
 }
 }
 
 
@@ -729,9 +821,9 @@ func amneziaWGConfigText(server *amneziawg.ServerSettings, client *model.Client,
 	if len(dns) > 0 {
 	if len(dns) > 0 {
 		fmt.Fprintf(&b, "DNS = %s\n", strings.Join(dns, ", "))
 		fmt.Fprintf(&b, "DNS = %s\n", strings.Join(dns, ", "))
 	}
 	}
-	if server.MTU > 0 {
-		fmt.Fprintf(&b, "MTU = %d\n", server.MTU)
-	}
+	// Always emitted: a missing MTU line leaves the client on its own 1420
+	// default and fragments the client-to-server direction once S4 passes 20.
+	fmt.Fprintf(&b, "MTU = %d\n", amneziawg.EffectiveMTU(server.MTU, server.S4))
 
 
 	fmt.Fprintf(&b, "Jc = %d\n", server.Jc)
 	fmt.Fprintf(&b, "Jc = %d\n", server.Jc)
 	fmt.Fprintf(&b, "Jmin = %d\n", server.Jmin)
 	fmt.Fprintf(&b, "Jmin = %d\n", server.Jmin)
@@ -820,12 +912,8 @@ func (s *SubService) genAmneziaWGLink(inbound *model.Inbound, email string) stri
 	return "vpn://" + base64.RawURLEncoding.EncodeToString([]byte(text))
 	return "vpn://" + base64.RawURLEncoding.EncodeToString([]byte(text))
 }
 }
 
 
-// genMtprotoLink builds a per-client Telegram proxy deep link for an mtproto
-// inbound: the server/port pair plus the client's own FakeTLS secret. The link
-// carries no remark fragment — Telegram proxy deep links have no name field, and
-// a trailing "#remark" is appended to the last query value by lenient parsers,
-// corrupting the server address. The remark is shown separately in the panel UI.
-// Returns "" when the client has no secret.
+// genMtprotoLink builds one Telegram link per advertised endpoint with the client's FakeTLS secret.
+// It omits remarks because lenient parsers fold a fragment into the last query value.
 func (s *SubService) genMtprotoLink(inbound *model.Inbound, email string) string {
 func (s *SubService) genMtprotoLink(inbound *model.Inbound, email string) string {
 	if inbound.Protocol != model.MTProto {
 	if inbound.Protocol != model.MTProto {
 		return ""
 		return ""
@@ -834,12 +922,28 @@ func (s *SubService) genMtprotoLink(inbound *model.Inbound, email string) string
 	if !ok || resolved.Secret == "" {
 	if !ok || resolved.Secret == "" {
 		return ""
 		return ""
 	}
 	}
-	params := map[string]string{
-		"server": s.resolveInboundAddress(inbound),
-		"port":   fmt.Sprintf("%d", inbound.Port),
-		"secret": resolved.Secret,
+	endpoints := []ShareEndpoint{s.inboundDefaultEndpoint(inbound)}
+	stream := unmarshalStreamSettings(inbound.StreamSettings)
+	if externalProxies, ok := stream["externalProxy"].([]any); ok && len(externalProxies) > 0 {
+		overrides := make([]ShareEndpoint, 0, len(externalProxies))
+		for _, raw := range externalProxies {
+			if ep, ok := raw.(map[string]any); ok {
+				overrides = append(overrides, externalProxyToEndpoint(ep))
+			}
+		}
+		if len(overrides) > 0 {
+			endpoints = overrides
+		}
+	}
+	links := make([]string, 0, len(endpoints))
+	for _, endpoint := range endpoints {
+		links = append(links, buildLinkWithParams("tg://proxy", map[string]string{
+			"server": endpoint.Address,
+			"port":   fmt.Sprintf("%d", endpoint.Port),
+			"secret": resolved.Secret,
+		}, ""))
 	}
 	}
-	return buildLinkWithParams("tg://proxy", params, "")
+	return strings.Join(links, "\n")
 }
 }
 
 
 // Protocol link generators are intentionally ordered as:
 // Protocol link generators are intentionally ordered as:

+ 41 - 1
internal/sub/service_amneziawg_test.go

@@ -3,6 +3,7 @@ package sub
 import (
 import (
 	"encoding/base64"
 	"encoding/base64"
 	"slices"
 	"slices"
+	"strconv"
 	"strings"
 	"strings"
 	"testing"
 	"testing"
 
 
@@ -201,7 +202,7 @@ func peerFields(t *testing.T, conf string) []string {
 		t.Fatalf("config has no [Peer] block:\n%s", conf)
 		t.Fatalf("config has no [Peer] block:\n%s", conf)
 	}
 	}
 	var got []string
 	var got []string
-	for _, line := range strings.Split(conf[idx:], "\n") {
+	for line := range strings.SplitSeq(conf[idx:], "\n") {
 		key := strings.TrimSpace(strings.SplitN(line, "=", 2)[0])
 		key := strings.TrimSpace(strings.SplitN(line, "=", 2)[0])
 		if slices.Contains(peerFieldOrder, key) {
 		if slices.Contains(peerFieldOrder, key) {
 			got = append(got, key)
 			got = append(got, key)
@@ -275,3 +276,42 @@ func TestAmneziaWGConfigTextRejectsNewlineInjection(t *testing.T) {
 		})
 		})
 	}
 	}
 }
 }
+
+// Guards an asymmetry: the server derives its MTU from S4, but a config with no
+// MTU line leaves the client at 1420 and fragments client-to-server only.
+func TestAmneziaWGConfigTextAlwaysCarriesTheServerMTU(t *testing.T) {
+	t.Parallel()
+
+	client := &model.Client{
+		Email:      "peer-1",
+		PrivateKey: "clientPrivateKeyBase64ValueForTests00000000=",
+		AllowedIPs: []string{"10.8.1.2/32"},
+	}
+	cases := []struct {
+		name      string
+		serverMTU int
+		s4        int
+		want      string
+	}{
+		{"unset falls back to the S4-aware default", 0, 27, "MTU = 1393"},
+		{"unset with no S4 keeps the plain default", 0, 0, "MTU = 1420"},
+		{"an explicit MTU wins", 1380, 27, "MTU = 1380"},
+	}
+	for _, tc := range cases {
+		t.Run(tc.name, func(t *testing.T) {
+			server := &amneziawg.ServerSettings{
+				PublicKey: "serverPubKeyBase64ValueForTests000000000000=",
+				MTU:       tc.serverMTU,
+				S4:        tc.s4,
+			}
+			got := amneziaWGConfigText(server, client, "203.0.113.7", 51820, "peer-1")
+			if !strings.Contains(got, tc.want+"\n") {
+				t.Errorf("expected %q in the client config\n%s", tc.want, got)
+			}
+			want := "MTU = " + strconv.Itoa(amneziawg.EffectiveMTU(tc.serverMTU, tc.s4))
+			if !strings.Contains(got, want+"\n") {
+				t.Errorf("client MTU must equal the server's effective MTU (%s)", want)
+			}
+		})
+	}
+}

+ 365 - 0
internal/sub/service_info_node_test.go

@@ -0,0 +1,365 @@
+package sub
+
+import (
+	"fmt"
+	"net/url"
+	"strings"
+	"testing"
+	"time"
+
+	"github.com/mhsanaei/3x-ui/v3/internal/database"
+	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
+	"github.com/mhsanaei/3x-ui/v3/internal/web/service"
+	"github.com/mhsanaei/3x-ui/v3/internal/xray"
+)
+
+func setupInfoNodeTestDB(t *testing.T) {
+	t.Helper()
+	if err := database.InitDB(t.TempDir() + "/test_infonode.db"); err != nil {
+		t.Fatalf("InitDB: %v", err)
+	}
+	t.Cleanup(func() {
+		_ = database.CloseDB()
+	})
+	db := database.GetDB()
+	if err := db.AutoMigrate(
+		&model.Inbound{},
+		&model.ClientRecord{},
+		&model.ClientInbound{},
+		&xray.ClientTraffic{},
+		&model.Setting{},
+	); err != nil {
+		t.Fatalf("AutoMigrate: %v", err)
+	}
+}
+
+func TestSubService_InfoNode_Active(t *testing.T) {
+	setupInfoNodeTestDB(t)
+	db := database.GetDB()
+
+	ib := &model.Inbound{
+		Id:             1,
+		UserId:         1,
+		Up:             0,
+		Down:           0,
+		Total:          0,
+		Remark:         "Germany-VLESS",
+		Enable:         true,
+		Port:           443,
+		Protocol:       model.VLESS,
+		Settings:       `{"clients":[{"id":"c1-uuid","email":"[email protected]","subId":"sub-active","enable":true,"totalGB":10737418240,"expiryTime":0}]}`,
+		StreamSettings: `{"network":"tcp","security":"none"}`,
+	}
+	if err := db.Create(ib).Error; err != nil {
+		t.Fatal(err)
+	}
+	rec := &model.ClientRecord{
+		Id:      1,
+		Email:   "[email protected]",
+		SubID:   "sub-active",
+		UUID:    "c1-uuid",
+		Enable:  true,
+		TotalGB: 10737418240, // 10 GB
+	}
+	if err := db.Create(rec).Error; err != nil {
+		t.Fatal(err)
+	}
+	if err := db.Create(&model.ClientInbound{InboundId: 1, ClientId: 1}).Error; err != nil {
+		t.Fatal(err)
+	}
+	if err := db.Create(&xray.ClientTraffic{
+		InboundId: 1,
+		Email:     "[email protected]",
+		Up:        1073741824, // 1 GB
+		Down:      1073741824, // 1 GB
+		Total:     10737418240,
+		Enable:    true,
+	}).Error; err != nil {
+		t.Fatal(err)
+	}
+
+	svc := NewSubService("{{EMAIL}}|📊{{TRAFFIC_LEFT}}")
+	svc.subInfoNodeEnable = true
+	svc.subscriptionBody = true
+
+	links, emails, _, traffic, err := svc.GetSubs("sub-active", "sub.example.com")
+	if err != nil {
+		t.Fatalf("GetSubs error: %v", err)
+	}
+	if len(links) != 2 {
+		t.Fatalf("expected 2 links (dummy info node + 1 vless link), got %d: %v", len(links), links)
+	}
+	if len(emails) != 1 || emails[0] != "[email protected]" {
+		t.Fatalf("emails = %v, want [[email protected]]", emails)
+	}
+	if !traffic.Enable {
+		t.Fatalf("traffic should be enabled")
+	}
+
+	// First link must be the dummy socks node
+	if !strings.HasPrefix(links[0], "socks://127.0.0.1:1080#") {
+		t.Fatalf("first link must be dummy socks node, got: %q", links[0])
+	}
+	decodedRemark, _ := url.QueryUnescape(strings.TrimPrefix(links[0], "socks://127.0.0.1:1080#"))
+	if !strings.Contains(decodedRemark, "[email protected]") || !strings.Contains(decodedRemark, "8.00GB") {
+		t.Fatalf("expected dummy remark to contain [email protected] and 8.00GB, got: %q", decodedRemark)
+	}
+
+	// Second link must be the clean vless link without traffic left tokens
+	if !strings.HasPrefix(links[1], "vless://") {
+		t.Fatalf("second link must be vless, got: %q", links[1])
+	}
+	if strings.Contains(links[1], "8.00GB") {
+		t.Fatalf("server link must have clean remark without usage stats, got: %q", links[1])
+	}
+}
+
+func TestSubService_InfoNode_Expired(t *testing.T) {
+	setupInfoNodeTestDB(t)
+	db := database.GetDB()
+
+	expiredTime := time.Now().Add(-24 * time.Hour).UnixMilli()
+	ib := &model.Inbound{
+		Id:             1,
+		UserId:         1,
+		Remark:         "Germany-VLESS",
+		Enable:         true,
+		Port:           443,
+		Protocol:       model.VLESS,
+		Settings:       fmt.Sprintf(`{"clients":[{"id":"c1-uuid","email":"[email protected]","subId":"sub-exp","enable":true,"expiryTime":%d}]}`, expiredTime),
+		StreamSettings: `{"network":"tcp","security":"none"}`,
+	}
+	if err := db.Create(ib).Error; err != nil {
+		t.Fatal(err)
+	}
+	rec := &model.ClientRecord{
+		Id:         1,
+		Email:      "[email protected]",
+		SubID:      "sub-exp",
+		UUID:       "c1-uuid",
+		Enable:     true,
+		ExpiryTime: expiredTime,
+	}
+	if err := db.Create(rec).Error; err != nil {
+		t.Fatal(err)
+	}
+	if err := db.Create(&model.ClientInbound{InboundId: 1, ClientId: 1}).Error; err != nil {
+		t.Fatal(err)
+	}
+	if err := db.Create(&xray.ClientTraffic{
+		InboundId:  1,
+		Email:      "[email protected]",
+		ExpiryTime: expiredTime,
+		Enable:     true,
+	}).Error; err != nil {
+		t.Fatal(err)
+	}
+
+	svc := NewSubService("{{INBOUND}}|{{EMAIL}}")
+	svc.subInfoNodeEnable = true
+	svc.subExpiredTemplate = service.DefaultSubExpiredTemplate
+	svc.subscriptionBody = true
+
+	links, _, _, _, err := svc.GetSubs("sub-exp", "sub.example.com")
+	if err != nil {
+		t.Fatalf("GetSubs error: %v", err)
+	}
+	if len(links) != 1 {
+		t.Fatalf("expected ONLY 1 link (dummy expired node), got %d: %v", len(links), links)
+	}
+	if !strings.HasPrefix(links[0], "socks://127.0.0.1:1080#") {
+		t.Fatalf("expired link must be dummy socks node, got: %q", links[0])
+	}
+	decodedRemark, _ := url.QueryUnescape(strings.TrimPrefix(links[0], "socks://127.0.0.1:1080#"))
+	if !strings.Contains(decodedRemark, "Expired") || !strings.Contains(decodedRemark, "[email protected]") {
+		t.Fatalf("expected expired remark, got: %q", decodedRemark)
+	}
+}
+
+func TestSubService_InfoNode_TrafficDepleted(t *testing.T) {
+	setupInfoNodeTestDB(t)
+	db := database.GetDB()
+
+	ib := &model.Inbound{
+		Id:             1,
+		UserId:         1,
+		Remark:         "Germany-VLESS",
+		Enable:         true,
+		Port:           443,
+		Protocol:       model.VLESS,
+		Settings:       `{"clients":[{"id":"c1-uuid","email":"[email protected]","subId":"sub-dep","enable":true,"totalGB":5368709120}]}`,
+		StreamSettings: `{"network":"tcp","security":"none"}`,
+	}
+	if err := db.Create(ib).Error; err != nil {
+		t.Fatal(err)
+	}
+	rec := &model.ClientRecord{
+		Id:      1,
+		Email:   "[email protected]",
+		SubID:   "sub-dep",
+		UUID:    "c1-uuid",
+		Enable:  true,
+		TotalGB: 5368709120, // 5 GB
+	}
+	if err := db.Create(rec).Error; err != nil {
+		t.Fatal(err)
+	}
+	if err := db.Create(&model.ClientInbound{InboundId: 1, ClientId: 1}).Error; err != nil {
+		t.Fatal(err)
+	}
+	if err := db.Create(&xray.ClientTraffic{
+		InboundId: 1,
+		Email:     "[email protected]",
+		Up:        3221225472, // 3 GB
+		Down:      2147483648, // 2 GB (total 5 GB used = depleted)
+		Total:     5368709120,
+		Enable:    true,
+	}).Error; err != nil {
+		t.Fatal(err)
+	}
+
+	svc := NewSubService("{{INBOUND}}|{{EMAIL}}")
+	svc.subInfoNodeEnable = true
+	svc.subTrafficDepletedTemplate = service.DefaultSubTrafficDepletedTemplate
+	svc.subscriptionBody = true
+
+	links, _, _, _, err := svc.GetSubs("sub-dep", "sub.example.com")
+	if err != nil {
+		t.Fatalf("GetSubs error: %v", err)
+	}
+	if len(links) != 1 {
+		t.Fatalf("expected ONLY 1 link (dummy depleted node), got %d: %v", len(links), links)
+	}
+	if !strings.HasPrefix(links[0], "socks://127.0.0.1:1080#") {
+		t.Fatalf("depleted link must be dummy socks node, got: %q", links[0])
+	}
+	decodedRemark, _ := url.QueryUnescape(strings.TrimPrefix(links[0], "socks://127.0.0.1:1080#"))
+	if !strings.Contains(decodedRemark, "Traffic Depleted") || !strings.Contains(decodedRemark, "[email protected]") {
+		t.Fatalf("expected depleted remark, got: %q", decodedRemark)
+	}
+}
+
+func TestSubService_GetSubs_NonSubscriptionBody_NoInfoNode(t *testing.T) {
+	setupInfoNodeTestDB(t)
+	db := database.GetDB()
+
+	ib := &model.Inbound{
+		Id:             1,
+		UserId:         1,
+		Remark:         "US-VLESS",
+		Enable:         true,
+		Port:           443,
+		Protocol:       model.VLESS,
+		Settings:       `{"clients":[{"id":"c1-uuid","email":"[email protected]","subId":"sub-panel","enable":true,"totalGB":10737418240}]}`,
+		StreamSettings: `{"network":"tcp","security":"none"}`,
+	}
+	if err := db.Create(ib).Error; err != nil {
+		t.Fatal(err)
+	}
+	rec := &model.ClientRecord{
+		Id:      1,
+		Email:   "[email protected]",
+		SubID:   "sub-panel",
+		UUID:    "c1-uuid",
+		Enable:  true,
+		TotalGB: 10737418240,
+	}
+	if err := db.Create(rec).Error; err != nil {
+		t.Fatal(err)
+	}
+	if err := db.Create(&model.ClientInbound{InboundId: 1, ClientId: 1}).Error; err != nil {
+		t.Fatal(err)
+	}
+	if err := db.Create(&xray.ClientTraffic{
+		InboundId: 1,
+		Email:     "[email protected]",
+		Up:        1073741824,
+		Down:      1073741824,
+		Total:     10737418240,
+		Enable:    true,
+	}).Error; err != nil {
+		t.Fatal(err)
+	}
+
+	// When called via GetSubs (e.g. from LinkProvider for admin QR / copy modals),
+	// subscriptionBody is false, so it should render the clean link and not add dummy nodes.
+	svc := NewSubService("{{INBOUND}}-{{EMAIL}}|📊{{TRAFFIC_LEFT}}|⏳{{DAYS_LEFT}}D")
+	svc.subInfoNodeEnable = true
+
+	links, _, _, _, err := svc.GetSubs("sub-panel", "sub.example.com")
+	if err != nil {
+		t.Fatalf("GetSubs error: %v", err)
+	}
+	if len(links) != 1 {
+		t.Fatalf("expected 1 clean link, got %d: %v", len(links), links)
+	}
+	if strings.HasPrefix(links[0], "socks://127.0.0.1:1080#") {
+		t.Fatalf("GetSubs must NOT return dummy socks node when subscriptionBody is false: %v", links[0])
+	}
+	if strings.Contains(links[0], "8.00GB") {
+		t.Fatalf("GetSubs must NOT contain snapshot usage stats in non-subscriptionBody context: %v", links[0])
+	}
+}
+
+func TestSubService_InfoNode_MultiClient_DeterministicPrimaryEmail(t *testing.T) {
+	setupInfoNodeTestDB(t)
+	db := database.GetDB()
+
+	ib := &model.Inbound{
+		Id:             1,
+		UserId:         1,
+		Remark:         "Germany-VLESS",
+		Enable:         true,
+		Port:           443,
+		Protocol:       model.VLESS,
+		Settings:       `{"clients":[{"id":"c1-uuid","email":"[email protected]","subId":"sub-multi","enable":true,"totalGB":10737418240},{"id":"c2-uuid","email":"[email protected]","subId":"sub-multi","enable":true,"totalGB":10737418240}]}`,
+		StreamSettings: `{"network":"tcp","security":"none"}`,
+	}
+	if err := db.Create(ib).Error; err != nil {
+		t.Fatal(err)
+	}
+	if err := db.Create(&model.ClientRecord{
+		Id: 1, Email: "[email protected]", SubID: "sub-multi", UUID: "c1-uuid", Enable: true, TotalGB: 10737418240,
+	}).Error; err != nil {
+		t.Fatal(err)
+	}
+	if err := db.Create(&model.ClientRecord{
+		Id: 2, Email: "[email protected]", SubID: "sub-multi", UUID: "c2-uuid", Enable: true, TotalGB: 10737418240,
+	}).Error; err != nil {
+		t.Fatal(err)
+	}
+	if err := db.Create(&model.ClientInbound{InboundId: 1, ClientId: 1}).Error; err != nil {
+		t.Fatal(err)
+	}
+	if err := db.Create(&model.ClientInbound{InboundId: 1, ClientId: 2}).Error; err != nil {
+		t.Fatal(err)
+	}
+	if err := db.Create(&xray.ClientTraffic{
+		InboundId: 1, Email: "[email protected]", Up: 100, Down: 100, Total: 10737418240, Enable: true,
+	}).Error; err != nil {
+		t.Fatal(err)
+	}
+	if err := db.Create(&xray.ClientTraffic{
+		InboundId: 1, Email: "[email protected]", Up: 100, Down: 100, Total: 10737418240, Enable: true,
+	}).Error; err != nil {
+		t.Fatal(err)
+	}
+
+	svc := NewSubService("{{EMAIL}}")
+	svc.subInfoNodeEnable = true
+	svc.subscriptionBody = true
+
+	for i := 0; i < 5; i++ {
+		links, _, _, _, err := svc.GetSubs("sub-multi", "sub.example.com")
+		if err != nil {
+			t.Fatalf("GetSubs error: %v", err)
+		}
+		if len(links) < 1 || !strings.HasPrefix(links[0], "socks://127.0.0.1:1080#") {
+			t.Fatalf("expected dummy socks node, got: %v", links)
+		}
+		decodedRemark, _ := url.QueryUnescape(strings.TrimPrefix(links[0], "socks://127.0.0.1:1080#"))
+		if decodedRemark != "[email protected]" {
+			t.Fatalf("expected primaryEmail '[email protected]' (sorted alphabetically), got %q", decodedRemark)
+		}
+	}
+}

+ 65 - 0
internal/sub/service_mtproto_test.go

@@ -65,6 +65,71 @@ func TestGenMtprotoLinkNoSecret(t *testing.T) {
 	}
 	}
 }
 }
 
 
+func TestGetSubsMtprotoUsesHostEndpoint(t *testing.T) {
+	initSubDB(t)
+	db := database.GetDB()
+
+	inbound := &model.Inbound{
+		Listen:   "127.0.0.1",
+		Port:     4060,
+		Protocol: model.MTProto,
+		Enable:   true,
+		Tag:      "mt-public-port",
+		Settings: `{"clients":[{"email":"u@mt","enable":true,"subId":"sub-public-port","secret":"` + mtprotoTestSecret + `"}]}`,
+	}
+	if err := db.Create(inbound).Error; err != nil {
+		t.Fatalf("create inbound: %v", err)
+	}
+	if err := db.Create(&model.Host{
+		InboundId: inbound.Id,
+		Remark:    "public",
+		Address:   "proxy.example.com",
+		Port:      443,
+		Security:  "same",
+	}).Error; err != nil {
+		t.Fatalf("create host: %v", err)
+	}
+	client := &model.ClientRecord{Email: "u@mt", SubID: "sub-public-port", Enable: true, Secret: mtprotoTestSecret}
+	if err := db.Create(client).Error; err != nil {
+		t.Fatalf("create client: %v", err)
+	}
+	if err := db.Create(&model.ClientInbound{ClientId: client.Id, InboundId: inbound.Id}).Error; err != nil {
+		t.Fatalf("attach client: %v", err)
+	}
+
+	links, _, _, _, err := NewSubService("").GetSubs(client.SubID, "sub.example.com")
+	if err != nil {
+		t.Fatalf("GetSubs: %v", err)
+	}
+	if len(links) != 1 {
+		t.Fatalf("links = %d, want 1: %v", len(links), links)
+	}
+	u, err := url.Parse(links[0])
+	if err != nil {
+		t.Fatalf("parse link: %v", err)
+	}
+	if got := u.Query().Get("server"); got != "proxy.example.com" {
+		t.Fatalf("server = %q, want proxy.example.com", got)
+	}
+	if got := u.Query().Get("port"); got != "443" {
+		t.Fatalf("port = %q, want public host port 443", got)
+	}
+	clientLinks := NewLinkProvider().LinksForClient("sub.example.com", inbound, client.Email)
+	if len(clientLinks) != 1 {
+		t.Fatalf("client links = %d, want 1: %v", len(clientLinks), clientLinks)
+	}
+	clientURL, err := url.Parse(clientLinks[0])
+	if err != nil {
+		t.Fatalf("parse client link: %v", err)
+	}
+	if got := clientURL.Query().Get("server"); got != "proxy.example.com" {
+		t.Fatalf("client link server = %q, want proxy.example.com", got)
+	}
+	if got := clientURL.Query().Get("port"); got != "443" {
+		t.Fatalf("client link port = %q, want 443", got)
+	}
+}
+
 // Regression: an mtproto inbound must resolve for a subscription id the same way
 // Regression: an mtproto inbound must resolve for a subscription id the same way
 // every other client-bearing protocol does. It was previously dropped from the
 // every other client-bearing protocol does. It was previously dropped from the
 // getInboundsBySubId protocol allowlist, so multi-client MTProto subscriptions
 // getInboundsBySubId protocol allowlist, so multi-client MTProto subscriptions

+ 11 - 2
internal/web/controller/inbound.go

@@ -59,6 +59,15 @@ func (a *InboundController) broadcastInboundsUpdate(userId int) {
 	websocket.BroadcastInbounds(inbounds)
 	websocket.BroadcastInbounds(inbounds)
 }
 }
 
 
+// inboundServiceFor tells the service whether this request is a master's
+// node-sync push, so the node stores the row instead of re-judging it.
+func (a *InboundController) inboundServiceFor(c *gin.Context) *service.InboundService {
+	svc := a.inboundService
+	scope, _ := c.Get("api_token_scope")
+	svc.FromNodeSync = scope == model.ApiScopeNodeSync
+	return &svc
+}
+
 // initRouter initializes the routes for inbound-related operations.
 // initRouter initializes the routes for inbound-related operations.
 func (a *InboundController) initRouter(g *gin.RouterGroup) {
 func (a *InboundController) initRouter(g *gin.RouterGroup) {
 	g.GET("/list", a.getInbounds)
 	g.GET("/list", a.getInbounds)
@@ -162,7 +171,7 @@ func (a *InboundController) addInbound(c *gin.Context) {
 		inbound.NodeID = nil
 		inbound.NodeID = nil
 	}
 	}
 
 
-	inbound, needRestart, err := a.inboundService.AddInbound(inbound)
+	inbound, needRestart, err := a.inboundServiceFor(c).AddInbound(inbound)
 	if err != nil {
 	if err != nil {
 		jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
 		jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
 		return
 		return
@@ -242,7 +251,7 @@ func (a *InboundController) updateInbound(c *gin.Context) {
 	if inbound.NodeID != nil && *inbound.NodeID == 0 {
 	if inbound.NodeID != nil && *inbound.NodeID == 0 {
 		inbound.NodeID = nil
 		inbound.NodeID = nil
 	}
 	}
-	inbound, needRestart, err := a.inboundService.UpdateInbound(inbound)
+	inbound, needRestart, err := a.inboundServiceFor(c).UpdateInbound(inbound)
 	if err != nil {
 	if err != nil {
 		jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
 		jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
 		return
 		return

+ 93 - 0
internal/web/controller/inbound_node_sync_test.go

@@ -0,0 +1,93 @@
+package controller
+
+import (
+	"net/http"
+	"net/http/httptest"
+	"net/url"
+	"path/filepath"
+	"strconv"
+	"strings"
+	"testing"
+
+	"github.com/gin-gonic/gin"
+
+	"github.com/mhsanaei/3x-ui/v3/internal/database"
+	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
+	"github.com/mhsanaei/3x-ui/v3/internal/util/crypto"
+	"github.com/mhsanaei/3x-ui/v3/internal/web/runtime"
+)
+
+// A sub-node stores whatever the master pushes. A master row whose certificate
+// predates the TLS guard must still land, or the node silently falls out of sync.
+func TestNodeSyncPushSkipsOperatorTLSGuard(t *testing.T) {
+	gin.SetMode(gin.TestMode)
+	dbDir := t.TempDir()
+	t.Setenv("XUI_DB_FOLDER", dbDir)
+	if err := database.InitDB(filepath.Join(dbDir, "x-ui.db")); err != nil {
+		t.Fatalf("InitDB: %v", err)
+	}
+	t.Cleanup(func() { _ = database.CloseDB() })
+	prev := runtime.GetManager()
+	runtime.SetManager(runtime.NewManager(runtime.LocalDeps{APIPort: func() int { return 0 }, SetNeedRestart: func() {}}))
+	t.Cleanup(func() { runtime.SetManager(prev) })
+
+	for name, scope := range map[string]string{"node-sync": model.ApiScopeNodeSync, "admin": model.ApiScopeAdmin} {
+		row := &model.ApiToken{Name: name, Token: crypto.HashTokenSHA256(name + "-token"), Enabled: true, Scope: scope}
+		if err := database.GetDB().Create(row).Error; err != nil {
+			t.Fatalf("seed %s token: %v", name, err)
+		}
+	}
+
+	engine := gin.New()
+	a := &APIController{}
+	api := engine.Group("/panel/api")
+	api.Use(a.checkAPIAuth, a.enforceTokenScope)
+	NewInboundController(api.Group("/inbounds"))
+
+	const legacyStream = `{"network":"tcp","security":"tls","tlsSettings":{"certificates":[{"certificateFile":"","keyFile":"","certificate":[],"key":[]}]}}`
+	add := func(t *testing.T, token string, port int) string {
+		t.Helper()
+		form := url.Values{
+			"protocol":       {"vless"},
+			"port":           {strconv.Itoa(port)},
+			"tag":            {"tls-legacy-" + strconv.Itoa(port)},
+			"enable":         {"true"},
+			"settings":       {`{"clients":[]}`},
+			"streamSettings": {legacyStream},
+		}
+		req := httptest.NewRequest(http.MethodPost, "/panel/api/inbounds/add", strings.NewReader(form.Encode()))
+		req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+		req.Header.Set("Authorization", "Bearer "+token)
+		w := httptest.NewRecorder()
+		engine.ServeHTTP(w, req)
+		return w.Body.String()
+	}
+	rows := func(t *testing.T, tag string) int64 {
+		t.Helper()
+		var n int64
+		if err := database.GetDB().Model(&model.Inbound{}).Where("tag = ?", tag).Count(&n).Error; err != nil {
+			t.Fatalf("count %s: %v", tag, err)
+		}
+		return n
+	}
+
+	t.Run("a master push lands on the node", func(t *testing.T) {
+		body := add(t, "node-sync-token", 45001)
+		if !strings.Contains(body, `"success":true`) {
+			t.Fatalf("node-sync add rejected: %s", body)
+		}
+		if got := rows(t, "tls-legacy-45001"); got != 1 {
+			t.Fatalf("stored rows = %d, want 1", got)
+		}
+	})
+
+	t.Run("an operator token is still held to the guard", func(t *testing.T) {
+		body := add(t, "admin-token", 45002)
+		if !strings.Contains(body, `"success":false`) || !strings.Contains(body, "TLS") {
+			t.Fatalf("admin add should fail on TLS, got: %s", body)
+		}
+		if got := rows(t, "tls-legacy-45002"); got != 0 {
+			t.Fatalf("stored rows = %d, want 0", got)
+		}
+	})
+}

+ 9 - 6
internal/web/entity/entity.go

@@ -30,12 +30,15 @@ type AllSetting struct {
 	IpLimitAllowlist  string `json:"ipLimitAllowlist" form:"ipLimitAllowlist"`
 	IpLimitAllowlist  string `json:"ipLimitAllowlist" form:"ipLimitAllowlist"`
 	PanelOutbound     string `json:"panelOutbound" form:"panelOutbound"`
 	PanelOutbound     string `json:"panelOutbound" form:"panelOutbound"`
 
 
-	PageSize                  int    `json:"pageSize" form:"pageSize" validate:"gte=0,lte=1000"`
-	ExpireDiff                int    `json:"expireDiff" form:"expireDiff" validate:"gte=0"`
-	TrafficDiff               int    `json:"trafficDiff" form:"trafficDiff" validate:"gte=0,lte=100"`
-	RemarkTemplate            string `json:"remarkTemplate" form:"remarkTemplate"`
-	SubShowIdentityOnAllLinks bool   `json:"subShowIdentityOnAllLinks" form:"subShowIdentityOnAllLinks"`
-	Datepicker                string `json:"datepicker" form:"datepicker"`
+	PageSize                   int    `json:"pageSize" form:"pageSize" validate:"gte=0,lte=1000"`
+	ExpireDiff                 int    `json:"expireDiff" form:"expireDiff" validate:"gte=0"`
+	TrafficDiff                int    `json:"trafficDiff" form:"trafficDiff" validate:"gte=0,lte=100"`
+	RemarkTemplate             string `json:"remarkTemplate" form:"remarkTemplate"`
+	SubShowIdentityOnAllLinks  bool   `json:"subShowIdentityOnAllLinks" form:"subShowIdentityOnAllLinks"`
+	SubInfoNodeEnable          bool   `json:"subInfoNodeEnable" form:"subInfoNodeEnable"`
+	SubExpiredTemplate         string `json:"subExpiredTemplate" form:"subExpiredTemplate"`
+	SubTrafficDepletedTemplate string `json:"subTrafficDepletedTemplate" form:"subTrafficDepletedTemplate"`
+	Datepicker                 string `json:"datepicker" form:"datepicker"`
 
 
 	TgBotEnable     bool   `json:"tgBotEnable" form:"tgBotEnable"`
 	TgBotEnable     bool   `json:"tgBotEnable" form:"tgBotEnable"`
 	TgBotToken      string `json:"tgBotToken" form:"tgBotToken"`
 	TgBotToken      string `json:"tgBotToken" form:"tgBotToken"`

+ 2 - 1
internal/web/runtime/local.go

@@ -161,7 +161,8 @@ func (l *Local) updateMtprotoInbound(ctx context.Context, oldIb, newIb *model.In
 // AmneziaWG-to-AmneziaWG edit, Manager.Ensure's own fingerprint comparison
 // AmneziaWG-to-AmneziaWG edit, Manager.Ensure's own fingerprint comparison
 // can reconfigure the running embedded Device in place via IpcSet instead
 // can reconfigure the running embedded Device in place via IpcSet instead
 // of always rebuilding it (see internal/amneziawgnet.Manager.ensureLocked --
 // of always rebuilding it (see internal/amneziawgnet.Manager.ensureLocked --
-// only an address/MTU change forces a rebuild there, not a peer edit).
+// only an address or effective-MTU change forces a rebuild there, S4
+// included, not a peer edit).
 //
 //
 // Every exit path below only touches the embedded Device via
 // Every exit path below only touches the embedded Device via
 // amneziawgnet.GetManager() -- none of it rebuilds Xray's own config, which
 // amneziawgnet.GetManager() -- none of it rebuilds Xray's own config, which

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

@@ -34,6 +34,9 @@ import (
 type InboundService struct {
 type InboundService struct {
 	clientService   ClientService
 	clientService   ClientService
 	fallbackService FallbackService
 	fallbackService FallbackService
+	// FromNodeSync marks a master push: the row was validated where the operator
+	// acted, and a node that refuses it only falls out of sync.
+	FromNodeSync bool
 }
 }
 
 
 func normalizeTrafficResetDay(day int) int {
 func normalizeTrafficResetDay(day int) int {
@@ -57,6 +60,11 @@ func normalizeInboundShareAddress(inbound *model.Inbound) {
 	if inbound == nil {
 	if inbound == nil {
 		return
 		return
 	}
 	}
+	if inbound.Protocol == model.MTProto {
+		inbound.ShareAddrStrategy = "listen"
+		inbound.ShareAddr = ""
+		return
+	}
 	inbound.ShareAddrStrategy = normalizeInboundShareAddrStrategy(inbound.ShareAddrStrategy)
 	inbound.ShareAddrStrategy = normalizeInboundShareAddrStrategy(inbound.ShareAddrStrategy)
 	if addr, err := normalizeInboundShareHost(inbound.ShareAddr); err == nil {
 	if addr, err := normalizeInboundShareHost(inbound.ShareAddr); err == nil {
 		inbound.ShareAddr = addr
 		inbound.ShareAddr = addr
@@ -69,6 +77,11 @@ func normalizeInboundShareAddressStrict(inbound *model.Inbound) error {
 	if inbound == nil {
 	if inbound == nil {
 		return nil
 		return nil
 	}
 	}
+	if inbound.Protocol == model.MTProto {
+		inbound.ShareAddrStrategy = "listen"
+		inbound.ShareAddr = ""
+		return nil
+	}
 	inbound.ShareAddrStrategy = normalizeInboundShareAddrStrategy(inbound.ShareAddrStrategy)
 	inbound.ShareAddrStrategy = normalizeInboundShareAddrStrategy(inbound.ShareAddrStrategy)
 	addr, err := normalizeInboundShareHost(inbound.ShareAddr)
 	addr, err := normalizeInboundShareHost(inbound.ShareAddr)
 	if err != nil {
 	if err != nil {
@@ -113,6 +126,17 @@ func normalizeInboundShareHost(raw string) (string, error) {
 	return host, nil
 	return host, nil
 }
 }
 
 
+func legacyMtprotoShareAddr(inbound *model.Inbound) string {
+	if inbound == nil || inbound.Protocol != model.MTProto || strings.TrimSpace(inbound.ShareAddrStrategy) != "custom" {
+		return ""
+	}
+	addr, err := normalizeInboundShareHost(inbound.ShareAddr)
+	if err != nil {
+		return ""
+	}
+	return addr
+}
+
 func normalizeInboundShareAddressColumns(tx *gorm.DB) error {
 func normalizeInboundShareAddressColumns(tx *gorm.DB) error {
 	if tx == nil || !tx.Migrator().HasColumn(&model.Inbound{}, "share_addr_strategy") {
 	if tx == nil || !tx.Migrator().HasColumn(&model.Inbound{}, "share_addr_strategy") {
 		return nil
 		return nil
@@ -589,6 +613,64 @@ func canonicalizeStreamNetworkKey(streamSettings string) string {
 	return string(out)
 	return string(out)
 }
 }
 
 
+// validateInboundTLSCertificates rejects incomplete TLS credentials before a save
+// can restart Xray. File paths belong to the node, so only presence is checked.
+func validateInboundTLSCertificates(streamSettings string) error {
+	if strings.TrimSpace(streamSettings) == "" {
+		return nil
+	}
+	var stream struct {
+		Security    string          `json:"security"`
+		TLSSettings json.RawMessage `json:"tlsSettings"`
+	}
+	if err := json.Unmarshal([]byte(streamSettings), &stream); err != nil {
+		return common.NewError("Invalid inbound stream settings: ", err)
+	}
+	if !strings.EqualFold(stream.Security, "tls") {
+		return nil
+	}
+	var settings struct {
+		Certificates []struct {
+			CertificateFile string   `json:"certificateFile"`
+			KeyFile         string   `json:"keyFile"`
+			Certificate     []string `json:"certificate"`
+			Key             []string `json:"key"`
+			Usage           string   `json:"usage"`
+		} `json:"certificates"`
+	}
+	if len(stream.TLSSettings) > 0 {
+		if err := json.Unmarshal(stream.TLSSettings, &settings); err != nil {
+			return common.NewError("Invalid inbound TLS settings: ", err)
+		}
+	}
+	hasServerCertificate := false
+	for i, cert := range settings.Certificates {
+		// Match Xray's file-over-inline precedence for each credential.
+		certificate := cert.CertificateFile
+		if certificate == "" {
+			certificate = strings.Join(cert.Certificate, "\n")
+		}
+		if strings.TrimSpace(certificate) == "" {
+			return common.NewErrorf("TLS certificate %d is missing. Configure a certificate file path or certificate content before saving the inbound.", i+1)
+		}
+		if strings.EqualFold(cert.Usage, "verify") {
+			continue
+		}
+		key := cert.KeyFile
+		if key == "" {
+			key = strings.Join(cert.Key, "\n")
+		}
+		if strings.TrimSpace(key) == "" {
+			return common.NewErrorf("TLS certificate %d is missing its private key. Configure a private key file path or private key content before saving the inbound.", i+1)
+		}
+		hasServerCertificate = true
+	}
+	if !hasServerCertificate {
+		return common.NewError("TLS requires a server certificate and private key. Configure an encipherment or issue certificate before saving the inbound.")
+	}
+	return nil
+}
+
 // finalMaskRealityTcpMasks returns the stream's finalmask.tcp masks when the
 // finalMaskRealityTcpMasks returns the stream's finalmask.tcp masks when the
 // stream uses REALITY security, or nil otherwise. A non-empty result means
 // stream uses REALITY security, or nil otherwise. A non-empty result means
 // this stream carries the finalmask+REALITY combination that panics
 // this stream carries the finalmask+REALITY combination that panics
@@ -943,9 +1025,15 @@ func (s *InboundService) normalizeMtprotoXrayPort(inbound *model.Inbound, oldSet
 // Returns the created inbound, whether Xray needs restart, and any error.
 // Returns the created inbound, whether Xray needs restart, and any error.
 func (s *InboundService) AddInbound(inbound *model.Inbound) (*model.Inbound, bool, error) {
 func (s *InboundService) AddInbound(inbound *model.Inbound) (*model.Inbound, bool, error) {
 	inbound.Id = 0
 	inbound.Id = 0
+	legacyShareAddr := legacyMtprotoShareAddr(inbound)
 	inbound.TrafficResetDay = normalizeTrafficResetDay(inbound.TrafficResetDay)
 	inbound.TrafficResetDay = normalizeTrafficResetDay(inbound.TrafficResetDay)
 	// Normalize streamSettings based on protocol
 	// Normalize streamSettings based on protocol
 	s.normalizeStreamSettings(inbound)
 	s.normalizeStreamSettings(inbound)
+	if !s.FromNodeSync {
+		if err := validateInboundTLSCertificates(inbound.StreamSettings); err != nil {
+			return inbound, false, err
+		}
+	}
 	if err := validateFinalMaskRealityCombo(inbound.StreamSettings); err != nil {
 	if err := validateFinalMaskRealityCombo(inbound.StreamSettings); err != nil {
 		return inbound, false, err
 		return inbound, false, err
 	}
 	}
@@ -1120,6 +1208,9 @@ func (s *InboundService) AddInbound(inbound *model.Inbound) (*model.Inbound, boo
 		if _, err := database.CreateHostsFromExternalProxy(tx, inbound.Id, inbound.StreamSettings); err != nil {
 		if _, err := database.CreateHostsFromExternalProxy(tx, inbound.Id, inbound.StreamSettings); err != nil {
 			return err
 			return err
 		}
 		}
+		if err := database.CreateHostFromMtprotoCustomShareAddr(tx, inbound.Id, legacyShareAddr); err != nil {
+			return err
+		}
 		if inbound.NodeID != nil {
 		if inbound.NodeID != nil {
 			nodeID := *inbound.NodeID
 			nodeID := *inbound.NodeID
 			if err := (&NodeService{}).EnsureInboundTagAllowedTx(tx, nodeID, inbound.Tag); err != nil {
 			if err := (&NodeService{}).EnsureInboundTagAllowedTx(tx, nodeID, inbound.Tag); err != nil {
@@ -1463,6 +1554,7 @@ func (s *InboundService) SetInboundEnable(id int, enable bool) (bool, error) {
 }
 }
 
 
 func (s *InboundService) UpdateInbound(inbound *model.Inbound) (*model.Inbound, bool, error) {
 func (s *InboundService) UpdateInbound(inbound *model.Inbound) (*model.Inbound, bool, error) {
+	legacyShareAddr := legacyMtprotoShareAddr(inbound)
 	inbound.TrafficResetDay = normalizeTrafficResetDay(inbound.TrafficResetDay)
 	inbound.TrafficResetDay = normalizeTrafficResetDay(inbound.TrafficResetDay)
 	// Normalize streamSettings based on protocol
 	// Normalize streamSettings based on protocol
 	s.normalizeStreamSettings(inbound)
 	s.normalizeStreamSettings(inbound)
@@ -1494,6 +1586,15 @@ func (s *InboundService) UpdateInbound(inbound *model.Inbound) (*model.Inbound,
 	if err != nil {
 	if err != nil {
 		return inbound, false, err
 		return inbound, false, err
 	}
 	}
+	// Grandfather a row that was already stored incomplete so it stays editable;
+	// only a save that breaks a previously valid TLS block is refused.
+	if !s.FromNodeSync {
+		if err := validateInboundTLSCertificates(inbound.StreamSettings); err != nil {
+			if validateInboundTLSCertificates(oldInbound.StreamSettings) == nil {
+				return inbound, false, err
+			}
+		}
+	}
 	// Restore the stored NodeID before the port-conflict check so a node inbound
 	// Restore the stored NodeID before the port-conflict check so a node inbound
 	// stays scoped to its own node (the payload's nodeId is unreliable, often absent).
 	// stays scoped to its own node (the payload's nodeId is unreliable, often absent).
 	inbound.NodeID = oldInbound.NodeID
 	inbound.NodeID = oldInbound.NodeID
@@ -1634,6 +1735,9 @@ func (s *InboundService) UpdateInbound(inbound *model.Inbound) (*model.Inbound,
 			}
 			}
 			oldInbound.ShareAddrStrategy = inbound.ShareAddrStrategy
 			oldInbound.ShareAddrStrategy = inbound.ShareAddrStrategy
 			oldInbound.ShareAddr = inbound.ShareAddr
 			oldInbound.ShareAddr = inbound.ShareAddr
+			if err := database.CreateHostFromMtprotoCustomShareAddr(tx, inbound.Id, legacyShareAddr); err != nil {
+				return err
+			}
 		}
 		}
 		if oldTagWasAuto && inbound.Tag == tag {
 		if oldTagWasAuto && inbound.Tag == tag {
 			inbound.Tag = ""
 			inbound.Tag = ""

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

@@ -35,7 +35,7 @@ func durableTestInbound(nodeID *int, tag string, port int) *model.Inbound {
 		Enable:         true,
 		Enable:         true,
 		Port:           port,
 		Port:           port,
 		Protocol:       model.VLESS,
 		Protocol:       model.VLESS,
-		StreamSettings: `{"network":"tcp","security":"tls"}`,
+		StreamSettings: `{"network":"tcp","security":"tls","tlsSettings":{"certificates":[{"certificateFile":"/node/cert.pem","keyFile":"/node/key.pem"}]}}`,
 		Settings:       `{"clients":[],"decryption":"none"}`,
 		Settings:       `{"clients":[],"decryption":"none"}`,
 	}
 	}
 }
 }

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

@@ -101,3 +101,75 @@ func TestAddInbound_NoExternalProxyCreatesNoHosts(t *testing.T) {
 		t.Fatalf("host count = %d, want 0", count)
 		t.Fatalf("host count = %d, want 0", count)
 	}
 	}
 }
 }
+
+func TestAddInboundImportConvertsMtprotoCustomShareAddrToHost(t *testing.T) {
+	setupConflictDB(t)
+	inbound := &model.Inbound{
+		UserId: 1, Tag: "mt-import", Port: 4060, Protocol: model.MTProto,
+		Settings:       `{"clients":[{"email":"mt-user","enable":true,"secret":"ee0123456789abcdef0123456789abcdef"}]}`,
+		StreamSettings: `{}`, ShareAddrStrategy: "custom", ShareAddr: "proxy.example.com",
+	}
+	created, _, err := (&InboundService{}).AddInbound(inbound)
+	if err != nil {
+		t.Fatalf("AddInbound: %v", err)
+	}
+	if created.ShareAddrStrategy != "listen" || created.ShareAddr != "" {
+		t.Fatalf("share fields = (%q, %q), want (listen, empty)", created.ShareAddrStrategy, created.ShareAddr)
+	}
+	var hosts []model.Host
+	if err := database.GetDB().Where("inbound_id = ?", created.Id).Find(&hosts).Error; err != nil {
+		t.Fatalf("load hosts: %v", err)
+	}
+	if len(hosts) != 1 || hosts[0].Address != "proxy.example.com" || hosts[0].Port != 0 {
+		t.Fatalf("hosts = %+v, want one inherited-port proxy.example.com host", hosts)
+	}
+}
+
+func TestAddInboundImportDropsInvalidMtprotoCustomShareAddr(t *testing.T) {
+	setupConflictDB(t)
+	inbound := &model.Inbound{
+		UserId: 1, Tag: "mt-bad-import", Port: 4061, Protocol: model.MTProto,
+		Settings:       `{"clients":[{"email":"mt-bad","enable":true,"secret":"ee0123456789abcdef0123456789abcdef"}]}`,
+		StreamSettings: `{}`, ShareAddrStrategy: "custom", ShareAddr: "https://proxy.example.com/path",
+	}
+	created, _, err := (&InboundService{}).AddInbound(inbound)
+	if err != nil {
+		t.Fatalf("AddInbound: %v", err)
+	}
+	var count int64
+	if err := database.GetDB().Model(&model.Host{}).Where("inbound_id = ?", created.Id).Count(&count).Error; err != nil {
+		t.Fatalf("count hosts: %v", err)
+	}
+	if count != 0 {
+		t.Fatalf("host count = %d, want 0", count)
+	}
+}
+
+func TestUpdateInboundConvertsMtprotoCustomShareAddrToHost(t *testing.T) {
+	setupConflictDB(t)
+	seedInboundConflict(t, "mt-update", "127.0.0.1", 4062, model.MTProto, `{}`,
+		`{"clients":[{"email":"mt-upd","enable":true,"secret":"ee0123456789abcdef0123456789abcdef"}]}`)
+
+	var existing model.Inbound
+	if err := database.GetDB().Where("tag = ?", "mt-update").First(&existing).Error; err != nil {
+		t.Fatalf("read seeded row: %v", err)
+	}
+	update := existing
+	update.ShareAddrStrategy = "custom"
+	update.ShareAddr = "edge.example.com"
+	updated, _, err := (&InboundService{}).UpdateInbound(&update)
+	if err != nil {
+		t.Fatalf("UpdateInbound: %v", err)
+	}
+	if updated.ShareAddrStrategy != "listen" || updated.ShareAddr != "" {
+		t.Fatalf("share fields = (%q, %q), want (listen, empty)", updated.ShareAddrStrategy, updated.ShareAddr)
+	}
+
+	var hosts []model.Host
+	if err := database.GetDB().Where("inbound_id = ?", existing.Id).Find(&hosts).Error; err != nil {
+		t.Fatalf("load hosts: %v", err)
+	}
+	if len(hosts) != 1 || hosts[0].Address != "edge.example.com" || hosts[0].Port != 0 {
+		t.Fatalf("hosts = %+v, want one inherited-port edge.example.com host", hosts)
+	}
+}

+ 185 - 0
internal/web/service/inbound_tls_test.go

@@ -0,0 +1,185 @@
+package service
+
+import (
+	"reflect"
+	"strings"
+	"testing"
+
+	"github.com/mhsanaei/3x-ui/v3/internal/database"
+	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
+)
+
+func TestValidateInboundTLSCertificates(t *testing.T) {
+	tests := []struct {
+		name           string
+		streamSettings string
+		wantErr        bool
+	}{
+		{"empty stream", "", false},
+		{"whitespace stream", " \t\n", false},
+		{"none ignores stale TLS settings", `{"security":"none","tlsSettings":{"certificates":[{}]}}`, false},
+		{"reality needs no TLS certificate", `{"security":"reality","realitySettings":{}}`, false},
+		{"missing TLS settings", `{"security":"tls"}`, true},
+		{"uppercase TLS security", `{"security":"TLS","tlsSettings":{}}`, true},
+		{"mixed-case TLS security", `{"security":"Tls","tlsSettings":{}}`, true},
+		{"null TLS settings", `{"security":"tls","tlsSettings":null}`, true},
+		{"missing certificates", `{"security":"tls","tlsSettings":{}}`, true},
+		{"null certificates", `{"security":"tls","tlsSettings":{"certificates":null}}`, true},
+		{"empty certificates", `{"security":"tls","tlsSettings":{"certificates":[]}}`, true},
+		{"null certificate row", `{"security":"tls","tlsSettings":{"certificates":[null]}}`, true},
+		{"empty default file fields", `{"security":"tls","tlsSettings":{"certificates":[{"certificateFile":"","keyFile":""}]}}`, true},
+		{"empty default inline fields", `{"security":"tls","tlsSettings":{"certificates":[{"certificate":[],"key":[]}]}}`, true},
+		{"whitespace file fields", `{"security":"tls","tlsSettings":{"certificates":[{"certificateFile":" \t","keyFile":" \n"}]}}`, true},
+		{"whitespace inline certificate", `{"security":"tls","tlsSettings":{"certificates":[{"certificate":[" ","\t"],"key":["private key"]}]}}`, true},
+		{"whitespace inline key", `{"security":"tls","tlsSettings":{"certificates":[{"certificate":["certificate"],"key":[" ","\n"]}]}}`, true},
+		{"missing private key", `{"security":"tls","tlsSettings":{"certificates":[{"certificateFile":"/node/cert.pem"}]}}`, true},
+		{"missing certificate", `{"security":"tls","tlsSettings":{"certificates":[{"keyFile":"/node/key.pem"}]}}`, true},
+		{"verify only", `{"security":"tls","tlsSettings":{"certificates":[{"usage":"verify","certificateFile":"/node/ca.pem"}]}}`, true},
+		{"verify with private key still needs server certificate", `{"security":"tls","tlsSettings":{"certificates":[{"usage":"verify","certificateFile":"/node/ca.pem","keyFile":"/node/key.pem"}]}}`, true},
+		{"issue needs private key", `{"security":"tls","tlsSettings":{"certificates":[{"usage":"issue","certificateFile":"/node/ca.pem"}]}}`, true},
+		{"file credentials with default usage", `{"security":"tls","tlsSettings":{"certificates":[{"certificateFile":"/node/cert.pem","keyFile":"/node/key.pem"}]}}`, false},
+		{"inline credentials", `{"security":"tls","tlsSettings":{"certificates":[{"certificate":["certificate"],"key":["private key"]}]}}`, false},
+		{"certificate file and inline key", `{"security":"tls","tlsSettings":{"certificates":[{"certificateFile":"/node/cert.pem","key":["private key"]}]}}`, false},
+		{"inline certificate and key file", `{"security":"tls","tlsSettings":{"certificates":[{"certificate":["certificate"],"keyFile":"/node/key.pem"}]}}`, false},
+		{"encipherment usage", `{"security":"tls","tlsSettings":{"certificates":[{"usage":"encipherment","certificateFile":"/node/cert.pem","keyFile":"/node/key.pem"}]}}`, false},
+		{"issue usage", `{"security":"tls","tlsSettings":{"certificates":[{"usage":"issue","certificateFile":"/node/ca.pem","keyFile":"/node/ca-key.pem"}]}}`, false},
+		{"unknown usage defaults to encipherment like Xray", `{"security":"tls","tlsSettings":{"certificates":[{"usage":"custom","certificateFile":"/node/cert.pem","keyFile":"/node/key.pem"}]}}`, false},
+		{"verify and server certificates", `{"security":"tls","tlsSettings":{"certificates":[{"usage":"verify","certificateFile":"/node/ca.pem"},{"certificateFile":"/node/cert.pem","keyFile":"/node/key.pem"}]}}`, false},
+		{"verify usage is case insensitive", `{"security":"tls","tlsSettings":{"certificates":[{"usage":"VERIFY","certificateFile":"/node/ca.pem"},{"certificateFile":"/node/cert.pem","keyFile":"/node/key.pem"}]}}`, false},
+		{"empty extra certificate row", `{"security":"tls","tlsSettings":{"certificates":[{"certificateFile":"/node/cert.pem","keyFile":"/node/key.pem"},{}]}}`, true},
+		{"empty extra verify certificate", `{"security":"tls","tlsSettings":{"certificates":[{"certificateFile":"/node/cert.pem","keyFile":"/node/key.pem"},{"usage":"verify"}]}}`, true},
+		{"whitespace certificate file overrides inline content", `{"security":"tls","tlsSettings":{"certificates":[{"certificateFile":" ","certificate":["certificate"],"key":["private key"]}]}}`, true},
+		{"whitespace key file overrides inline content", `{"security":"tls","tlsSettings":{"certificates":[{"certificate":["certificate"],"keyFile":" ","key":["private key"]}]}}`, true},
+		{"malformed stream", `{"security":"tls"`, true},
+		{"malformed TLS settings", `{"security":"tls","tlsSettings":"invalid"}`, true},
+		{"malformed certificate list", `{"security":"tls","tlsSettings":{"certificates":{}}}`, true},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			err := validateInboundTLSCertificates(tt.streamSettings)
+			if (err != nil) != tt.wantErr {
+				t.Fatalf("validateInboundTLSCertificates() error = %v, wantErr %v", err, tt.wantErr)
+			}
+		})
+	}
+}
+
+func TestValidateInboundTLSCertificatesIdentifiesIncompleteRow(t *testing.T) {
+	err := validateInboundTLSCertificates(`{"security":"tls","tlsSettings":{"certificates":[{"certificateFile":"/node/cert.pem","keyFile":"/node/key.pem"},{"certificateFile":"/node/other.pem"}]}}`)
+	if err == nil || !strings.Contains(err.Error(), "TLS certificate 2") || !strings.Contains(err.Error(), "private key") {
+		t.Fatalf("expected actionable error for the second certificate's private key, got %v", err)
+	}
+}
+
+func TestAddInboundRejectsMissingTLSCertificates(t *testing.T) {
+	setupConflictDB(t)
+	mgr := useTestRuntimeManager(t)
+	fake := &fakeNodeRuntime{}
+	mgr.SetLocalRuntimeOverride(fake)
+
+	inbound := &model.Inbound{
+		Tag:            "tls-missing-44310",
+		Enable:         true,
+		Listen:         "0.0.0.0",
+		Port:           44310,
+		Protocol:       model.VLESS,
+		StreamSettings: `{"network":"tcp","security":"tls","tlsSettings":{"certificates":[{"certificateFile":"","keyFile":""}]}}`,
+		Settings:       `{"clients":[]}`,
+	}
+	_, needRestart, err := (&InboundService{}).AddInbound(inbound)
+	if err == nil || !strings.Contains(err.Error(), "TLS") {
+		t.Fatalf("AddInbound: expected TLS validation error, got %v", err)
+	}
+	if needRestart {
+		t.Fatal("AddInbound: rejected TLS configuration requested a restart")
+	}
+	var count int64
+	if err := database.GetDB().Model(&model.Inbound{}).Count(&count).Error; err != nil {
+		t.Fatalf("count inbounds: %v", err)
+	}
+	if count != 0 {
+		t.Fatalf("AddInbound: rejected TLS configuration created %d rows", count)
+	}
+	if fake.addInbound.Load() != 0 || fake.updateInbound.Load() != 0 || fake.delInbound.Load() != 0 {
+		t.Fatal("AddInbound: rejected TLS configuration reached the runtime")
+	}
+}
+
+func TestUpdateInboundRejectsMissingTLSCertificates(t *testing.T) {
+	setupConflictDB(t)
+	mgr := useTestRuntimeManager(t)
+	fake := &fakeNodeRuntime{}
+	mgr.SetLocalRuntimeOverride(fake)
+
+	seedInboundConflict(t, "tls-existing-44311", "0.0.0.0", 44311, model.VLESS,
+		`{"network":"tcp","security":"tls","tlsSettings":{"certificates":[{"certificateFile":"/node/cert.pem","keyFile":"/node/key.pem"}]}}`, `{"clients":[]}`)
+	var existing model.Inbound
+	if err := database.GetDB().Where("tag = ?", "tls-existing-44311").First(&existing).Error; err != nil {
+		t.Fatalf("load existing inbound: %v", err)
+	}
+	update := existing
+	update.Remark = "must not be saved"
+	update.Port = 44312
+	update.StreamSettings = `{"network":"tcp","security":"tls","tlsSettings":{"certificates":[{"certificateFile":"/node/cert.pem"}]}}`
+	_, needRestart, err := (&InboundService{}).UpdateInbound(&update)
+	if err == nil || !strings.Contains(err.Error(), "TLS") {
+		t.Fatalf("UpdateInbound: expected TLS validation error, got %v", err)
+	}
+	if needRestart {
+		t.Fatal("UpdateInbound: rejected TLS configuration requested a restart")
+	}
+	var reloaded model.Inbound
+	if err := database.GetDB().First(&reloaded, existing.Id).Error; err != nil {
+		t.Fatalf("reload existing inbound: %v", err)
+	}
+	if !reflect.DeepEqual(reloaded, existing) {
+		t.Fatal("UpdateInbound: rejected TLS configuration changed the stored inbound")
+	}
+	if fake.addInbound.Load() != 0 || fake.updateInbound.Load() != 0 || fake.delInbound.Load() != 0 {
+		t.Fatal("UpdateInbound: rejected TLS configuration reached the runtime")
+	}
+}
+
+// The panel used to seed a TLS inbound with an all-empty certificate, so rows in
+// that shape predate the guard and must stay editable — see UpdateInbound.
+func TestUpdateInboundAllowsUntouchedLegacyTLSCertificates(t *testing.T) {
+	const legacyStream = `{"network":"tcp","security":"tls","tlsSettings":{"certificates":[{"certificateFile":"","keyFile":"","certificate":[],"key":[]}]}}`
+
+	tests := []struct {
+		name           string
+		streamSettings string
+	}{
+		{"remark-only edit resends the stored block", legacyStream},
+		{"node push re-encodes the same block", `{"network":"tcp","security":"tls","tlsSettings":{"certificates":[{"key":[],"certificate":[],"keyFile":"","certificateFile":""}]}}`},
+		{"a partial fix to the stored credentials is tolerated", `{"network":"tcp","security":"tls","tlsSettings":{"certificates":[{"certificateFile":"/node/cert.pem"}]}}`},
+		{"completing the credentials is accepted", `{"network":"tcp","security":"tls","tlsSettings":{"certificates":[{"certificateFile":"/node/cert.pem","keyFile":"/node/key.pem"}]}}`},
+	}
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			setupConflictDB(t)
+			mgr := useTestRuntimeManager(t)
+			mgr.SetLocalRuntimeOverride(&fakeNodeRuntime{})
+
+			seedInboundConflict(t, "tls-legacy-44321", "0.0.0.0", 44321, model.VLESS, legacyStream, `{"clients":[]}`)
+			var existing model.Inbound
+			if err := database.GetDB().Where("tag = ?", "tls-legacy-44321").First(&existing).Error; err != nil {
+				t.Fatalf("load legacy inbound: %v", err)
+			}
+
+			update := existing
+			update.Remark = "renamed"
+			update.StreamSettings = tt.streamSettings
+			if _, _, err := (&InboundService{}).UpdateInbound(&update); err != nil {
+				t.Fatalf("UpdateInbound: %v", err)
+			}
+			var reloaded model.Inbound
+			if err := database.GetDB().First(&reloaded, existing.Id).Error; err != nil {
+				t.Fatalf("reload inbound: %v", err)
+			}
+			if reloaded.Remark != "renamed" {
+				t.Fatalf("UpdateInbound: remark = %q, want %q", reloaded.Remark, "renamed")
+			}
+		})
+	}
+}

+ 12 - 0
internal/web/service/inbound_update_tag_test.go

@@ -204,3 +204,15 @@ func TestNormalizeInboundShareAddressStrict_RequiresHostOnly(t *testing.T) {
 		})
 		})
 	}
 	}
 }
 }
+
+func TestNormalizeInboundShareAddressStrictDropsMtprotoCustomAddress(t *testing.T) {
+	inbound := &model.Inbound{
+		Protocol: model.MTProto, ShareAddrStrategy: "custom", ShareAddr: "proxy.example.com",
+	}
+	if err := normalizeInboundShareAddressStrict(inbound); err != nil {
+		t.Fatalf("normalizeInboundShareAddressStrict: %v", err)
+	}
+	if inbound.ShareAddrStrategy != "listen" || inbound.ShareAddr != "" {
+		t.Fatalf("share fields = (%q, %q), want (listen, empty)", inbound.ShareAddrStrategy, inbound.ShareAddr)
+	}
+}

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

@@ -18,7 +18,7 @@ func TestProbeRejectsOversizedStatusBody(t *testing.T) {
 		w.Header().Set("Content-Type", "application/json")
 		w.Header().Set("Content-Type", "application/json")
 		_, _ = w.Write([]byte(`{"success":true,"obj":{"cpuPct":1,"panelVersion":"`))
 		_, _ = w.Write([]byte(`{"success":true,"obj":{"cpuPct":1,"panelVersion":"`))
 		pad := strings.Repeat("x", 1<<20)
 		pad := strings.Repeat("x", 1<<20)
-		for i := 0; i < 3; i++ {
+		for range 3 {
 			_, _ = w.Write([]byte(pad))
 			_, _ = w.Write([]byte(pad))
 		}
 		}
 		_, _ = w.Write([]byte(`"}}`))
 		_, _ = w.Write([]byte(`"}}`))

+ 4 - 0
internal/web/service/panel/api_token.go

@@ -124,6 +124,10 @@ func (s *ApiTokenService) RecreateByName(name string) (*ApiTokenView, error) {
 	if name == "" {
 	if name == "" {
 		return nil, common.NewError("token name is required")
 		return nil, common.NewError("token name is required")
 	}
 	}
+	// Same column, same limit as Create: the CLI now feeds this operator input.
+	if len(name) > 64 {
+		return nil, common.NewError("token name must be 64 characters or fewer")
+	}
 	plaintext := random.Seq(apiTokenLength)
 	plaintext := random.Seq(apiTokenLength)
 	row := &model.ApiToken{Name: name, Token: crypto.HashTokenSHA256(plaintext), Enabled: true}
 	row := &model.ApiToken{Name: name, Token: crypto.HashTokenSHA256(plaintext), Enabled: true}
 	if err := database.GetDB().Transaction(func(tx *gorm.DB) error {
 	if err := database.GetDB().Transaction(func(tx *gorm.DB) error {

+ 25 - 0
internal/web/service/panel/api_token_test.go

@@ -2,6 +2,7 @@ package panel
 
 
 import (
 import (
 	"errors"
 	"errors"
+	"strings"
 	"testing"
 	"testing"
 
 
 	"gorm.io/gorm"
 	"gorm.io/gorm"
@@ -68,6 +69,30 @@ func TestRecreateByNamePreservesTokenWhenReplacementFails(t *testing.T) {
 	}
 	}
 }
 }
 
 
+// Create caps the name at 64 characters; RecreateByName writes the same column
+// and now takes operator input from -tokenName, so it must cap it too.
+func TestRecreateByNameRejectsOverlongName(t *testing.T) {
+	t.Setenv("XUI_DB_FOLDER", t.TempDir())
+	if err := database.InitDB(config.GetDBPath()); err != nil {
+		t.Fatalf("init db: %v", err)
+	}
+	t.Cleanup(func() { _ = database.CloseDB() })
+
+	const wantErr = "token name must be 64 characters or fewer"
+
+	svc := ApiTokenService{}
+	_, err := svc.RecreateByName(strings.Repeat("n", 65))
+	if err == nil {
+		t.Fatal("expected a 65-character token name to be rejected")
+	}
+	if got := strings.TrimSpace(err.Error()); got != wantErr {
+		t.Fatalf("error = %q, want %q — any other error would pass a bare nil check", got, wantErr)
+	}
+	if _, err := svc.RecreateByName(strings.Repeat("n", 64)); err != nil {
+		t.Fatalf("64 characters is the documented limit, got: %v", err)
+	}
+}
+
 func TestRecreateByNameKeepsOneToken(t *testing.T) {
 func TestRecreateByNameKeepsOneToken(t *testing.T) {
 	t.Setenv("XUI_DB_FOLDER", t.TempDir())
 	t.Setenv("XUI_DB_FOLDER", t.TempDir())
 	if err := database.InitDB(config.GetDBPath()); err != nil {
 	if err := database.InitDB(config.GetDBPath()); err != nil {

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

@@ -2336,11 +2336,11 @@ func resolveGeofileTag(client *http.Client, latestURL string) (string, error) {
 // redirect target.
 // redirect target.
 func geofileTagFromLocation(location string) (string, error) {
 func geofileTagFromLocation(location string) (string, error) {
 	const marker = "/releases/download/"
 	const marker = "/releases/download/"
-	idx := strings.Index(location, marker)
-	if idx < 0 {
+	_, after, ok := strings.Cut(location, marker)
+	if !ok {
 		return "", common.NewErrorf("unexpected release redirect %q", location)
 		return "", common.NewErrorf("unexpected release redirect %q", location)
 	}
 	}
-	tag, _, found := strings.Cut(location[idx+len(marker):], "/")
+	tag, _, found := strings.Cut(after, "/")
 	if !found || tag == "" {
 	if !found || tag == "" {
 		return "", common.NewErrorf("unexpected release redirect %q", location)
 		return "", common.NewErrorf("unexpected release redirect %q", location)
 	}
 	}

+ 22 - 5
internal/web/service/setting.go

@@ -34,11 +34,13 @@ import (
 var xrayTemplateConfig string
 var xrayTemplateConfig string
 
 
 const (
 const (
-	DefaultSubClashUserAgentRegex = `(?i)(clash|mihomo)`
-	DefaultSubJsonUserAgentRegex  = ``
-	DefaultRemarkTemplate         = "{{INBOUND}}-{{EMAIL}}|📊{{TRAFFIC_LEFT}}|⏳{{DAYS_LEFT}}D"
-	DefaultTrustedProxyCIDRs      = "127.0.0.1/32,::1/128"
-	maxRegexLength                = 2048
+	DefaultSubClashUserAgentRegex     = `(?i)(clash|mihomo)`
+	DefaultSubJsonUserAgentRegex      = ``
+	DefaultRemarkTemplate             = "{{INBOUND}}-{{EMAIL}}|📊{{TRAFFIC_LEFT}}|⏳{{DAYS_LEFT}}D"
+	DefaultSubExpiredTemplate         = "⛔ {{EMAIL}} | Expired: {{EXPIRE_DATE}}"
+	DefaultSubTrafficDepletedTemplate = "🚫 {{EMAIL}} | Traffic Depleted | {{TRAFFIC_USED}}/{{TRAFFIC_TOTAL}}"
+	DefaultTrustedProxyCIDRs          = "127.0.0.1/32,::1/128"
+	maxRegexLength                    = 2048
 )
 )
 
 
 var defaultValueMap = map[string]string{
 var defaultValueMap = map[string]string{
@@ -70,6 +72,9 @@ var defaultValueMap = map[string]string{
 	"trafficDiff":                 "0",
 	"trafficDiff":                 "0",
 	"remarkTemplate":              DefaultRemarkTemplate,
 	"remarkTemplate":              DefaultRemarkTemplate,
 	"subShowIdentityOnAllLinks":   "false",
 	"subShowIdentityOnAllLinks":   "false",
+	"subInfoNodeEnable":           "false",
+	"subExpiredTemplate":          DefaultSubExpiredTemplate,
+	"subTrafficDepletedTemplate":  DefaultSubTrafficDepletedTemplate,
 	"timeLocation":                "Local",
 	"timeLocation":                "Local",
 	"tgBotEnable":                 "false",
 	"tgBotEnable":                 "false",
 	"tgBotToken":                  "",
 	"tgBotToken":                  "",
@@ -676,6 +681,18 @@ func (s *SettingService) GetSubShowIdentityOnAllLinks() (bool, error) {
 	return s.getBool("subShowIdentityOnAllLinks")
 	return s.getBool("subShowIdentityOnAllLinks")
 }
 }
 
 
+func (s *SettingService) GetSubInfoNodeEnable() (bool, error) {
+	return s.getBool("subInfoNodeEnable")
+}
+
+func (s *SettingService) GetSubExpiredTemplate() (string, error) {
+	return s.getString("subExpiredTemplate")
+}
+
+func (s *SettingService) GetSubTrafficDepletedTemplate() (string, error) {
+	return s.getString("subTrafficDepletedTemplate")
+}
+
 func (s *SettingService) GetSecret() ([]byte, error) {
 func (s *SettingService) GetSecret() ([]byte, error) {
 	secret, err := s.getString("secret")
 	secret, err := s.getString("secret")
 	if secret == "" || secret == defaultValueMap["secret"] {
 	if secret == "" || secret == defaultValueMap["secret"] {

+ 44 - 0
internal/web/service/setting_sub_info_node_test.go

@@ -0,0 +1,44 @@
+package service
+
+import (
+	"testing"
+)
+
+func TestSubInfoNodeSettingsDefaultsAndPersists(t *testing.T) {
+	setupSettingTestDB(t)
+	s := &SettingService{}
+
+	settings, err := s.GetAllSetting()
+	if err != nil {
+		t.Fatal(err)
+	}
+	if settings.SubInfoNodeEnable {
+		t.Fatal("expected default SubInfoNodeEnable false")
+	}
+	if settings.SubExpiredTemplate != DefaultSubExpiredTemplate {
+		t.Fatalf("expected default SubExpiredTemplate %q, got %q", DefaultSubExpiredTemplate, settings.SubExpiredTemplate)
+	}
+	if settings.SubTrafficDepletedTemplate != DefaultSubTrafficDepletedTemplate {
+		t.Fatalf("expected default SubTrafficDepletedTemplate %q, got %q", DefaultSubTrafficDepletedTemplate, settings.SubTrafficDepletedTemplate)
+	}
+
+	settings.SubInfoNodeEnable = true
+	settings.SubExpiredTemplate = "custom expired"
+	settings.SubTrafficDepletedTemplate = "custom depleted"
+	if err := s.UpdateAllSetting(settings, SecretClears{}); err != nil {
+		t.Fatal(err)
+	}
+
+	gotEnabled, err := s.GetSubInfoNodeEnable()
+	if err != nil || !gotEnabled {
+		t.Fatalf("expected true, got %v, err %v", gotEnabled, err)
+	}
+	gotExp, err := s.GetSubExpiredTemplate()
+	if err != nil || gotExp != "custom expired" {
+		t.Fatalf("expected 'custom expired', got %q, err %v", gotExp, err)
+	}
+	gotDep, err := s.GetSubTrafficDepletedTemplate()
+	if err != nil || gotDep != "custom depleted" {
+		t.Fatalf("expected 'custom depleted', got %q, err %v", gotDep, err)
+	}
+}

+ 1 - 1
internal/web/service/tgbot/tgbot_send.go

@@ -85,7 +85,7 @@ func pageMessage(message string, limit int) []string {
 	}
 	}
 
 
 	pages := make([]string, 0)
 	pages := make([]string, 0)
-	for _, block := range strings.Split(message, "\r\n\r\n") {
+	for block := range strings.SplitSeq(message, "\r\n\r\n") {
 		for _, page := range splitMessageLines(block, limit) {
 		for _, page := range splitMessageLines(block, limit) {
 			last := len(pages) - 1
 			last := len(pages) - 1
 			if last >= 0 && len(pages[last])+len("\r\n\r\n")+len(page) <= limit {
 			if last >= 0 && len(pages[last])+len("\r\n\r\n")+len(page) <= limit {

Niektóre pliki nie zostały wyświetlone z powodu dużej ilości zmienionych plików