mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-08-16 16:20:59 +00:00
feat(inbounds): add a narrow endpoint for subscription sort order (#6179)
* feat(inbounds): add a narrow endpoint for subscription sort order Changing an inbound's position in subscription output currently goes through /update/:id, which takes a whole inbound: the caller has to send settings and the entire client list back, and whatever it read before the edit is what gets written. Two people reordering and editing clients in the same inbound race on one blob, and the reorder wins by overwriting. Mirror the existing /setEnable/:id shape. The handler takes only the index and the service reads the stored inbound, so nothing in the request can reach the settings JSON. Node-owned inbounds are marked dirty in the same transaction and pushed through the existing runtime update. * fix(nodes): scope sub sort index updates --------- Co-authored-by: n0ctal <293235942+n0ctal@users.noreply.github.com>
This commit is contained in:
@@ -1,8 +1,7 @@
|
|||||||
---
|
---
|
||||||
title: Inbounds
|
title: Inbounds
|
||||||
description: >-
|
description: Manage inbound configurations and their clients. All endpoints live
|
||||||
Manage inbound configurations and their clients. All endpoints live under
|
under /panel/api/inbounds and require a logged-in session or Bearer token.
|
||||||
/panel/api/inbounds and require a logged-in session or Bearer token.
|
|
||||||
Link-generating endpoints honour forwarded headers only when the request comes
|
Link-generating endpoints honour forwarded headers only when the request comes
|
||||||
from a configured trusted proxy.
|
from a configured trusted proxy.
|
||||||
full: true
|
full: true
|
||||||
@@ -11,25 +10,20 @@ _openapi:
|
|||||||
- ./public/openapi.json
|
- ./public/openapi.json
|
||||||
toc:
|
toc:
|
||||||
- depth: 2
|
- depth: 2
|
||||||
title: >-
|
title: List every inbound owned by the authenticated user, including each
|
||||||
List every inbound owned by the authenticated user, including each
|
|
||||||
inbound’s clientStats traffic counters. settings, streamSettings, and
|
inbound’s clientStats traffic counters. settings, streamSettings, and
|
||||||
sniffing are returned as nested JSON objects (no escaped strings);
|
sniffing are returned as nested JSON objects (no escaped strings);
|
||||||
legacy callers that send them back as JSON-encoded strings are still
|
legacy callers that send them back as JSON-encoded strings are still
|
||||||
accepted on write.
|
accepted on write.
|
||||||
url: >-
|
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'
|
||||||
#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
|
- depth: 2
|
||||||
title: >-
|
title: Same shape as /list but with settings.clients[] stripped down to {email,
|
||||||
Same shape as /list but with settings.clients[] stripped down to {email,
|
|
||||||
enable, comment} and ClientStats not enriched with UUID/SubId. Use this
|
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
|
for list pages; fetch /get/:id when you need the full per-client payload
|
||||||
(uuid, password, flow, ...).
|
(uuid, password, flow, ...).
|
||||||
url: >-
|
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-'
|
||||||
#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
|
- depth: 2
|
||||||
title: >-
|
title: Lightweight picker projection of the authenticated user’s inbounds.
|
||||||
Lightweight picker projection of the authenticated user’s inbounds.
|
|
||||||
Returns id, remark, tag, protocol, port, a server-computed
|
Returns id, remark, tag, protocol, port, a server-computed
|
||||||
tlsFlowCapable flag (true for VLESS on TCP with tls or reality, or on
|
tlsFlowCapable flag (true for VLESS on TCP with tls or reality, or on
|
||||||
XHTTP with VLESS encryption / vlessenc enabled), and ssMethod (the
|
XHTTP with VLESS encryption / vlessenc enabled), and ssMethod (the
|
||||||
@@ -38,110 +32,86 @@ _openapi:
|
|||||||
dropdowns and attach pickers — it skips settings, streamSettings, and
|
dropdowns and attach pickers — it skips settings, streamSettings, and
|
||||||
clientStats so the payload stays small even on panels with thousands of
|
clientStats so the payload stays small even on panels with thousands of
|
||||||
clients.
|
clients.
|
||||||
url: >-
|
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'
|
||||||
#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
|
- depth: 2
|
||||||
title: Fetch a single inbound by numeric ID.
|
title: Fetch a single inbound by numeric ID.
|
||||||
url: '#fetch-a-single-inbound-by-numeric-id'
|
url: '#fetch-a-single-inbound-by-numeric-id'
|
||||||
- depth: 2
|
- depth: 2
|
||||||
title: >-
|
title: Create a new inbound. Send the full inbound payload (protocol, port,
|
||||||
Create a new inbound. Send the full inbound payload (protocol, port,
|
|
||||||
settings, streamSettings, sniffing, remark, expiryTime, total, enable).
|
settings, streamSettings, sniffing, remark, expiryTime, total, enable).
|
||||||
settings, streamSettings, and sniffing may be sent as nested JSON
|
settings, streamSettings, and sniffing may be sent as nested JSON
|
||||||
objects (preferred) or as JSON-encoded strings (legacy).
|
objects (preferred) or as JSON-encoded strings (legacy).
|
||||||
url: >-
|
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'
|
||||||
#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
|
- depth: 2
|
||||||
title: Delete an inbound by ID. Also removes its associated client stats rows.
|
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'
|
url: '#delete-an-inbound-by-id-also-removes-its-associated-client-stats-rows'
|
||||||
- depth: 2
|
- depth: 2
|
||||||
title: >-
|
title: Delete many inbounds in one call. Processes the list sequentially;
|
||||||
Delete many inbounds in one call. Processes the list sequentially;
|
|
||||||
failures are reported per id and the rest still proceed. Restarts xray
|
failures are reported per id and the rest still proceed. Restarts xray
|
||||||
at most once.
|
at most once.
|
||||||
url: >-
|
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'
|
||||||
#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
|
- depth: 2
|
||||||
title: >-
|
title: Replace an inbound’s configuration. Body shape mirrors /add. Heavy on
|
||||||
Replace an inbound’s configuration. Body shape mirrors /add. Heavy on
|
|
||||||
inbounds with thousands of clients — prefer /setEnable for enable-only
|
inbounds with thousands of clients — prefer /setEnable for enable-only
|
||||||
flips.
|
flips.
|
||||||
url: >-
|
url: '#replace-an-inbounds-configuration-body-shape-mirrors-add-heavy-on-inbounds-with-thousands-of-clients--prefer-setenable-for-enable-only-flips'
|
||||||
#replace-an-inbounds-configuration-body-shape-mirrors-add-heavy-on-inbounds-with-thousands-of-clients--prefer-setenable-for-enable-only-flips
|
|
||||||
- depth: 2
|
- depth: 2
|
||||||
title: >-
|
title: Toggle only the enable flag without serialising the whole settings JSON.
|
||||||
Toggle only the enable flag without serialising the whole settings JSON.
|
|
||||||
Recommended for UI switches on large inbounds.
|
Recommended for UI switches on large inbounds.
|
||||||
url: >-
|
url: '#toggle-only-the-enable-flag-without-serialising-the-whole-settings-json-recommended-for-ui-switches-on-large-inbounds'
|
||||||
#toggle-only-the-enable-flag-without-serialising-the-whole-settings-json-recommended-for-ui-switches-on-large-inbounds
|
|
||||||
- depth: 2
|
- depth: 2
|
||||||
title: >-
|
title: Zero out upload + download counters for a single inbound. Does not touch
|
||||||
Zero out upload + download counters for a single inbound. Does not touch
|
|
||||||
per-client counters.
|
per-client counters.
|
||||||
url: >-
|
url: '#zero-out-upload--download-counters-for-a-single-inbound-does-not-touch-per-client-counters'
|
||||||
#zero-out-upload--download-counters-for-a-single-inbound-does-not-touch-per-client-counters
|
|
||||||
- depth: 2
|
- depth: 2
|
||||||
title: >-
|
title: Remove every client attached to a single inbound while keeping the
|
||||||
Remove every client attached to a single inbound while keeping the
|
|
||||||
inbound itself. Collects emails from settings.clients[] and feeds them
|
inbound itself. Collects emails from settings.clients[] and feeds them
|
||||||
into the optimized bulk-delete path (runtime user removal + traffic-row
|
into the optimized bulk-delete path (runtime user removal + traffic-row
|
||||||
cleanup + SyncInbound). Destructive and cannot be undone.
|
cleanup + SyncInbound). Destructive and cannot be undone.
|
||||||
url: >-
|
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'
|
||||||
#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
|
- depth: 2
|
||||||
title: >-
|
title: Reset upload + download counters on every inbound. Destructive —
|
||||||
Reset upload + download counters on every inbound. Destructive —
|
|
||||||
accounting history is lost.
|
accounting history is lost.
|
||||||
url: >-
|
url: '#reset-upload--download-counters-on-every-inbound-destructive--accounting-history-is-lost'
|
||||||
#reset-upload--download-counters-on-every-inbound-destructive--accounting-history-is-lost
|
|
||||||
- depth: 2
|
- depth: 2
|
||||||
title: >-
|
title: Bulk-import an inbound from a JSON blob (e.g. one exported via the UI).
|
||||||
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.
|
The body uses form encoding with a single "data" field.
|
||||||
url: >-
|
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'
|
||||||
#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
|
- depth: 2
|
||||||
title: >-
|
title: Receive a master panel's aggregated per-client usage, keyed by the
|
||||||
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
|
master's GUID. Stored in a side table used only for the UI display
|
||||||
overlay and local quota enforcement — never folded into the local
|
overlay and local quota enforcement — never folded into the local
|
||||||
counters that masters poll, so delta accounting stays intact. Called
|
counters that masters poll, so delta accounting stays intact. Called
|
||||||
panel-to-panel by the node traffic sync job.
|
panel-to-panel by the node traffic sync job.
|
||||||
url: >-
|
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'
|
||||||
#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
|
- depth: 2
|
||||||
title: >-
|
title: List the fallback rules attached to a master VLESS/Trojan TCP-TLS
|
||||||
List the fallback rules attached to a master VLESS/Trojan TCP-TLS
|
|
||||||
inbound. Each rule links one child inbound (the dest) to optional
|
inbound. Each rule links one child inbound (the dest) to optional
|
||||||
SNI/ALPN/path/dest/xver match criteria. When dest is empty the child
|
SNI/ALPN/path/dest/xver match criteria. When dest is empty the child
|
||||||
inbound's listen+port is used.
|
inbound's listen+port is used.
|
||||||
url: >-
|
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'
|
||||||
#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
|
- depth: 2
|
||||||
title: >-
|
title: Replace the entire fallback list for a master inbound. Body is JSON.
|
||||||
Replace the entire fallback list for a master inbound. Body is JSON.
|
|
||||||
Triggers an Xray restart.
|
Triggers an Xray restart.
|
||||||
url: >-
|
url: '#replace-the-entire-fallback-list-for-a-master-inbound-body-is-json-triggers-an-xray-restart'
|
||||||
#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:
|
structuredData:
|
||||||
headings:
|
headings:
|
||||||
- content: >-
|
- content: List every inbound owned by the authenticated user, including each
|
||||||
List every inbound owned by the authenticated user, including each
|
|
||||||
inbound’s clientStats traffic counters. settings, streamSettings, and
|
inbound’s clientStats traffic counters. settings, streamSettings, and
|
||||||
sniffing are returned as nested JSON objects (no escaped strings);
|
sniffing are returned as nested JSON objects (no escaped strings);
|
||||||
legacy callers that send them back as JSON-encoded strings are still
|
legacy callers that send them back as JSON-encoded strings are still
|
||||||
accepted on write.
|
accepted on write.
|
||||||
id: >-
|
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
|
||||||
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
|
||||||
- content: >-
|
|
||||||
Same shape as /list but with settings.clients[] stripped down to
|
|
||||||
{email, enable, comment} and ClientStats not enriched with UUID/SubId.
|
{email, enable, comment} and ClientStats not enriched with UUID/SubId.
|
||||||
Use this for list pages; fetch /get/:id when you need the full
|
Use this for list pages; fetch /get/:id when you need the full
|
||||||
per-client payload (uuid, password, flow, ...).
|
per-client payload (uuid, password, flow, ...).
|
||||||
id: >-
|
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-
|
||||||
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.
|
||||||
- content: >-
|
|
||||||
Lightweight picker projection of the authenticated user’s inbounds.
|
|
||||||
Returns id, remark, tag, protocol, port, a server-computed
|
Returns id, remark, tag, protocol, port, a server-computed
|
||||||
tlsFlowCapable flag (true for VLESS on TCP with tls or reality, or on
|
tlsFlowCapable flag (true for VLESS on TCP with tls or reality, or on
|
||||||
XHTTP with VLESS encryption / vlessenc enabled), and ssMethod (the
|
XHTTP with VLESS encryption / vlessenc enabled), and ssMethod (the
|
||||||
@@ -150,80 +120,58 @@ _openapi:
|
|||||||
dropdowns and attach pickers — it skips settings, streamSettings, and
|
dropdowns and attach pickers — it skips settings, streamSettings, and
|
||||||
clientStats so the payload stays small even on panels with thousands
|
clientStats so the payload stays small even on panels with thousands
|
||||||
of clients.
|
of clients.
|
||||||
id: >-
|
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
|
||||||
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.
|
- content: Fetch a single inbound by numeric ID.
|
||||||
id: fetch-a-single-inbound-by-numeric-id
|
id: fetch-a-single-inbound-by-numeric-id
|
||||||
- content: >-
|
- content: Create a new inbound. Send the full inbound payload (protocol, port,
|
||||||
Create a new inbound. Send the full inbound payload (protocol, port,
|
|
||||||
settings, streamSettings, sniffing, remark, expiryTime, total,
|
settings, streamSettings, sniffing, remark, expiryTime, total,
|
||||||
enable). settings, streamSettings, and sniffing may be sent as nested
|
enable). settings, streamSettings, and sniffing may be sent as nested
|
||||||
JSON objects (preferred) or as JSON-encoded strings (legacy).
|
JSON objects (preferred) or as JSON-encoded strings (legacy).
|
||||||
id: >-
|
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
|
||||||
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.
|
||||||
- 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
|
id: delete-an-inbound-by-id-also-removes-its-associated-client-stats-rows
|
||||||
- content: >-
|
- content: Delete many inbounds in one call. Processes the list sequentially;
|
||||||
Delete many inbounds in one call. Processes the list sequentially;
|
|
||||||
failures are reported per id and the rest still proceed. Restarts xray
|
failures are reported per id and the rest still proceed. Restarts xray
|
||||||
at most once.
|
at most once.
|
||||||
id: >-
|
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
|
||||||
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
|
||||||
- content: >-
|
|
||||||
Replace an inbound’s configuration. Body shape mirrors /add. Heavy on
|
|
||||||
inbounds with thousands of clients — prefer /setEnable for enable-only
|
inbounds with thousands of clients — prefer /setEnable for enable-only
|
||||||
flips.
|
flips.
|
||||||
id: >-
|
id: replace-an-inbounds-configuration-body-shape-mirrors-add-heavy-on-inbounds-with-thousands-of-clients--prefer-setenable-for-enable-only-flips
|
||||||
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
|
||||||
- content: >-
|
|
||||||
Toggle only the enable flag without serialising the whole settings
|
|
||||||
JSON. Recommended for UI switches on large inbounds.
|
JSON. Recommended for UI switches on large inbounds.
|
||||||
id: >-
|
id: toggle-only-the-enable-flag-without-serialising-the-whole-settings-json-recommended-for-ui-switches-on-large-inbounds
|
||||||
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
|
||||||
- content: >-
|
|
||||||
Zero out upload + download counters for a single inbound. Does not
|
|
||||||
touch per-client counters.
|
touch per-client counters.
|
||||||
id: >-
|
id: zero-out-upload--download-counters-for-a-single-inbound-does-not-touch-per-client-counters
|
||||||
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
|
||||||
- content: >-
|
|
||||||
Remove every client attached to a single inbound while keeping the
|
|
||||||
inbound itself. Collects emails from settings.clients[] and feeds them
|
inbound itself. Collects emails from settings.clients[] and feeds them
|
||||||
into the optimized bulk-delete path (runtime user removal +
|
into the optimized bulk-delete path (runtime user removal +
|
||||||
traffic-row cleanup + SyncInbound). Destructive and cannot be undone.
|
traffic-row cleanup + SyncInbound). Destructive and cannot be undone.
|
||||||
id: >-
|
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
|
||||||
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 —
|
||||||
- content: >-
|
|
||||||
Reset upload + download counters on every inbound. Destructive —
|
|
||||||
accounting history is lost.
|
accounting history is lost.
|
||||||
id: >-
|
id: reset-upload--download-counters-on-every-inbound-destructive--accounting-history-is-lost
|
||||||
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).
|
||||||
- content: >-
|
The body uses form encoding with a single "data" field.
|
||||||
Bulk-import an inbound from a JSON blob (e.g. one exported via the
|
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
|
||||||
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: >-
|
|
||||||
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
|
master's GUID. Stored in a side table used only for the UI display
|
||||||
overlay and local quota enforcement — never folded into the local
|
overlay and local quota enforcement — never folded into the local
|
||||||
counters that masters poll, so delta accounting stays intact. Called
|
counters that masters poll, so delta accounting stays intact. Called
|
||||||
panel-to-panel by the node traffic sync job.
|
panel-to-panel by the node traffic sync job.
|
||||||
id: >-
|
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
|
||||||
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
|
||||||
- content: >-
|
|
||||||
List the fallback rules attached to a master VLESS/Trojan TCP-TLS
|
|
||||||
inbound. Each rule links one child inbound (the dest) to optional
|
inbound. Each rule links one child inbound (the dest) to optional
|
||||||
SNI/ALPN/path/dest/xver match criteria. When dest is empty the child
|
SNI/ALPN/path/dest/xver match criteria. When dest is empty the child
|
||||||
inbound's listen+port is used.
|
inbound's listen+port is used.
|
||||||
id: >-
|
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
|
||||||
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.
|
||||||
- content: >-
|
|
||||||
Replace the entire fallback list for a master inbound. Body is JSON.
|
|
||||||
Triggers an Xray restart.
|
Triggers an Xray restart.
|
||||||
id: >-
|
id: replace-the-entire-fallback-list-for-a-master-inbound-body-is-json-triggers-an-xray-restart
|
||||||
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: []
|
contents: []
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -236,7 +184,7 @@ export default function Layout(props) {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{props.children}
|
{props.children}
|
||||||
<Comp document="./public/openapi.json" webhooks={[]} operations={[{"path":"/panel/api/inbounds/list","method":"get"},{"path":"/panel/api/inbounds/list/slim","method":"get"},{"path":"/panel/api/inbounds/options","method":"get"},{"path":"/panel/api/inbounds/get/{id}","method":"get"},{"path":"/panel/api/inbounds/add","method":"post"},{"path":"/panel/api/inbounds/del/{id}","method":"post"},{"path":"/panel/api/inbounds/bulkDel","method":"post"},{"path":"/panel/api/inbounds/update/{id}","method":"post"},{"path":"/panel/api/inbounds/setEnable/{id}","method":"post"},{"path":"/panel/api/inbounds/{id}/resetTraffic","method":"post"},{"path":"/panel/api/inbounds/{id}/delAllClients","method":"post"},{"path":"/panel/api/inbounds/resetAllTraffics","method":"post"},{"path":"/panel/api/inbounds/import","method":"post"},{"path":"/panel/api/inbounds/pushClientTraffics","method":"post"},{"path":"/panel/api/inbounds/{id}/fallbacks","method":"get"},{"path":"/panel/api/inbounds/{id}/fallbacks","method":"post"}]} showTitle />
|
<Comp document="./public/openapi.json" webhooks={[]} operations={[{"path":"/panel/api/inbounds/list","method":"get"},{"path":"/panel/api/inbounds/list/slim","method":"get"},{"path":"/panel/api/inbounds/options","method":"get"},{"path":"/panel/api/inbounds/get/{id}","method":"get"},{"path":"/panel/api/inbounds/add","method":"post"},{"path":"/panel/api/inbounds/del/{id}","method":"post"},{"path":"/panel/api/inbounds/bulkDel","method":"post"},{"path":"/panel/api/inbounds/update/{id}","method":"post"},{"path":"/panel/api/inbounds/setEnable/{id}","method":"post"},{"path":"/panel/api/inbounds/{id}/resetTraffic","method":"post"},{"path":"/panel/api/inbounds/{id}/delAllClients","method":"post"},{"path":"/panel/api/inbounds/resetAllTraffics","method":"post"},{"path":"/panel/api/inbounds/import","method":"post"},{"path":"/panel/api/inbounds/pushClientTraffics","method":"post"},{"path":"/panel/api/inbounds/{id}/fallbacks","method":"get"},{"path":"/panel/api/inbounds/{id}/fallbacks","method":"post"},{"path":"/panel/api/inbounds/{id}/subSortIndex","method":"post"}]} showTitle />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -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": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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": {
|
"/panel/api/inbounds/{id}/resetTraffic": {
|
||||||
"post": {
|
"post": {
|
||||||
"tags": [
|
"tags": [
|
||||||
|
|||||||
@@ -183,6 +183,15 @@ export const sections: readonly Section[] = [
|
|||||||
],
|
],
|
||||||
body: '{\n "enable": false\n}',
|
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',
|
method: 'POST',
|
||||||
path: '/panel/api/inbounds/:id/resetTraffic',
|
path: '/panel/api/inbounds/:id/resetTraffic',
|
||||||
|
|||||||
@@ -73,6 +73,7 @@ func (a *InboundController) initRouter(g *gin.RouterGroup) {
|
|||||||
g.POST("/bulkDel", a.bulkDelInbounds)
|
g.POST("/bulkDel", a.bulkDelInbounds)
|
||||||
g.POST("/update/:id", a.updateInbound)
|
g.POST("/update/:id", a.updateInbound)
|
||||||
g.POST("/setEnable/:id", a.setInboundEnable)
|
g.POST("/setEnable/:id", a.setInboundEnable)
|
||||||
|
g.POST("/:id/subSortIndex", a.setInboundSubSortIndex)
|
||||||
g.POST("/:id/resetTraffic", a.resetInboundTraffic)
|
g.POST("/:id/resetTraffic", a.resetInboundTraffic)
|
||||||
g.POST("/:id/delAllClients", a.delAllInboundClients)
|
g.POST("/:id/delAllClients", a.delAllInboundClients)
|
||||||
g.POST("/resetAllTraffics", a.resetAllTraffics)
|
g.POST("/resetAllTraffics", a.resetAllTraffics)
|
||||||
@@ -255,11 +256,30 @@ func (a *InboundController) updateInbound(c *gin.Context) {
|
|||||||
notifyClientsChanged()
|
notifyClientsChanged()
|
||||||
}
|
}
|
||||||
|
|
||||||
// setInboundEnable flips only the enable flag of an inbound. This is a
|
// setInboundSubSortIndex changes only subscription ordering without sending
|
||||||
// dedicated endpoint because the regular update path serialises the entire
|
// the inbound's settings/client payload.
|
||||||
// settings JSON (every client) — far too heavy for an interactive switch
|
func (a *InboundController) setInboundSubSortIndex(c *gin.Context) {
|
||||||
// on inbounds with thousands of clients. Frontend optimistically updates
|
id, err := strconv.Atoi(c.Param("id"))
|
||||||
// the UI; we just persist + sync xray + nudge other open admin sessions.
|
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) {
|
func (a *InboundController) setInboundEnable(c *gin.Context) {
|
||||||
id, err := strconv.Atoi(c.Param("id"))
|
id, err := strconv.Atoi(c.Param("id"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -458,6 +458,16 @@ func (r *Remote) UpdateInbound(ctx context.Context, oldIb, newIb *model.Inbound)
|
|||||||
return nil
|
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
|
// ReconcileInbound pushes ib only when its wire payload differs from the last
|
||||||
// successful push, or when the node no longer reports the tag (existsOnNode
|
// successful push, or when the node no longer reports the tag (existsOnNode
|
||||||
// false) — a node that dropped/restarted must still be re-seeded. Returns
|
// false) — a node that dropped/restarted must still be re-seeded. Returns
|
||||||
|
|||||||
@@ -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):
|
// TestReadCappedBody_Boundary pins the cap+1 contract cheaply (no large allocs):
|
||||||
// a body of exactly limit is accepted; limit+1 and beyond are rejected.
|
// a body of exactly limit is accepted; limit+1 and beyond are rejected.
|
||||||
func TestReadCappedBody_Boundary(t *testing.T) {
|
func TestReadCappedBody_Boundary(t *testing.T) {
|
||||||
|
|||||||
@@ -1264,6 +1264,54 @@ func (s *InboundService) GetInboundDetail(id int) (*model.Inbound, error) {
|
|||||||
return inbound, nil
|
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) {
|
func (s *InboundService) SetInboundEnable(id int, enable bool) (bool, error) {
|
||||||
inbound, err := s.GetInbound(id)
|
inbound, err := s.GetInbound(id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -24,6 +24,7 @@ type fakeNodeRuntime struct {
|
|||||||
deleteClient atomic.Int32
|
deleteClient atomic.Int32
|
||||||
deleteUser atomic.Int32
|
deleteUser atomic.Int32
|
||||||
updateInbound atomic.Int32
|
updateInbound atomic.Int32
|
||||||
|
updateSubSort atomic.Int32
|
||||||
updateUser atomic.Int32
|
updateUser atomic.Int32
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -44,6 +45,11 @@ func (f *fakeNodeRuntime) UpdateInbound(context.Context, *model.Inbound, *model.
|
|||||||
return nil
|
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) AddUser(context.Context, *model.Inbound, map[string]any) error { return nil }
|
||||||
|
|
||||||
func (f *fakeNodeRuntime) RemoveUser(context.Context, *model.Inbound, string) error { return nil }
|
func (f *fakeNodeRuntime) RemoveUser(context.Context, *model.Inbound, string) error { return nil }
|
||||||
|
|||||||
Reference in New Issue
Block a user