1
0

4 Revīzijas 1250fbb734 ... a3e617215c

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

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

@@ -494,13 +494,41 @@ jobs:
       - uses: actions/checkout@v7
       - uses: actions/checkout@v7
         with:
         with:
           persist-credentials: false
           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
       - uses: actions/checkout@v7
         with:
         with:
-          ref: refs/pull/${{ github.event.pull_request.number || github.event.issue.number }}/head
+          ref: ${{ steps.pinned-sha.outputs.sha }}
           path: pr-head
           path: pr-head
           persist-credentials: false
           persist-credentials: false
           allow-unsafe-pr-checkout: true
           allow-unsafe-pr-checkout: true
@@ -534,7 +562,7 @@ jobs:
           if-no-files-found: ignore
           if-no-files-found: ignore
           retention-days: 7
           retention-days: 7
       - name: Fail if the review posted nothing
       - name: Fail if the review posted nothing
-        if: ${{ !cancelled() }}
+        if: ${{ !cancelled() && steps.pinned-sha.outcome == 'success' }}
         env:
         env:
           GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
           GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
           REPO: ${{ github.repository }}
           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'
       url: '#fetch-one-client-by-email-including-the-inbound-ids-and-external-config-ids-it-is-attached-to'
     - depth: 2
     - depth: 2
       title: Create a new client and attach it to one or more inbounds in a single
       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
     - depth: 2
       title: Update an existing client by email. Changes propagate to every attached
       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
         inbound. Body is the JSON client payload — supply the full set of fields
