1
0

7 Commity 43bc915397 ... b53a5515d6

Autor SHA1 Správa Dátum
  Sanaei b53a5515d6 fix(frontend): make the jalali expiry clear button actually clear 22 hodín pred
  sonic 3fa88adbd7 fix(inbounds): surface form validation errors (#6084) 23 hodín pred
  Farhan Zare 930a0ed59d feat(inbound): DisableFlow — opt an inbound out of auto XTLS Vision (#5689) (#5698) 23 hodín pred
  Sanaei f22df49a71 fix(sub): restore the subscription info page for browser visits 1 deň pred
  n0ctal b4e4478699 feat(inbounds): add a narrow endpoint for subscription sort order (#6179) 1 deň pred
  n0ctal bab39393f1 fix(nodes): validate every certificate in the node mTLS trust bundle (#6188) 1 deň pred
  Sanaei 338822ab07 fix(sub): keep copy page within mobile viewport 1 deň pred
56 zmenil súbory, kde vykonal 1177 pridanie a 467 odobranie
  1. 68 120
      docs/content/docs/en/reference/api/inbounds.mdx
  2. 54 0
      docs/public/openapi.json
  3. 60 0
      frontend/public/openapi.json
  4. 6 0
      frontend/src/components/form/DateTimePicker.css
  5. 12 1
      frontend/src/components/form/DateTimePicker.tsx
  6. 1 0
      frontend/src/generated/examples.ts
  7. 5 0
      frontend/src/generated/schemas.ts
  8. 1 0
      frontend/src/generated/types.ts
  9. 1 0
      frontend/src/generated/zod.ts
  10. 4 0
      frontend/src/lib/xray/inbound-form-adapter.ts
  11. 3 0
      frontend/src/models/dbinbound.ts
  12. 9 0
      frontend/src/pages/api-docs/endpoints.ts
  13. 64 3
      frontend/src/pages/inbounds/form/InboundFormModal.tsx
  14. 1 0
      frontend/src/schemas/forms/inbound-form.ts
  15. 30 0
      frontend/src/test/inbound-form-adapter.test.ts
  16. 106 2
      frontend/src/test/inbound-form-modal.test.tsx
  17. 2 0
      internal/database/model/model.go
  18. 1 1
      internal/sub/clash_service.go
  19. 25 0
      internal/sub/clash_service_test.go
  20. 79 100
      internal/sub/controller.go
  21. 0 133
      internal/sub/controller_browser_test.go
  22. 4 27
      internal/sub/info_endpoint_test.go
  23. 1 1
      internal/sub/json_service.go
  24. 10 0
      internal/sub/json_service_test.go
  25. 2 2
      internal/sub/service.go
  26. 10 0
      internal/sub/service_flow_test.go
  27. 25 5
      internal/web/controller/inbound.go
  28. 11 0
      internal/web/runtime/remote.go
  29. 42 0
      internal/web/runtime/remote_test.go
  30. 2 1
      internal/web/service/client_bulk.go
  31. 1 1
      internal/web/service/client_crud.go
  32. 69 4
      internal/web/service/inbound.go
  33. 1 0
      internal/web/service/inbound_clients.go
  34. 211 0
      internal/web/service/inbound_disable_flow_test.go
  35. 31 0
      internal/web/service/inbound_flow_restore.go
  36. 3 0
      internal/web/service/inbound_migration.go
  37. 1 0
      internal/web/service/inbound_node.go
  38. 57 0
      internal/web/service/inbound_subsort_test.go
  39. 6 0
      internal/web/service/node_bulk_dispatch_test.go
  40. 4 12
      internal/web/service/node_mtls.go
  41. 38 2
      internal/web/service/setting_mtls.go
  42. 74 0
      internal/web/service/setting_mtls_bundle_test.go
  43. 3 0
      internal/web/service/xray.go
  44. 3 4
      internal/web/translation/ar-EG.json
  45. 3 4
      internal/web/translation/en-US.json
  46. 3 4
      internal/web/translation/es-ES.json
  47. 3 4
      internal/web/translation/fa-IR.json
  48. 3 4
      internal/web/translation/id-ID.json
  49. 3 4
      internal/web/translation/ja-JP.json
  50. 3 4
      internal/web/translation/pt-BR.json
  51. 3 4
      internal/web/translation/ru-RU.json
  52. 3 4
      internal/web/translation/tr-TR.json
  53. 3 4
      internal/web/translation/uk-UA.json
  54. 3 4
      internal/web/translation/vi-VN.json
  55. 3 4
      internal/web/translation/zh-CN.json
  56. 3 4
      internal/web/translation/zh-TW.json

+ 68 - 120
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}
-      <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 />
     </>
   );
 }

+ 54 - 0
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": {}
+                  }
+                }
+              }
+            }
+          }
+        }
+      }
     }
   }
 }

+ 60 - 0
frontend/public/openapi.json

@@ -1935,6 +1935,10 @@
             },
             "type": "array"
           },
+          "disableFlow": {
+            "example": false,
+            "type": "boolean"
+          },
           "down": {
             "description": "Download traffic in bytes",
             "format": "int64",
@@ -2064,6 +2068,7 @@
         },
         "required": [
           "clientStats",
+          "disableFlow",
           "down",
           "enable",
           "expiryTime",
@@ -3328,6 +3333,7 @@
                           "uuid": "e18c9a96-71bf-48d4-933f-8b9a46d4290c"
                         }
                       ],
+                      "disableFlow": false,
                       "down": 0,
                       "enable": true,
                       "expiryTime": 0,
@@ -3837,6 +3843,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": [

+ 6 - 0
frontend/src/components/form/DateTimePicker.css

@@ -69,3 +69,9 @@
 .jdp-ultra .jdp-clear:hover {
   color: rgba(255, 255, 255, 0.45);
 }
+
+/* With no value the library still paints today's date into its readOnly input;
+   hide it so an empty (or just-cleared) expiry actually looks empty. */
+.jdp-wrap.jdp-empty input {
+  color: transparent !important;
+}

+ 12 - 1
frontend/src/components/form/DateTimePicker.tsx

