9 Komitmen a5e68f410f ... acf3603dc8

Pembuat SHA1 Pesan Tanggal
  Sanaei acf3603dc8 refactor(ci): review pull requests with one senior-engineer role 3 jam lalu
  DuQi 47d2303334 fix(inbounds): reject missing TLS certificates before saving (#6429) 4 jam lalu
  Rouzbeh† 9f76a66dcf feat(sub): add dummy info node and status configs for subscriptions (#6412) 5 jam lalu
  Gleb Gudkov b8597314f8 docs(api): mark collection responses nullable (#6430) 5 jam lalu
  YoungReckless4 3cd3836d77 fix(amneziawg): account for S4 junk in the default tunnel MTU (#6376) 5 jam lalu
  Amirmohammad Sadat Shokouhi 5a63d5d468 fix(mtproto): use hosts for public share links (#6369) 7 jam lalu
  ilyusha d2ac3b4d7a fix(cli): let -getApiToken name the token it regenerates (#6405) 8 jam lalu
  Sanaei 2d151d7648 Update deps and simplify parsing 8 jam lalu
  Sanaei 2ec6c73613 feat(xray): update xray-core to v26.9.8 and adapt panel 8 jam lalu
100 mengubah file dengan 4061 tambahan dan 682 penghapusan
  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`.
 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
-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.
 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
       issues: read
       id-token: write
+    env:
+      GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+      REPO: ${{ github.repository }}
+      PR: ${{ github.event.pull_request.number || github.event.issue.number }}
     steps:
       - name: Record when this run started
         id: started
@@ -43,8 +47,6 @@ jobs:
         if: github.event_name == 'issue_comment'
         continue-on-error: true
         env:
-          GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
-          REPO: ${{ github.repository }}
           COMMENT_ID: ${{ github.event.comment.id }}
         run: gh api "repos/${REPO}/issues/comments/${COMMENT_ID}/reactions" -f content=eyes
       - uses: actions/checkout@v7
@@ -55,9 +57,6 @@ jobs:
       - name: Pin the head this run reviews
         id: pinned-sha
         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 }}
           COMMENT_AT: ${{ github.event.comment.created_at }}
         run: |
@@ -80,23 +79,19 @@ jobs:
             exit 1
           fi
           echo "sha=${HEAD_SHA}" >> "$GITHUB_OUTPUT"
-      # An automatic re-review of a head that already has one spends a whole run
-      # to reach the same conclusion, so settle it here rather than in the model.
-      - name: Skip a head that already has a review
+      # 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
         if: github.event_name == 'pull_request_target'
-        env:
-          GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
-          REPO: ${{ github.repository }}
-          PR: ${{ github.event.pull_request.number }}
-          HEAD_SHA: ${{ steps.pinned-sha.outputs.sha }}
         run: |
           set -euo pipefail
           posted=$(gh api "repos/${REPO}/issues/${PR}/comments" --paginate \
-            --jq "[.[] | select(.user.login == \"github-actions[bot]\") | select((.body | contains(\"Reviewed head:\")) and (.body | contains(\"${HEAD_SHA}\")))] | length")
+            --jq '[.[] | select(.user.login == "github-actions[bot]") | select(.body | contains("Reviewed head:"))] | length' \
+            | awk '{n += $1} END {print n + 0}')
           if [ "$posted" != "0" ]; then
             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
       # 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.
@@ -107,26 +102,6 @@ jobs:
           path: pr-head
           persist-credentials: false
           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
         id: review
         if: steps.reviewed.outputs.done != 'true'
@@ -137,25 +112,99 @@ jobs:
           github_token: ${{ secrets.GITHUB_TOKEN }}
           claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
           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: |
             --model claude-opus-5
             --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
         if: always()
         env:
           NODE_OPTIONS: ""
         uses: actions/upload-artifact@v7
         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
           if-no-files-found: ignore
           retention-days: 7
@@ -165,9 +214,6 @@ jobs:
         id: throttled
         if: ${{ !cancelled() && steps.review.outcome == 'failure' }}
         env:
-          GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
-          REPO: ${{ github.repository }}
-          PR: ${{ github.event.pull_request.number || github.event.issue.number }}
           TRANSCRIPT: ${{ runner.temp }}/claude-execution-output.json
         run: |
           set -euo pipefail
@@ -182,23 +228,19 @@ jobs:
           echo "skipped=true" >> "$GITHUB_OUTPUT"
           echo "::notice::No review of #${PR}: ${reason}."
           gh pr comment "$PR" --repo "$REPO" --body "No review ran on this head: ${reason}. Nothing in this pull request was examined. A maintainer can ask for one with \`@claude review\`."
+      # 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
         if: ${{ !cancelled() && steps.pinned-sha.outcome == 'success' && steps.reviewed.outputs.done != 'true' && steps.throttled.outputs.skipped != 'true' }}
         env:
-          GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
-          REPO: ${{ github.repository }}
-          PR: ${{ github.event.pull_request.number || github.event.issue.number }}
+          HEAD_SHA: ${{ steps.pinned-sha.outputs.sha }}
           STARTED_AT: ${{ steps.started.outputs.at }}
         run: |
           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
-            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
           fi

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

@@ -124,7 +124,7 @@ jobs:
           cd x-ui/bin
 
           # 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
             fetch ${Xray_URL}Xray-linux-64.zip
             unzip Xray-linux-64.zip
@@ -287,7 +287,7 @@ jobs:
           cd x-ui\bin
 
           # 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"
           Expand-Archive -Path "Xray-windows-64.zip" -DestinationPath .
           Remove-Item "Xray-windows-64.zip"

+ 1 - 1
DockerInit.sh

@@ -32,7 +32,7 @@ if [ -z "$MTG_MULTI_VER" ]; then
 fi
 mkdir -p 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"
 rm -f "Xray-linux-${ARCH}.zip" geoip.dat geosite.dat
 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:
 
-| 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
 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
   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
   `docs/public/openapi.json` with the docs MDX regenerated
   (`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
   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
@@ -92,7 +97,7 @@ surface — still 🟣, but open the summary with it.
 
 ## 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
 near-certain about and that actually breaks something:
 
@@ -125,24 +130,27 @@ near-certain about and that actually breaks something:
 
 ## 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
 
-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
 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
 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.
       url: '#return-every-protocol-url-vless-vmess-trojan-ss-hysteria-hy2-for-clients-matching-the-subscription-id-same-result-set-as-the-configured-subpath-endpoint-but-as-a-json-array--no-base64-when-an-inbound-has-streamsettingsexternalproxy-set-one-url-is-emitted-per-external-proxy-empty-array-when-the-subid-has-no-enabled-clients'
     - depth: 2
-      title: 'Return every URL for one client across all attached inbounds — the same
-        strings the Copy URL button copies in the panel UI. Supported protocols:
-        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:
     headings:
       - 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
           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:
       - content: >-
           Fields the server fills in when they are omitted — a valid value sent

+ 34 - 2
docs/public/openapi.json

@@ -217,6 +217,9 @@
           "subEncrypt": {
             "type": "boolean"
           },
+          "subExpiredTemplate": {
+            "type": "string"
+          },
           "subHideSettings": {
             "type": "boolean"
           },
@@ -226,6 +229,9 @@
           "subIncyRoutingRules": {
             "type": "string"
           },
+          "subInfoNodeEnable": {
+            "type": "boolean"
+          },
           "subJsonAlwaysArray": {
             "type": "boolean"
           },
@@ -288,6 +294,9 @@
           "subTitle": {
             "type": "string"
           },
+          "subTrafficDepletedTemplate": {
+            "type": "string"
+          },
           "subURI": {
             "type": "string"
           },
@@ -433,9 +442,11 @@
           "subEnable",
           "subEnableRouting",
           "subEncrypt",
+          "subExpiredTemplate",
           "subHideSettings",
           "subIncyEnableRouting",
           "subIncyRoutingRules",
+          "subInfoNodeEnable",
           "subJsonAlwaysArray",
           "subJsonAutoDetect",
           "subJsonEnable",
@@ -456,6 +467,7 @@
           "subSupportUrl",
           "subThemeDir",
           "subTitle",
+          "subTrafficDepletedTemplate",
           "subURI",
           "subUpdates",
           "tgBotAPIServer",
@@ -696,6 +708,9 @@
           "subEncrypt": {
             "type": "boolean"
           },
+          "subExpiredTemplate": {
+            "type": "string"
+          },
           "subHideSettings": {
             "type": "boolean"
           },
@@ -705,6 +720,9 @@
           "subIncyRoutingRules": {
             "type": "string"
           },
+          "subInfoNodeEnable": {
+            "type": "boolean"
+          },
           "subJsonAlwaysArray": {
             "type": "boolean"
           },
@@ -767,6 +785,9 @@
           "subTitle": {
             "type": "string"
           },
+          "subTrafficDepletedTemplate": {
+            "type": "string"
+          },
           "subURI": {
             "type": "string"
           },
@@ -919,9 +940,11 @@
           "subEnable",
           "subEnableRouting",
           "subEncrypt",
+          "subExpiredTemplate",
           "subHideSettings",
           "subIncyEnableRouting",
           "subIncyRoutingRules",
+          "subInfoNodeEnable",
           "subJsonAlwaysArray",
           "subJsonAutoDetect",
           "subJsonEnable",
@@ -942,6 +965,7 @@
           "subSupportUrl",
           "subThemeDir",
           "subTitle",
+          "subTrafficDepletedTemplate",
           "subURI",
           "subUpdates",
           "tgBotAPIServer",
@@ -4340,7 +4364,13 @@
                     "msg": {
                       "type": "string"
                     },
-                    "obj": {}
+                    "obj": {
+                      "type": "array",
+                      "nullable": true,
+                      "items": {
+                        "type": "string"
+                      }
+                    }
                   }
                 },
                 "example": {
@@ -6399,6 +6429,7 @@
                     },
                     "obj": {
                       "type": "array",
+                      "nullable": true,
                       "items": {
                         "type": "string"
                       }
@@ -6480,6 +6511,7 @@
                     },
                     "obj": {
                       "type": "array",
+                      "nullable": true,
                       "items": {
                         "$ref": "#/components/schemas/LogEntry"
                       }
@@ -9705,7 +9737,7 @@
         "tags": [
           "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",
         "parameters": [
           {

+ 218 - 248
frontend/package-lock.json

@@ -15,18 +15,18 @@
         "@noble/hashes": "^2.4.0",
         "@tanstack/react-query": "^5.102.8",
         "@tanstack/react-query-devtools": "^5.102.8",
-        "antd": "^6.6.2",
+        "antd": "^6.6.3",
         "codemirror": "^6.0.2",
         "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",
         "react": "^19.2.8",
         "react-dom": "^19.2.8",
         "react-hook-form": "^7.87.0",
         "react-i18next": "^17.0.13",
         "react-router": "^8.3.1",
-        "swagger-ui-react": "^5.32.14",
+        "swagger-ui-react": "^5.32.15",
         "uplot": "^1.6.32",
         "zod": "^4.5.4"
       },
@@ -45,12 +45,12 @@
         "@vitest/coverage-v8": "^5.0.0",
         "husky": "^9.1.7",
         "jsdom": "^30.0.1",
-        "lint-staged": "^17.4.1",
+        "lint-staged": "^17.5.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",
-        "playwright": "^1.62.1",
+        "playwright": "^1.63.0",
         "storybook": "^10.6.0",
         "typescript": "7.0.2",
         "vite": "8.2.2",
@@ -2380,9 +2380,9 @@
       ]
     },
     "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": [
         "arm"
       ],
@@ -2397,9 +2397,9 @@
       }
     },
     "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": [
         "arm64"
       ],
@@ -2414,9 +2414,9 @@
       }
     },
     "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": [
         "arm64"
       ],
@@ -2431,9 +2431,9 @@
       }
     },
     "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": [
         "x64"
       ],
