diff --git a/docs/content/docs/en/reference/api/inbounds.mdx b/docs/content/docs/en/reference/api/inbounds.mdx
index 02de5561a..9ca337ea2 100644
--- a/docs/content/docs/en/reference/api/inbounds.mdx
+++ b/docs/content/docs/en/reference/api/inbounds.mdx
@@ -1,8 +1,7 @@
---
title: Inbounds
-description: >-
- Manage inbound configurations and their clients. All endpoints live under
- /panel/api/inbounds and require a logged-in session or Bearer token.
+description: Manage inbound configurations and their clients. All endpoints live
+ under /panel/api/inbounds and require a logged-in session or Bearer token.
Link-generating endpoints honour forwarded headers only when the request comes
from a configured trusted proxy.
full: true
@@ -11,25 +10,20 @@ _openapi:
- ./public/openapi.json
toc:
- depth: 2
- title: >-
- List every inbound owned by the authenticated user, including each
+ title: List every inbound owned by the authenticated user, including each
inbound’s clientStats traffic counters. settings, streamSettings, and
sniffing are returned as nested JSON objects (no escaped strings);
legacy callers that send them back as JSON-encoded strings are still
accepted on write.
- url: >-
- #list-every-inbound-owned-by-the-authenticated-user-including-each-inbounds-clientstats-traffic-counters-settings-streamsettings-and-sniffing-are-returned-as-nested-json-objects-no-escaped-strings-legacy-callers-that-send-them-back-as-json-encoded-strings-are-still-accepted-on-write
+ url: '#list-every-inbound-owned-by-the-authenticated-user-including-each-inbounds-clientstats-traffic-counters-settings-streamsettings-and-sniffing-are-returned-as-nested-json-objects-no-escaped-strings-legacy-callers-that-send-them-back-as-json-encoded-strings-are-still-accepted-on-write'
- depth: 2
- title: >-
- Same shape as /list but with settings.clients[] stripped down to {email,
+ title: Same shape as /list but with settings.clients[] stripped down to {email,
enable, comment} and ClientStats not enriched with UUID/SubId. Use this
for list pages; fetch /get/:id when you need the full per-client payload
(uuid, password, flow, ...).
- url: >-
- #same-shape-as-list-but-with-settingsclients-stripped-down-to-email-enable-comment-and-clientstats-not-enriched-with-uuidsubid-use-this-for-list-pages-fetch-getid-when-you-need-the-full-per-client-payload-uuid-password-flow-
+ url: '#same-shape-as-list-but-with-settingsclients-stripped-down-to-email-enable-comment-and-clientstats-not-enriched-with-uuidsubid-use-this-for-list-pages-fetch-getid-when-you-need-the-full-per-client-payload-uuid-password-flow-'
- depth: 2
- title: >-
- Lightweight picker projection of the authenticated user’s inbounds.
+ title: Lightweight picker projection of the authenticated user’s inbounds.
Returns id, remark, tag, protocol, port, a server-computed
tlsFlowCapable flag (true for VLESS on TCP with tls or reality, or on
XHTTP with VLESS encryption / vlessenc enabled), and ssMethod (the
@@ -38,110 +32,86 @@ _openapi:
dropdowns and attach pickers — it skips settings, streamSettings, and
clientStats so the payload stays small even on panels with thousands of
clients.
- url: >-
- #lightweight-picker-projection-of-the-authenticated-users-inbounds-returns-id-remark-tag-protocol-port-a-server-computed-tlsflowcapable-flag-true-for-vless-on-tcp-with-tls-or-reality-or-on-xhttp-with-vless-encryption--vlessenc-enabled-and-ssmethod-the-shadowsocks-cipher-empty-for-non-shadowsocks-inbounds--used-by-the-client-ui-to-generate-a-valid-shadowsocks-2022-psk-use-this-for-dropdowns-and-attach-pickers--it-skips-settings-streamsettings-and-clientstats-so-the-payload-stays-small-even-on-panels-with-thousands-of-clients
+ url: '#lightweight-picker-projection-of-the-authenticated-users-inbounds-returns-id-remark-tag-protocol-port-a-server-computed-tlsflowcapable-flag-true-for-vless-on-tcp-with-tls-or-reality-or-on-xhttp-with-vless-encryption--vlessenc-enabled-and-ssmethod-the-shadowsocks-cipher-empty-for-non-shadowsocks-inbounds--used-by-the-client-ui-to-generate-a-valid-shadowsocks-2022-psk-use-this-for-dropdowns-and-attach-pickers--it-skips-settings-streamsettings-and-clientstats-so-the-payload-stays-small-even-on-panels-with-thousands-of-clients'
- depth: 2
title: Fetch a single inbound by numeric ID.
url: '#fetch-a-single-inbound-by-numeric-id'
- depth: 2
- title: >-
- Create a new inbound. Send the full inbound payload (protocol, port,
+ title: Create a new inbound. Send the full inbound payload (protocol, port,
settings, streamSettings, sniffing, remark, expiryTime, total, enable).
settings, streamSettings, and sniffing may be sent as nested JSON
objects (preferred) or as JSON-encoded strings (legacy).
- url: >-
- #create-a-new-inbound-send-the-full-inbound-payload-protocol-port-settings-streamsettings-sniffing-remark-expirytime-total-enable-settings-streamsettings-and-sniffing-may-be-sent-as-nested-json-objects-preferred-or-as-json-encoded-strings-legacy
+ url: '#create-a-new-inbound-send-the-full-inbound-payload-protocol-port-settings-streamsettings-sniffing-remark-expirytime-total-enable-settings-streamsettings-and-sniffing-may-be-sent-as-nested-json-objects-preferred-or-as-json-encoded-strings-legacy'
- depth: 2
title: Delete an inbound by ID. Also removes its associated client stats rows.
url: '#delete-an-inbound-by-id-also-removes-its-associated-client-stats-rows'
- depth: 2
- title: >-
- Delete many inbounds in one call. Processes the list sequentially;
+ title: Delete many inbounds in one call. Processes the list sequentially;
failures are reported per id and the rest still proceed. Restarts xray
at most once.
- url: >-
- #delete-many-inbounds-in-one-call-processes-the-list-sequentially-failures-are-reported-per-id-and-the-rest-still-proceed-restarts-xray-at-most-once
+ url: '#delete-many-inbounds-in-one-call-processes-the-list-sequentially-failures-are-reported-per-id-and-the-rest-still-proceed-restarts-xray-at-most-once'
- depth: 2
- title: >-
- Replace an inbound’s configuration. Body shape mirrors /add. Heavy on
+ title: Replace an inbound’s configuration. Body shape mirrors /add. Heavy on
inbounds with thousands of clients — prefer /setEnable for enable-only
flips.
- url: >-
- #replace-an-inbounds-configuration-body-shape-mirrors-add-heavy-on-inbounds-with-thousands-of-clients--prefer-setenable-for-enable-only-flips
+ url: '#replace-an-inbounds-configuration-body-shape-mirrors-add-heavy-on-inbounds-with-thousands-of-clients--prefer-setenable-for-enable-only-flips'
- depth: 2
- title: >-
- Toggle only the enable flag without serialising the whole settings JSON.
+ title: Toggle only the enable flag without serialising the whole settings JSON.
Recommended for UI switches on large inbounds.
- url: >-
- #toggle-only-the-enable-flag-without-serialising-the-whole-settings-json-recommended-for-ui-switches-on-large-inbounds
+ url: '#toggle-only-the-enable-flag-without-serialising-the-whole-settings-json-recommended-for-ui-switches-on-large-inbounds'
- depth: 2
- title: >-
- Zero out upload + download counters for a single inbound. Does not touch
+ title: Zero out upload + download counters for a single inbound. Does not touch
per-client counters.
- url: >-
- #zero-out-upload--download-counters-for-a-single-inbound-does-not-touch-per-client-counters
+ url: '#zero-out-upload--download-counters-for-a-single-inbound-does-not-touch-per-client-counters'
- depth: 2
- title: >-
- Remove every client attached to a single inbound while keeping the
+ title: Remove every client attached to a single inbound while keeping the
inbound itself. Collects emails from settings.clients[] and feeds them
into the optimized bulk-delete path (runtime user removal + traffic-row
cleanup + SyncInbound). Destructive and cannot be undone.
- url: >-
- #remove-every-client-attached-to-a-single-inbound-while-keeping-the-inbound-itself-collects-emails-from-settingsclients-and-feeds-them-into-the-optimized-bulk-delete-path-runtime-user-removal--traffic-row-cleanup--syncinbound-destructive-and-cannot-be-undone
+ url: '#remove-every-client-attached-to-a-single-inbound-while-keeping-the-inbound-itself-collects-emails-from-settingsclients-and-feeds-them-into-the-optimized-bulk-delete-path-runtime-user-removal--traffic-row-cleanup--syncinbound-destructive-and-cannot-be-undone'
- depth: 2
- title: >-
- Reset upload + download counters on every inbound. Destructive —
+ title: Reset upload + download counters on every inbound. Destructive —
accounting history is lost.
- url: >-
- #reset-upload--download-counters-on-every-inbound-destructive--accounting-history-is-lost
+ url: '#reset-upload--download-counters-on-every-inbound-destructive--accounting-history-is-lost'
- depth: 2
- title: >-
- Bulk-import an inbound from a JSON blob (e.g. one exported via the UI).
+ title: Bulk-import an inbound from a JSON blob (e.g. one exported via the UI).
The body uses form encoding with a single "data" field.
- url: >-
- #bulk-import-an-inbound-from-a-json-blob-eg-one-exported-via-the-ui-the-body-uses-form-encoding-with-a-single-data-field
+ url: '#bulk-import-an-inbound-from-a-json-blob-eg-one-exported-via-the-ui-the-body-uses-form-encoding-with-a-single-data-field'
- depth: 2
- title: >-
- Receive a master panel's aggregated per-client usage, keyed by the
+ title: Receive a master panel's aggregated per-client usage, keyed by the
master's GUID. Stored in a side table used only for the UI display
overlay and local quota enforcement — never folded into the local
counters that masters poll, so delta accounting stays intact. Called
panel-to-panel by the node traffic sync job.
- url: >-
- #receive-a-master-panels-aggregated-per-client-usage-keyed-by-the-masters-guid-stored-in-a-side-table-used-only-for-the-ui-display-overlay-and-local-quota-enforcement--never-folded-into-the-local-counters-that-masters-poll-so-delta-accounting-stays-intact-called-panel-to-panel-by-the-node-traffic-sync-job
+ url: '#receive-a-master-panels-aggregated-per-client-usage-keyed-by-the-masters-guid-stored-in-a-side-table-used-only-for-the-ui-display-overlay-and-local-quota-enforcement--never-folded-into-the-local-counters-that-masters-poll-so-delta-accounting-stays-intact-called-panel-to-panel-by-the-node-traffic-sync-job'
- depth: 2
- title: >-
- List the fallback rules attached to a master VLESS/Trojan TCP-TLS
+ title: List the fallback rules attached to a master VLESS/Trojan TCP-TLS
inbound. Each rule links one child inbound (the dest) to optional
SNI/ALPN/path/dest/xver match criteria. When dest is empty the child
inbound's listen+port is used.
- url: >-
- #list-the-fallback-rules-attached-to-a-master-vlesstrojan-tcp-tls-inbound-each-rule-links-one-child-inbound-the-dest-to-optional-snialpnpathdestxver-match-criteria-when-dest-is-empty-the-child-inbounds-listenport-is-used
+ url: '#list-the-fallback-rules-attached-to-a-master-vlesstrojan-tcp-tls-inbound-each-rule-links-one-child-inbound-the-dest-to-optional-snialpnpathdestxver-match-criteria-when-dest-is-empty-the-child-inbounds-listenport-is-used'
- depth: 2
- title: >-
- Replace the entire fallback list for a master inbound. Body is JSON.
+ title: Replace the entire fallback list for a master inbound. Body is JSON.
Triggers an Xray restart.
- url: >-
- #replace-the-entire-fallback-list-for-a-master-inbound-body-is-json-triggers-an-xray-restart
+ url: '#replace-the-entire-fallback-list-for-a-master-inbound-body-is-json-triggers-an-xray-restart'
+ - depth: 2
+ title: Set only the subscription sort order. Reads the stored inbound, so a
+ reorder cannot carry a stale client list over a concurrent edit.
+ url: '#set-only-the-subscription-sort-order-reads-the-stored-inbound-so-a-reorder-cannot-carry-a-stale-client-list-over-a-concurrent-edit'
structuredData:
headings:
- - content: >-
- List every inbound owned by the authenticated user, including each
+ - content: List every inbound owned by the authenticated user, including each
inbound’s clientStats traffic counters. settings, streamSettings, and
sniffing are returned as nested JSON objects (no escaped strings);
legacy callers that send them back as JSON-encoded strings are still
accepted on write.
- id: >-
- list-every-inbound-owned-by-the-authenticated-user-including-each-inbounds-clientstats-traffic-counters-settings-streamsettings-and-sniffing-are-returned-as-nested-json-objects-no-escaped-strings-legacy-callers-that-send-them-back-as-json-encoded-strings-are-still-accepted-on-write
- - content: >-
- Same shape as /list but with settings.clients[] stripped down to
+ id: list-every-inbound-owned-by-the-authenticated-user-including-each-inbounds-clientstats-traffic-counters-settings-streamsettings-and-sniffing-are-returned-as-nested-json-objects-no-escaped-strings-legacy-callers-that-send-them-back-as-json-encoded-strings-are-still-accepted-on-write
+ - content: Same shape as /list but with settings.clients[] stripped down to
{email, enable, comment} and ClientStats not enriched with UUID/SubId.
Use this for list pages; fetch /get/:id when you need the full
per-client payload (uuid, password, flow, ...).
- id: >-
- same-shape-as-list-but-with-settingsclients-stripped-down-to-email-enable-comment-and-clientstats-not-enriched-with-uuidsubid-use-this-for-list-pages-fetch-getid-when-you-need-the-full-per-client-payload-uuid-password-flow-
- - content: >-
- Lightweight picker projection of the authenticated user’s inbounds.
+ id: same-shape-as-list-but-with-settingsclients-stripped-down-to-email-enable-comment-and-clientstats-not-enriched-with-uuidsubid-use-this-for-list-pages-fetch-getid-when-you-need-the-full-per-client-payload-uuid-password-flow-
+ - content: Lightweight picker projection of the authenticated user’s inbounds.
Returns id, remark, tag, protocol, port, a server-computed
tlsFlowCapable flag (true for VLESS on TCP with tls or reality, or on
XHTTP with VLESS encryption / vlessenc enabled), and ssMethod (the
@@ -150,80 +120,58 @@ _openapi:
dropdowns and attach pickers — it skips settings, streamSettings, and
clientStats so the payload stays small even on panels with thousands
of clients.
- id: >-
- lightweight-picker-projection-of-the-authenticated-users-inbounds-returns-id-remark-tag-protocol-port-a-server-computed-tlsflowcapable-flag-true-for-vless-on-tcp-with-tls-or-reality-or-on-xhttp-with-vless-encryption--vlessenc-enabled-and-ssmethod-the-shadowsocks-cipher-empty-for-non-shadowsocks-inbounds--used-by-the-client-ui-to-generate-a-valid-shadowsocks-2022-psk-use-this-for-dropdowns-and-attach-pickers--it-skips-settings-streamsettings-and-clientstats-so-the-payload-stays-small-even-on-panels-with-thousands-of-clients
+ id: lightweight-picker-projection-of-the-authenticated-users-inbounds-returns-id-remark-tag-protocol-port-a-server-computed-tlsflowcapable-flag-true-for-vless-on-tcp-with-tls-or-reality-or-on-xhttp-with-vless-encryption--vlessenc-enabled-and-ssmethod-the-shadowsocks-cipher-empty-for-non-shadowsocks-inbounds--used-by-the-client-ui-to-generate-a-valid-shadowsocks-2022-psk-use-this-for-dropdowns-and-attach-pickers--it-skips-settings-streamsettings-and-clientstats-so-the-payload-stays-small-even-on-panels-with-thousands-of-clients
- content: Fetch a single inbound by numeric ID.
id: fetch-a-single-inbound-by-numeric-id
- - content: >-
- Create a new inbound. Send the full inbound payload (protocol, port,
+ - content: Create a new inbound. Send the full inbound payload (protocol, port,
settings, streamSettings, sniffing, remark, expiryTime, total,
enable). settings, streamSettings, and sniffing may be sent as nested
JSON objects (preferred) or as JSON-encoded strings (legacy).
- id: >-
- create-a-new-inbound-send-the-full-inbound-payload-protocol-port-settings-streamsettings-sniffing-remark-expirytime-total-enable-settings-streamsettings-and-sniffing-may-be-sent-as-nested-json-objects-preferred-or-as-json-encoded-strings-legacy
- - content: >-
- Delete an inbound by ID. Also removes its associated client stats
- rows.
+ id: create-a-new-inbound-send-the-full-inbound-payload-protocol-port-settings-streamsettings-sniffing-remark-expirytime-total-enable-settings-streamsettings-and-sniffing-may-be-sent-as-nested-json-objects-preferred-or-as-json-encoded-strings-legacy
+ - content: Delete an inbound by ID. Also removes its associated client stats rows.
id: delete-an-inbound-by-id-also-removes-its-associated-client-stats-rows
- - content: >-
- Delete many inbounds in one call. Processes the list sequentially;
+ - content: Delete many inbounds in one call. Processes the list sequentially;
failures are reported per id and the rest still proceed. Restarts xray
at most once.
- id: >-
- delete-many-inbounds-in-one-call-processes-the-list-sequentially-failures-are-reported-per-id-and-the-rest-still-proceed-restarts-xray-at-most-once
- - content: >-
- Replace an inbound’s configuration. Body shape mirrors /add. Heavy on
+ id: delete-many-inbounds-in-one-call-processes-the-list-sequentially-failures-are-reported-per-id-and-the-rest-still-proceed-restarts-xray-at-most-once
+ - content: Replace an inbound’s configuration. Body shape mirrors /add. Heavy on
inbounds with thousands of clients — prefer /setEnable for enable-only
flips.
- id: >-
- replace-an-inbounds-configuration-body-shape-mirrors-add-heavy-on-inbounds-with-thousands-of-clients--prefer-setenable-for-enable-only-flips
- - content: >-
- Toggle only the enable flag without serialising the whole settings
+ id: replace-an-inbounds-configuration-body-shape-mirrors-add-heavy-on-inbounds-with-thousands-of-clients--prefer-setenable-for-enable-only-flips
+ - content: Toggle only the enable flag without serialising the whole settings
JSON. Recommended for UI switches on large inbounds.
- id: >-
- toggle-only-the-enable-flag-without-serialising-the-whole-settings-json-recommended-for-ui-switches-on-large-inbounds
- - content: >-
- Zero out upload + download counters for a single inbound. Does not
+ id: toggle-only-the-enable-flag-without-serialising-the-whole-settings-json-recommended-for-ui-switches-on-large-inbounds
+ - content: Zero out upload + download counters for a single inbound. Does not
touch per-client counters.
- id: >-
- zero-out-upload--download-counters-for-a-single-inbound-does-not-touch-per-client-counters
- - content: >-
- Remove every client attached to a single inbound while keeping the
+ id: zero-out-upload--download-counters-for-a-single-inbound-does-not-touch-per-client-counters
+ - content: Remove every client attached to a single inbound while keeping the
inbound itself. Collects emails from settings.clients[] and feeds them
into the optimized bulk-delete path (runtime user removal +
traffic-row cleanup + SyncInbound). Destructive and cannot be undone.
- id: >-
- remove-every-client-attached-to-a-single-inbound-while-keeping-the-inbound-itself-collects-emails-from-settingsclients-and-feeds-them-into-the-optimized-bulk-delete-path-runtime-user-removal--traffic-row-cleanup--syncinbound-destructive-and-cannot-be-undone
- - content: >-
- Reset upload + download counters on every inbound. Destructive —
+ id: remove-every-client-attached-to-a-single-inbound-while-keeping-the-inbound-itself-collects-emails-from-settingsclients-and-feeds-them-into-the-optimized-bulk-delete-path-runtime-user-removal--traffic-row-cleanup--syncinbound-destructive-and-cannot-be-undone
+ - content: Reset upload + download counters on every inbound. Destructive —
accounting history is lost.
- id: >-
- reset-upload--download-counters-on-every-inbound-destructive--accounting-history-is-lost
- - content: >-
- Bulk-import an inbound from a JSON blob (e.g. one exported via the
- UI). The body uses form encoding with a single "data" field.
- id: >-
- bulk-import-an-inbound-from-a-json-blob-eg-one-exported-via-the-ui-the-body-uses-form-encoding-with-a-single-data-field
- - content: >-
- Receive a master panel's aggregated per-client usage, keyed by the
+ id: reset-upload--download-counters-on-every-inbound-destructive--accounting-history-is-lost
+ - content: Bulk-import an inbound from a JSON blob (e.g. one exported via the UI).
+ The body uses form encoding with a single "data" field.
+ id: bulk-import-an-inbound-from-a-json-blob-eg-one-exported-via-the-ui-the-body-uses-form-encoding-with-a-single-data-field
+ - content: Receive a master panel's aggregated per-client usage, keyed by the
master's GUID. Stored in a side table used only for the UI display
overlay and local quota enforcement — never folded into the local
counters that masters poll, so delta accounting stays intact. Called
panel-to-panel by the node traffic sync job.
- id: >-
- receive-a-master-panels-aggregated-per-client-usage-keyed-by-the-masters-guid-stored-in-a-side-table-used-only-for-the-ui-display-overlay-and-local-quota-enforcement--never-folded-into-the-local-counters-that-masters-poll-so-delta-accounting-stays-intact-called-panel-to-panel-by-the-node-traffic-sync-job
- - content: >-
- List the fallback rules attached to a master VLESS/Trojan TCP-TLS
+ id: receive-a-master-panels-aggregated-per-client-usage-keyed-by-the-masters-guid-stored-in-a-side-table-used-only-for-the-ui-display-overlay-and-local-quota-enforcement--never-folded-into-the-local-counters-that-masters-poll-so-delta-accounting-stays-intact-called-panel-to-panel-by-the-node-traffic-sync-job
+ - content: List the fallback rules attached to a master VLESS/Trojan TCP-TLS
inbound. Each rule links one child inbound (the dest) to optional
SNI/ALPN/path/dest/xver match criteria. When dest is empty the child
inbound's listen+port is used.
- id: >-
- list-the-fallback-rules-attached-to-a-master-vlesstrojan-tcp-tls-inbound-each-rule-links-one-child-inbound-the-dest-to-optional-snialpnpathdestxver-match-criteria-when-dest-is-empty-the-child-inbounds-listenport-is-used
- - content: >-
- Replace the entire fallback list for a master inbound. Body is JSON.
+ id: list-the-fallback-rules-attached-to-a-master-vlesstrojan-tcp-tls-inbound-each-rule-links-one-child-inbound-the-dest-to-optional-snialpnpathdestxver-match-criteria-when-dest-is-empty-the-child-inbounds-listenport-is-used
+ - content: Replace the entire fallback list for a master inbound. Body is JSON.
Triggers an Xray restart.
- id: >-
- replace-the-entire-fallback-list-for-a-master-inbound-body-is-json-triggers-an-xray-restart
+ id: replace-the-entire-fallback-list-for-a-master-inbound-body-is-json-triggers-an-xray-restart
+ - content: Set only the subscription sort order. Reads the stored inbound, so a
+ reorder cannot carry a stale client list over a concurrent edit.
+ id: set-only-the-subscription-sort-order-reads-the-stored-inbound-so-a-reorder-cannot-carry-a-stale-client-list-over-a-concurrent-edit
contents: []
---
@@ -236,7 +184,7 @@ export default function Layout(props) {
return (
<>
{props.children}
-
+
>
);
}
\ No newline at end of file
diff --git a/docs/public/openapi.json b/docs/public/openapi.json
index d82f6de1d..863b08968 100644
--- a/docs/public/openapi.json
+++ b/docs/public/openapi.json
@@ -10200,6 +10200,60 @@
}
}
}
+ },
+ "/panel/api/inbounds/{id}/subSortIndex": {
+ "post": {
+ "tags": [
+ "Inbounds"
+ ],
+ "summary": "Set only the subscription sort order. Reads the stored inbound, so a reorder cannot carry a stale client list over a concurrent edit.",
+ "operationId": "post_panel_api_inbounds_id_subSortIndex",
+ "parameters": [
+ {
+ "name": "id",
+ "in": "path",
+ "required": true,
+ "description": "Inbound ID.",
+ "schema": {
+ "type": "integer"
+ }
+ }
+ ],
+ "requestBody": {
+ "required": true,
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object"
+ },
+ "example": {
+ "subSortIndex": 2
+ }
+ }
+ }
+ },
+ "responses": {
+ "200": {
+ "description": "Successful response",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "properties": {
+ "success": {
+ "type": "boolean"
+ },
+ "msg": {
+ "type": "string"
+ },
+ "obj": {}
+ }
+ }
+ }
+ }
+ }
+ }
+ }
}
}
}
diff --git a/frontend/public/openapi.json b/frontend/public/openapi.json
index 9f2b4e957..53178b385 100644
--- a/frontend/public/openapi.json
+++ b/frontend/public/openapi.json
@@ -3837,6 +3837,60 @@
}
}
},
+ "/panel/api/inbounds/{id}/subSortIndex": {
+ "post": {
+ "tags": [
+ "Inbounds"
+ ],
+ "summary": "Set only the subscription sort order. Reads the stored inbound, so a reorder cannot carry a stale client list over a concurrent edit.",
+ "operationId": "post_panel_api_inbounds_id_subSortIndex",
+ "parameters": [
+ {
+ "name": "id",
+ "in": "path",
+ "required": true,
+ "description": "Inbound ID.",
+ "schema": {
+ "type": "integer"
+ }
+ }
+ ],
+ "requestBody": {
+ "required": true,
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object"
+ },
+ "example": {
+ "subSortIndex": 2
+ }
+ }
+ }
+ },
+ "responses": {
+ "200": {
+ "description": "Successful response",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "properties": {
+ "success": {
+ "type": "boolean"
+ },
+ "msg": {
+ "type": "string"
+ },
+ "obj": {}
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ },
"/panel/api/inbounds/{id}/resetTraffic": {
"post": {
"tags": [
diff --git a/frontend/src/pages/api-docs/endpoints.ts b/frontend/src/pages/api-docs/endpoints.ts
index 06e6e6f41..2decc2658 100644
--- a/frontend/src/pages/api-docs/endpoints.ts
+++ b/frontend/src/pages/api-docs/endpoints.ts
@@ -183,6 +183,15 @@ export const sections: readonly Section[] = [
],
body: '{\n "enable": false\n}',
},
+ {
+ method: 'POST',
+ path: '/panel/api/inbounds/:id/subSortIndex',
+ summary: 'Set only the subscription sort order. Reads the stored inbound, so a reorder cannot carry a stale client list over a concurrent edit.',
+ params: [
+ { name: 'id', in: 'path', type: 'number', desc: 'Inbound ID.' },
+ ],
+ body: '{\n "subSortIndex": 2\n}',
+ },
{
method: 'POST',
path: '/panel/api/inbounds/:id/resetTraffic',
diff --git a/internal/web/controller/inbound.go b/internal/web/controller/inbound.go
index d641dea00..f9460e8e7 100644
--- a/internal/web/controller/inbound.go
+++ b/internal/web/controller/inbound.go
@@ -73,6 +73,7 @@ func (a *InboundController) initRouter(g *gin.RouterGroup) {
g.POST("/bulkDel", a.bulkDelInbounds)
g.POST("/update/:id", a.updateInbound)
g.POST("/setEnable/:id", a.setInboundEnable)
+ g.POST("/:id/subSortIndex", a.setInboundSubSortIndex)
g.POST("/:id/resetTraffic", a.resetInboundTraffic)
g.POST("/:id/delAllClients", a.delAllInboundClients)
g.POST("/resetAllTraffics", a.resetAllTraffics)
@@ -255,11 +256,30 @@ func (a *InboundController) updateInbound(c *gin.Context) {
notifyClientsChanged()
}
-// setInboundEnable flips only the enable flag of an inbound. This is a
-// dedicated endpoint because the regular update path serialises the entire
-// settings JSON (every client) — far too heavy for an interactive switch
-// on inbounds with thousands of clients. Frontend optimistically updates
-// the UI; we just persist + sync xray + nudge other open admin sessions.
+// setInboundSubSortIndex changes only subscription ordering without sending
+// the inbound's settings/client payload.
+func (a *InboundController) setInboundSubSortIndex(c *gin.Context) {
+ id, err := strconv.Atoi(c.Param("id"))
+ if err != nil {
+ jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.inboundUpdateSuccess"), err)
+ return
+ }
+ type form struct {
+ SubSortIndex int `json:"subSortIndex" form:"subSortIndex" binding:"required,min=1"`
+ }
+ var f form
+ if err := c.ShouldBind(&f); err != nil {
+ jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
+ return
+ }
+ if err := a.inboundService.SetInboundSubSortIndex(id, f.SubSortIndex); err != nil {
+ jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
+ return
+ }
+ jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.inboundUpdateSuccess"), nil)
+ websocket.BroadcastInvalidate(websocket.MessageTypeInbounds)
+}
+
func (a *InboundController) setInboundEnable(c *gin.Context) {
id, err := strconv.Atoi(c.Param("id"))
if err != nil {
diff --git a/internal/web/runtime/remote.go b/internal/web/runtime/remote.go
index c8ca0596f..a851e66bf 100644
--- a/internal/web/runtime/remote.go
+++ b/internal/web/runtime/remote.go
@@ -458,6 +458,16 @@ func (r *Remote) UpdateInbound(ctx context.Context, oldIb, newIb *model.Inbound)
return nil
}
+func (r *Remote) SetInboundSubSortIndex(ctx context.Context, ib *model.Inbound, index int) error {
+ id, err := r.resolveRemoteID(ctx, ib.Tag)
+ if err != nil {
+ return err
+ }
+ payload := url.Values{"subSortIndex": []string{strconv.Itoa(index)}}
+ _, err = r.do(ctx, http.MethodPost, "panel/api/inbounds/"+strconv.Itoa(id)+"/subSortIndex", payload)
+ return err
+}
+
// ReconcileInbound pushes ib only when its wire payload differs from the last
// successful push, or when the node no longer reports the tag (existsOnNode
// false) — a node that dropped/restarted must still be re-seeded. Returns
diff --git a/internal/web/runtime/remote_test.go b/internal/web/runtime/remote_test.go
index 2701ca738..35b06b55b 100644
--- a/internal/web/runtime/remote_test.go
+++ b/internal/web/runtime/remote_test.go
@@ -54,6 +54,37 @@ func TestRemoteDo_AcceptsNormalResponse(t *testing.T) {
}
}
+func TestRemoteSetInboundSubSortIndexSendsOnlyNarrowField(t *testing.T) {
+ var posted url.Values
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
+ w.Header().Set("Content-Type", "application/json")
+ switch req.URL.Path {
+ case "/panel/api/inbounds/list":
+ _, _ = w.Write([]byte(`{"success":true,"obj":[{"id":42,"tag":"remote-tag"}]}`))
+ case "/panel/api/inbounds/42/subSortIndex":
+ if err := req.ParseForm(); err != nil {
+ t.Fatalf("ParseForm: %v", err)
+ }
+ posted = req.PostForm
+ _, _ = w.Write([]byte(`{"success":true}`))
+ default:
+ http.NotFound(w, req)
+ }
+ }))
+ defer srv.Close()
+ r := NewRemote(nodeForPlainServer(t, srv, "verify", "tok"), nil)
+ ib := &model.Inbound{Tag: "remote-tag", Settings: `{"clients":[{"email":"newer"}]}`}
+ if err := r.SetInboundSubSortIndex(context.Background(), ib, 7); err != nil {
+ t.Fatalf("SetInboundSubSortIndex: %v", err)
+ }
+ if got := posted.Get("subSortIndex"); got != "7" {
+ t.Fatalf("subSortIndex = %q, want 7", got)
+ }
+ if len(posted) != 1 {
+ t.Fatalf("posted fields = %v, want only subSortIndex", posted)
+ }
+}
+
// TestReadCappedBody_Boundary pins the cap+1 contract cheaply (no large allocs):
// a body of exactly limit is accepted; limit+1 and beyond are rejected.
func TestReadCappedBody_Boundary(t *testing.T) {
diff --git a/internal/web/service/inbound.go b/internal/web/service/inbound.go
index 09753b7b7..4da26afd1 100644
--- a/internal/web/service/inbound.go
+++ b/internal/web/service/inbound.go
@@ -1264,6 +1264,54 @@ func (s *InboundService) GetInboundDetail(id int) (*model.Inbound, error) {
return inbound, nil
}
+// SetInboundSubSortIndex changes only the subscription sort order, so a
+// reorder cannot carry a stale settings/client payload over another edit.
+func (s *InboundService) SetInboundSubSortIndex(id int, index int) error {
+ index = normalizeSubSortIndex(index)
+ inbound, err := s.GetInbound(id)
+ if err != nil {
+ return err
+ }
+ if inbound.SubSortIndex == index {
+ return nil
+ }
+
+ db := database.GetDB()
+ if err := db.Transaction(func(tx *gorm.DB) error {
+ if err := tx.Model(model.Inbound{}).Where("id = ?", id).
+ Update("sub_sort_index", index).Error; err != nil {
+ return err
+ }
+ if inbound.NodeID != nil {
+ return (&NodeService{}).MarkNodeDirtyTx(tx, *inbound.NodeID)
+ }
+ return nil
+ }); err != nil {
+ return err
+ }
+ inbound.SubSortIndex = index
+
+ if inbound.NodeID == nil {
+ return nil
+ }
+ rt, push, _, perr := s.nodePushPlan(inbound)
+ if perr != nil {
+ return perr
+ }
+ if push {
+ narrow, ok := rt.(interface {
+ SetInboundSubSortIndex(context.Context, *model.Inbound, int) error
+ })
+ if !ok {
+ return fmt.Errorf("runtime %s does not support narrow subscription ordering updates", rt.Name())
+ }
+ if err := narrow.SetInboundSubSortIndex(context.Background(), inbound, index); err != nil {
+ logger.Warning("SetInboundSubSortIndex: remote metadata update on", rt.Name(), "failed:", err)
+ }
+ }
+ return nil
+}
+
func (s *InboundService) SetInboundEnable(id int, enable bool) (bool, error) {
inbound, err := s.GetInbound(id)
if err != nil {
diff --git a/internal/web/service/inbound_subsort_test.go b/internal/web/service/inbound_subsort_test.go
new file mode 100644
index 000000000..0bee27193
--- /dev/null
+++ b/internal/web/service/inbound_subsort_test.go
@@ -0,0 +1,57 @@
+package service
+
+import (
+ "path/filepath"
+ "testing"
+
+ "github.com/mhsanaei/3x-ui/v3/internal/database"
+ "github.com/mhsanaei/3x-ui/v3/internal/database/model"
+)
+
+func TestSetInboundSubSortIndexLeavesSettingsUntouched(t *testing.T) {
+ if err := database.InitDB(filepath.Join(t.TempDir(), "x-ui.db")); err != nil {
+ t.Fatalf("init db: %v", err)
+ }
+ t.Cleanup(func() { _ = database.CloseDB() })
+
+ const settings = `{"clients":[{"email":"a@example.test","id":"11111111-1111-1111-1111-111111111111"}]}`
+ ib := &model.Inbound{UserId: 1, Remark: "r", Port: 21001, Protocol: model.VLESS, Settings: settings, SubSortIndex: 1, Enable: true}
+ if err := database.GetDB().Create(ib).Error; err != nil {
+ t.Fatalf("seed: %v", err)
+ }
+
+ svc := InboundService{}
+ if err := svc.SetInboundSubSortIndex(ib.Id, 7); err != nil {
+ t.Fatalf("set: %v", err)
+ }
+
+ var got model.Inbound
+ if err := database.GetDB().First(&got, ib.Id).Error; err != nil {
+ t.Fatalf("reload: %v", err)
+ }
+ if got.SubSortIndex != 7 {
+ t.Fatalf("subSortIndex = %d, want 7", got.SubSortIndex)
+ }
+ if got.Settings != settings {
+ t.Fatalf("settings were rewritten:\n got %s\nwant %s", got.Settings, settings)
+ }
+}
+
+func TestSetInboundSubSortIndexUsesNarrowNodeUpdate(t *testing.T) {
+ setupBulkDB(t)
+ nodeID, fake := setupNodeRuntime(t)
+ ib := nodeInbound(t, nodeID, 21002, []model.Client{{Email: "a@example.test", ID: "11111111-1111-1111-1111-111111111111"}})
+ ib.SubSortIndex = 1
+ if err := database.GetDB().Model(ib).Update("sub_sort_index", 1).Error; err != nil {
+ t.Fatal(err)
+ }
+ if err := (&InboundService{}).SetInboundSubSortIndex(ib.Id, 7); err != nil {
+ t.Fatalf("set: %v", err)
+ }
+ if got := fake.updateSubSort.Load(); got != 1 {
+ t.Fatalf("narrow node updates = %d, want 1", got)
+ }
+ if got := fake.updateInbound.Load(); got != 0 {
+ t.Fatalf("full snapshot node updates = %d, want 0", got)
+ }
+}
diff --git a/internal/web/service/node_bulk_dispatch_test.go b/internal/web/service/node_bulk_dispatch_test.go
index 7c26bdfbb..4381b1662 100644
--- a/internal/web/service/node_bulk_dispatch_test.go
+++ b/internal/web/service/node_bulk_dispatch_test.go
@@ -24,6 +24,7 @@ type fakeNodeRuntime struct {
deleteClient atomic.Int32
deleteUser atomic.Int32
updateInbound atomic.Int32
+ updateSubSort atomic.Int32
updateUser atomic.Int32
}
@@ -44,6 +45,11 @@ func (f *fakeNodeRuntime) UpdateInbound(context.Context, *model.Inbound, *model.
return nil
}
+func (f *fakeNodeRuntime) SetInboundSubSortIndex(context.Context, *model.Inbound, int) error {
+ f.updateSubSort.Add(1)
+ return nil
+}
+
func (f *fakeNodeRuntime) AddUser(context.Context, *model.Inbound, map[string]any) error { return nil }
func (f *fakeNodeRuntime) RemoveUser(context.Context, *model.Inbound, string) error { return nil }