@@ -61,6 +61,16 @@ export default function DateTimePicker({
   // Bumped on clear: persian-calendar-suite reads `value` only on mount, so
   // remounting via key is the only way to reflect an externally cleared value.
   const [clearNonce, setClearNonce] = useState(0);
+  // Mounted without a value, persian-calendar-suite seeds today and emits it —
+  // which would instantly undo a clear. Armed across every (re)mount.
+  const suppressMountEmit = useRef(true);
+
+  useEffect(() => {
+    suppressMountEmit.current = false;
+    return () => {
+      suppressMountEmit.current = true;
+    };
+  }, [clearNonce]);
 
   const persianTheme = useMemo(() => {
     if (isUltra) return ULTRA_DARK_THEME;
@@ -80,11 +90,12 @@ export default function DateTimePicker({
 
   if (datepicker === 'jalalian') {
     return (
-      <div ref={jalaliRef} className={`jdp-wrap${isDark ? ' jdp-dark' : ''}${isUltra ? ' jdp-ultra' : ''}${disabled ? ' jdp-disabled' : ''}`}>
+      <div ref={jalaliRef} className={`jdp-wrap${isDark ? ' jdp-dark' : ''}${isUltra ? ' jdp-ultra' : ''}${disabled ? ' jdp-disabled' : ''}${value ? '' : ' jdp-empty'}`}>
         <PersianDateTimePicker
           key={clearNonce}
           value={value ? value.valueOf() : null}
           onChange={(next: number | string | null) => {
+            if (suppressMountEmit.current) return;
             if (next == null || next === '') {
               onChange(null);
               return;

+ 1 - 0
frontend/src/generated/examples.ts

@@ -484,6 +484,7 @@ export const EXAMPLES: Record<string, unknown> = {
         "uuid": "e18c9a96-71bf-48d4-933f-8b9a46d4290c"
       }
     ],
+    "disableFlow": false,
     "down": 0,
     "enable": true,
     "expiryTime": 0,

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

@@ -1909,6 +1909,10 @@ export const SCHEMAS: Record<string, unknown> = {
         },
         "type": "array"
       },
+      "disableFlow": {
+        "example": false,
+        "type": "boolean"
+      },
       "down": {
         "description": "Download traffic in bytes",
         "format": "int64",
@@ -2038,6 +2042,7 @@ export const SCHEMAS: Record<string, unknown> = {
     },
     "required": [
       "clientStats",
+      "disableFlow",
       "down",
       "enable",
       "expiryTime",

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

@@ -452,6 +452,7 @@ export interface HostGroup {
 
 export interface Inbound {
   clientStats: ClientTraffic[];
+  disableFlow: boolean;
   down: number;
   enable: boolean;
   expiryTime: number;

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

@@ -487,6 +487,7 @@ export type HostGroup = z.infer<typeof HostGroupSchema>;
 
 export const InboundSchema = z.object({
   clientStats: z.array(z.lazy(() => ClientTrafficSchema)),
+  disableFlow: z.boolean(),
   down: z.number().int(),
   enable: z.boolean(),
   expiryTime: z.number().int(),

+ 4 - 0
frontend/src/lib/xray/inbound-form-adapter.ts

@@ -47,6 +47,7 @@ export interface RawInboundRow {
   shareAddrStrategy?: string;
   shareAddr?: string;
   subSortIndex?: number;
+  disableFlow?: boolean;
   clientStats?: unknown;
 }
 
@@ -75,6 +76,7 @@ export interface WireInboundPayload {
   shareAddrStrategy: ShareAddrStrategy;
   shareAddr: string;
   subSortIndex: number;
+  disableFlow: boolean;
 }
 
 function coerceJsonObject(value: unknown): Record<string, unknown> {
@@ -210,6 +212,7 @@ export function rawInboundToFormValues(row: RawInboundRow): InboundFormValues {
     shareAddrStrategy: coerceShareAddrStrategy(row.shareAddrStrategy),
     shareAddr: row.shareAddr ?? '',
     subSortIndex: Math.max(1, row.subSortIndex ?? 1),
+    disableFlow: row.disableFlow ?? false,
     protocol,
     settings,
   } as InboundFormValues;
@@ -361,6 +364,7 @@ export function formValuesToWirePayload(values: InboundFormValues): WireInboundP
     shareAddrStrategy: values.shareAddrStrategy,
     shareAddr: values.shareAddr,
     subSortIndex: values.subSortIndex,
+    disableFlow: values.disableFlow,
   };
   if (values.nodeId != null) payload.nodeId = values.nodeId;
   return payload;

+ 3 - 0
frontend/src/models/dbinbound.ts

@@ -44,6 +44,7 @@ export type DBInboundInit = Partial<{
     shareAddrStrategy: string;
     shareAddr: string;
     subSortIndex: number;
+    disableFlow: boolean;
     originNodeGuid: string;
     fallbackParent: FallbackParentRef | null;
 }>;
@@ -92,6 +93,7 @@ export class DBInbound {
     shareAddrStrategy: string;
     shareAddr: string;
     subSortIndex: number;
+    disableFlow: boolean;
     originNodeGuid: string;
     fallbackParent: FallbackParentRef | null;
 
@@ -122,6 +124,7 @@ export class DBInbound {
         this.shareAddrStrategy = "node";
         this.shareAddr = "";
         this.subSortIndex = 1;
+        this.disableFlow = false;
         this.originNodeGuid = "";
         this.fallbackParent = null;
         if (data == null) {

+ 9 - 0
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',

+ 64 - 3
frontend/src/pages/inbounds/form/InboundFormModal.tsx

@@ -127,6 +127,42 @@ function isValidShareAddrInput(value: string): boolean {
   return SHARE_ADDR_HOSTNAME_RE.test(v);
 }
 
+interface RhfValidationIssue {
+  path: PropertyKey[];
+  message: string;
+}
+
+function firstRhfValidationIssue(
+  value: unknown,
+  path: PropertyKey[] = [],
+): RhfValidationIssue | null {
+  if (!value || typeof value !== 'object') return null;
+  const record = value as Record<string, unknown>;
+  // `type` is what marks a react-hook-form leaf FieldError; anything else is a group.
+  if ('type' in record) {
+    return { path, message: typeof record.message === 'string' ? record.message : '' };
+  }
+  for (const key of Object.keys(record)) {
+    const issue = firstRhfValidationIssue(record[key], [...path, key]);
+    if (issue) return issue;
+  }
+  return null;
+}
+
+function tabForValidationPath(path: PropertyKey[]): string {
+  if (path[0] === 'settings') return 'protocol';
+  if (path[0] === 'sniffing') return 'sniffing';
+  if (path[0] === 'streamSettings') {
+    if (
+      path[1] === 'security'
+      || path[1] === 'realitySettings'
+      || path[1] === 'tlsSettings'
+    ) return 'security';
+    return 'stream';
+  }
+  return 'basic';
+}
+
 interface InboundFormModalProps {
   open: boolean;
   onClose: () => void;
@@ -195,6 +231,7 @@ export default function InboundFormModal({
   const [saving, setSaving] = useState(false);
   const [scanning, setScanning] = useState(false);
   const [scanResult, setScanResult] = useState<RealityScanResult | null>(null);
+  const [activeTab, setActiveTab] = useState('basic');
   const {
     fallbacks,
     fallbackChildOptions,
@@ -356,6 +393,7 @@ export default function InboundFormModal({
       : buildAddModeValues();
     methods.reset(initial);
     setScanResult(null);
+    setActiveTab('basic');
     const initialTag = (initial.tag ?? '') as string;
     autoTagRef.current = isAutoInboundTag(initialTag, {
       port: initial.port ?? 0,
@@ -430,6 +468,9 @@ export default function InboundFormModal({
       if (!NODE_ELIGIBLE_PROTOCOLS[next]) {
         setV('nodeId', null);
       }
+      if (next !== Protocols.VLESS) {
+        setV('disableFlow', false);
+      }
       if (next === Protocols.HYSTERIA) {
         setV('streamSettings', {
           network: 'hysteria',
@@ -457,8 +498,7 @@ export default function InboundFormModal({
     /* eslint-disable-next-line react-hooks/exhaustive-deps */
   }, [mode, methods]);
 
-  const submit = async () => {
-    if (!(await methods.trigger())) return;
+  const saveValues = async () => {
     /*
      * getValues() returns the entire form store, including settings.clients and
      * settings.fallbacks which have no bound field (clients are managed via the
@@ -500,6 +540,17 @@ export default function InboundFormModal({
     }
   };
 
+  /*
+   * Field errors render inline, but every tab is force-rendered, so an error on
+   * a hidden tab looks like a dead Save button — jump to it and say what broke.
+   */
+  const submit = methods.handleSubmit(saveValues, (errors) => {
+    const issue = firstRhfValidationIssue(errors);
+    if (!issue) return;
+    setActiveTab(tabForValidationPath(issue.path));
+    messageApi.error(formatInboundIssue(issue, methods.getValues(), t));
+  });
+
   const title = mode === 'edit'
     ? t('pages.inbounds.modifyInbound')
     : t('pages.inbounds.addInbound');
@@ -581,6 +632,16 @@ export default function InboundFormModal({
         <InputNumber min={1} />
       </FormField>
 
+      {protocol === Protocols.VLESS && (
+        <FormField
+          name="disableFlow"
+          valueProp="checked"
+          label={labelWithHint(t('pages.inbounds.form.disableFlow'), t('pages.inbounds.form.disableFlowHelp'))}
+        >
+          <Switch />
+        </FormField>
+      )}
+
       <FormField
         name="port"
         label={t('pages.inbounds.port')}
@@ -948,7 +1009,7 @@ export default function InboundFormModal({
             wrapperCol={{ sm: { span: 14 } }}
             labelWrap
           >
-            <Tabs items={[
+            <Tabs activeKey={activeTab} onChange={setActiveTab} items={[
               { key: 'basic', label: t('pages.xray.basicTemplate'), children: basicTab, forceRender: true },
               ...(([
                 Protocols.VLESS,

+ 1 - 0
frontend/src/schemas/forms/inbound-form.ts

@@ -28,6 +28,7 @@ export const InboundDbFieldsSchema = z.object({
   shareAddrStrategy: ShareAddrStrategySchema.default('node'),
   shareAddr: z.string().default(''),
   subSortIndex: z.number().int().min(1).default(1),
+  disableFlow: z.boolean().default(false),
 });
 export type InboundDbFields = z.infer<typeof InboundDbFieldsSchema>;
 

+ 30 - 0
frontend/src/test/inbound-form-adapter.test.ts

@@ -6,6 +6,7 @@ import {
   formValuesToWirePayload,
   type RawInboundRow,
 } from '@/lib/xray/inbound-form-adapter';
+import { DBInbound, type DBInboundInit } from '@/models/dbinbound';
 import { InboundDbFieldsSchema, InboundFormSchema } from '@/schemas/forms/inbound-form';
 import { normalizeXhttpForWire } from '@/lib/xray/stream-wire-normalize';
 import { SockoptStreamSettingsSchema } from '@/schemas/protocols/stream/sockopt';
@@ -287,6 +288,35 @@ describe('formValuesToWirePayload', () => {
   });
 });
 
+describe('disableFlow', () => {
+  it('DBInbound constructor preserves disableFlow from the API row', () => {
+    expect(new DBInbound({ disableFlow: true }).disableFlow).toBe(true);
+    expect(new DBInbound({ disableFlow: false }).disableFlow).toBe(false);
+  });
+
+  it('DBInbound defaults disableFlow to false when the API omits it', () => {
+    expect(new DBInbound({ protocol: 'vless' }).disableFlow).toBe(false);
+    expect(new DBInbound().disableFlow).toBe(false);
+  });
+
+  it('rawInboundToFormValues reads disableFlow and defaults to false', () => {
+    expect(rawInboundToFormValues({ ...vlessRow, disableFlow: true }).disableFlow).toBe(true);
+    expect(rawInboundToFormValues(vlessRow).disableFlow).toBe(false);
+  });
+
+  it('formValuesToWirePayload includes disableFlow', () => {
+    const values = rawInboundToFormValues({ ...vlessRow, disableFlow: true });
+    expect(formValuesToWirePayload(values).disableFlow).toBe(true);
+  });
+
+  it('disableFlow survives raw → DBInbound → values → payload (the edit round-trip)', () => {
+    const db = new DBInbound({ ...vlessRow, disableFlow: true } as unknown as DBInboundInit);
+    const values = rawInboundToFormValues(db as unknown as RawInboundRow);
+    const payload = formValuesToWirePayload(values);
+    expect(payload.disableFlow).toBe(true);
+  });
+});
+
 describe('subSortIndex', () => {
   it('rawInboundToFormValues defaults to 1 when field is absent', () => {
     const values = rawInboundToFormValues({ ...vlessRow, subSortIndex: undefined });

+ 106 - 2
frontend/src/test/inbound-form-modal.test.tsx

@@ -1,9 +1,10 @@
-import { describe, it, expect } from 'vitest';
-import { screen, act, render, cleanup } from '@testing-library/react';
+import { describe, it, expect, vi } from 'vitest';
+import { screen, act, render, cleanup, fireEvent, waitFor } from '@testing-library/react';
 
 import InboundFormModal from '@/pages/inbounds/form/InboundFormModal';
 import { DBInbound } from '@/models/dbinbound';
 import { ThemeProvider } from '@/hooks/useTheme';
+import { HttpUtil } from '@/utils';
 import {
   renderWithProviders,
   fieldLabels,
@@ -11,6 +12,19 @@ import {
   chooseSelectOption,
 } from './test-utils';
 
+const { messageError } = vi.hoisted(() => ({ messageError: vi.fn() }));
+
+vi.mock('antd', async (importOriginal) => {
+  const actual = await importOriginal<typeof import('antd')>();
+  return {
+    ...actual,
+    message: {
+      ...actual.message,
+      useMessage: () => [{ error: messageError }, null],
+    },
+  };
+});
+
 function renderModal() {
   return renderWithProviders(
     <InboundFormModal
@@ -25,6 +39,63 @@ function renderModal() {
   );
 }
 
+function primaryButton(): HTMLElement {
+  const button = document.querySelector('.ant-modal-footer .ant-btn-primary');
+  if (!button) throw new Error('Primary modal button not found');
+  return button as HTMLElement;
+}
+
+function cloneLikeVlessInbound(target: string) {
+  return new DBInbound({
+    id: 42,
+    port: 41234,
+    listen: '',
+    protocol: 'vless',
+    remark: 'source clone',
+    enable: false,
+    settings: {
+      clients: [],
+      decryption: 'none',
+      encryption: 'none',
+      fallbacks: [],
+    },
+    streamSettings: {
+      network: 'tcp',
+      security: 'reality',
+      tcpSettings: { header: { type: 'none' } },
+      realitySettings: {
+        target,
+        serverNames: ['example.com'],
+        privateKey: 'test-private-key',
+        shortIds: ['abcd'],
+        settings: {
+          publicKey: 'test-public-key',
+          fingerprint: 'chrome',
+          spiderX: '/',
+        },
+      },
+    },
+    sniffing: { enabled: false },
+    nodeId: null,
+    shareAddrStrategy: 'listen',
+    shareAddr: '',
+  });
+}
+
+function renderCloneLikeEdit(dbInbound: DBInbound) {
+  renderWithProviders(
+    <InboundFormModal
+      open
+      mode="edit"
+      dbInbound={dbInbound}
+      dbInbounds={[dbInbound]}
+      availableNodes={[]}
+      onClose={() => {}}
+      onSaved={() => {}}
+    />,
+  );
+}
+
 describe('InboundFormModal', () => {
   it('renders add mode without crashing', () => {
     renderModal();
@@ -141,4 +212,37 @@ describe('InboundFormModal', () => {
     expect(strategyItem('Node address')).toBeTruthy();
     expect(strategyItem('Inbound listen')).toBeFalsy();
   });
+
+  it('surfaces a Reality validation error and switches to its tab', async () => {
+    const post = vi.mocked(HttpUtil.post);
+    post.mockClear();
+    messageError.mockClear();
+    renderCloneLikeEdit(cloneLikeVlessInbound('example.com'));
+
+    fireEvent.click(primaryButton());
+
+    await waitFor(() => {
+      const securityTab = screen.getByRole('tab', { name: 'Security' });
+      expect(securityTab.getAttribute('aria-selected')).toBe('true');
+    });
+    expect(messageError).toHaveBeenCalledWith(
+      expect.stringContaining('REALITY target must include a port'),
+    );
+    expect(post).not.toHaveBeenCalled();
+  });
+
+  it('submits a valid clone-like Reality inbound', async () => {
+    const post = vi.mocked(HttpUtil.post);
+    post.mockClear();
+    renderCloneLikeEdit(cloneLikeVlessInbound('example.com:443'));
+
+    fireEvent.click(primaryButton());
+
+    await waitFor(() => {
+      expect(post).toHaveBeenCalledWith(
+        '/panel/api/inbounds/update/42',
+        expect.objectContaining({ enable: false, port: 41234, protocol: 'vless' }),
+      );
+    });
+  });
 });

+ 2 - 0
internal/database/model/model.go

@@ -70,6 +70,8 @@ type Inbound struct {
 	ShareAddrStrategy string   `json:"shareAddrStrategy" form:"shareAddrStrategy" gorm:"column:share_addr_strategy;default:node" validate:"omitempty,oneof=node listen custom"`
 	ShareAddr         string   `json:"shareAddr" form:"shareAddr" gorm:"column:share_addr"`
 
+	DisableFlow bool `json:"disableFlow" form:"disableFlow" gorm:"column:disable_flow;default:false" example:"false"`
+
 	// OriginNodeGuid is the panelGuid of the node that physically hosts this
 	// inbound, propagated up across hops (#4983). Empty for an inbound that
 	// lives on this panel's own xray; set to the originating node's GUID when

+ 1 - 1
internal/sub/clash_service.go

@@ -248,7 +248,7 @@ func (s *SubClashService) buildProxy(subReq *SubService, inbound *model.Inbound,
 		proxy["uuid"] = applyVlessRoute(client.ID, hostVlessRoute(ep))
 		inboundSettings := subReq.linkSettings(inbound)
 		streamSecurity, _ := stream["security"].(string)
-		if client.Flow != "" && vlessFlowAllowed(network, streamSecurity, inboundSettings) {
+		if client.Flow != "" && !inbound.DisableFlow && vlessFlowAllowed(network, streamSecurity, inboundSettings) {
 			proxy["flow"] = client.Flow
 		}
 		if encryption, ok := inboundSettings["encryption"].(string); ok {

+ 25 - 0
internal/sub/clash_service_test.go

@@ -255,6 +255,31 @@ func TestBuildProxy_VLESSFlowXhttpRealityVlessenc(t *testing.T) {
 	}
 }
 
+func TestBuildProxy_VLESSFlowSuppressedByDisableFlow(t *testing.T) {
+	svc := &SubClashService{SubService: &SubService{}}
+	inbound := &model.Inbound{
+		Listen:      "203.0.113.1",
+		Port:        443,
+		Protocol:    model.VLESS,
+		Remark:      "disabled-flow",
+		Settings:    `{"encryption":"` + testMlkemEncryption + `"}`,
+		DisableFlow: true,
+	}
+	client := model.Client{ID: "11111111-2222-4333-8444-555555555555", Flow: "xtls-rprx-vision"}
+	stream := map[string]any{
+		"network":         "xhttp",
+		"xhttpSettings":   map[string]any{"path": "/", "mode": "auto"},
+		"security":        "reality",
+		"realitySettings": map[string]any{"publicKey": "pub", "serverName": "example.com", "shortId": "abcd"},
+	}
+
+	proxy := svc.buildProxy(svc.SubService, inbound, client, stream, nil)
+
+	if _, ok := proxy["flow"]; ok {
+		t.Fatalf("DisableFlow inbound must not carry a flow in the Clash proxy: %#v", proxy)
+	}
+}
+
 func TestBuildProxy_VLESSFlowDroppedWithoutVisionSupport(t *testing.T) {
 	svc := &SubClashService{SubService: &SubService{}}
 	inbound := &model.Inbound{

+ 79 - 100
internal/sub/controller.go

@@ -1,10 +1,12 @@
 package sub
 
 import (
+	"bytes"
 	"encoding/base64"
+	"encoding/json"
 	"fmt"
-	stdhtml "html"
 	"html/template"
+	"io/fs"
 	"net/http"
 	"net/url"
 	"os"
@@ -16,8 +18,6 @@ import (
 	"unicode"
 
 	"github.com/gin-gonic/gin"
-	"github.com/nicksnyder/go-i18n/v2/i18n"
-	"golang.org/x/text/language"
 
 	"github.com/mhsanaei/3x-ui/v3/internal/logger"
 	"github.com/mhsanaei/3x-ui/v3/internal/web/service"
@@ -296,18 +296,23 @@ func (a *SUBController) initRouter(g *gin.RouterGroup) {
 	}
 }
 
-// maybeServeSubPage validates the subscription and renders a copy-only page.
-// The full page embeds share links and must never handle browser navigation.
+// maybeServeSubPage renders the HTML info page when the request comes from a
+// browser (Accept: text/html) or explicitly asks for it (?html=1 or ?view=html).
+// It reports whether the request was handled. The remark template's per-client
+// info is for the content a client app imports — the raw subscription body. A
+// browser viewing the HTML info page gets clean, name-only remarks (usage is
+// shown in the page summary).
 func (a *SUBController) maybeServeSubPage(c *gin.Context) bool {
-	explicit := explicitSubPageRequest(c)
-	if !explicit && !a.isBrowserSubscriptionRequest(c) {
+	accept := c.GetHeader("Accept")
+	wantsHTML := strings.Contains(strings.ToLower(accept), "text/html") || c.Query("html") == "1" || strings.EqualFold(c.Query("view"), "html")
+	if !wantsHTML {
 		return false
 	}
-	_, ok := a.buildSubPageData(c)
+	page, ok := a.buildSubPageData(c)
 	if !ok {
 		return true
 	}
-	a.serveSubscriptionCopyPage(c)
+	a.serveSubPage(c, page.BasePath, page)
 	return true
 }
 
@@ -491,106 +496,80 @@ func compileUserAgentRegex(name, pattern, defaultPattern string) *regexp.Regexp
 	return regexp.MustCompile(defaultPattern)
 }
 
-// explicitSubPageRequest reports whether the caller explicitly asked for HTML.
-func explicitSubPageRequest(c *gin.Context) bool {
-	return c.Query("html") == "1" || strings.EqualFold(c.Query("view"), "html")
-}
-
-func (a *SUBController) isBrowserSubscriptionRequest(c *gin.Context) bool {
-	accept := strings.ToLower(c.GetHeader("Accept"))
-	if strings.Contains(accept, "text/html") {
-		return true
+// serveSubPage renders internal/web/dist/subpage.html for the current subscription
+// request. The Vite-built SPA reads window.__SUB_PAGE_DATA__ on mount —
+// we inject that here, along with window.X_UI_BASE_PATH so the
+// page's static asset references resolve correctly when the panel runs
+// behind a URL prefix.
+func (a *SUBController) serveSubPage(c *gin.Context, basePath string, page PageData) {
+	var body []byte
+	if diskBody, diskErr := os.ReadFile("internal/web/dist/subpage.html"); diskErr == nil {
+		body = diskBody
+	} else {
+		readBody, err := fs.ReadFile(distFS, "dist/subpage.html")
+		if err != nil {
+			c.String(http.StatusInternalServerError, "missing embedded subpage")
+			return
+		}
+		body = readBody
 	}
 
-	fetchDest := strings.ToLower(c.GetHeader("Sec-Fetch-Dest"))
-	fetchMode := strings.ToLower(c.GetHeader("Sec-Fetch-Mode"))
-	if fetchDest == "document" || fetchMode == "navigate" {
-		return true
+	// Vite emits absolute asset URLs (`/assets/...`); when the panel is
+	// installed under a custom URL prefix, rewrite them so the bundle
+	// loads from `<basePath>assets/...` where the static handler is
+	// actually mounted.
+	if basePath != "/" && basePath != "" {
+		body = bytes.ReplaceAll(body, []byte(`src="/assets/`), []byte(`src="`+basePath+`assets/`))
+		body = bytes.ReplaceAll(body, []byte(`href="/assets/`), []byte(`href="`+basePath+`assets/`))
 	}
 
-	rawUA := c.GetHeader("User-Agent")
-	ua := strings.ToLower(rawUA)
-	if rawUA == "" {
-		return false
-	}
-	if shouldAutoServeClash(a.subClashAutoDetect, a.clashEnabled, false, rawUA, a.clashUserAgent) ||
-		shouldAutoServeJson(a.jsonAutoDetect, a.jsonEnabled, false, rawUA, a.jsonUserAgent) {
-		return false
-	}
-	if strings.Contains(ua, "mozilla/") {
-		vpnClients := []string{
-			"clash", "mihomo", "sing-box", "v2ray", "xray", "hiddify",
-			"nekobox", "shadowrocket", "streisand", "v2box", "incy", "happ",
-		}
-		for _, client := range vpnClients {
-			if strings.Contains(ua, client) {
-				return false
-			}
-		}
-		return true
-	}
-	return false
-}
+	subData := a.subPageContext(page)
 
-func (a *SUBController) serveSubscriptionCopyPage(c *gin.Context) {
-	setNoCacheHeaders(c)
-	title := localizeRequest(c, "subCopyPageTitle")
-	heading := localizeRequest(c, "subCopyPageHeading")
-	instructions := localizeRequest(c, "subCopyPageInstructions")
-	lang := requestLanguage(c)
-	page := `<!doctype html>
-<html lang="{{LANG}}">
-<head>
-  <meta charset="utf-8">
-  <meta name="viewport" content="width=device-width, initial-scale=1">
-  <meta name="robots" content="noindex,nofollow">
-  <title>{{TITLE}}</title>
-  <style>
-    html, body { margin: 0; min-height: 100%; background: #050505; color: #f2f2f2; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; }
-    body { min-height: 100vh; display: flex; align-items: center; justify-content: center; text-align: center; }
-    main { max-width: 520px; padding: 32px; }
-    h1 { margin: 0 0 14px; font-size: 24px; font-weight: 650; letter-spacing: -0.02em; }
-    p { margin: 0; color: #b8b8b8; font-size: 16px; line-height: 1.55; }
-  </style>
-</head>
-<body>
-  <main>
-    <h1>{{HEADING}}</h1>
-    <p>{{INSTRUCTIONS}}</p>
-  </main>
-</body>
-</html>`
-	page = strings.NewReplacer(
-		"{{LANG}}", stdhtml.EscapeString(lang),
-		"{{TITLE}}", stdhtml.EscapeString(title),
-		"{{HEADING}}", stdhtml.EscapeString(heading),
-		"{{INSTRUCTIONS}}", stdhtml.EscapeString(instructions),
-	).Replace(page)
-	c.Data(http.StatusOK, "text/html; charset=utf-8", []byte(page))
-}
-
-func localizeRequest(c *gin.Context, key string) string {
-	if value, ok := c.Get("localizer"); ok {
-		if localizer, ok := value.(*i18n.Localizer); ok {
-			if msg, err := localizer.Localize(&i18n.LocalizeConfig{MessageID: key}); err == nil {
-				return msg
+	// When an admin has configured a custom subscription theme, render it
+	// instead of the default SPA. We render into a buffer first so a template
+	// that fails mid-execution can't leave a partially-written (corrupt)
+	// response — on any error we log and fall through to the default page.
+	if themeDir, _ := a.settingService.GetSubThemeDir(); themeDir != "" {
+		if tmpl, err := a.loadSubTemplate(themeDir); err != nil {
+			logger.Error("sub: custom template parse failed, using default page:", err)
+		} else if tmpl == nil {
+			logger.Warning("sub: subThemeDir set but no usable template found, using default page:", themeDir)
+		} else {
+			var buf bytes.Buffer
+			if execErr := tmpl.Execute(&buf, subData); execErr != nil {
+				logger.Error("sub: custom template execution failed, using default page:", execErr)
+			} else {
+				setNoCacheHeaders(c)
+				c.Data(http.StatusOK, "text/html; charset=utf-8", buf.Bytes())
+				return
 			}
 		}
 	}
-	fallbacks := map[string]string{
-		"subCopyPageTitle":        "Subscription link",
-		"subCopyPageHeading":      "This is a subscription link",
-		"subCopyPageInstructions": "You do not need to open it in a browser. Copy this page address and paste it into the app.",
-	}
-	return fallbacks[key]
-}
 
-func requestLanguage(c *gin.Context) string {
-	tag, _, _ := language.ParseAcceptLanguage(c.GetHeader("Accept-Language"))
-	if len(tag) == 0 {
-		return "en-US"
-	}
-	return tag[0].String()
+	subDataJSON, err := json.Marshal(subData)
+	if err != nil {
+		subDataJSON = []byte("{}")
+	}
+
+	// Defense-in-depth string-escape for the basePath embed — admin-
+	// controlled but cheap to harden.
+	jsEscape := strings.NewReplacer(
+		`\`, `\\`,
+		`"`, `\"`,
+		"\n", `\n`,
+		"\r", `\r`,
+		"<", `<`,
+		">", `>`,
+		"&", `&`,
+	)
+	escapedBase := jsEscape.Replace(basePath)
+
+	inject := []byte(`<script>window.X_UI_BASE_PATH="` + escapedBase + `";` +
+		`window.__SUB_PAGE_DATA__=` + string(subDataJSON) + `;</script></head>`)
+	out := bytes.Replace(body, []byte("</head>"), inject, 1)
+
+	setNoCacheHeaders(c)
+	c.Data(http.StatusOK, "text/html; charset=utf-8", out)
 }
 
 // subPageContext builds the shared view-model map: the template context for

+ 0 - 133
internal/sub/controller_browser_test.go

@@ -1,133 +0,0 @@
-package sub
-
-import (
-	"net/http"
-	"net/http/httptest"
-	"regexp"
-	"strings"
-	"testing"
-
-	"github.com/gin-gonic/gin"
-	"github.com/nicksnyder/go-i18n/v2/i18n"
-	"golang.org/x/text/language"
-)
-
-func TestIsBrowserSubscriptionRequest(t *testing.T) {
-	gin.SetMode(gin.TestMode)
-
-	tests := []struct {
-		name   string
-		accept string
-		ua     string
-		dest   string
-		mode   string
-		query  string
-		want   bool
-	}{
-		{name: "explicit html query is not implicit navigation", query: "?html=1", want: false},
-		{name: "html accept", accept: "text/html,application/xhtml+xml", want: true},
-		{name: "browser navigation with wildcard accept", accept: "*/*", ua: "Mozilla/5.0 Safari/605.1.15", dest: "document", mode: "navigate", want: true},
-		{name: "browser ua fallback", accept: "*/*", ua: "Mozilla/5.0 Chrome/126.0.0.0", want: true},
-		{name: "vpn client wildcard", accept: "*/*", ua: "Incy/3.3.0", want: false},
-		{name: "vpn client with mozilla token", accept: "*/*", ua: "Mozilla/5.0 Incy/3.3.0", want: false},
-		{name: "plain client", accept: "*/*", ua: "Go-http-client/2.0", want: false},
-	}
-
-	for _, tt := range tests {
-		t.Run(tt.name, func(t *testing.T) {
-			w := httptest.NewRecorder()
-			c, _ := gin.CreateTestContext(w)
-			req := httptest.NewRequest(http.MethodGet, "/sub/abc"+tt.query, nil)
-			if tt.accept != "" {
-				req.Header.Set("Accept", tt.accept)
-			}
-			if tt.ua != "" {
-				req.Header.Set("User-Agent", tt.ua)
-			}
-			if tt.dest != "" {
-				req.Header.Set("Sec-Fetch-Dest", tt.dest)
-			}
-			if tt.mode != "" {
-				req.Header.Set("Sec-Fetch-Mode", tt.mode)
-			}
-			c.Request = req
-
-			if got := (&SUBController{}).isBrowserSubscriptionRequest(c); got != tt.want {
-				t.Fatalf("isBrowserSubscriptionRequest() = %v, want %v", got, tt.want)
-			}
-		})
-	}
-}
-
-func TestBrowserClassificationHonorsConfiguredFormatMatchers(t *testing.T) {
-	cases := []struct {
-		name string
-		new  func() *SUBController
-	}{
-		{"clash", func() *SUBController {
-			return &SUBController{subClashAutoDetect: true, clashEnabled: true, clashUserAgent: regexp.MustCompile(`Custom-Client`)}
-		}},
-		{"json", func() *SUBController {
-			return &SUBController{jsonAutoDetect: true, jsonEnabled: true, jsonUserAgent: regexp.MustCompile(`Custom-Client`)}
-		}},
-	}
-	for _, tc := range cases {
-		t.Run(tc.name, func(t *testing.T) {
-			c, _ := gin.CreateTestContext(httptest.NewRecorder())
-			c.Request = httptest.NewRequest(http.MethodGet, "/sub/abc", nil)
-			c.Request.Header.Set("User-Agent", "Mozilla/5.0 Custom-Client/1.0")
-			if tc.new().isBrowserSubscriptionRequest(c) {
-				t.Fatal("configured subscription client was classified as a browser")
-			}
-		})
-	}
-}
-
-func TestSubscriptionCopyPageUsesRequestLocale(t *testing.T) {
-	bundle := i18n.NewBundle(language.English)
-	for id, text := range map[string]string{
-		"subCopyPageTitle":        "Titre localisé",
-		"subCopyPageHeading":      "En-tête localisé",
-		"subCopyPageInstructions": "Instructions localisées",
-	} {
-		bundle.AddMessages(language.French, &i18n.Message{ID: id, Other: text})
-	}
-	w := httptest.NewRecorder()
-	c, _ := gin.CreateTestContext(w)
-	c.Request = httptest.NewRequest(http.MethodGet, "/sub/abc", nil)
-	c.Request.Header.Set("Accept-Language", "fr-FR")
-	c.Set("localizer", i18n.NewLocalizer(bundle, "fr-FR"))
-
-	(&SUBController{}).serveSubscriptionCopyPage(c)
-	if body := w.Body.String(); !strings.Contains(body, `<html lang="fr-FR">`) ||
-		!strings.Contains(body, "Titre localisé") || !strings.Contains(body, "Instructions localisées") {
-		t.Fatalf("copy page was not localized from the request: %s", body)
-	}
-}
-
-func TestExplicitSubPageRequest(t *testing.T) {
-	gin.SetMode(gin.TestMode)
-
-	tests := []struct {
-		name  string
-		query string
-		want  bool
-	}{
-		{name: "html=1", query: "?html=1", want: true},
-		{name: "view=html", query: "?view=HTML", want: true},
-		{name: "no query", query: "", want: false},
-		{name: "unrelated query", query: "?format=info", want: false},
-	}
-
-	for _, tt := range tests {
-		t.Run(tt.name, func(t *testing.T) {
-			w := httptest.NewRecorder()
-			c, _ := gin.CreateTestContext(w)
-			c.Request = httptest.NewRequest(http.MethodGet, "/sub/abc"+tt.query, nil)
-
-			if got := explicitSubPageRequest(c); got != tt.want {
-				t.Fatalf("explicitSubPageRequest() = %v, want %v", got, tt.want)
-			}
-		})
-	}
-}

+ 4 - 27
internal/sub/info_endpoint_test.go

@@ -118,33 +118,10 @@ func TestSubInfoEndpoint_HTMLPageStillWinsWithoutFormatParam(t *testing.T) {
 	if ct := w.Header().Get("Content-Type"); !strings.Contains(ct, "text/html") {
 		t.Fatalf("Content-Type = %q, want text/html for a browser request", ct)
 	}
-	if strings.Contains(w.Body.String(), "__SUB_PAGE_DATA__") {
-		t.Fatal("copy-only browser page must not embed subscription page data")
+	if !strings.Contains(w.Body.String(), "__SUB_PAGE_DATA__") {
+		t.Fatal("browser request must still get the SPA page with injected page data")
 	}
-	if !strings.Contains(w.Body.String(), "This is a subscription link") {
-		t.Fatalf("browser request did not get the copy-only page; body=%s", w.Body.String())
-	}
-}
-
-func TestExplicitHTMLRequestUsesCopyOnlyPage(t *testing.T) {
-	gin.SetMode(gin.TestMode)
-	initSubDB(t)
-	seedInfoEndpointSub(t, "explicit-html", "explicit@x")
-	oldDistFS := distFS
-	distFS = testDistFS
-	t.Cleanup(func() { distFS = oldDistFS })
-
-	router := gin.New()
-	NewSUBController(router.Group("/"))
-	req := httptest.NewRequest(http.MethodGet, "/sub/explicit-html?html=1", nil)
-	req.Host = "sub.example.com"
-	w := httptest.NewRecorder()
-	router.ServeHTTP(w, req)
-
-	if w.Code != http.StatusOK {
-		t.Fatalf("status = %d, want 200", w.Code)
-	}
-	if strings.Contains(w.Body.String(), "__SUB_PAGE_DATA__") {
-		t.Fatal("explicit HTML request exposed subscription page data")
+	if !strings.Contains(w.Body.String(), `"isOnline":false`) {
+		t.Fatalf("injected page data must carry isOnline; body=%s", w.Body.String())
 	}
 }

+ 1 - 1
internal/sub/json_service.go

@@ -429,7 +429,7 @@ func (s *SubJsonService) genVless(subReq *SubService, inbound *model.Inbound, st
 		"encryption": encryption,
 		"level":      8,
 	}
-	if client.Flow != "" {
+	if client.Flow != "" && !inbound.DisableFlow {
 		settings["flow"] = client.Flow
 	}
 	outbound.Settings = settings

+ 10 - 0
internal/sub/json_service_test.go

@@ -133,6 +133,16 @@ func TestSubJsonServiceVlessFlattened(t *testing.T) {
 	}
 }
 
+func TestSubJsonServiceVlessFlowSuppressedByDisableFlow(t *testing.T) {
+	inbound := &model.Inbound{Listen: "1.2.3.4", Port: 443, Protocol: model.VLESS, Settings: `{"encryption":"none"}`, DisableFlow: true}
+	client := model.Client{ID: "uuid-1", Flow: "xtls-rprx-vision"}
+
+	settings := outboundSettings(t, NewSubJsonService("", "", "", nil).genVless(&SubService{}, inbound, nil, client, ""))
+	if _, ok := settings["flow"]; ok {
+		t.Fatalf("DisableFlow inbound must not carry a flow in the JSON outbound: %#v", settings)
+	}
+}
+
 func TestSubJsonServiceVmessFlattened(t *testing.T) {
 	inbound := &model.Inbound{Listen: "1.2.3.4", Port: 443, Protocol: model.VMESS, Settings: `{}`}
 	client := model.Client{ID: "uuid-2"}

+ 2 - 2
internal/sub/service.go

@@ -822,7 +822,7 @@ func (s *SubService) genVlessLink(inbound *model.Inbound, email string) string {
 	default:
 		params["security"] = "none"
 	}
-	if len(client.Flow) > 0 && vlessFlowAllowed(streamNetwork, security, settings) {
+	if len(client.Flow) > 0 && !inbound.DisableFlow && vlessFlowAllowed(streamNetwork, security, settings) {
 		params["flow"] = client.Flow
 	}
 
@@ -872,7 +872,7 @@ func (s *SubService) genTrojanLink(inbound *model.Inbound, email string) string
 		applyShareTLSParams(stream, params)
 	case "reality":
 		applyShareRealityParams(stream, params, subKey(client))
-		if streamNetwork == "tcp" && len(client.Flow) > 0 {
+		if streamNetwork == "tcp" && len(client.Flow) > 0 && !inbound.DisableFlow {
 			params["flow"] = client.Flow
 		}
 	default:

+ 10 - 0
internal/sub/service_flow_test.go

@@ -81,6 +81,16 @@ func TestGenVlessLink_NoFlowXhttpRealityWithoutVlessenc(t *testing.T) {
 	}
 }
 
+func TestGenVlessLink_DisableFlowSuppressesFlow(t *testing.T) {
+	s := &SubService{}
+	ib := flowTestInbound(xhttpRealityStream, testMlkemEncryption)
+	ib.DisableFlow = true
+	link := s.genVlessLink(ib, "user")
+	if strings.Contains(link, "flow=") {
+		t.Fatalf("DisableFlow inbound must not carry a flow even when the transport is capable, got %q", link)
+	}
+}
+
 func TestGenVlessLink_FlowTcpRealityStillWorks(t *testing.T) {
 	stream := `{
 		"network": "tcp",

+ 25 - 5
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 {

+ 11 - 0
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
@@ -809,6 +819,7 @@ func wireInbound(ib *model.Inbound, remoteNodeID int) url.Values {
 	}
 	v.Set("shareAddrStrategy", shareAddrStrategy)
 	v.Set("shareAddr", ib.ShareAddr)
+	v.Set("disableFlow", strconv.FormatBool(ib.DisableFlow))
 	if ib.TrafficReset != "" {
 		v.Set("trafficReset", ib.TrafficReset)
 	}

+ 42 - 0
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) {
@@ -154,6 +185,17 @@ func TestWireInboundIncludesShareAddressFields(t *testing.T) {
 	}
 }
 
+// A node that does not mirror DisableFlow re-injects Vision into its own xray
+// config and share links, undoing the opt-out on every multi-node deployment.
+func TestWireInboundCarriesDisableFlow(t *testing.T) {
+	if got := wireInbound(&model.Inbound{DisableFlow: true}, 0).Get("disableFlow"); got != "true" {
+		t.Fatalf("disableFlow = %q, want true", got)
+	}
+	if got := wireInbound(&model.Inbound{}, 0).Get("disableFlow"); got != "false" {
+		t.Fatalf("disableFlow = %q, want false", got)
+	}
+}
+
 func TestRemoteHTTPClientEgressProxy(t *testing.T) {
 	// OutboundTag + a resolver → a dedicated proxy client (not the shared default).
 	withTag := NewRemote(&model.Node{Id: 1, Scheme: "https", TlsVerifyMode: "verify", OutboundTag: "warp"}, stubEgress{url: "socks5://127.0.0.1:1080"})

+ 2 - 1
internal/web/service/client_bulk.go

@@ -590,7 +590,8 @@ func (s *ClientService) bulkAdjustInboundClients(
 	// resolve it once. Clearing flow is always allowed; setting a vision flow
 	// is only honored on an inbound that can carry it.
 	flowEligible := flow == bulkFlowClear ||
-		inboundCanEnableTlsFlow(string(oldInbound.Protocol), oldInbound.StreamSettings, oldInbound.Settings)
+		(!oldInbound.DisableFlow &&
+			inboundCanEnableTlsFlow(string(oldInbound.Protocol), oldInbound.StreamSettings, oldInbound.Settings))
 
 	interfaceClients, _ := settings["clients"].([]any)
 	foundEmails := map[string]bool{}

+ 1 - 1
internal/web/service/client_crud.go

@@ -196,7 +196,7 @@ func mtprotoDomainFromSettings(settings string) string {
 }
 
 func clientWithInboundFlow(c model.Client, ib *model.Inbound) model.Client {
-	if !inboundCanEnableTlsFlow(string(ib.Protocol), ib.StreamSettings, ib.Settings) {
+	if ib.DisableFlow || !inboundCanEnableTlsFlow(string(ib.Protocol), ib.StreamSettings, ib.Settings) {
 		c.Flow = ""
 	}
 	return c

+ 69 - 4
internal/web/service/inbound.go

@@ -344,9 +344,10 @@ func (s *InboundService) GetInboundOptions(userId int) ([]InboundOption, error)
 		ShareAddrStrategy string `gorm:"column:share_addr_strategy"`
 		NodeId            *int   `gorm:"column:node_id"`
 		NodeAddress       string `gorm:"column:node_address"`
+		DisableFlow       bool   `gorm:"column:disable_flow"`
 	}
 	err := db.Table("inbounds").
-		Select("inbounds.id, inbounds.remark, inbounds.tag, inbounds.protocol, inbounds.port, inbounds.enable, inbounds.stream_settings, inbounds.settings, inbounds.listen, inbounds.share_addr, inbounds.share_addr_strategy, inbounds.node_id, COALESCE(nodes.address, '') AS node_address").
+		Select("inbounds.id, inbounds.remark, inbounds.tag, inbounds.protocol, inbounds.port, inbounds.enable, inbounds.stream_settings, inbounds.settings, inbounds.listen, inbounds.share_addr, inbounds.share_addr_strategy, inbounds.node_id, COALESCE(nodes.address, '') AS node_address, inbounds.disable_flow").
 		Joins("LEFT JOIN nodes ON nodes.id = inbounds.node_id").
 		Where("inbounds.user_id = ?", userId).
 		Order("inbounds.id ASC").
@@ -368,7 +369,7 @@ func (s *InboundService) GetInboundOptions(userId int) ([]InboundOption, error)
 			Protocol:          r.Protocol,
 			Port:              r.Port,
 			Enable:            r.Enable,
-			TlsFlowCapable:    inboundCanEnableTlsFlow(r.Protocol, r.StreamSettings, r.Settings),
+			TlsFlowCapable:    !r.DisableFlow && inboundCanEnableTlsFlow(r.Protocol, r.StreamSettings, r.Settings),
 			SsMethod:          inboundShadowsocksMethod(r.Protocol, r.Settings),
 			WgPublicKey:       wgPublicKey,
 			WgMtu:             wgMtu,
@@ -954,6 +955,15 @@ func (s *InboundService) AddInbound(inbound *model.Inbound) (*model.Inbound, boo
 		return inbound, false, common.NewError("Duplicate email:", existEmail)
 	}
 
+	if inbound.DisableFlow {
+		if stripped, changed := stripClientFlows(inbound.Settings); changed {
+			inbound.Settings = stripped
+		}
+		for i := range clients {
+			clients[i].Flow = ""
+		}
+	}
+
 	// Ensure created_at and updated_at on clients in settings
 	if len(clients) > 0 {
 		var settings map[string]any
@@ -1264,6 +1274,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 {
@@ -1458,8 +1516,14 @@ func (s *InboundService) UpdateInbound(inbound *model.Inbound) (*model.Inbound,
 		// VLESS inbound just became flow-eligible (e.g. vlessenc was enabled on an
 		// XHTTP inbound), restore Vision for clients whose intended flow is Vision
 		// but was stripped while the inbound was ineligible.
-		if restored, changed := s.restoreVisionFlowForEligibleInbound(tx, inbound.Settings, inbound.StreamSettings, inbound.Protocol); changed {
-			inbound.Settings = restored
+		if !inbound.DisableFlow {
+			if restored, changed := s.restoreVisionFlowForEligibleInbound(tx, inbound.Settings, inbound.StreamSettings, inbound.Protocol); changed {
+				inbound.Settings = restored
+			}
+		} else {
+			if stripped, changed := stripClientFlows(inbound.Settings); changed {
+				inbound.Settings = stripped
+			}
 		}
 
 		oldInbound.Total = inbound.Total
@@ -1472,6 +1536,7 @@ func (s *InboundService) UpdateInbound(inbound *model.Inbound) (*model.Inbound,
 		oldInbound.Listen = inbound.Listen
 		oldInbound.Port = inbound.Port
 		oldInbound.Protocol = inbound.Protocol
+		oldInbound.DisableFlow = inbound.DisableFlow
 		oldInbound.Settings = inbound.Settings
 		oldInbound.StreamSettings = inbound.StreamSettings
 		oldInbound.Sniffing = inbound.Sniffing

+ 1 - 0
internal/web/service/inbound_clients.go

@@ -221,6 +221,7 @@ func (s *InboundService) buildTargetClientFromSource(source model.Client, target
 	case model.VLESS:
 		target.ID = s.generateRandomCredential(targetProtocol)
 		if (flow == "xtls-rprx-vision" || flow == "xtls-rprx-vision-udp443") &&
+			!targetInbound.DisableFlow &&
 			inboundCanEnableTlsFlow(string(targetProtocol), targetInbound.StreamSettings, targetInbound.Settings) {
 			target.Flow = flow
 		}

+ 211 - 0
internal/web/service/inbound_disable_flow_test.go

@@ -0,0 +1,211 @@
+package service
+
+import (
+	"encoding/json"
+	"path/filepath"
+	"testing"
+
+	"github.com/mhsanaei/3x-ui/v3/internal/database"
+	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
+
+	"gorm.io/gorm"
+)
+
+const visionTest = "xtls-rprx-vision"
+
+func clientFlowsInSettings(t *testing.T, settings string) map[string]string {
+	t.Helper()
+	var parsed map[string]any
+	if err := json.Unmarshal([]byte(settings), &parsed); err != nil {
+		t.Fatalf("parse settings: %v", err)
+	}
+	out := map[string]string{}
+	clients, _ := parsed["clients"].([]any)
+	for _, c := range clients {
+		cm, ok := c.(map[string]any)
+		if !ok {
+			continue
+		}
+		email, _ := cm["email"].(string)
+		flow, _ := cm["flow"].(string)
+		out[email] = flow
+	}
+	return out
+}
+
+func TestStripClientFlows(t *testing.T) {
+	cases := []struct {
+		name        string
+		in          string
+		wantChanged bool
+		wantFlows   map[string]string
+	}{
+		{
+			name:        "clears vision on all clients",
+			in:          `{"clients":[{"email":"a","flow":"` + visionTest + `"},{"email":"b","flow":"` + visionTest + `"}]}`,
+			wantChanged: true,
+			wantFlows:   map[string]string{"a": "", "b": ""},
+		},
+		{
+			name:        "mixed flows: clears only the non-empty",
+			in:          `{"clients":[{"email":"a","flow":"` + visionTest + `"},{"email":"b","flow":""}]}`,
+			wantChanged: true,
+			wantFlows:   map[string]string{"a": "", "b": ""},
+		},
+		{
+			name:        "no flows: unchanged",
+			in:          `{"clients":[{"email":"a","flow":""},{"email":"b"}]}`,
+			wantChanged: false,
+			wantFlows:   map[string]string{"a": "", "b": ""},
+		},
+		{
+			name:        "no clients: unchanged",
+			in:          `{"decryption":"none"}`,
+			wantChanged: false,
+		},
+		{
+			name:        "malformed json: unchanged",
+			in:          `{not json`,
+			wantChanged: false,
+		},
+	}
+	for _, tc := range cases {
+		t.Run(tc.name, func(t *testing.T) {
+			out, changed := stripClientFlows(tc.in)
+			if changed != tc.wantChanged {
+				t.Fatalf("changed = %v, want %v", changed, tc.wantChanged)
+			}
+			if !changed {
+				if out != tc.in {
+					t.Fatalf("unchanged input must be returned verbatim, got %q", out)
+				}
+				return
+			}
+			got := clientFlowsInSettings(t, out)
+			for email, want := range tc.wantFlows {
+				if got[email] != want {
+					t.Errorf("flow[%s] = %q, want %q", email, got[email], want)
+				}
+			}
+		})
+	}
+}
+
+func initFlowTestDB(t *testing.T) *gorm.DB {
+	t.Helper()
+	dbDir := t.TempDir()
+	t.Setenv("XUI_DB_FOLDER", dbDir)
+	if err := database.InitDB(filepath.Join(dbDir, "x-ui.db")); err != nil {
+		t.Fatalf("InitDB: %v", err)
+	}
+	t.Cleanup(func() { _ = database.CloseDB() })
+	return database.GetDB()
+}
+
+func TestAddInbound_DisableFlowClampsClientFlow(t *testing.T) {
+	initFlowTestDB(t)
+	ibSvc := &InboundService{}
+
+	in := &model.Inbound{
+		Tag: "dis-add", Enable: true, Port: 52001, Protocol: model.VLESS,
+		StreamSettings: `{"network":"tcp","security":"reality"}`,
+		Settings:       `{"clients":[{"id":"u1","email":"a@x","flow":"` + visionTest + `","subId":"s1","enable":true}]}`,
+		DisableFlow:    true,
+	}
+	if _, _, err := ibSvc.AddInbound(in); err != nil {
+		t.Fatalf("AddInbound: %v", err)
+	}
+
+	got, err := ibSvc.GetInbound(in.Id)
+	if err != nil {
+		t.Fatalf("GetInbound: %v", err)
+	}
+	if !got.DisableFlow {
+		t.Error("DisableFlow not persisted on created inbound")
+	}
+	if f := clientFlowsInSettings(t, got.Settings)["a@x"]; f != "" {
+		t.Errorf("settings flow = %q, want empty (clamped at creation)", f)
+	}
+	list, err := ibSvc.clientService.ListForInbound(nil, in.Id)
+	if err != nil {
+		t.Fatalf("ListForInbound: %v", err)
+	}
+	if len(list) != 1 || list[0].Flow != "" {
+		t.Errorf("flow_override = %#v, want empty (xray must not expect Vision)", list)
+	}
+}
+
+func TestUpdateInbound_DisableFlowPersistsStripsAndResistsRestore(t *testing.T) {
+	db := initFlowTestDB(t)
+	ibSvc := &InboundService{}
+	cs := &ClientService{}
+
+	const email = "shared@x"
+	const uid = "ce8d33df-3a64-4f10-8f9b-91c3a8e0d001"
+
+	sibling := &model.Inbound{
+		Tag: "sib", Enable: true, Port: 52101, Protocol: model.VLESS,
+		StreamSettings: `{"network":"tcp","security":"reality"}`,
+		Settings:       `{"clients":[{"id":"` + uid + `","email":"` + email + `","flow":"` + visionTest + `","subId":"s1","enable":true}]}`,
+	}
+	if err := db.Create(sibling).Error; err != nil {
+		t.Fatalf("create sibling: %v", err)
+	}
+	sc, _ := ibSvc.GetClients(sibling)
+	if err := cs.SyncInbound(nil, sibling.Id, sc); err != nil {
+		t.Fatalf("sync sibling: %v", err)
+	}
+
+	target := &model.Inbound{
+		Tag: "tgt", Enable: true, Port: 52102, Protocol: model.VLESS,
+		StreamSettings: `{"network":"tcp","security":"reality"}`,
+		Settings:       `{"clients":[{"id":"` + uid + `","email":"` + email + `","flow":"` + visionTest + `","subId":"s1","enable":true}]}`,
+	}
+	if err := db.Create(target).Error; err != nil {
+		t.Fatalf("create target: %v", err)
+	}
+	tc, _ := ibSvc.GetClients(target)
+	if err := cs.SyncInbound(nil, target.Id, tc); err != nil {
+		t.Fatalf("sync target: %v", err)
+	}
+
+	upd := *target
+	upd.DisableFlow = true
+	if _, _, err := ibSvc.UpdateInbound(&upd); err != nil {
+		t.Fatalf("UpdateInbound: %v", err)
+	}
+
+	reloaded, err := ibSvc.GetInbound(target.Id)
+	if err != nil {
+		t.Fatalf("GetInbound: %v", err)
+	}
+	if !reloaded.DisableFlow {
+		t.Fatal("DisableFlow did not persist through UpdateInbound (blocking regression)")
+	}
+	if f := clientFlowsInSettings(t, reloaded.Settings)["shared@x"]; f != "" {
+		t.Errorf("target settings flow = %q, want empty after disable", f)
+	}
+	list, err := cs.ListForInbound(nil, target.Id)
+	if err != nil {
+		t.Fatalf("ListForInbound(target): %v", err)
+	}
+	if len(list) != 1 || list[0].Flow != "" {
+		t.Errorf("target flow_override = %#v, want empty", list)
+	}
+
+	ibSvc.MigrationRestoreVisionFlow()
+	reloaded2, err := ibSvc.GetInbound(target.Id)
+	if err != nil {
+		t.Fatalf("GetInbound after restore: %v", err)
+	}
+	if f := clientFlowsInSettings(t, reloaded2.Settings)["shared@x"]; f != "" {
+		t.Errorf("after MigrationRestoreVisionFlow target flow = %q, want empty (must not self-revert)", f)
+	}
+	sList, err := cs.ListForInbound(nil, sibling.Id)
+	if err != nil {
+		t.Fatalf("ListForInbound(sibling): %v", err)
+	}
+	if len(sList) != 1 || sList[0].Flow != visionTest {
+		t.Errorf("sibling flow_override = %#v, want Vision preserved", sList)
+	}
+}

+ 31 - 0
internal/web/service/inbound_flow_restore.go

@@ -88,3 +88,34 @@ func (s *InboundService) restoreVisionFlowForEligibleInbound(tx *gorm.DB, settin
 	}
 	return string(out), true
 }
+
+func stripClientFlows(settings string) (string, bool) {
+	var parsed map[string]any
+	if err := json.Unmarshal([]byte(settings), &parsed); err != nil {
+		return settings, false
+	}
+	clients, ok := parsed["clients"].([]any)
+	if !ok || len(clients) == 0 {
+		return settings, false
+	}
+	changed := false
+	for i := range clients {
+		cm, ok := clients[i].(map[string]any)
+		if !ok {
+			continue
+		}
+		if flow, _ := cm["flow"].(string); flow != "" {
+			cm["flow"] = ""
+			clients[i] = cm
+			changed = true
+		}
+	}
+	if !changed {
+		return settings, false
+	}
+	out, err := json.MarshalIndent(parsed, "", "  ")
+	if err != nil {
+		return settings, false
+	}
+	return string(out), true
+}

+ 3 - 0
internal/web/service/inbound_migration.go

@@ -301,6 +301,9 @@ func (s *InboundService) MigrationRestoreVisionFlow() {
 		return
 	}
 	for _, ib := range inbounds {
+		if ib.DisableFlow {
+			continue
+		}
 		restored, changed := s.restoreVisionFlowForEligibleInbound(nil, ib.Settings, ib.StreamSettings, ib.Protocol)
 		if !changed {
 			continue

+ 1 - 0
internal/web/service/inbound_node.go

@@ -627,6 +627,7 @@ func (s *InboundService) setRemoteTrafficLocked(nodeID int, snap *runtime.Traffi
 				Up:                   snapIb.Up,
 				Down:                 snapIb.Down,
 				ShareAddrStrategy:    "node",
+				DisableFlow:          snapIb.DisableFlow,
 			}
 			if err := tx.Create(&newIb).Error; err != nil {
 				logger.Warningf("setRemoteTraffic: create central inbound for tag %q failed: %v", snapIb.Tag, err)

+ 57 - 0
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":"[email protected]","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: "[email protected]", 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)
+	}
+}

+ 6 - 0
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 }

+ 4 - 12
internal/web/service/node_mtls.go

@@ -2,8 +2,6 @@ package service
 
 import (
 	"crypto/tls"
-	"crypto/x509"
-	"encoding/pem"
 	"strings"
 
 	"github.com/mhsanaei/3x-ui/v3/internal/util/common"
@@ -42,19 +40,13 @@ func (s *NodeService) ReloadMasterMtlsClient() error {
 	return nil
 }
 
-// SetNodeMtlsTrustCA stores the CA certificate this panel trusts for incoming
-// node-API client certificates. An empty value clears it (mTLS off). A
-// non-empty value must be a PEM certificate (fail closed). Takes effect on the
-// next panel restart, when the listener's ClientCAs is rebuilt.
+// SetNodeMtlsTrustCA stores the CA certificate bundle trusted for incoming
+// node-API clients. An empty value clears it; changes apply after restart.
 func (s *NodeService) SetNodeMtlsTrustCA(caPem string) error {
 	caPem = strings.TrimSpace(caPem)
 	if caPem != "" {
-		block, _ := pem.Decode([]byte(caPem))
-		if block == nil || block.Type != "CERTIFICATE" {
-			return common.NewError("trust CA must be a PEM-encoded certificate")
-		}
-		if _, err := x509.ParseCertificate(block.Bytes); err != nil {
-			return common.NewError("invalid trust CA certificate: " + err.Error())
+		if _, err := parseCertificateBundlePEM([]byte(caPem)); err != nil {
+			return common.NewError("invalid trust CA certificate bundle: ", err)
 		}
 	}
 	return (&SettingService{}).setString(settingNodeMtlsClientCA, caPem)

+ 38 - 2
internal/web/service/setting_mtls.go

@@ -1,11 +1,14 @@
 package service
 
 import (
+	"bytes"
 	"crypto/sha256"
 	"crypto/tls"
 	"crypto/x509"
 	"encoding/hex"
 	"encoding/pem"
+	"errors"
+	"fmt"
 	"strings"
 	"sync"
 
@@ -189,9 +192,42 @@ func (s *SettingService) NodeMtlsClientCAPool() (*x509.CertPool, error) {
 	if caPem == "" {
 		return nil, nil
 	}
+	certs, err := parseCertificateBundlePEM([]byte(caPem))
+	if err != nil {
+		return nil, fmt.Errorf("nodeMtlsClientCAPem is not a valid certificate bundle: %w", err)
+	}
 	pool := x509.NewCertPool()
-	if !pool.AppendCertsFromPEM([]byte(caPem)) {
-		return nil, common.NewError("nodeMtlsClientCAPem is not a valid certificate")
+	for _, cert := range certs {
+		pool.AddCert(cert)
 	}
 	return pool, nil
 }
+
+// parseCertificateBundlePEM avoids AppendCertsFromPEM because that helper can
+// silently accept a bundle after parsing only its first certificate.
+func parseCertificateBundlePEM(bundle []byte) ([]*x509.Certificate, error) {
+	rest := bytes.TrimSpace(bundle)
+	if len(rest) == 0 {
+		return nil, errors.New("certificate bundle is empty")
+	}
+	certs := make([]*x509.Certificate, 0, 1)
+	for len(rest) > 0 {
+		if !bytes.HasPrefix(rest, []byte("-----BEGIN CERTIFICATE-----")) {
+			return nil, errors.New("certificate bundle contains malformed or non-PEM data")
+		}
+		block, next := pem.Decode(rest)
+		if block == nil {
+			return nil, errors.New("certificate bundle contains malformed or non-PEM data")
+		}
+		if block.Type != "CERTIFICATE" {
+			return nil, errors.New("certificate bundle contains a non-certificate PEM block")
+		}
+		cert, err := x509.ParseCertificate(block.Bytes)
+		if err != nil {
+			return nil, errors.New("certificate bundle contains an invalid certificate")
+		}
+		certs = append(certs, cert)
+		rest = bytes.TrimSpace(next)
+	}
+	return certs, nil
+}

+ 74 - 0
internal/web/service/setting_mtls_bundle_test.go

@@ -0,0 +1,74 @@
+package service
+
+import (
+	"strings"
+	"testing"
+
+	"github.com/mhsanaei/3x-ui/v3/internal/util/crypto"
+)
+
+func mustNodeCAPEM(t *testing.T, name string) string {
+	t.Helper()
+	ca, err := crypto.GenerateNodeCA(name)
+	if err != nil {
+		t.Fatalf("GenerateNodeCA(%q): %v", name, err)
+	}
+	return string(ca.CertPEM)
+}
+
+func TestParseCertificateBundlePEM(t *testing.T) {
+	first := mustNodeCAPEM(t, "bundle test CA one")
+	second := mustNodeCAPEM(t, "bundle test CA two")
+
+	corrupt := strings.Replace(second, "-----BEGIN CERTIFICATE-----\n", "-----BEGIN CERTIFICATE-----\nAA", 1)
+
+	tests := []struct {
+		name      string
+		bundle    string
+		wantCerts int
+		wantErr   string
+	}{
+		{name: "single certificate", bundle: first, wantCerts: 1},
+		{name: "two certificates", bundle: first + second, wantCerts: 2},
+		{name: "empty", bundle: "", wantErr: "certificate bundle is empty"},
+		{name: "whitespace only", bundle: "\n\t  \n", wantErr: "certificate bundle is empty"},
+		{name: "leading non-PEM data", bundle: "junk\n" + first, wantErr: "certificate bundle contains malformed or non-PEM data"},
+		{name: "interstitial non-PEM data", bundle: first + "junk\n" + second, wantErr: "certificate bundle contains malformed or non-PEM data"},
+		{name: "second certificate corrupt", bundle: first + corrupt, wantErr: "certificate bundle contains malformed or non-PEM data"},
+		{name: "trailing non-PEM data", bundle: first + "not a certificate\n", wantErr: "certificate bundle contains malformed or non-PEM data"},
+		{name: "non-certificate block", bundle: first + "-----BEGIN PRIVATE KEY-----\nAAAA\n-----END PRIVATE KEY-----\n", wantErr: "certificate bundle contains malformed or non-PEM data"},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			certs, err := parseCertificateBundlePEM([]byte(tt.bundle))
+			if tt.wantErr != "" {
+				if err == nil || err.Error() != tt.wantErr {
+					t.Fatalf("parseCertificateBundlePEM() error = %v, want %q", err, tt.wantErr)
+				}
+				return
+			}
+			if err != nil {
+				t.Fatalf("parseCertificateBundlePEM(): %v", err)
+			}
+			if len(certs) != tt.wantCerts {
+				t.Fatalf("parseCertificateBundlePEM() = %d certs, want %d", len(certs), tt.wantCerts)
+			}
+		})
+	}
+}
+
+func TestNodeMtlsClientCAPoolRejectsPartiallyValidBundle(t *testing.T) {
+	s := setupSettingMtlsDB(t)
+
+	valid := mustNodeCAPEM(t, "pool test CA")
+	if err := s.setString("nodeMtlsClientCAPem", valid+"-----BEGIN CERTIFICATE-----\nnot base64\n-----END CERTIFICATE-----\n"); err != nil {
+		t.Fatalf("setString: %v", err)
+	}
+
+	pool, err := s.NodeMtlsClientCAPool()
+	want := "nodeMtlsClientCAPem is not a valid certificate bundle: certificate bundle contains malformed or non-PEM data"
+	if err == nil || err.Error() != want {
+		t.Fatalf("NodeMtlsClientCAPool() = %v, error = %v, want %q", pool, err, want)
+	}
+}

+ 3 - 0
internal/web/service/xray.go

@@ -204,6 +204,9 @@ func (s *XrayService) GetXrayConfig() (*xray.Config, error) {
 			if flow == "xtls-rprx-vision-udp443" {
 				flow = "xtls-rprx-vision"
 			}
+			if inbound.DisableFlow {
+				flow = ""
+			}
 			entry := map[string]any{"email": c.Email}
 			switch inbound.Protocol {
 			case model.VLESS:

+ 3 - 4
internal/web/translation/ar-EG.json

@@ -617,6 +617,8 @@
         "shareAddrHelp": "يُستخدم فقط عندما تكون استراتيجية عنوان المشاركة مخصصة. أدخل اسم مضيف أو عنوان IP بدون بروتوكول أو منفذ.",
         "subSortIndex": "ترتيب الروابط في الاشتراك",
         "subSortIndexHelp": "موضع روابط هذا الوارد في مخرجات الاشتراك (صفحة الاشتراك وتطبيقات العملاء). القيم الأقل تظهر أولاً، والقيم المتساوية تحافظ على ترتيب الإنشاء. لا يؤثر على قائمة الواردات في اللوحة.",
+        "disableFlow": "تعطيل تدفق XTLS",
+        "disableFlowHelp": "استثناء هذا الـ inbound من الحقن التلقائي لـ xtls-rprx-vision، حتى عندما يكون النقل قادرًا على الـ flow (مثل inbound من نوع XHTTP عبر نفق مع تشفير VLESS). يحتفظ العملاء بـ Vision على باقي الـ inbounds القادرة ضمن نفس الاشتراك. لـ VLESS فقط.",
         "shareAddrStrategyOptions": {
           "node": "عنوان العقدة",
           "listen": "عنوان استماع الوارد",
@@ -2075,8 +2077,5 @@
     "statusFailed": "فشل",
     "statusDown": "غير متصل",
     "statusUp": "متصل"
-  },
-  "subCopyPageTitle": "رابط الاشتراك",
-  "subCopyPageHeading": "هذا رابط اشتراك",
-  "subCopyPageInstructions": "لا حاجة لفتحه في المتصفح. انسخ عنوان هذه الصفحة والصقه في التطبيق."
+  }
 }

+ 3 - 4
internal/web/translation/en-US.json

@@ -638,6 +638,8 @@
         "shareAddrHelp": "Used only when the share address strategy is Custom. Enter a host or IP without a scheme or port.",
         "subSortIndex": "Subscription sort order",
         "subSortIndexHelp": "Position of this inbound's links in subscription output (sub page and client apps). Lower values come first; equal values keep creation order. Does not affect the panel inbound list.",
+        "disableFlow": "Disable XTLS flow",
+        "disableFlowHelp": "Opt this inbound out of automatic xtls-rprx-vision injection, even when its transport is flow-capable (e.g. a tunneled XHTTP inbound with VLESS encryption). Clients keep Vision on your other capable inbounds in the same subscription. VLESS only.",
         "shareAddrStrategyOptions": {
           "node": "Node address",
           "listen": "Inbound listen",
@@ -2075,8 +2077,5 @@
     "statusFailed": "FAILED",
     "statusDown": "DOWN",
     "statusUp": "UP"
-  },
-  "subCopyPageTitle": "Subscription link",
-  "subCopyPageHeading": "This is a subscription link",
-  "subCopyPageInstructions": "You do not need to open it in a browser. Copy this page address and paste it into the app."
+  }
 }

+ 3 - 4
internal/web/translation/es-ES.json

@@ -638,6 +638,8 @@
         "shareAddrHelp": "Solo se usa cuando la estrategia de dirección para compartir es Personalizada. Introduce un host o IP sin esquema ni puerto.",
         "subSortIndex": "Orden en la suscripción",
         "subSortIndexHelp": "Posición de los enlaces de esta entrada en la salida de la suscripción (página de suscripción y apps cliente). Los valores más bajos van primero; con valores iguales se mantiene el orden de creación. No afecta a la lista de entradas del panel.",
+        "disableFlow": "Desactivar el flujo XTLS",
+        "disableFlowHelp": "Excluye este inbound de la inyección automática de xtls-rprx-vision, incluso cuando su transporte admite flow (p. ej. un inbound XHTTP tunelizado con cifrado VLESS). Los clientes mantienen Vision en tus demás inbounds compatibles de la misma suscripción. Solo VLESS.",
         "shareAddrStrategyOptions": {
           "node": "Dirección del nodo",
           "listen": "Dirección de escucha del inbound",
@@ -2075,8 +2077,5 @@
     "statusFailed": "FALLIDO",
     "statusDown": "CAÍDO",
     "statusUp": "ACTIVO"
-  },
-  "subCopyPageTitle": "Enlace de suscripción",
-  "subCopyPageHeading": "Este es un enlace de suscripción",
-  "subCopyPageInstructions": "No necesita abrirlo en el navegador. Copie la dirección de esta página y péguela en la aplicación."
+  }
 }

+ 3 - 4
internal/web/translation/fa-IR.json

@@ -638,6 +638,8 @@
         "shareAddrHelp": "فقط زمانی استفاده می‌شود که راهبرد آدرس اشتراک‌گذاری روی سفارشی باشد. میزبان یا IP را بدون طرح و پورت وارد کنید.",
         "subSortIndex": "ترتیب در اشتراک",
         "subSortIndexHelp": "جایگاه لینک‌های این ورودی در خروجی اشتراک (صفحه اشتراک و برنامه‌های کلاینت). مقدار کمتر اول می‌آید و مقدارهای برابر ترتیب ایجاد را حفظ می‌کنند. روی فهرست ورودی‌های پنل تأثیری ندارد.",
+        "disableFlow": "غیرفعال‌کردن جریان XTLS",
+        "disableFlowHelp": "این inbound را از تزریق خودکار xtls-rprx-vision کنار بگذارید، حتی وقتی ترنسپورت آن از flow پشتیبانی می‌کند (مثلاً یک inbound از نوع XHTTP تونل‌شده با رمزنگاری VLESS). کلاینت‌ها Vision را روی سایر inboundهای سازگار در همان اشتراک حفظ می‌کنند. فقط برای VLESS.",
         "shareAddrStrategyOptions": {
           "node": "آدرس نود",
           "listen": "آدرس شنود ورودی",
@@ -2075,8 +2077,5 @@
     "statusFailed": "ناموفق",
     "statusDown": "قطع",
     "statusUp": "وصل"
-  },
-  "subCopyPageTitle": "پیوند اشتراک",
-  "subCopyPageHeading": "این یک پیوند اشتراک است",
-  "subCopyPageInstructions": "نیازی نیست آن را در مرورگر باز کنید. نشانی این صفحه را کپی و در برنامه جای‌گذاری کنید."
+  }
 }

+ 3 - 4
internal/web/translation/id-ID.json

@@ -617,6 +617,8 @@
         "shareAddrHelp": "Hanya digunakan saat strategi alamat berbagi adalah Kustom. Masukkan host atau IP tanpa skema atau port.",
         "subSortIndex": "Urutan dalam langganan",
         "subSortIndexHelp": "Posisi tautan inbound ini dalam keluaran langganan (halaman langganan dan aplikasi klien). Nilai lebih kecil tampil lebih dulu; nilai sama mempertahankan urutan pembuatan. Tidak memengaruhi daftar inbound di panel.",
+        "disableFlow": "Nonaktifkan flow XTLS",
+        "disableFlowHelp": "Kecualikan inbound ini dari injeksi otomatis xtls-rprx-vision, meskipun transport-nya mendukung flow (mis. inbound XHTTP yang dituneling dengan enkripsi VLESS). Klien tetap memakai Vision pada inbound lain yang mendukung dalam langganan yang sama. Hanya VLESS.",
         "shareAddrStrategyOptions": {
           "node": "Alamat node",
           "listen": "Alamat listen inbound",
@@ -2075,8 +2077,5 @@
     "statusFailed": "GAGAL",
     "statusDown": "MATI",
     "statusUp": "AKTIF"
-  },
-  "subCopyPageTitle": "Tautan langganan",
-  "subCopyPageHeading": "Ini adalah tautan langganan",
-  "subCopyPageInstructions": "Anda tidak perlu membukanya di browser. Salin alamat halaman ini dan tempelkan ke aplikasi."
+  }
 }

+ 3 - 4
internal/web/translation/ja-JP.json

@@ -638,6 +638,8 @@
         "shareAddrHelp": "共有アドレス戦略がカスタムの場合のみ使用されます。スキームやポートを含めずにホスト名またはIPを入力してください。",
         "subSortIndex": "サブスクリプションでの並び順",
         "subSortIndexHelp": "サブスクリプション出力(サブスクリプションページおよびクライアントアプリ)におけるこのインバウンドのリンクの位置。値が小さいほど先頭に表示され、同じ値の場合は作成順が維持されます。パネルのインバウンド一覧には影響しません。",
+        "disableFlow": "XTLS フローを無効化",
+        "disableFlowHelp": "トランスポートが flow に対応している場合でも(例: VLESS 暗号化付きのトンネル化された XHTTP インバウンド)、このインバウンドを xtls-rprx-vision の自動付与から除外します。クライアントは同じサブスクリプション内の他の対応インバウンドでは Vision を維持します。VLESS のみ。",
         "shareAddrStrategyOptions": {
           "node": "ノードアドレス",
           "listen": "インバウンドのリッスンアドレス",
@@ -2075,8 +2077,5 @@
     "statusFailed": "失敗",
     "statusDown": "ダウン",
     "statusUp": "アップ"
-  },
-  "subCopyPageTitle": "サブスクリプションリンク",
-  "subCopyPageHeading": "これはサブスクリプションリンクです",
-  "subCopyPageInstructions": "ブラウザーで開く必要はありません。このページのアドレスをコピーしてアプリに貼り付けてください。"
+  }
 }

+ 3 - 4
internal/web/translation/pt-BR.json

@@ -638,6 +638,8 @@
         "shareAddrHelp": "Usado apenas quando a estratégia de endereço de compartilhamento é Personalizada. Informe um host ou IP sem esquema nem porta.",
         "subSortIndex": "Ordem na assinatura",
         "subSortIndexHelp": "Posição dos links desta entrada na saída da assinatura (página de assinatura e aplicativos cliente). Valores menores vêm primeiro; valores iguais mantêm a ordem de criação. Não afeta a lista de entradas do painel.",
+        "disableFlow": "Desativar o flow XTLS",
+        "disableFlowHelp": "Exclui este inbound da injeção automática de xtls-rprx-vision, mesmo quando o transporte suporta flow (ex.: um inbound XHTTP tunelado com criptografia VLESS). Os clientes mantêm o Vision nos seus outros inbounds compatíveis da mesma assinatura. Somente VLESS.",
         "shareAddrStrategyOptions": {
           "node": "Endereço do nó",
           "listen": "Endereço de escuta do inbound",
@@ -2075,8 +2077,5 @@
     "statusFailed": "FALHOU",
     "statusDown": "INATIVO",
     "statusUp": "ATIVO"
-  },
-  "subCopyPageTitle": "Link de assinatura",
-  "subCopyPageHeading": "Este é um link de assinatura",
-  "subCopyPageInstructions": "Não é necessário abri-lo no navegador. Copie o endereço desta página e cole-o no aplicativo."
+  }
 }

+ 3 - 4
internal/web/translation/ru-RU.json

@@ -638,6 +638,8 @@
         "shareAddrHelp": "Используется только когда стратегия адреса для ссылок — пользовательская. Укажите хост или IP без схемы и порта.",
         "subSortIndex": "Порядок в подписке",
         "subSortIndexHelp": "Позиция ссылок этого входящего в выдаче подписки (страница подписки и клиентские приложения). Меньшие значения идут первыми; при равных значениях сохраняется порядок создания. Не влияет на список входящих в панели.",
+        "disableFlow": "Отключить поток XTLS",
+        "disableFlowHelp": "Исключить этот inbound из автоматического добавления xtls-rprx-vision, даже если его транспорт поддерживает flow (например, туннелированный XHTTP inbound с шифрованием VLESS). Клиенты сохраняют Vision на других подходящих inbound в той же подписке. Только VLESS.",
         "shareAddrStrategyOptions": {
           "node": "Адрес узла",
           "listen": "Адрес прослушивания inbound",
@@ -2075,8 +2077,5 @@
     "statusFailed": "НЕУДАЧНО",
     "statusDown": "НЕДОСТУПЕН",
     "statusUp": "РАБОТАЕТ"
-  },
-  "subCopyPageTitle": "Ссылка подписки",
-  "subCopyPageHeading": "Это ссылка подписки",
-  "subCopyPageInstructions": "Открывать её в браузере не нужно. Скопируйте адрес этой страницы и вставьте его в приложение."
+  }
 }

+ 3 - 4
internal/web/translation/tr-TR.json

@@ -617,6 +617,8 @@
         "shareAddrHelp": "Yalnızca paylaşım adresi stratejisi Özel olduğunda kullanılır. Şema veya port olmadan bir ana makine ya da IP girin.",
         "subSortIndex": "Abonelikte sıralama",
         "subSortIndexHelp": "Bu gelen bağlantının linklerinin abonelik çıktısındaki (abonelik sayfası ve istemci uygulamaları) konumu. Küçük değerler önce gelir; eşit değerlerde oluşturulma sırası korunur. Paneldeki gelen bağlantı listesini etkilemez.",
+        "disableFlow": "XTLS akışını devre dışı bırak",
+        "disableFlowHelp": "Taşıması flow destekliyor olsa bile (ör. VLESS şifrelemeli, tünellenmiş bir XHTTP inbound) bu inbound'u otomatik xtls-rprx-vision eklemenin dışında tut. İstemciler aynı abonelikteki diğer uygun inbound'larda Vision'ı korur. Yalnızca VLESS.",
         "shareAddrStrategyOptions": {
           "node": "Düğüm adresi",
           "listen": "Inbound dinleme adresi",
@@ -2075,8 +2077,5 @@
     "statusFailed": "BAŞARISIZ",
     "statusDown": "ÇEVRİMDIŞI",
     "statusUp": "ÇEVRİMİÇİ"
-  },
-  "subCopyPageTitle": "Abonelik bağlantısı",
-  "subCopyPageHeading": "Bu bir abonelik bağlantısıdır",
-  "subCopyPageInstructions": "Tarayıcıda açmanız gerekmez. Bu sayfanın adresini kopyalayıp uygulamaya yapıştırın."
+  }
 }

+ 3 - 4
internal/web/translation/uk-UA.json

@@ -617,6 +617,8 @@
         "shareAddrHelp": "Використовується лише коли стратегія адреси поширення — користувацька. Введіть хост або IP без схеми та порту.",
         "subSortIndex": "Порядок у підписці",
         "subSortIndexHelp": "Позиція посилань цього вхідного у виводі підписки (сторінка підписки та клієнтські застосунки). Менші значення йдуть першими; за однакових значень зберігається порядок створення. Не впливає на список вхідних у панелі.",
+        "disableFlow": "Вимкнути потік XTLS",
+        "disableFlowHelp": "Виключити цей inbound з автоматичного додавання xtls-rprx-vision, навіть якщо його транспорт підтримує flow (наприклад, тунельований XHTTP inbound із шифруванням VLESS). Клієнти зберігають Vision на інших сумісних inbound у тій самій підписці. Лише VLESS.",
         "echSockopt": "ECH Sockopt",
         "echSockoptTip": "Параметри сокета для з'єднання, яке Xray використовує для отримання списку конфігурацій ECH (наприклад, спрямувати запит через вихідний dialerProxy). Залиште вимкненим, щоб використовувати типові значення.",
         "curvePreferences": "Налаштування кривих",
@@ -2075,8 +2077,5 @@
     "statusFailed": "НЕВДАЛО",
     "statusDown": "НЕДОСТУПНО",
     "statusUp": "ДОСТУПНО"
-  },
-  "subCopyPageTitle": "Посилання підписки",
-  "subCopyPageHeading": "Це посилання підписки",
-  "subCopyPageInstructions": "Відкривати його в браузері не потрібно. Скопіюйте адресу цієї сторінки та вставте її в застосунок."
+  }
 }

+ 3 - 4
internal/web/translation/vi-VN.json

@@ -638,6 +638,8 @@
         "shareAddrHelp": "Chỉ dùng khi chiến lược địa chỉ chia sẻ là Tùy chỉnh. Nhập host hoặc IP không kèm giao thức hoặc cổng.",
         "subSortIndex": "Thứ tự trong gói đăng ký",
         "subSortIndexHelp": "Vị trí liên kết của inbound này trong nội dung gói đăng ký (trang đăng ký và ứng dụng khách). Giá trị nhỏ hơn xếp trước; giá trị bằng nhau giữ thứ tự tạo. Không ảnh hưởng đến danh sách inbound trong bảng điều khiển.",
+        "disableFlow": "Tắt luồng XTLS",
+        "disableFlowHelp": "Loại inbound này khỏi việc tự động thêm xtls-rprx-vision, ngay cả khi transport của nó hỗ trợ flow (ví dụ một inbound XHTTP đi qua tunnel với mã hóa VLESS). Client vẫn giữ Vision trên các inbound tương thích khác trong cùng subscription. Chỉ dành cho VLESS.",
         "shareAddrStrategyOptions": {
           "node": "Địa chỉ node",
           "listen": "Địa chỉ listen inbound",
@@ -2075,8 +2077,5 @@
     "statusFailed": "THẤT BẠI",
     "statusDown": "NGỪNG HOẠT ĐỘNG",
     "statusUp": "HOẠT ĐỘNG"
-  },
-  "subCopyPageTitle": "Liên kết đăng ký",
-  "subCopyPageHeading": "Đây là liên kết đăng ký",
-  "subCopyPageInstructions": "Bạn không cần mở liên kết trong trình duyệt. Hãy sao chép địa chỉ trang này và dán vào ứng dụng."
+  }
 }

+ 3 - 4
internal/web/translation/zh-CN.json

@@ -637,6 +637,8 @@
         "shareAddrHelp": "仅在分享地址策略为自定义时使用。填写不带协议和端口的域名或 IP。",
         "subSortIndex": "订阅排序",
         "subSortIndexHelp": "此入站的链接在订阅输出(订阅页面和客户端应用)中的位置。数值越小越靠前;数值相同时保持创建顺序。不影响面板中的入站列表。",
+        "disableFlow": "禁用 XTLS flow",
+        "disableFlowHelp": "让此入站跳过自动注入 xtls-rprx-vision,即使其传输支持 flow(例如启用 VLESS 加密的隧道化 XHTTP 入站)。客户端在同一订阅中的其他可用入站上仍保留 Vision。仅限 VLESS。",
         "shareAddrStrategyOptions": {
           "node": "节点地址",
           "listen": "入站监听地址",
@@ -2075,8 +2077,5 @@
     "statusFailed": "失败",
     "statusDown": "断开",
     "statusUp": "恢复"
-  },
-  "subCopyPageTitle": "订阅链接",
-  "subCopyPageHeading": "这是一个订阅链接",
-  "subCopyPageInstructions": "无需在浏览器中打开。请复制此页面地址并粘贴到应用中。"
+  }
 }

+ 3 - 4
internal/web/translation/zh-TW.json

@@ -638,6 +638,8 @@
         "shareAddrHelp": "僅在分享地址策略為自訂時使用。填寫不帶協定和連接埠的網域或 IP。",
         "subSortIndex": "訂閱排序",
         "subSortIndexHelp": "此入站的連結在訂閱輸出(訂閱頁面和客戶端應用)中的位置。數值越小越靠前;數值相同時保持建立順序。不影響面板中的入站清單。",
+        "disableFlow": "停用 XTLS flow",
+        "disableFlowHelp": "讓此入站略過自動注入 xtls-rprx-vision,即使其傳輸支援 flow(例如啟用 VLESS 加密的通道化 XHTTP 入站)。用戶端在同一訂閱中的其他可用入站上仍保留 Vision。僅限 VLESS。",
         "shareAddrStrategyOptions": {
           "node": "節點地址",
           "listen": "入站監聽地址",
@@ -2075,8 +2077,5 @@
     "statusFailed": "失敗",
     "statusDown": "中斷",
     "statusUp": "恢復"
-  },
-  "subCopyPageTitle": "訂閱連結",
-  "subCopyPageHeading": "這是訂閱連結",
-  "subCopyPageInstructions": "無需在瀏覽器中開啟。請複製此頁面位址並貼到應用程式中。"
+  }
 }