@@ -2448,9 +2448,9 @@
       }
     },
     "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": [
         "x64"
       ],
@@ -2465,9 +2465,9 @@
       }
     },
     "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": [
         "arm"
       ],
@@ -2482,9 +2482,9 @@
       }
     },
     "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": [
         "arm"
       ],
@@ -2499,9 +2499,9 @@
       }
     },
     "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": [
         "arm64"
       ],
@@ -2519,9 +2519,9 @@
       }
     },
     "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": [
         "arm64"
       ],
@@ -2539,9 +2539,9 @@
       }
     },
     "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": [
         "ppc64"
       ],
@@ -2559,9 +2559,9 @@
       }
     },
     "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": [
         "riscv64"
       ],
@@ -2579,9 +2579,9 @@
       }
     },
     "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": [
         "riscv64"
       ],
@@ -2599,9 +2599,9 @@
       }
     },
     "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": [
         "s390x"
       ],
@@ -2619,9 +2619,9 @@
       }
     },
     "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": [
         "x64"
       ],
@@ -2639,9 +2639,9 @@
       }
     },
     "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": [
         "x64"
       ],
@@ -2659,9 +2659,9 @@
       }
     },
     "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": [
         "arm64"
       ],
@@ -2676,9 +2676,9 @@
       }
     },
     "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": [
         "arm64"
       ],
@@ -2693,9 +2693,9 @@
       }
     },
     "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": [
         "ia32"
       ],
@@ -2710,9 +2710,9 @@
       }
     },
     "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": [
         "x64"
       ],
@@ -2811,9 +2811,9 @@
       ]
     },
     "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": [
         "arm"
       ],
@@ -2828,9 +2828,9 @@
       }
     },
     "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": [
         "arm64"
       ],
@@ -2845,9 +2845,9 @@
       }
     },
     "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": [
         "arm64"
       ],
@@ -2862,9 +2862,9 @@
       }
     },
     "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": [
         "x64"
       ],
@@ -2879,9 +2879,9 @@
       }
     },
     "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": [
         "x64"
       ],
@@ -2896,9 +2896,9 @@
       }
     },
     "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": [
         "arm"
       ],
@@ -2913,9 +2913,9 @@
       }
     },
     "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": [
         "arm"
       ],
@@ -2930,9 +2930,9 @@
       }
     },
     "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": [
         "arm64"
       ],
@@ -2950,9 +2950,9 @@
       }
     },
     "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": [
         "arm64"
       ],
@@ -2970,9 +2970,9 @@
       }
     },
     "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": [
         "ppc64"
       ],
@@ -2990,9 +2990,9 @@
       }
     },
     "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": [
         "riscv64"
       ],
@@ -3010,9 +3010,9 @@
       }
     },
     "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": [
         "riscv64"
       ],
@@ -3030,9 +3030,9 @@
       }
     },
     "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": [
         "s390x"
       ],
@@ -3050,9 +3050,9 @@
       }
     },
     "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": [
         "x64"
       ],
@@ -3070,9 +3070,9 @@
       }
     },
     "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": [
         "x64"
       ],
@@ -3090,9 +3090,9 @@
       }
     },
     "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": [
         "arm64"
       ],
@@ -3107,9 +3107,9 @@
       }
     },
     "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": [
         "arm64"
       ],
@@ -3124,9 +3124,9 @@
       }
     },
     "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": [
         "ia32"
       ],
@@ -3141,9 +3141,9 @@
       }
     },
     "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": [
         "x64"
       ],
