4 Komitmen 1250fbb734 ... a3e617215c

Pembuat SHA1 Pesan Tanggal
  Sanaei a3e617215c fix(ci): pin the head the review job checks out 8 jam lalu
  yzxcj797 a255ab7c65 fix(node): don't stamp InboundsAdoptedAt when the sync adopted nothing (#6284) 9 jam lalu
  ilyusha af3e6c11b6 docs(api): document WireGuard and mtproto secret generation on clients/add (#6282) 9 jam lalu
  dawn b73ceae081 fix(frontend): refresh subscription settings after save (#6287) 9 jam lalu

+ 34 - 6
.github/workflows/claude-bot.yml

@@ -494,13 +494,41 @@ jobs:
       - uses: actions/checkout@v7
         with:
           persist-credentials: false
-      # Read-only: this job holds a write-scoped token, so building or running
-      # anything out of pr-head/ would turn the review into a pwn-request.
-      # checkout v7 refuses a fork PR ref outright unless that risk is accepted
-      # here, and nearly every pull request to this repository is from a fork.
+      # An `@claude review` vouches for the head that existed when it was typed;
+      # a push after it would swap the code out from under that approval.
+      - 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: |
+          set -euo pipefail
+          if [ -n "$PAYLOAD_SHA" ]; then
+            echo "sha=${PAYLOAD_SHA}" >> "$GITHUB_OUTPUT"
+            exit 0
+          fi
+          head=$(gh api "repos/${REPO}/pulls/${PR}" --jq '"\(.head.sha) \(.head.repo.pushed_at // "")"')
+          HEAD_SHA=${head%% *}
+          HEAD_PUSHED_AT=${head#* }
+          if [ -z "$HEAD_PUSHED_AT" ]; then
+            gh pr comment "$PR" --repo "$REPO" --body "The head repository of this pull request is gone, so the code to review cannot be verified. Nothing was reviewed."
+            echo "::error::The head repository is unavailable; refusing to check it out."
+            exit 1
+          fi
+          if [ "$(date -d "$HEAD_PUSHED_AT" +%s)" -gt "$(date -d "$COMMENT_AT" +%s)" ]; then
+            gh pr comment "$PR" --repo "$REPO" --body "The head branch was pushed to at ${HEAD_PUSHED_AT}, after this review was requested at ${COMMENT_AT}, so the code that would be checked out here is not the code the request vouched for. Nothing was reviewed. Ask again to review the current head."
+            echo "::error::The head moved after the request; refusing to check it out."
+            exit 1
+          fi
+          echo "sha=${HEAD_SHA}" >> "$GITHUB_OUTPUT"
+      # Read-only, and pinned to one immutable commit: this job holds a
+      # write-scoped token, so running anything out of pr-head/ would be a pwn-request.
       - uses: actions/checkout@v7
         with:
-          ref: refs/pull/${{ github.event.pull_request.number || github.event.issue.number }}/head
+          ref: ${{ steps.pinned-sha.outputs.sha }}
           path: pr-head
           persist-credentials: false
           allow-unsafe-pr-checkout: true
@@ -534,7 +562,7 @@ jobs:
           if-no-files-found: ignore
           retention-days: 7
       - name: Fail if the review posted nothing
-        if: ${{ !cancelled() }}
+        if: ${{ !cancelled() && steps.pinned-sha.outcome == 'success' }}
         env:
           GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
           REPO: ${{ github.repository }}

+ 57 - 10
docs/content/docs/en/reference/api/clients.mdx

@@ -28,10 +28,9 @@ _openapi:
       url: '#fetch-one-client-by-email-including-the-inbound-ids-and-external-config-ids-it-is-attached-to'
     - depth: 2
       title: Create a new client and attach it to one or more inbounds in a single
-        call. Body is JSON. Per-protocol secrets (UUID for VLESS/VMess, password
-        for Trojan/Shadowsocks, auth for Hysteria) are generated server-side
-        when omitted, so callers can send only the universal fields.
-      url: '#create-a-new-client-and-attach-it-to-one-or-more-inbounds-in-a-single-call-body-is-json-per-protocol-secrets-uuid-for-vlessvmess-password-for-trojanshadowsocks-auth-for-hysteria-are-generated-server-side-when-omitted-so-callers-can-send-only-the-universal-fields'
+        call. Body is JSON. Per-protocol secrets are generated server-side when
+        omitted, so callers can send only the universal fields.
+      url: '#create-a-new-client-and-attach-it-to-one-or-more-inbounds-in-a-single-call-body-is-json-per-protocol-secrets-are-generated-server-side-when-omitted-so-callers-can-send-only-the-universal-fields'
     - depth: 2
       title: Update an existing client by email. Changes propagate to every attached
         inbound. Body is the JSON client payload — supply the full set of fields
@@ -278,11 +277,9 @@ _openapi:
           config IDs it is attached to.
         id: fetch-one-client-by-email-including-the-inbound-ids-and-external-config-ids-it-is-attached-to
       - content: Create a new client and attach it to one or more inbounds in a single
-          call. Body is JSON. Per-protocol secrets (UUID for VLESS/VMess,
-          password for Trojan/Shadowsocks, auth for Hysteria) are generated
-          server-side when omitted, so callers can send only the universal
-          fields.
-        id: create-a-new-client-and-attach-it-to-one-or-more-inbounds-in-a-single-call-body-is-json-per-protocol-secrets-uuid-for-vlessvmess-password-for-trojanshadowsocks-auth-for-hysteria-are-generated-server-side-when-omitted-so-callers-can-send-only-the-universal-fields
+          call. Body is JSON. Per-protocol secrets are generated server-side
+          when omitted, so callers can send only the universal fields.
+        id: create-a-new-client-and-attach-it-to-one-or-more-inbounds-in-a-single-call-body-is-json-per-protocol-secrets-are-generated-server-side-when-omitted-so-callers-can-send-only-the-universal-fields
       - content: Update an existing client by email. Changes propagate to every attached
           inbound. Body is the JSON client payload — supply the full set of
           fields you want to keep (the server replaces the row, it does not
@@ -477,7 +474,57 @@ _openapi:
       - content: Remove a single registered HWID device by its id, freeing one slot
           under the HWID limit.
         id: remove-a-single-registered-hwid-device-by-its-id-freeing-one-slot-under-the-hwid-limit
-    contents: []
+    contents:
+      - content: >-
+          Fields the server fills in when they are omitted — a valid value sent
+          by the caller is never overwritten. Re-adding an email that already
+          exists, with its stored `subId`, reuses the stored `id`, `password`,
+          `auth` and `secret` instead of minting new ones, so the identity stays
+          in sync across its inbounds.
+
+
+          - **VLESS / VMess** — `id`, a fresh UUID
+
+          - **Trojan** — `password`
+
+          - **Shadowsocks** — `password`. On a `2022-blake3-*` inbound a
+          supplied password that does not base64-decode to the key length of the
+          cipher (16 or 32 bytes) is replaced by a generated key and the call
+          still succeeds, so read the client back if you did not let the server
+          pick. Legacy ciphers keep any non-empty password
+
+          - **Hysteria** — `auth`
+
+          - **mtproto** — `secret`, a FakeTLS secret derived from the fronting
+          domain of the inbound, or from `www.cloudflare.com` when it has none
+
+          - **WireGuard** — `privateKey` and `publicKey` when both are blank, or
+          `publicKey` alone when only a `privateKey` was sent, plus
+          `allowedIPs`: one free `/32` taken from the /24 the existing peers of
+          that inbound already sit in, or from `10.0.0.0/24` when it has none
+
+
+          Accepted on the same body but never generated: `preSharedKey` and
+          `keepAlive` (WireGuard), `adTag` (mtproto).
+
+
+          WireGuard is the only one of these that can fail. Allocation widens
+          the search to the containing /16 before giving up with `wireguard: no
+          free address available in <scope>`, and an `allowedIPs` supplied by
+          the caller is validated instead of allocated: `wireguard: allowedIPs
+          entry already used by another client: <address>` when a different
+          client of that same inbound already holds it. The check is per
+          inbound, so the same address on two different inbounds is accepted.
+          The same validation runs on POST /panel/api/clients/{email}/attach,
+          where a client that already carries an address brings it along.
+        heading: create-a-new-client-and-attach-it-to-one-or-more-inbounds-in-a-single-call-body-is-json-per-protocol-secrets-are-generated-server-side-when-omitted-so-callers-can-send-only-the-universal-fields
+      - content: 'A WireGuard client brings its stored `allowedIPs` into the new inbound
+          instead of being given a fresh address, so the call fails with
+          `wireguard: allowedIPs entry already used by another client:
+          <address>` when a different client of the target inbound already holds
+          it. Free the address on that inbound first — see POST
+          /panel/api/clients/add for the full rule.'
+        heading: attach-an-existing-client-to-one-or-more-additional-inbounds-body-is-json
 ---
 
 {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */}

+ 57 - 10
docs/content/docs/fa/reference/api/clients.mdx

@@ -37,11 +37,10 @@ _openapi:
     - depth: 2
       title: >-
         Create a new client and attach it to one or more inbounds in a single
-        call. Body is JSON. Per-protocol secrets (UUID for VLESS/VMess, password
-        for Trojan/Shadowsocks, auth for Hysteria) are generated server-side
-        when omitted, so callers can send only the universal fields.
+        call. Body is JSON. Per-protocol secrets are generated server-side when
+        omitted, so callers can send only the universal fields.
       url: >-
-        #create-a-new-client-and-attach-it-to-one-or-more-inbounds-in-a-single-call-body-is-json-per-protocol-secrets-uuid-for-vlessvmess-password-for-trojanshadowsocks-auth-for-hysteria-are-generated-server-side-when-omitted-so-callers-can-send-only-the-universal-fields
+        #create-a-new-client-and-attach-it-to-one-or-more-inbounds-in-a-single-call-body-is-json-per-protocol-secrets-are-generated-server-side-when-omitted-so-callers-can-send-only-the-universal-fields
     - depth: 2
       title: >-
         Update an existing client by email. Changes propagate to every attached
@@ -352,12 +351,10 @@ _openapi:
           fetch-one-client-by-email-including-the-inbound-ids-and-external-config-ids-it-is-attached-to
       - content: >-
           Create a new client and attach it to one or more inbounds in a single
-          call. Body is JSON. Per-protocol secrets (UUID for VLESS/VMess,
-          password for Trojan/Shadowsocks, auth for Hysteria) are generated
-          server-side when omitted, so callers can send only the universal
-          fields.
+          call. Body is JSON. Per-protocol secrets are generated server-side
+          when omitted, so callers can send only the universal fields.
         id: >-
-          create-a-new-client-and-attach-it-to-one-or-more-inbounds-in-a-single-call-body-is-json-per-protocol-secrets-uuid-for-vlessvmess-password-for-trojanshadowsocks-auth-for-hysteria-are-generated-server-side-when-omitted-so-callers-can-send-only-the-universal-fields
+          create-a-new-client-and-attach-it-to-one-or-more-inbounds-in-a-single-call-body-is-json-per-protocol-secrets-are-generated-server-side-when-omitted-so-callers-can-send-only-the-universal-fields
       - content: >-
           Update an existing client by email. Changes propagate to every
           attached inbound. Body is the JSON client payload — supply the full
@@ -610,7 +607,57 @@ _openapi:
           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
-    contents: []
+    contents:
+      - content: >-
+          Fields the server fills in when they are omitted — a valid value sent
+          by the caller is never overwritten. Re-adding an email that already
+          exists, with its stored `subId`, reuses the stored `id`, `password`,
+          `auth` and `secret` instead of minting new ones, so the identity stays
+          in sync across its inbounds.
+
+
+          - **VLESS / VMess** — `id`, a fresh UUID
+
+          - **Trojan** — `password`
+
+          - **Shadowsocks** — `password`. On a `2022-blake3-*` inbound a
+          supplied password that does not base64-decode to the key length of the
+          cipher (16 or 32 bytes) is replaced by a generated key and the call
+          still succeeds, so read the client back if you did not let the server
+          pick. Legacy ciphers keep any non-empty password
+
+          - **Hysteria** — `auth`
+
+          - **mtproto** — `secret`, a FakeTLS secret derived from the fronting
+          domain of the inbound, or from `www.cloudflare.com` when it has none
+
+          - **WireGuard** — `privateKey` and `publicKey` when both are blank, or
+          `publicKey` alone when only a `privateKey` was sent, plus
+          `allowedIPs`: one free `/32` taken from the /24 the existing peers of
+          that inbound already sit in, or from `10.0.0.0/24` when it has none
+
+
+          Accepted on the same body but never generated: `preSharedKey` and
+          `keepAlive` (WireGuard), `adTag` (mtproto).
+
+
+          WireGuard is the only one of these that can fail. Allocation widens
+          the search to the containing /16 before giving up with `wireguard: no
+          free address available in <scope>`, and an `allowedIPs` supplied by
+          the caller is validated instead of allocated: `wireguard: allowedIPs
+          entry already used by another client: <address>` when a different
+          client of that same inbound already holds it. The check is per
+          inbound, so the same address on two different inbounds is accepted.
+          The same validation runs on POST /panel/api/clients/{email}/attach,
+          where a client that already carries an address brings it along.
+        heading: create-a-new-client-and-attach-it-to-one-or-more-inbounds-in-a-single-call-body-is-json-per-protocol-secrets-are-generated-server-side-when-omitted-so-callers-can-send-only-the-universal-fields
+      - content: 'A WireGuard client brings its stored `allowedIPs` into the new inbound
+          instead of being given a fresh address, so the call fails with
+          `wireguard: allowedIPs entry already used by another client:
+          <address>` when a different client of the target inbound already holds
+          it. Free the address on that inbound first — see POST
+          /panel/api/clients/add for the full rule.'
+        heading: attach-an-existing-client-to-one-or-more-additional-inbounds-body-is-json
 ---
 
 {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */}

+ 57 - 10
docs/content/docs/ru/reference/api/clients.mdx

@@ -37,11 +37,10 @@ _openapi:
     - depth: 2
       title: >-
         Create a new client and attach it to one or more inbounds in a single
-        call. Body is JSON. Per-protocol secrets (UUID for VLESS/VMess, password
-        for Trojan/Shadowsocks, auth for Hysteria) are generated server-side
-        when omitted, so callers can send only the universal fields.
+        call. Body is JSON. Per-protocol secrets are generated server-side when
+        omitted, so callers can send only the universal fields.
       url: >-
-        #create-a-new-client-and-attach-it-to-one-or-more-inbounds-in-a-single-call-body-is-json-per-protocol-secrets-uuid-for-vlessvmess-password-for-trojanshadowsocks-auth-for-hysteria-are-generated-server-side-when-omitted-so-callers-can-send-only-the-universal-fields
+        #create-a-new-client-and-attach-it-to-one-or-more-inbounds-in-a-single-call-body-is-json-per-protocol-secrets-are-generated-server-side-when-omitted-so-callers-can-send-only-the-universal-fields
     - depth: 2
       title: >-
         Update an existing client by email. Changes propagate to every attached
@@ -352,12 +351,10 @@ _openapi:
           fetch-one-client-by-email-including-the-inbound-ids-and-external-config-ids-it-is-attached-to
       - content: >-
           Create a new client and attach it to one or more inbounds in a single
-          call. Body is JSON. Per-protocol secrets (UUID for VLESS/VMess,
-          password for Trojan/Shadowsocks, auth for Hysteria) are generated
-          server-side when omitted, so callers can send only the universal
-          fields.
+          call. Body is JSON. Per-protocol secrets are generated server-side
+          when omitted, so callers can send only the universal fields.
         id: >-
-          create-a-new-client-and-attach-it-to-one-or-more-inbounds-in-a-single-call-body-is-json-per-protocol-secrets-uuid-for-vlessvmess-password-for-trojanshadowsocks-auth-for-hysteria-are-generated-server-side-when-omitted-so-callers-can-send-only-the-universal-fields
+          create-a-new-client-and-attach-it-to-one-or-more-inbounds-in-a-single-call-body-is-json-per-protocol-secrets-are-generated-server-side-when-omitted-so-callers-can-send-only-the-universal-fields
       - content: >-
           Update an existing client by email. Changes propagate to every
           attached inbound. Body is the JSON client payload — supply the full
@@ -610,7 +607,57 @@ _openapi:
           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
-    contents: []
+    contents:
+      - content: >-
+          Fields the server fills in when they are omitted — a valid value sent
+          by the caller is never overwritten. Re-adding an email that already
+          exists, with its stored `subId`, reuses the stored `id`, `password`,
+          `auth` and `secret` instead of minting new ones, so the identity stays
+          in sync across its inbounds.
+
+
+          - **VLESS / VMess** — `id`, a fresh UUID
+
+          - **Trojan** — `password`
+
+          - **Shadowsocks** — `password`. On a `2022-blake3-*` inbound a
+          supplied password that does not base64-decode to the key length of the
+          cipher (16 or 32 bytes) is replaced by a generated key and the call
+          still succeeds, so read the client back if you did not let the server
+          pick. Legacy ciphers keep any non-empty password
+
+          - **Hysteria** — `auth`
+
+          - **mtproto** — `secret`, a FakeTLS secret derived from the fronting
+          domain of the inbound, or from `www.cloudflare.com` when it has none
+
+          - **WireGuard** — `privateKey` and `publicKey` when both are blank, or
+          `publicKey` alone when only a `privateKey` was sent, plus
+          `allowedIPs`: one free `/32` taken from the /24 the existing peers of
+          that inbound already sit in, or from `10.0.0.0/24` when it has none
+
+
+          Accepted on the same body but never generated: `preSharedKey` and
+          `keepAlive` (WireGuard), `adTag` (mtproto).
+
+
+          WireGuard is the only one of these that can fail. Allocation widens
+          the search to the containing /16 before giving up with `wireguard: no
+          free address available in <scope>`, and an `allowedIPs` supplied by
+          the caller is validated instead of allocated: `wireguard: allowedIPs
+          entry already used by another client: <address>` when a different
+          client of that same inbound already holds it. The check is per
+          inbound, so the same address on two different inbounds is accepted.
+          The same validation runs on POST /panel/api/clients/{email}/attach,
+          where a client that already carries an address brings it along.
+        heading: create-a-new-client-and-attach-it-to-one-or-more-inbounds-in-a-single-call-body-is-json-per-protocol-secrets-are-generated-server-side-when-omitted-so-callers-can-send-only-the-universal-fields
+      - content: 'A WireGuard client brings its stored `allowedIPs` into the new inbound
+          instead of being given a fresh address, so the call fails with
+          `wireguard: allowedIPs entry already used by another client:
+          <address>` when a different client of the target inbound already holds
+          it. Free the address on that inbound first — see POST
+          /panel/api/clients/add for the full rule.'
+        heading: attach-an-existing-client-to-one-or-more-additional-inbounds-body-is-json
 ---
 
 {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */}

+ 30 - 9
docs/content/docs/zh/reference/api/clients.mdx

@@ -30,11 +30,9 @@ _openapi:
         #fetch-one-client-by-email-including-the-inbound-ids-and-external-config-ids-it-is-attached-to
     - depth: 2
       title: >-
-        在一次调用中创建一个新客户端并将其挂载到一个或多个入站。请求体为 JSON。各协议的密钥
-        (VLESS/VMess 的 UUID、Trojan/Shadowsocks 的 password、Hysteria 的 auth)在
-        省略时由服务端生成,因此调用方只需发送通用字段。
+        在一次调用中创建一个新客户端并将其挂载到一个或多个入站。请求体为 JSON。各协议的密钥在省略时由服务端生成,因此调用方只需发送通用字段。
       url: >-
-        #create-a-new-client-and-attach-it-to-one-or-more-inbounds-in-a-single-call-body-is-json-per-protocol-secrets-uuid-for-vlessvmess-password-for-trojanshadowsocks-auth-for-hysteria-are-generated-server-side-when-omitted-so-callers-can-send-only-the-universal-fields
+        #create-a-new-client-and-attach-it-to-one-or-more-inbounds-in-a-single-call-body-is-json-per-protocol-secrets-are-generated-server-side-when-omitted-so-callers-can-send-only-the-universal-fields
     - depth: 2
       title: >-
         按 email 更新现有客户端。变更会传播到每个挂载的入站。请求体为 JSON 客户端载荷——
@@ -290,11 +288,9 @@ _openapi:
         id: >-
           fetch-one-client-by-email-including-the-inbound-ids-and-external-config-ids-it-is-attached-to
       - content: >-
-          在一次调用中创建一个新客户端并将其挂载到一个或多个入站。请求体为 JSON。各协议的密钥
-          (VLESS/VMess 的 UUID、Trojan/Shadowsocks 的 password、Hysteria 的 auth)在
-          省略时由服务端生成,因此调用方只需发送通用字段。
+          在一次调用中创建一个新客户端并将其挂载到一个或多个入站。请求体为 JSON。各协议的密钥在省略时由服务端生成,因此调用方只需发送通用字段。
         id: >-
-          create-a-new-client-and-attach-it-to-one-or-more-inbounds-in-a-single-call-body-is-json-per-protocol-secrets-uuid-for-vlessvmess-password-for-trojanshadowsocks-auth-for-hysteria-are-generated-server-side-when-omitted-so-callers-can-send-only-the-universal-fields
+          create-a-new-client-and-attach-it-to-one-or-more-inbounds-in-a-single-call-body-is-json-per-protocol-secrets-are-generated-server-side-when-omitted-so-callers-can-send-only-the-universal-fields
       - content: >-
           按 email 更新现有客户端。变更会传播到每个挂载的入站。请求体为 JSON 客户端载荷——
           请提供你希望保留的完整字段集(服务端会替换整条记录,而非局部更新)。
@@ -493,7 +489,32 @@ _openapi:
           (socks、http、mixed、wireguard、dokodemo、tunnel)不产生任何内容。
         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
-    contents: []
+    contents:
+      - content: >-
+          服务端在字段被省略时自动填充;调用方提供的有效值不会被覆盖。若以已存在的 email 重新添加,且其已存储的 `subId` 一致,则沿用已存储的 `id`、`password`、`auth` 和 `secret`,而不是重新生成,以保证同一身份在其各个入站之间保持一致。
+
+
+          - **VLESS / VMess** —— `id`,新生成的 UUID
+
+          - **Trojan** —— `password`
+
+          - **Shadowsocks** —— `password`。在 `2022-blake3-*` 入站上,若调用方提供的 password 经 base64 解码后的长度不等于该加密方式所需的密钥长度(16 或 32 字节),它会被替换为服务端生成的密钥,且调用仍然返回成功;因此若不打算交由服务端生成,请回读该客户端确认。传统加密方式则保留任何非空 password
+
+          - **Hysteria** —— `auth`
+
+          - **mtproto** —— `secret`,由该入站的伪装域名派生的 FakeTLS 密钥;该入站未设置伪装域名时,则取自 `www.cloudflare.com`
+
+          - **WireGuard** —— 两个密钥都为空时生成 `privateKey` 与 `publicKey`;只提供了 `privateKey` 时仅推导 `publicKey`。此外还会分配 `allowedIPs`:从该入站现有对端所在的 /24 中取一个空闲的 `/32`,若该入站尚无对端,则取自 `10.0.0.0/24`
+
+
+          同一请求体也接受、但服务端不会自动生成的字段:`preSharedKey` 与 `keepAlive`(WireGuard)、`adTag`(mtproto)。
+
+
+          其中只有 WireGuard 这一步可能失败。分配地址时会先把搜索范围扩大到所属的 /16,之后才以 `wireguard: no free address available in <scope>` 放弃;而调用方自行提供的 `allowedIPs` 只做校验、不做分配:当同一入站上的另一个客户端已占用该地址时,返回 `wireguard: allowedIPs entry already used by another client: <address>`。该校验按入站进行,因此同一地址出现在两个不同入站上是允许的。POST /panel/api/clients/{email}/attach 也执行同样的校验——已带有地址的客户端会把该地址带入新的入站。
+        heading: create-a-new-client-and-attach-it-to-one-or-more-inbounds-in-a-single-call-body-is-json-per-protocol-secrets-are-generated-server-side-when-omitted-so-callers-can-send-only-the-universal-fields
+      - content: >-
+          WireGuard 客户端会把已存储的 `allowedIPs` 带入新入站,而不是获得新分配的地址;因此当目标入站上的另一个客户端已占用该地址时,调用会以 `wireguard: allowedIPs entry already used by another client: <address>` 失败。请先在该入站上释放该地址——完整规则见 POST /panel/api/clients/add。
+        heading: attach-an-existing-client-to-one-or-more-additional-inbounds-body-is-json
 ---
 
 {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */}

+ 3 - 1
docs/public/openapi.json

@@ -4976,8 +4976,9 @@
         "tags": [
           "Clients"
         ],
-        "summary": "Create a new client and attach it to one or more inbounds in a single call. Body is JSON. Per-protocol secrets (UUID for VLESS/VMess, password for Trojan/Shadowsocks, auth for Hysteria) are generated server-side when omitted, so callers can send only the universal fields.",
+        "summary": "Create a new client and attach it to one or more inbounds in a single call. Body is JSON. Per-protocol secrets are generated server-side when omitted, so callers can send only the universal fields.",
         "operationId": "post_panel_api_clients_add",
+        "description": "Fields the server fills in when they are omitted — a valid value sent by the caller is never overwritten. Re-adding an email that already exists, with its stored `subId`, reuses the stored `id`, `password`, `auth` and `secret` instead of minting new ones, so the identity stays in sync across its inbounds.\n\n- **VLESS / VMess** — `id`, a fresh UUID\n- **Trojan** — `password`\n- **Shadowsocks** — `password`. On a `2022-blake3-*` inbound a supplied password that does not base64-decode to the key length of the cipher (16 or 32 bytes) is replaced by a generated key and the call still succeeds, so read the client back if you did not let the server pick. Legacy ciphers keep any non-empty password\n- **Hysteria** — `auth`\n- **mtproto** — `secret`, a FakeTLS secret derived from the fronting domain of the inbound, or from `www.cloudflare.com` when it has none\n- **WireGuard** — `privateKey` and `publicKey` when both are blank, or `publicKey` alone when only a `privateKey` was sent, plus `allowedIPs`: one free `/32` taken from the /24 the existing peers of that inbound already sit in, or from `10.0.0.0/24` when it has none\n\nAccepted on the same body but never generated: `preSharedKey` and `keepAlive` (WireGuard), `adTag` (mtproto).\n\nWireGuard is the only one of these that can fail. Allocation widens the search to the containing /16 before giving up with `wireguard: no free address available in <scope>`, and an `allowedIPs` supplied by the caller is validated instead of allocated: `wireguard: allowedIPs entry already used by another client: <address>` when a different client of that same inbound already holds it. The check is per inbound, so the same address on two different inbounds is accepted. The same validation runs on POST /panel/api/clients/{email}/attach, where a client that already carries an address brings it along.",
         "requestBody": {
           "required": true,
           "content": {
@@ -5152,6 +5153,7 @@
         ],
         "summary": "Attach an existing client to one or more additional inbounds. Body is JSON.",
         "operationId": "post_panel_api_clients_email_attach",
+        "description": "A WireGuard client brings its stored `allowedIPs` into the new inbound instead of being given a fresh address, so the call fails with `wireguard: allowedIPs entry already used by another client: <address>` when a different client of the target inbound already holds it. Free the address on that inbound first — see POST /panel/api/clients/add for the full rule.",
         "parameters": [
           {
             "name": "email",

+ 3 - 1
frontend/public/openapi.json

@@ -6245,8 +6245,9 @@
         "tags": [
           "Clients"
         ],
-        "summary": "Create a new client and attach it to one or more inbounds in a single call. Body is JSON. Per-protocol secrets (UUID for VLESS/VMess, password for Trojan/Shadowsocks, auth for Hysteria) are generated server-side when omitted, so callers can send only the universal fields.",
+        "summary": "Create a new client and attach it to one or more inbounds in a single call. Body is JSON. Per-protocol secrets are generated server-side when omitted, so callers can send only the universal fields.",
         "operationId": "post_panel_api_clients_add",
+        "description": "Fields the server fills in when they are omitted — a valid value sent by the caller is never overwritten. Re-adding an email that already exists, with its stored `subId`, reuses the stored `id`, `password`, `auth` and `secret` instead of minting new ones, so the identity stays in sync across its inbounds.\n\n- **VLESS / VMess** — `id`, a fresh UUID\n- **Trojan** — `password`\n- **Shadowsocks** — `password`. On a `2022-blake3-*` inbound a supplied password that does not base64-decode to the key length of the cipher (16 or 32 bytes) is replaced by a generated key and the call still succeeds, so read the client back if you did not let the server pick. Legacy ciphers keep any non-empty password\n- **Hysteria** — `auth`\n- **mtproto** — `secret`, a FakeTLS secret derived from the fronting domain of the inbound, or from `www.cloudflare.com` when it has none\n- **WireGuard** — `privateKey` and `publicKey` when both are blank, or `publicKey` alone when only a `privateKey` was sent, plus `allowedIPs`: one free `/32` taken from the /24 the existing peers of that inbound already sit in, or from `10.0.0.0/24` when it has none\n\nAccepted on the same body but never generated: `preSharedKey` and `keepAlive` (WireGuard), `adTag` (mtproto).\n\nWireGuard is the only one of these that can fail. Allocation widens the search to the containing /16 before giving up with `wireguard: no free address available in <scope>`, and an `allowedIPs` supplied by the caller is validated instead of allocated: `wireguard: allowedIPs entry already used by another client: <address>` when a different client of that same inbound already holds it. The check is per inbound, so the same address on two different inbounds is accepted. The same validation runs on POST /panel/api/clients/{email}/attach, where a client that already carries an address brings it along.",
         "requestBody": {
           "required": true,
           "content": {
@@ -6423,6 +6424,7 @@
         ],
         "summary": "Attach an existing client to one or more additional inbounds. Body is JSON.",
         "operationId": "post_panel_api_clients_email_attach",
+        "description": "A WireGuard client brings its stored `allowedIPs` into the new inbound instead of being given a fresh address, so the call fails with `wireguard: allowedIPs entry already used by another client: <address>` when a different client of the target inbound already holds it. Free the address on that inbound first — see POST /panel/api/clients/add for the full rule.",
         "parameters": [
           {
             "name": "email",

+ 1 - 0
frontend/src/api/queries/useAllSettings.ts

@@ -77,6 +77,7 @@ export function useAllSettings() {
       if (!msg?.success) return;
       if (saved) markSaved(saved);
       queryClient.invalidateQueries({ queryKey: keys.settings.all() });
+      queryClient.invalidateQueries({ queryKey: keys.settings.defaults() });
     },
   });
 

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

@@ -844,13 +844,15 @@ export const sections: readonly Section[] = [
         method: 'POST',
         path: '/panel/api/clients/add',
         summary:
-          'Create a new client and attach it to one or more inbounds in a single call. Body is JSON. Per-protocol secrets (UUID for VLESS/VMess, password for Trojan/Shadowsocks, auth for Hysteria) are generated server-side when omitted, so callers can send only the universal fields.',
+          'Create a new client and attach it to one or more inbounds in a single call. Body is JSON. Per-protocol secrets are generated server-side when omitted, so callers can send only the universal fields.',
+        description:
+          'Fields the server fills in when they are omitted — a valid value sent by the caller is never overwritten. Re-adding an email that already exists, with its stored `subId`, reuses the stored `id`, `password`, `auth` and `secret` instead of minting new ones, so the identity stays in sync across its inbounds.\n\n- **VLESS / VMess** — `id`, a fresh UUID\n- **Trojan** — `password`\n- **Shadowsocks** — `password`. On a `2022-blake3-*` inbound a supplied password that does not base64-decode to the key length of the cipher (16 or 32 bytes) is replaced by a generated key and the call still succeeds, so read the client back if you did not let the server pick. Legacy ciphers keep any non-empty password\n- **Hysteria** — `auth`\n- **mtproto** — `secret`, a FakeTLS secret derived from the fronting domain of the inbound, or from `www.cloudflare.com` when it has none\n- **WireGuard** — `privateKey` and `publicKey` when both are blank, or `publicKey` alone when only a `privateKey` was sent, plus `allowedIPs`: one free `/32` taken from the /24 the existing peers of that inbound already sit in, or from `10.0.0.0/24` when it has none\n\nAccepted on the same body but never generated: `preSharedKey` and `keepAlive` (WireGuard), `adTag` (mtproto).\n\nWireGuard is the only one of these that can fail. Allocation widens the search to the containing /16 before giving up with `wireguard: no free address available in <scope>`, and an `allowedIPs` supplied by the caller is validated instead of allocated: `wireguard: allowedIPs entry already used by another client: <address>` when a different client of that same inbound already holds it. The check is per inbound, so the same address on two different inbounds is accepted. The same validation runs on POST /panel/api/clients/{email}/attach, where a client that already carries an address brings it along.',
         params: [
           {
             name: 'client',
             in: 'body (json)',
             type: 'object',
-            desc: 'Client fields: email, subId, id (uuid), password, auth, flow, totalGB, expiryTime, limitIp, limitHwid, tgId (numeric Telegram user ID, 0 = none), comment, enable.',
+            desc: 'Client fields: email, subId, id (uuid), password, auth, flow, totalGB, expiryTime, limitIp, limitHwid, tgId (numeric Telegram user ID, 0 = none), comment, enable. Protocol-specific: secret and adTag (mtproto), privateKey, publicKey, preSharedKey, allowedIPs and keepAlive (WireGuard).',
           },
           {
             name: 'inboundIds',
@@ -898,6 +900,8 @@ export const sections: readonly Section[] = [
         method: 'POST',
         path: '/panel/api/clients/:email/attach',
         summary: 'Attach an existing client to one or more additional inbounds. Body is JSON.',
+        description:
+          'A WireGuard client brings its stored `allowedIPs` into the new inbound instead of being given a fresh address, so the call fails with `wireguard: allowedIPs entry already used by another client: <address>` when a different client of the target inbound already holds it. Free the address on that inbound first — see POST /panel/api/clients/add for the full rule.',
         params: [
           { name: 'email', in: 'path', type: 'string', desc: 'Client email (unique identifier).' },
           {

+ 61 - 0
frontend/src/test/use-all-settings.test.tsx

@@ -13,6 +13,67 @@ afterEach(() => {
 });
 
 describe('useAllSettings', () => {
+  it('refreshes cached default settings after a successful save', async () => {
+    vi.spyOn(HttpUtil, 'post').mockResolvedValue(new Msg(true, '', {}));
+    const queryClient = makeTestQueryClient();
+    const fetchDefaults = vi
+      .fn()
+      .mockResolvedValueOnce({ subURI: 'https://example.com/sub/' })
+      .mockResolvedValueOnce({ subURI: 'https://example.com/my_custom_path/' });
+    const defaultsQuery = {
+      queryKey: keys.settings.defaults(),
+      queryFn: fetchDefaults,
+      staleTime: Infinity,
+    };
+    const wrapper = ({ children }: { children: ReactNode }) => (
+      <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
+    );
+
+    await queryClient.fetchQuery(defaultsQuery);
+    const { result } = renderHook(() => useAllSettings(), { wrapper });
+
+    await waitFor(() => expect(result.current.fetched).toBe(true));
+    await act(async () => {
+      await result.current.saveAll();
+    });
+
+    const defaults = await queryClient.fetchQuery(defaultsQuery);
+    expect(fetchDefaults).toHaveBeenCalledTimes(2);
+    expect(defaults.subURI).toBe('https://example.com/my_custom_path/');
+  });
+
+  it('keeps cached default settings when a save fails', async () => {
+    vi.spyOn(HttpUtil, 'post').mockImplementation(async (url) => {
+      if (url === '/panel/api/setting/update') return new Msg(false, 'Save failed');
+      return new Msg(true, '', {});
+    });
+    const queryClient = makeTestQueryClient();
+    const fetchDefaults = vi
+      .fn()
+      .mockResolvedValueOnce({ subURI: 'https://example.com/sub/' })
+      .mockResolvedValueOnce({ subURI: 'https://example.com/my_custom_path/' });
+    const defaultsQuery = {
+      queryKey: keys.settings.defaults(),
+      queryFn: fetchDefaults,
+      staleTime: Infinity,
+    };
+    const wrapper = ({ children }: { children: ReactNode }) => (
+      <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
+    );
+
+    await queryClient.fetchQuery(defaultsQuery);
+    const { result } = renderHook(() => useAllSettings(), { wrapper });
+
+    await waitFor(() => expect(result.current.fetched).toBe(true));
+    await act(async () => {
+      await result.current.saveAll();
+    });
+
+    const defaults = await queryClient.fetchQuery(defaultsQuery);
+    expect(fetchDefaults).toHaveBeenCalledOnce();
+    expect(defaults.subURI).toBe('https://example.com/sub/');
+  });
+
   it('accepts legacy overlength regex settings without logging a response validation warning', async () => {
     const subJsonUserAgentRegex = 'x'.repeat(2_049);
     vi.spyOn(HttpUtil, 'post').mockResolvedValue(new Msg(true, '', { subJsonUserAgentRegex }));

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

@@ -390,6 +390,7 @@ func (j *NodeTrafficSyncJob) syncOne(mgr *runtime.Manager, n *model.Node, doIpSy
 		return nil
 	}
 	snap.ManagedAliases = rt.AdoptedInboundAliases()
+	syncCanAdopt := syncCanAdoptInbounds(n, snap.ManagedAliases)
 	service.FilterNodeSnapshot(n, snap)
 	_, _, dirty, _, _ := j.nodeService.NodeSyncState(n.Id)
 	if !dirty {
@@ -414,7 +415,7 @@ func (j *NodeTrafficSyncJob) syncOne(mgr *runtime.Manager, n *model.Node, doIpSy
 	if changed {
 		j.structural.set()
 	}
-	if !dirty && n.InboundsAdoptedAt == 0 {
+	if !dirty && n.InboundsAdoptedAt == 0 && syncCanAdopt {
 		if markErr := j.nodeService.MarkNodeInboundsAdopted(n.Id); markErr != nil {
 			logger.Warningf("node traffic sync: mark inbounds adopted for %s failed: %v", n.Name, markErr)
 		}
@@ -475,3 +476,12 @@ func (j *NodeTrafficSyncJob) syncOne(mgr *runtime.Manager, n *model.Node, doIpSy
 	}
 	return active
 }
+
+// Whether this sync can perform the "first clean adoption" that
+// InboundsAdoptedAt records (#6283).
+func syncCanAdoptInbounds(n *model.Node, adoptedAliases []string) bool {
+	if n == nil || n.InboundSyncMode != "selected" {
+		return true
+	}
+	return len(n.InboundTags) > 0 || len(adoptedAliases) > 0
+}

+ 43 - 0
internal/web/job/node_traffic_sync_job_test.go

@@ -3,6 +3,8 @@ package job
 import (
 	"sync"
 	"testing"
+
+	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
 )
 
 func TestAtomicBool_DefaultIsFalse(t *testing.T) {
@@ -67,3 +69,44 @@ func TestAtomicBool_ConcurrentSettersExactlyOneTakeWins(t *testing.T) {
 		t.Fatalf("expected exactly one reader to observe true, got %d", trueCount)
 	}
 }
+
+// Regression (#6283): a node onboarded in selected mode with an empty tag
+// list empties its snapshot via FilterNodeSnapshot before the merge sees it,
+// so that sync adopts nothing and must not stamp InboundsAdoptedAt.
+func TestSyncCanAdoptInbounds(t *testing.T) {
+	cases := []struct {
+		name     string
+		node     *model.Node
+		aliases  []string
+		expected bool
+	}{
+		{"all mode always adopts", &model.Node{InboundSyncMode: "all"}, nil, true},
+		{
+			"selected with tags adopts",
+			&model.Node{InboundSyncMode: "selected", InboundTags: []string{"in-443-tcp"}},
+			nil,
+			true,
+		},
+		{
+			"selected empty with adopted alias adopts",
+			&model.Node{InboundSyncMode: "selected"},
+			[]string{"in-443-tcp"},
+			true,
+		},
+		{
+			// The reported bug: registering in selected mode and choosing
+			// tags afterwards stamped adoption while adopting nothing.
+			"selected empty with no aliases adopts nothing",
+			&model.Node{InboundSyncMode: "selected"},
+			nil,
+			false,
+		},
+	}
+	for _, c := range cases {
+		t.Run(c.name, func(t *testing.T) {
+			if got := syncCanAdoptInbounds(c.node, c.aliases); got != c.expected {
+				t.Fatalf("syncCanAdoptInbounds(%+v, %v) = %v, want %v", c.node, c.aliases, got, c.expected)
+			}
+		})
+	}
+}