@@ -278,11 +277,9 @@ _openapi:
           config IDs it is attached to.
           config IDs it is attached to.
         id: fetch-one-client-by-email-including-the-inbound-ids-and-external-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
       - 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
       - content: Update an existing client by email. Changes propagate to every attached
           inbound. Body is the JSON client payload — supply the full set of
           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
           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
       - content: Remove a single registered HWID device by its id, freeing one slot
           under the HWID limit.
           under the HWID limit.
         id: 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. */}
 {/* 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
     - depth: 2
       title: >-
       title: >-
         Create a new client and attach it to one or more inbounds in a single
         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: >-
       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
     - depth: 2
       title: >-
       title: >-
         Update an existing client by email. Changes propagate to every attached
         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
           fetch-one-client-by-email-including-the-inbound-ids-and-external-config-ids-it-is-attached-to
       - content: >-
       - content: >-
           Create a new client and attach it to one or more inbounds in a single
           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: >-
         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: >-
       - content: >-
           Update an existing client by email. Changes propagate to every
           Update an existing client by email. Changes propagate to every
           attached inbound. Body is the JSON client payload — supply the full
           attached inbound. Body is the JSON client payload — supply the full
@@ -610,7 +607,57 @@ _openapi:
           dokodemo, tunnel) contribute nothing.
           dokodemo, tunnel) contribute nothing.
         id: >-
         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
           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. */}
 {/* 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
     - depth: 2
       title: >-
       title: >-
         Create a new client and attach it to one or more inbounds in a single
         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: >-
       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
     - depth: 2
       title: >-
       title: >-
         Update an existing client by email. Changes propagate to every attached
         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
           fetch-one-client-by-email-including-the-inbound-ids-and-external-config-ids-it-is-attached-to
       - content: >-
       - content: >-
           Create a new client and attach it to one or more inbounds in a single
           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: >-
         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: >-
       - content: >-
           Update an existing client by email. Changes propagate to every
           Update an existing client by email. Changes propagate to every
           attached inbound. Body is the JSON client payload — supply the full
           attached inbound. Body is the JSON client payload — supply the full
@@ -610,7 +607,57 @@ _openapi:
           dokodemo, tunnel) contribute nothing.
           dokodemo, tunnel) contribute nothing.
         id: >-
         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
           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. */}
 {/* 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
         #fetch-one-client-by-email-including-the-inbound-ids-and-external-config-ids-it-is-attached-to
     - depth: 2
     - depth: 2
       title: >-
       title: >-
-        在一次调用中创建一个新客户端并将其挂载到一个或多个入站。请求体为 JSON。各协议的密钥
-        (VLESS/VMess 的 UUID、Trojan/Shadowsocks 的 password、Hysteria 的 auth)在
-        省略时由服务端生成,因此调用方只需发送通用字段。
+        在一次调用中创建一个新客户端并将其挂载到一个或多个入站。请求体为 JSON。各协议的密钥在省略时由服务端生成,因此调用方只需发送通用字段。
       url: >-
       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
     - depth: 2
       title: >-
       title: >-
         按 email 更新现有客户端。变更会传播到每个挂载的入站。请求体为 JSON 客户端载荷——
         按 email 更新现有客户端。变更会传播到每个挂载的入站。请求体为 JSON 客户端载荷——
@@ -290,11 +288,9 @@ _openapi:
         id: >-
         id: >-
           fetch-one-client-by-email-including-the-inbound-ids-and-external-config-ids-it-is-attached-to
           fetch-one-client-by-email-including-the-inbound-ids-and-external-config-ids-it-is-attached-to
       - content: >-
       - content: >-
-          在一次调用中创建一个新客户端并将其挂载到一个或多个入站。请求体为 JSON。各协议的密钥
-          (VLESS/VMess 的 UUID、Trojan/Shadowsocks 的 password、Hysteria 的 auth)在
-          省略时由服务端生成,因此调用方只需发送通用字段。
+          在一次调用中创建一个新客户端并将其挂载到一个或多个入站。请求体为 JSON。各协议的密钥在省略时由服务端生成,因此调用方只需发送通用字段。
         id: >-
         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: >-
       - content: >-
           按 email 更新现有客户端。变更会传播到每个挂载的入站。请求体为 JSON 客户端载荷——
           按 email 更新现有客户端。变更会传播到每个挂载的入站。请求体为 JSON 客户端载荷——
           请提供你希望保留的完整字段集(服务端会替换整条记录,而非局部更新)。
           请提供你希望保留的完整字段集(服务端会替换整条记录,而非局部更新)。
@@ -493,7 +489,32 @@ _openapi:
           (socks、http、mixed、wireguard、dokodemo、tunnel)不产生任何内容。
           (socks、http、mixed、wireguard、dokodemo、tunnel)不产生任何内容。
         id: >-
         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
           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. */}
 {/* 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": [
         "tags": [
           "Clients"
           "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",
         "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": {
         "requestBody": {
           "required": true,
           "required": true,
           "content": {
           "content": {
@@ -5152,6 +5153,7 @@
         ],
         ],
         "summary": "Attach an existing client to one or more additional inbounds. Body is JSON.",
         "summary": "Attach an existing client to one or more additional inbounds. Body is JSON.",
         "operationId": "post_panel_api_clients_email_attach",
         "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": [
         "parameters": [
           {
           {
             "name": "email",
             "name": "email",

+ 3 - 1
frontend/public/openapi.json

@@ -6245,8 +6245,9 @@
         "tags": [
         "tags": [
           "Clients"
           "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",
         "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": {
         "requestBody": {
           "required": true,
           "required": true,
           "content": {
           "content": {
@@ -6423,6 +6424,7 @@
         ],
         ],
         "summary": "Attach an existing client to one or more additional inbounds. Body is JSON.",
         "summary": "Attach an existing client to one or more additional inbounds. Body is JSON.",
         "operationId": "post_panel_api_clients_email_attach",
         "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": [
         "parameters": [
           {
           {
             "name": "email",
             "name": "email",

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

@@ -77,6 +77,7 @@ export function useAllSettings() {
       if (!msg?.success) return;
       if (!msg?.success) return;
       if (saved) markSaved(saved);
       if (saved) markSaved(saved);
       queryClient.invalidateQueries({ queryKey: keys.settings.all() });
       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',
         method: 'POST',
         path: '/panel/api/clients/add',
         path: '/panel/api/clients/add',
         summary:
         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: [
         params: [
           {
           {
             name: 'client',
             name: 'client',
             in: 'body (json)',
             in: 'body (json)',
             type: 'object',
             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',
             name: 'inboundIds',
@@ -898,6 +900,8 @@ export const sections: readonly Section[] = [
         method: 'POST',
         method: 'POST',
         path: '/panel/api/clients/:email/attach',
         path: '/panel/api/clients/:email/attach',
         summary: 'Attach an existing client to one or more additional inbounds. Body is JSON.',
         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: [
         params: [
           { name: 'email', in: 'path', type: 'string', desc: 'Client email (unique identifier).' },
           { 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', () => {
 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 () => {
   it('accepts legacy overlength regex settings without logging a response validation warning', async () => {
     const subJsonUserAgentRegex = 'x'.repeat(2_049);
     const subJsonUserAgentRegex = 'x'.repeat(2_049);
     vi.spyOn(HttpUtil, 'post').mockResolvedValue(new Msg(true, '', { subJsonUserAgentRegex }));
     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
 		return nil
 	}
 	}
 	snap.ManagedAliases = rt.AdoptedInboundAliases()
 	snap.ManagedAliases = rt.AdoptedInboundAliases()
+	syncCanAdopt := syncCanAdoptInbounds(n, snap.ManagedAliases)
 	service.FilterNodeSnapshot(n, snap)
 	service.FilterNodeSnapshot(n, snap)
 	_, _, dirty, _, _ := j.nodeService.NodeSyncState(n.Id)
 	_, _, dirty, _, _ := j.nodeService.NodeSyncState(n.Id)
 	if !dirty {
 	if !dirty {
@@ -414,7 +415,7 @@ func (j *NodeTrafficSyncJob) syncOne(mgr *runtime.Manager, n *model.Node, doIpSy
 	if changed {
 	if changed {
 		j.structural.set()
 		j.structural.set()
 	}
 	}
-	if !dirty && n.InboundsAdoptedAt == 0 {
+	if !dirty && n.InboundsAdoptedAt == 0 && syncCanAdopt {
 		if markErr := j.nodeService.MarkNodeInboundsAdopted(n.Id); markErr != nil {
 		if markErr := j.nodeService.MarkNodeInboundsAdopted(n.Id); markErr != nil {
 			logger.Warningf("node traffic sync: mark inbounds adopted for %s failed: %v", n.Name, markErr)
 			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
 	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 (
 import (
 	"sync"
 	"sync"
 	"testing"
 	"testing"
+
+	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
 )
 )
 
 
 func TestAtomicBool_DefaultIsFalse(t *testing.T) {
 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)
 		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)
+			}
+		})
+	}
+}