@@ -3504,15 +3504,15 @@
       }
     },
     "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",
       "dependencies": {
         "@rc-component/overflow": "^1.0.0",
         "@rc-component/resize-observer": "^1.0.0",
         "@rc-component/trigger": "^3.6.15",
-        "@rc-component/util": "^1.11.1",
+        "@rc-component/util": "^1.13.0",
         "clsx": "^2.1.1"
       },
       "engines": {
@@ -3847,9 +3847,9 @@
       }
     },
     "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",
       "dependencies": {
         "is-mobile": "^5.0.0",
@@ -6146,16 +6146,16 @@
       }
     },
     "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",
       "dependencies": {
         "@ant-design/colors": "^8.0.1",
         "@ant-design/cssinjs": "^2.1.2",
         "@ant-design/cssinjs-utils": "^2.1.2",
         "@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",
         "@babel/runtime": "^7.29.2",
         "@rc-component/cascader": "~1.22.0",
@@ -6176,7 +6176,7 @@
         "@rc-component/mutate-observer": "^2.0.1",
         "@rc-component/notification": "~2.0.8",
         "@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/qrcode": "~2.0.0",
         "@rc-component/rate": "~1.0.1",
@@ -6194,7 +6194,7 @@
         "@rc-component/tree-select": "~1.16.1",
         "@rc-component/trigger": "^3.10.1",
         "@rc-component/upload": "~1.1.1",
-        "@rc-component/util": "^1.12.0",
+        "@rc-component/util": "^1.13.0",
         "clsx": "^2.1.1",
         "dayjs": "^1.11.11",
         "scroll-into-view-if-needed": "^3.1.0",
@@ -7343,21 +7343,6 @@
         "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": {
       "version": "1.1.2",
       "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
@@ -7623,9 +7608,9 @@
       }
     },
     "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": [
         {
           "type": "individual",
@@ -7671,9 +7656,9 @@
       "license": "BSD-3-Clause"
     },
     "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"
     },
     "node_modules/indent-string": {
@@ -8266,15 +8251,15 @@
       }
     },
     "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,
       "license": "MIT",
       "dependencies": {
         "picomatch": "^4.0.7",
         "string-argv": "^0.3.2",
-        "tinyexec": "^1.3.0"
+        "tinyexec": "^1.3.1"
       },
       "bin": {
         "lint-staged": "bin/lint-staged.js"
@@ -8289,6 +8274,16 @@
         "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": {
       "version": "4.18.1",
       "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz",
@@ -8674,29 +8669,17 @@
       }
     },
     "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",
       "dependencies": {
-        "@noble/hashes": "2.2.0"
+        "@noble/hashes": "2.4.0"
       },
       "funding": {
         "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": {
       "version": "1.4.3",
       "resolved": "https://registry.npmjs.org/outvariant/-/outvariant-1.4.3.tgz",
@@ -8774,13 +8757,13 @@
       }
     },
     "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,
       "license": "MIT",
       "dependencies": {
-        "tinypool": "2.1.0"
+        "tinypool": "2.1.2"
       },
       "bin": {
         "oxfmt": "bin/oxfmt"
@@ -8792,25 +8775,25 @@
         "url": "https://github.com/sponsors/oxc-project"
       },
       "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": {
         "svelte": "^5.0.0",
@@ -8826,9 +8809,9 @@
       }
     },
     "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,
       "license": "MIT",
       "bin": {
@@ -8841,25 +8824,25 @@
         "url": "https://github.com/sponsors/oxc-project"
       },
       "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": {
         "oxlint-tsgolint": ">=7.0.2001",
@@ -9008,28 +8991,25 @@
       }
     },
     "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,
       "license": "Apache-2.0",
       "dependencies": {
-        "playwright-core": "1.62.1"
+        "playwright-core": "1.63.0"
       },
       "bin": {
         "playwright": "cli.js"
       },
       "engines": {
         "node": ">=20"
-      },
-      "optionalDependencies": {
-        "fsevents": "2.3.2"
       }
     },
     "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,
       "license": "Apache-2.0",
       "bin": {
@@ -9492,15 +9472,6 @@
       "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==",
       "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": {
       "version": "5.0.0",
       "resolved": "https://registry.npmjs.org/refractor/-/refractor-5.0.0.tgz",
@@ -10129,9 +10100,9 @@
       }
     },
     "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",
       "dependencies": {
         "@babel/runtime-corejs3": "^7.27.1",
@@ -10143,7 +10114,7 @@
         "deep-extend": "0.6.0",
         "dompurify": "^3.4.13",
         "ieee754": "^1.2.1",
-        "immutable": "^4.3.9",
+        "immutable": "^5.1.9",
         "js-file-download": "^0.4.12",
         "js-yaml": "=4.3.1",
         "lodash": "^4.18.1",
@@ -10158,7 +10129,6 @@
         "react-redux": "^9.3.0",
         "react-syntax-highlighter": "^16.0.0",
         "redux": "^5.0.1",
-        "redux-immutable": "^4.0.0",
         "remarkable": "^2.0.1",
         "reselect": "^5.1.1",
         "serialize-error": "^8.1.0",
@@ -10248,9 +10218,9 @@
       }
     },
     "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,
       "license": "MIT",
       "engines": {

+ 8 - 8
frontend/package.json

@@ -41,18 +41,18 @@
     "@noble/hashes": "^2.4.0",
     "@tanstack/react-query": "^5.102.8",
     "@tanstack/react-query-devtools": "^5.102.8",
-    "antd": "^6.6.2",
+    "antd": "^6.6.3",
     "codemirror": "^6.0.2",
     "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",
     "react": "^19.2.8",
     "react-dom": "^19.2.8",
     "react-hook-form": "^7.87.0",
     "react-i18next": "^17.0.13",
     "react-router": "^8.3.1",
-    "swagger-ui-react": "^5.32.14",
+    "swagger-ui-react": "^5.32.15",
     "uplot": "^1.6.32",
     "zod": "^4.5.4"
   },
@@ -71,12 +71,12 @@
     "@vitest/coverage-v8": "^5.0.0",
     "husky": "^9.1.7",
     "jsdom": "^30.0.1",
-    "lint-staged": "^17.4.1",
+    "lint-staged": "^17.5.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",
-    "playwright": "^1.62.1",
+    "playwright": "^1.63.0",
     "storybook": "^10.6.0",
     "typescript": "7.0.2",
     "vite": "8.2.2",

+ 34 - 2
frontend/public/openapi.json

@@ -217,6 +217,9 @@
           "subEncrypt": {
             "type": "boolean"
           },
+          "subExpiredTemplate": {
+            "type": "string"
+          },
           "subHideSettings": {
             "type": "boolean"
           },
@@ -226,6 +229,9 @@
           "subIncyRoutingRules": {
             "type": "string"
           },
+          "subInfoNodeEnable": {
+            "type": "boolean"
+          },
           "subJsonAlwaysArray": {
             "type": "boolean"
           },
@@ -288,6 +294,9 @@
           "subTitle": {
             "type": "string"
           },
+          "subTrafficDepletedTemplate": {
+            "type": "string"
+          },
           "subURI": {
             "type": "string"
           },
@@ -433,9 +442,11 @@
           "subEnable",
           "subEnableRouting",
           "subEncrypt",
+          "subExpiredTemplate",
           "subHideSettings",
           "subIncyEnableRouting",
           "subIncyRoutingRules",
+          "subInfoNodeEnable",
           "subJsonAlwaysArray",
           "subJsonAutoDetect",
           "subJsonEnable",
@@ -456,6 +467,7 @@
           "subSupportUrl",
           "subThemeDir",
           "subTitle",
+          "subTrafficDepletedTemplate",
           "subURI",
           "subUpdates",
           "tgBotAPIServer",
@@ -696,6 +708,9 @@
           "subEncrypt": {
             "type": "boolean"
           },
+          "subExpiredTemplate": {
+            "type": "string"
+          },
           "subHideSettings": {
             "type": "boolean"
           },
@@ -705,6 +720,9 @@
           "subIncyRoutingRules": {
             "type": "string"
           },
+          "subInfoNodeEnable": {
+            "type": "boolean"
+          },
           "subJsonAlwaysArray": {
             "type": "boolean"
           },
@@ -767,6 +785,9 @@
           "subTitle": {
             "type": "string"
           },
+          "subTrafficDepletedTemplate": {
+            "type": "string"
+          },
           "subURI": {
             "type": "string"
           },
@@ -919,9 +940,11 @@
           "subEnable",
           "subEnableRouting",
           "subEncrypt",
+          "subExpiredTemplate",
           "subHideSettings",
           "subIncyEnableRouting",
           "subIncyRoutingRules",
+          "subInfoNodeEnable",
           "subJsonAlwaysArray",
           "subJsonAutoDetect",
           "subJsonEnable",
@@ -942,6 +965,7 @@
           "subSupportUrl",
           "subThemeDir",
           "subTitle",
+          "subTrafficDepletedTemplate",
           "subURI",
           "subUpdates",
           "tgBotAPIServer",
@@ -4340,7 +4364,13 @@
                     "msg": {
                       "type": "string"
                     },
-                    "obj": {}
+                    "obj": {
+                      "type": "array",
+                      "nullable": true,
+                      "items": {
+                        "type": "string"
+                      }
+                    }
                   }
                 },
                 "example": {
@@ -6399,6 +6429,7 @@
                     },
                     "obj": {
                       "type": "array",
+                      "nullable": true,
                       "items": {
                         "type": "string"
                       }
@@ -6480,6 +6511,7 @@
                     },
                     "obj": {
                       "type": "array",
+                      "nullable": true,
                       "items": {
                         "$ref": "#/components/schemas/LogEntry"
                       }
@@ -9705,7 +9737,7 @@
         "tags": [
           "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",
         "parameters": [
           {

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

@@ -245,7 +245,13 @@ function buildOperation(ep, tag) {
       );
     }
     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) {
       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,
     "subEnableRouting": false,
     "subEncrypt": false,
+    "subExpiredTemplate": "",
     "subHideSettings": false,
     "subIncyEnableRouting": false,
     "subIncyRoutingRules": "",
+    "subInfoNodeEnable": false,
     "subJsonAlwaysArray": false,
     "subJsonAutoDetect": false,
     "subJsonEnable": false,
@@ -81,6 +83,7 @@ export const EXAMPLES: Record<string, unknown> = {
     "subSupportUrl": "",
     "subThemeDir": "",
     "subTitle": "",
+    "subTrafficDepletedTemplate": "",
     "subURI": "",
     "subUpdates": 0,
     "tgBotAPIServer": "",
@@ -172,9 +175,11 @@ export const EXAMPLES: Record<string, unknown> = {
     "subEnable": false,
     "subEnableRouting": false,
     "subEncrypt": false,
+    "subExpiredTemplate": "",
     "subHideSettings": false,
     "subIncyEnableRouting": false,
     "subIncyRoutingRules": "",
+    "subInfoNodeEnable": false,
     "subJsonAlwaysArray": false,
     "subJsonAutoDetect": false,
     "subJsonEnable": false,
@@ -195,6 +200,7 @@ export const EXAMPLES: Record<string, unknown> = {
     "subSupportUrl": "",
     "subThemeDir": "",
     "subTitle": "",
+    "subTrafficDepletedTemplate": "",
     "subURI": "",
     "subUpdates": 0,
     "tgBotAPIServer": "",

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

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

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

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

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

@@ -82,9 +82,11 @@ export const AllSettingSchema = z.object({
   subEnable: z.boolean(),
   subEnableRouting: z.boolean(),
   subEncrypt: z.boolean(),
+  subExpiredTemplate: z.string(),
   subHideSettings: z.boolean(),
   subIncyEnableRouting: z.boolean(),
   subIncyRoutingRules: z.string(),
+  subInfoNodeEnable: z.boolean(),
   subJsonAlwaysArray: z.boolean(),
   subJsonAutoDetect: z.boolean(),
   subJsonEnable: z.boolean(),
@@ -105,6 +107,7 @@ export const AllSettingSchema = z.object({
   subSupportUrl: z.string(),
   subThemeDir: z.string(),
   subTitle: z.string(),
+  subTrafficDepletedTemplate: z.string(),
   subURI: z.string(),
   subUpdates: z.number().int().min(0).max(525600),
   tgBotAPIServer: z.string(),
@@ -198,9 +201,11 @@ export const AllSettingViewSchema = z.object({
   subEnable: z.boolean(),
   subEnableRouting: z.boolean(),
   subEncrypt: z.boolean(),
+  subExpiredTemplate: z.string(),
   subHideSettings: z.boolean(),
   subIncyEnableRouting: z.boolean(),
   subIncyRoutingRules: z.string(),
+  subInfoNodeEnable: z.boolean(),
   subJsonAlwaysArray: z.boolean(),
   subJsonAutoDetect: z.boolean(),
   subJsonEnable: z.boolean(),
@@ -221,6 +226,7 @@ export const AllSettingViewSchema = z.object({
   subSupportUrl: z.string(),
   subThemeDir: z.string(),
   subTitle: z.string(),
+  subTrafficDepletedTemplate: z.string(),
   subURI: z.string(),
   subUpdates: z.number().int().min(0).max(525600),
   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 { 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
 // backend's hostToExternalProxyMap reads.
@@ -54,3 +56,50 @@ export function hostToExternalProxyEntry(host: HostLinkInput): ExternalProxyEntr
     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));
 
+// 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
  * 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"
                   />
                 </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' }}>
                   TLS (optional)
                 </Divider>
@@ -1443,6 +1473,13 @@ function QuicParamsForm({ base, form }: { base: (string | number)[]; form: FormI
           <Form.Item label="Brutal Down" name={[...base, 'brutalDown']}>
             <Input placeholder="e.g. 100 mbps" />
           </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 />
       </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']}>
         <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 { normalizeStreamSettingsForWire } from '@/lib/xray/stream-wire-normalize';
 import { canEnableSniffing } from '@/lib/xray/protocol-capabilities';
+import { tlsCertUsesFiles } from '@/schemas/protocols/security/tls';
 import { SockoptStreamSettingsSchema } from '@/schemas/protocols/stream/sockopt';
 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 {
-  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 {

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

@@ -1,4 +1,5 @@
 import { Base64, Wireguard } from '@/utils';
+import { effectiveMtu } from '@/lib/xray/amneziawg-obfuscation';
 
 import type { Inbound } from '@/schemas/api/inbound';
 import type { AmneziawgInboundSettings } from '@/schemas/protocols/inbound/amneziawg';
@@ -985,9 +986,7 @@ export function genAmneziaWGConfig(input: GenAmneziaWGLinkInput): string {
   txt += `Address = ${(client.allowedIPs ?? []).join(', ')}\n`;
   const dns = [server.primaryDns, server.secondaryDns].filter((v) => !!v && v.trim() !== '');
   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 += `Jmin = ${server.jmin}\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 {
+  BlackholeOutboundFormSettings,
   DnsOutboundFormSettings,
   DnsRuleForm,
   FreedomFinalRuleForm,
@@ -243,6 +244,9 @@ function wireguardFromWire(raw: Raw): WireguardOutboundFormSettings {
       return (allowed.includes(s) ? s : '') as WireguardOutboundFormSettings['domainStrategy'];
     })(),
     reserved: reservedArr.join(','),
+    remoteDNS: asArray(raw.remoteDNS)
+      .map((x) => asString(x))
+      .join(','),
     peers,
     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 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 {
@@ -585,6 +592,12 @@ function wireguardToWire(s: WireguardOutboundFormSettings) {
           .map((x) => Number(x.trim()))
           .filter((n) => Number.isFinite(n))
       : undefined,
+    remoteDNS: s.remoteDNS
+      ? s.remoteDNS
+          .split(',')
+          .map((x) => x.trim())
+          .filter(Boolean)
+      : undefined,
     peers: s.peers.map((p) => ({
       publicKey: p.publicKey,
       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) {

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

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

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

@@ -46,6 +46,7 @@ export interface Endpoint {
   bodyRequiredOneOf?: string[];
   responseSchema?: string;
   responseSchemaArray?: boolean;
+  responseSchemaArrayNullable?: boolean;
   responseObjectSchema?: Record<string, unknown>;
   responses?: Record<string, Record<string, unknown>>;
   security?: readonly Record<string, readonly string[]>[];
@@ -265,6 +266,7 @@ export const sections: readonly Section[] = [
       {
         method: 'GET',
         path: '/panel/api/inbounds/allLinks',
+        responseObjectSchema: { type: 'array', nullable: true, items: { type: 'string' } },
         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.',
         response:
@@ -709,7 +711,7 @@ export const sections: readonly Section[] = [
           },
         ],
         body: 'level=info&syslog=false',
-        responseObjectSchema: { type: 'array', items: { type: 'string' } },
+        responseObjectSchema: { type: 'array', nullable: true, items: { type: 'string' } },
         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}',
       },
@@ -751,6 +753,7 @@ export const sections: readonly Section[] = [
         body: 'filter=error&showDirect=false&showBlocked=true&showProxy=true',
         responseSchema: 'LogEntry',
         responseSchemaArray: true,
+        responseSchemaArrayNullable: true,
       },
       {
         method: 'POST',
@@ -1561,7 +1564,7 @@ export const sections: readonly Section[] = [
         method: 'GET',
         path: '/panel/api/clients/links/:email',
         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: [
           { 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>
                           </Form.Item>
                         </Col>
-                        <Col xs={24} md={6}>
+                        <Col xs={24} md={12}>
                           <FormField
                             name="totalGB"
                             label={t('pages.clients.totalGB')}
@@ -859,7 +859,7 @@ export default function ClientFormModal({
                             <InputNumber min={0} step={1} style={{ width: '100%' }} />
                           </FormField>
                         </Col>
-                        <Col xs={24} md={6}>
+                        <Col xs={24} md={12}>
                           <Form.Item
                             label={t('pages.clients.limitIp')}
                             tooltip={t('pages.clients.limitIpDesc')}
@@ -894,7 +894,7 @@ export default function ClientFormModal({
                             </Tooltip>
                           </Form.Item>
                         </Col>
-                        <Col xs={24} md={6}>
+                        <Col xs={24} md={12}>
                           <Form.Item
                             label={t('pages.clients.limitHwid')}
                             tooltip={t('pages.clients.limitHwidDesc')}

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

@@ -1,5 +1,6 @@
 import { formatInboundLabel } from '@/lib/inbounds/label';
 import { preferPublicHost, resolveShareHost } from '@/lib/xray/inbound-link';
+import { effectiveMtu } from '@/lib/xray/amneziawg-obfuscation';
 import type { ClientRecord, InboundOption } from '@/hooks/useClients';
 
 // 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 lines = ['[Interface]', `PrivateKey = ${privateKey}`, `Address = ${address}`];
   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.
   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 { useWebSocket } from '@/hooks/useWebSocket';
 import { useNodesQuery } from '@/api/queries/useNodesQuery';
+import { useHostsQuery } from '@/api/queries/useHostsQuery';
+import { withMtprotoHostEndpoints } from '@/lib/hosts/host-link';
 import AppSidebar from '@/layouts/AppSidebar';
 const TextModal = lazy(() => import('@/components/feedback/TextModal'));
 import type { TextModalTab } from '@/components/feedback/TextModal';
@@ -112,6 +114,16 @@ export default function InboundsPage() {
   }, [messageApi]);
 
   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 map = new Map<number, ReturnType<typeof useNodesQuery>['nodes'][number]>();
     for (const n of nodesList || []) map.set(n.id, n);
@@ -325,11 +337,19 @@ export default function InboundsPage() {
   const exportInboundLinks = useCallback(
     (dbInbound: DBInbound) => {
       const projected = checkFallback(dbInbound);
+      const hostOverride = hostOverrideFor(dbInbound);
+      const fallbackHostname = preferPublicHost(window.location.hostname, subSettings.publicHost);
       const genInput = {
-        inbound: inboundFromDb(projected),
+        inbound: withMtprotoHostEndpoints(
+          inboundFromDb(projected),
+          dbInbound.id,
+          hosts,
+          hostOverride,
+          fallbackHostname,
+        ),
         remark: projected.remark,
-        hostOverride: hostOverrideFor(dbInbound),
-        fallbackHostname: preferPublicHost(window.location.hostname, subSettings.publicHost),
+        hostOverride,
+        fallbackHostname,
       };
       const content = genInboundLinks(genInput);
       const tabs: TextModalTab[] | undefined = projected.isWireguard
@@ -358,7 +378,7 @@ export default function InboundsPage() {
         tabs,
       });
     },
-    [checkFallback, hostOverrideFor, subSettings.publicHost, openText, t],
+    [checkFallback, hostOverrideFor, hosts, subSettings.publicHost, openText, t],
   );
 
   const exportInboundClipboard = useCallback(
@@ -708,16 +728,27 @@ export default function InboundsPage() {
 
         <Layout className="content-shell">
           <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" />
-              ) : fetchError ? (
+              ) : fetchError || hostsError ? (
                 <Result
                   status="error"
                   title={t('somethingWentWrong')}
-                  subTitle={fetchError}
+                  subTitle={fetchError || hostsError}
                   extra={
-                    <Button type="primary" onClick={refresh}>
+                    <Button
+                      type="primary"
+                      onClick={() => {
+                        void refresh();
+                        void refetchHosts();
+                      }}
+                    >
                       {t('refresh')}
                     </Button>
                   }
@@ -809,6 +840,7 @@ export default function InboundsPage() {
             ipLimitEnable={ipLimitEnable}
             tgBotEnable={tgBotEnable}
             subSettings={subSettings}
+            hosts={hosts}
             lastOnlineMap={lastOnlineMap}
             nodeAddress={infoNodeAddress}
           />
@@ -821,6 +853,7 @@ export default function InboundsPage() {
             client={null}
             nodeAddress={qrNodeAddress}
             subSettings={subSettings}
+            hosts={hosts}
           />
         </LazyMount>
         <LazyMount when={attachOpen}>

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

@@ -490,8 +490,14 @@ export default function InboundFormModal({
    */
   useEffect(() => {
     if (!open) return;
-    if (!availableNodesFetched || !protocol) return;
+    if (!protocol) return;
     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') {
       setV('shareAddrStrategy', 'listen');
     }
@@ -559,6 +565,7 @@ export default function InboundFormModal({
     const parsed = InboundFormSchema.safeParse(values);
     if (!parsed.success) {
       const issues = parsed.error.issues;
+      setActiveTab(tabForValidationPath(issues[0].path));
       messageApi.error(formatInboundValidation(issues, values, t));
       console.error(
         '[InboundFormModal] schema validation failed:',
@@ -643,37 +650,42 @@ export default function InboundFormModal({
         <Input placeholder={t('pages.inbounds.monitorDesc')} />
       </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

+ 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 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') {
     const index = path[2];
     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 />
               </FormField>
+              <FormField
+                label={t('pages.inbounds.form.xForwarded')}
+                name={[...MASQ_PATH, 'xForwarded']}
+                valueProp="checked"
+              >
+                <Switch />
+              </FormField>
               <FormField
                 label={t('pages.inbounds.form.skipTlsVerify')}
                 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
         name={['streamSettings', 'realitySettings', 'maxClientVer']}

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

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

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

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

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

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

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

@@ -182,6 +182,41 @@ export default function SubscriptionGeneralTab({
                 />
               </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
                 paddings="small"
                 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 { Select } from 'antd';
+import { Input, Select } from 'antd';
+import { useFormContext, useWatch } from 'react-hook-form';
 
 import { FormField } from '@/components/form/rhf';
 
 export default function BlackholeFields() {
   const { t } = useTranslation();
+  const { control } = useFormContext();
+  const type = useWatch({ control, name: 'settings.type' }) as string | undefined;
   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']}>
         <Input placeholder="comma-separated bytes, e.g. 1,2,3" />
       </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')}>
         <Button
           size="small"

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

@@ -79,6 +79,13 @@ export default function HysteriaForm() {
               >
                 <Switch />
               </FormField>
+              <FormField
+                label={t('pages.inbounds.form.xForwarded')}
+                name={[...MASQ, 'xForwarded']}
+                valueProp="checked"
+              >
+                <Switch />
+              </FormField>
               <FormField
                 label={t('pages.inbounds.form.skipTlsVerify')}
                 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 { 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';
 
-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 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(''),
   domainStrategy: z.union([WireguardDomainStrategySchema, z.literal('')]).default(''),
   reserved: z.string().default(''),
+  remoteDNS: z.string().default(''),
   peers: z.array(WireguardOutboundFormPeerSchema).default([]),
   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.
 export const BlackholeOutboundFormSettingsSchema = z.object({
   type: z.union([BlackholeResponseTypeSchema, z.literal('')]).default(''),
+  customResponseData: z.string().default(''),
 });
 export type BlackholeOutboundFormSettings = z.infer<typeof BlackholeOutboundFormSettingsSchema>;
 

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

@@ -1,13 +1,13 @@
 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>;
 
-// 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({
-  response: z.object({ type: BlackholeResponseTypeSchema }).optional(),
+  response: z
+    .object({ type: BlackholeResponseTypeSchema, customResponseData: z.string().optional() })
+    .optional(),
 });
 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
 // `network: never().optional()` handling. A present-but-invalid security
 // 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>;

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

@@ -52,9 +52,32 @@ export const TlsCertInlineSchema = z.object({
   usage: TlsCertUsageSchema.default('encipherment'),
   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>;
 
+// 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({
   // '' = None. Hysteria rejects uTLS fingerprints, and a chrome default
   // 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(),
   brutalUp: z.string().optional(),
   brutalDown: z.string().optional(),
+  brutalDisableLossCompensation: z.boolean().optional(),
   udpHop: QuicUdpHopSchema.optional(),
   initStreamReceiveWindow: 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(),
   keepAlivePeriod: z.number().int().min(2).max(60).optional(),
   disablePathMTUDiscovery: z.boolean().optional(),
+  disableChromeParrot: z.boolean().optional(),
+  disableGSO: z.boolean().optional(),
   maxIncomingStreams: z.number().int().min(8).optional(),
+  disableStatelessReset: z.boolean().optional(),
 });
 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(''),
   url: z.string().default(''),
   rewriteHost: z.boolean().default(false),
+  xForwarded: z.boolean().optional(),
   insecure: z.boolean().default(false),
   content: 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(),
   attrs: z.record(z.string(), z.string()).optional(),
   process: z.array(z.string()).optional(),
+  localOS: z.array(z.string()).optional(),
   outboundTag: z.string().optional(),
   balancerTag: 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(),
     remarkTemplate: z.string().optional(),
     subShowIdentityOnAllLinks: z.boolean().optional(),
+    subInfoNodeEnable: z.boolean().optional(),
+    subExpiredTemplate: z.string().optional(),
+    subTrafficDepletedTemplate: z.string().optional(),
     datepicker: z.enum(['gregorian', 'jalalian']).optional(),
     tgBotEnable: z.boolean().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`] = `
 {
   "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`] = `
 {
   "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);
   });
 });
+
+// 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.invalidField': '{field} — {reason}',
   'pages.inbounds.toasts.moreIssues': '{message}  (+{count} more)',
+  'pages.inbounds.toasts.invalidCertificate': 'TLS certificate {index}: {reason}',
   clients: 'clients',
 };
 
@@ -59,6 +60,14 @@ describe('formatInboundValidation', () => {
     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', () => {
     const issues = [
       { 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" />
 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', () => {
   const base = {
@@ -60,3 +61,69 @@ describe('hostToExternalProxyEntry', () => {
     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');
   });
 
+  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 () => {
     const node = { id: 1, name: 'arm2', enable: true, status: 'online' } as never;
     const buildInbound = () =>
@@ -240,6 +275,28 @@ describe('InboundFormModal', () => {
     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 () => {
     const post = vi.mocked(HttpUtil.post);
     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 { buildSpec } from '../../scripts/build-openapi.mjs';
+import { EXAMPLES } from '../generated/examples';
 
 interface OpenApiSchema {
   $ref?: string;
@@ -31,7 +32,7 @@ interface OpenApiOperation {
   responses: Record<
     string,
     {
-      content?: Record<string, { schema: OpenApiSchema }>;
+      content?: Record<string, { schema: OpenApiSchema; example?: unknown }>;
     }
   >;
   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', () => {
     expect(responseObjectSchema('/panel/api/server/logs/{count}', 'post')).toEqual({
       type: 'array',
+      nullable: true,
       items: { type: 'string' },
     });
     expect(responseObjectSchema('/panel/api/server/xraylogs/{count}', 'post')).toEqual({
       type: 'array',
+      nullable: true,
       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({
       $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' } });
   });
 
+  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', () => {
     const wire = {
       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: {
           alpn: ['h3'],
+          certificates: [{ certificateFile: '/cert/server.pem', keyFile: '/cert/server.key' }],
           settings: {
             fingerprint: '',
           },

+ 23 - 17
go.mod

@@ -4,18 +4,18 @@ go 1.27.1
 
 require (
 	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/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-yaml v1.19.2
 	github.com/google/uuid v1.6.0
 	github.com/gorilla/websocket v1.5.3
 	github.com/joho/godotenv v1.5.1
 	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/nicksnyder/go-i18n/v2 v2.6.1
 	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/shirou/gopsutil/v4 v4.26.8
 	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/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
 	golang.org/x/crypto v0.56.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
 	google.golang.org/grpc v1.83.2
 	google.golang.org/protobuf v1.36.12
@@ -44,7 +44,7 @@ require (
 require (
 	github.com/Azure/go-ntlmssp v0.1.1 // 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/sonic v1.15.3 // 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/ebitengine/purego v0.11.0 // 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-ole/go-ole v1.3.0 // 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/sessions v1.4.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/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/jackpal/go-nat-pmp v1.1.0 // indirect
 	github.com/jinzhu/inflection v1.0.0 // indirect
 	github.com/jinzhu/now v1.1.5 // indirect
 	github.com/json-iterator/go v1.1.12 // indirect
 	github.com/juju/ratelimit v1.0.2 // 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/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/mattn/go-isatty v0.0.24 // indirect
 	github.com/miekg/dns v1.1.73 // indirect
 	github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // 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/pion/dtls/v3 v3.1.8 // 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/quic-go v0.62.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/tklauser/go-sysconf v0.4.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/netns 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
-	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
-	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/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/wireguard v0.0.0-20260522210424-ecfc5a8d5446 // 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
 )

+ 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/andybalholm/brotli v1.2.3 h1:8H1qwOkl2LPfjf3YezB90JnCliZb6SInJ/OJkEbA5NQ=
 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/go.mod h1:v1zWfPm21Fb+OsyXN2VAHdL6TBb2L88anLQgdyje6R4=
 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/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/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/go.mod h1:VxccKfsSllpKshkBWgVgRniFFAzFb9csfngsqANjnLc=
 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/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/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/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
 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/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/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/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/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/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/go.mod h1:AiaICIRyfYg35RUkr8yESTqvSy7csK90qZ5xfvvsoNs=
 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/cpuid/v2 v2.4.0 h1:S6Hrbc7+ywsr0r+RLapfGBHfyefhCTwEh3A0tV913Dw=
 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/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
 github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
 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/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/go.mod h1:autxFIvghDt3jPTLoqZ9OZ7s9qTGNAWmYCjVFWPX/zg=
 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-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/go.mod h1:RW2Obtfd5NZHvOFe3zYG0W8koWOQtAzyHaLo8vASBuQ=
 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/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
 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/go.mod h1:K4z3Z3Qr6AA8yEjSry3JGScu506NlLl1O4Gqascmop4=
 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/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/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/go.mod h1:TE/Z6401Pi8tgr0nBZcM/xawAI6u3F6TTbz4nH/qw+8=
 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/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
 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/go.mod h1:e6FubmQouUNP73jtMLmcbxS6ydWIpOfhz34TSfO3JaE=
 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/xlzd/gotp v0.1.0 h1:37blvlKCh38s+fkem+fFh7sMnceltoIEBYTVXyoa5Po=
 github.com/xlzd/gotp v0.1.0/go.mod h1:ndLJ3JKzi3xLmUProq4LLxCuECL93dG9WASNLpHz8qg=
-github.com/xtls/reality v0.0.0-20260322125925-9234c772ba8f h1:iy2JRioxmUpoJ3SzbFPyTxHZMbR/rSHP7dOOgYaq1O8=
-github.com/xtls/reality v0.0.0-20260322125925-9234c772ba8f/go.mod h1:DsJblcWDGt76+FVqBVwbwRhxyyNJsGV48gJLch0OOWI=
-github.com/xtls/xray-core v1.260327.1-0.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/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
 github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0=
 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/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
 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=
 go4.org/netipx v0.0.0-20260823151212-3075585bcbeb h1:XBM4hvfwGAttkkiTIFfeigdfcL1xIfdKXqFdgiHGtDs=
 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/go.mod h1:OMW5y6CY9l38uPLmxU6l6pwcXp1obtLo3e6gT7gQR2I=
 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/net v0.58.0 h1:ynWG7rqYi4ccpTEuPZ2QGWHktVEM9DMCj9yzDE0Q7To=
 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-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 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.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/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/go.mod h1:deeaetjYA+DHMHg+sMSMI58GrEteJUUzzw7en6TJQcI=
 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=
 gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4=
 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/go.mod h1:YPI1hK3kDked6iHvgX3tR0y+nX/qpMFKhPgFsokw1S8=
 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}"
 
             # 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
             echo ""
@@ -1322,7 +1322,7 @@ EOF
 
             # Persist a machine-parseable credentials file for cloud-init / MOTD.
             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_HOST:=${server_ip}}"
             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())
 }
 
+// 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
 // static value gets profiled by DPI, defeating the point.
 func GenerateObfuscation31() Obfuscation31 {
@@ -113,7 +126,7 @@ func generateHValues() [4]string {
 	const lo = 5
 	bandSize := (awgHMax - lo + 1) / 4
 	var out [4]string
-	for i := 0; i < 4; i++ {
+	for i := range 4 {
 		bandLo := lo + i*bandSize
 		bandHi := bandLo + bandSize - 1
 		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) {
-	for i := 0; i < 200; i++ {
+	for range 200 {
 		o := GenerateObfuscation31()
 		if o.Jc < 3 || o.Jc > 6 {
 			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) {
-	for i := 0; i < 50; i++ {
+	for range 50 {
 		h := generateHValues()
 		var prev int64
 		for i, v := range h {
@@ -117,7 +117,7 @@ func validObfuscation() Obfuscation31 {
 }
 
 func TestValidateObfuscationAcceptsGenerated(t *testing.T) {
-	for i := 0; i < 50; i++ {
+	for range 50 {
 		if err := ValidateObfuscation(validObfuscation()); err != nil {
 			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
 // amneziawg-go's UAPI actually parses: uint32 for jc/jmin/jmax, uint16 for s1-s4.
 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) {
-	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 {
 			return portSpec{}, false
 		}

+ 2 - 10
internal/amneziawgnet/device.go

@@ -13,11 +13,6 @@ import (
 	"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
 // protection, content padding, and the five session-timing knobs) --
 // 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
 // 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 peer per inst.Peers entry. It does not attach a forwarder or start
 // 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)
 	}
 
-	mtu := inst.MTU
-	if mtu <= 0 {
-		mtu = defaultMTU
-	}
+	mtu := amneziawg.EffectiveMTU(inst.MTU, inst.Obfuscation.S4)
 
 	tun, gstack, err := createNetTUNWithStack(addrs, mtu)
 	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
 // reconcile, so no connection could ever survive past one tick); only
 // 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 {
 	inst, opts := d.Instance, d.Options
 	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 {
-	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

+ 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) {
 	cur := &managed{udpRelay: NewUDPRelay(SocksRelay{Addr: "invalid"}, nil)}
 	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 {
 	var history []string
 	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 {
-		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 {
 			if err := db.Create(&model.HistoryOfSeeders{SeederName: name}).Error; err != nil {
 				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 err := seedNodeInboundsAdopted(); err != nil {
 			return err
@@ -1308,6 +1365,10 @@ func runSeeders(isUsersEmpty bool) error {
 		return err
 	}
 
+	if err := seedMtprotoCustomShareAddrToHosts(); err != nil {
+		return err
+	}
+
 	if err := resetIpLimitsWithoutFail2ban(); err != nil {
 		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 {
 	pathKeys := []string{"webBasePath", "subPath", "subJsonPath", "subClashPath"}
 	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)
 	}
 }
+
+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"
 	"fmt"
 	"maps"
+	"slices"
 	"strings"
 
 	"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 hasInactiveExternal bool
+	var hasEnabledClient bool
 
 	seenEmails := make(map[string]struct{})
 	for _, inbound := range inbounds {
@@ -52,11 +54,17 @@ func (s *SubClashService) GetClash(subId string, host string) (string, string, e
 			injectExternalProxy(inbound, hostEps)
 		}
 		for _, client := range clients {
+			if client.Enable {
+				hasEnabledClient = true
+			}
 			seenEmails[client.Email] = struct{}{}
 			proxies = append(proxies, s.getProxies(subReq, inbound, client, host)...)
 		}
 	}
 	for _, ext := range externalLinks {
+		if ext.Enable {
+			hasEnabledClient = true
+		}
 		if !ext.Active {
 			seenEmails[ext.Email] = struct{}{}
 			hasInactiveExternal = true
@@ -82,8 +90,25 @@ func (s *SubClashService) GetClash(subId string, host string) (string, string, e
 	for e := range seenEmails {
 		emails = append(emails, e)
 	}
+	slices.Sort(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)
+
+	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 {
 		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)
 	for _, proxy := range proxies {
+		if isDummyProxy(proxy) && len(proxies) > 1 {
+			continue
+		}
 		if name, ok := proxy["name"].(string); ok && 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 {
 	typ, _ := proxy["type"].(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 hasInactiveExternal bool
+	var hasEnabledClient bool
 
 	seenEmails := make(map[string]struct{})
 	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
 		for _, client := range clients {
+			if client.Enable {
+				hasEnabledClient = true
+			}
 			seenEmails[client.Email] = struct{}{}
 			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...)
 	}
 	for _, ext := range externalLinks {
+		if ext.Enable {
+			hasEnabledClient = true
+		}
 		if !ext.Active {
 			seenEmails[ext.Email] = struct{}{}
 			hasInactiveExternal = true
@@ -162,8 +169,20 @@ func (s *SubJsonService) GetJson(subId string, host string, alwaysReturnArray bo
 	for e := range seenEmails {
 		emails = append(emails, e)
 	}
+	slices.Sort(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)
+
+	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 {
 		return "", header, nil
 	}
@@ -962,6 +981,32 @@ func (s *SubJsonService) genWireguard(inbound *model.Inbound, client model.Clien
 	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 {
 	merged := map[string]any{}
 	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 {
 	svc := p.build(host)
 	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))
 }
 

+ 21 - 0
internal/sub/links_test.go

@@ -2,7 +2,10 @@ package sub
 
 import (
 	"reflect"
+	"strings"
 	"testing"
+
+	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
 )
 
 func TestSplitLinkLines(t *testing.T) {
@@ -38,3 +41,21 @@ func TestSplitLinkLines_WhitespaceOnlyHasNoEntries(t *testing.T) {
 		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{}
 	}
 	key := templateInfoKey(client)
-	if s.usageShown[key] {
+	if s.subInfoNodeEnable || s.usageShown[key] {
 		remove := firstLinkOnlyBodyTokens
 		if s.showIdentityOnAllLinks {
 			remove = usageInfoTokens

+ 128 - 24
internal/sub/service.go

@@ -44,10 +44,13 @@ type SubService struct {
 	subscriptionBody bool
 	// usageShown emits info once per subscription identity, including twins.
 	// 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
 	// resolveInboundAddress can return the node's address for any
 	// 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
 // JSON/Clash with the zero value.
 func (s *SubService) loadRemarkSettings() {
-	var err error
-	s.datepicker, err = s.settingService.GetDatepicker()
-	if err != nil {
+	if s.datepicker == "" {
 		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)
 }
 
+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) {
 	var result []string
 	var emails []string
@@ -360,8 +442,18 @@ func (s *SubService) getSubs(subId string) ([]string, []string, int64, xray.Clie
 	for e := range seenEmails {
 		uniqueEmails = append(uniqueEmails, e)
 	}
+	slices.Sort(uniqueEmails)
 	traffic, lastOnline := s.AggregateTrafficByEmails(uniqueEmails)
 	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
 }
 
@@ -729,9 +821,9 @@ func amneziaWGConfigText(server *amneziawg.ServerSettings, client *model.Client,
 	if len(dns) > 0 {
 		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, "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))
 }
 
-// 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 {
 	if inbound.Protocol != model.MTProto {
 		return ""
@@ -834,12 +922,28 @@ func (s *SubService) genMtprotoLink(inbound *model.Inbound, email string) string
 	if !ok || resolved.Secret == "" {
 		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:

+ 41 - 1
internal/sub/service_amneziawg_test.go

@@ -3,6 +3,7 @@ package sub
 import (
 	"encoding/base64"
 	"slices"
+	"strconv"
 	"strings"
 	"testing"
 
@@ -201,7 +202,7 @@ func peerFields(t *testing.T, conf string) []string {
 		t.Fatalf("config has no [Peer] block:\n%s", conf)
 	}
 	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])
 		if slices.Contains(peerFieldOrder, 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
 // every other client-bearing protocol does. It was previously dropped from the
 // 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)
 }
 
+// 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.
 func (a *InboundController) initRouter(g *gin.RouterGroup) {
 	g.GET("/list", a.getInbounds)
@@ -162,7 +171,7 @@ func (a *InboundController) addInbound(c *gin.Context) {
 		inbound.NodeID = nil
 	}
 
-	inbound, needRestart, err := a.inboundService.AddInbound(inbound)
+	inbound, needRestart, err := a.inboundServiceFor(c).AddInbound(inbound)
 	if err != nil {
 		jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
 		return
@@ -242,7 +251,7 @@ func (a *InboundController) updateInbound(c *gin.Context) {
 	if inbound.NodeID != nil && *inbound.NodeID == 0 {
 		inbound.NodeID = nil
 	}
-	inbound, needRestart, err := a.inboundService.UpdateInbound(inbound)
+	inbound, needRestart, err := a.inboundServiceFor(c).UpdateInbound(inbound)
 	if err != nil {
 		jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
 		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"`
 	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"`
 	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
 // can reconfigure the running embedded Device in place via IpcSet instead
 // 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
 // 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 {
 	clientService   ClientService
 	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 {
@@ -57,6 +60,11 @@ func normalizeInboundShareAddress(inbound *model.Inbound) {
 	if inbound == nil {
 		return
 	}
+	if inbound.Protocol == model.MTProto {
+		inbound.ShareAddrStrategy = "listen"
+		inbound.ShareAddr = ""
+		return
+	}
 	inbound.ShareAddrStrategy = normalizeInboundShareAddrStrategy(inbound.ShareAddrStrategy)
 	if addr, err := normalizeInboundShareHost(inbound.ShareAddr); err == nil {
 		inbound.ShareAddr = addr
@@ -69,6 +77,11 @@ func normalizeInboundShareAddressStrict(inbound *model.Inbound) error {
 	if inbound == nil {
 		return nil
 	}
+	if inbound.Protocol == model.MTProto {
+		inbound.ShareAddrStrategy = "listen"
+		inbound.ShareAddr = ""
+		return nil
+	}
 	inbound.ShareAddrStrategy = normalizeInboundShareAddrStrategy(inbound.ShareAddrStrategy)
 	addr, err := normalizeInboundShareHost(inbound.ShareAddr)
 	if err != nil {
@@ -113,6 +126,17 @@ func normalizeInboundShareHost(raw string) (string, error) {
 	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 {
 	if tx == nil || !tx.Migrator().HasColumn(&model.Inbound{}, "share_addr_strategy") {
 		return nil
@@ -589,6 +613,64 @@ func canonicalizeStreamNetworkKey(streamSettings string) string {
 	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
 // stream uses REALITY security, or nil otherwise. A non-empty result means
 // 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.
 func (s *InboundService) AddInbound(inbound *model.Inbound) (*model.Inbound, bool, error) {
 	inbound.Id = 0
+	legacyShareAddr := legacyMtprotoShareAddr(inbound)
 	inbound.TrafficResetDay = normalizeTrafficResetDay(inbound.TrafficResetDay)
 	// Normalize streamSettings based on protocol
 	s.normalizeStreamSettings(inbound)
+	if !s.FromNodeSync {
+		if err := validateInboundTLSCertificates(inbound.StreamSettings); err != nil {
+			return inbound, false, err
+		}
+	}
 	if err := validateFinalMaskRealityCombo(inbound.StreamSettings); err != nil {
 		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 {
 			return err
 		}
+		if err := database.CreateHostFromMtprotoCustomShareAddr(tx, inbound.Id, legacyShareAddr); err != nil {
+			return err
+		}
 		if inbound.NodeID != nil {
 			nodeID := *inbound.NodeID
 			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) {
+	legacyShareAddr := legacyMtprotoShareAddr(inbound)
 	inbound.TrafficResetDay = normalizeTrafficResetDay(inbound.TrafficResetDay)
 	// Normalize streamSettings based on protocol
 	s.normalizeStreamSettings(inbound)
@@ -1494,6 +1586,15 @@ func (s *InboundService) UpdateInbound(inbound *model.Inbound) (*model.Inbound,
 	if err != nil {
 		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
 	// stays scoped to its own node (the payload's nodeId is unreliable, often absent).
 	inbound.NodeID = oldInbound.NodeID
@@ -1634,6 +1735,9 @@ func (s *InboundService) UpdateInbound(inbound *model.Inbound) (*model.Inbound,
 			}
 			oldInbound.ShareAddrStrategy = inbound.ShareAddrStrategy
 			oldInbound.ShareAddr = inbound.ShareAddr
+			if err := database.CreateHostFromMtprotoCustomShareAddr(tx, inbound.Id, legacyShareAddr); err != nil {
+				return err
+			}
 		}
 		if oldTagWasAuto && inbound.Tag == 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,
 		Port:           port,
 		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"}`,
 	}
 }

+ 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)
 	}
 }
+
+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.Write([]byte(`{"success":true,"obj":{"cpuPct":1,"panelVersion":"`))
 		pad := strings.Repeat("x", 1<<20)
-		for i := 0; i < 3; i++ {
+		for range 3 {
 			_, _ = w.Write([]byte(pad))
 		}
 		_, _ = 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 == "" {
 		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)
 	row := &model.ApiToken{Name: name, Token: crypto.HashTokenSHA256(plaintext), Enabled: true}
 	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 (
 	"errors"
+	"strings"
 	"testing"
 
 	"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) {
 	t.Setenv("XUI_DB_FOLDER", t.TempDir())
 	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.
 func geofileTagFromLocation(location string) (string, error) {
 	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)
 	}
-	tag, _, found := strings.Cut(location[idx+len(marker):], "/")
+	tag, _, found := strings.Cut(after, "/")
 	if !found || tag == "" {
 		return "", common.NewErrorf("unexpected release redirect %q", location)
 	}

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

@@ -34,11 +34,13 @@ import (
 var xrayTemplateConfig string
 
 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{
@@ -70,6 +72,9 @@ var defaultValueMap = map[string]string{
 	"trafficDiff":                 "0",
 	"remarkTemplate":              DefaultRemarkTemplate,
 	"subShowIdentityOnAllLinks":   "false",
+	"subInfoNodeEnable":           "false",
+	"subExpiredTemplate":          DefaultSubExpiredTemplate,
+	"subTrafficDepletedTemplate":  DefaultSubTrafficDepletedTemplate,
 	"timeLocation":                "Local",
 	"tgBotEnable":                 "false",
 	"tgBotToken":                  "",
@@ -676,6 +681,18 @@ func (s *SettingService) GetSubShowIdentityOnAllLinks() (bool, error) {
 	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) {
 	secret, err := s.getString("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)
-	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) {
 			last := len(pages) - 1
 			if last >= 0 && len(pages[last])+len("\r\n\r\n")+len(page) <= limit {

Beberapa file tidak ditampilkan karena terlalu banyak file yang berubah dalam diff ini