From 19f2f21d817e35229b36d13850fc3f0922522b59 Mon Sep 17 00:00:00 2001 From: Hamed Date: Tue, 3 Nov 2026 18:22:30 +0330 Subject: [PATCH] fix(inbounds): reject incompatible VLESS fallbacks and prevent Xray crashes (#7003) Xray refuses a VLESS inbound with server decryption whenever fallbacks is non-nil, even an empty list, so an API or imported inbound with decryption and fallbacks: [] took every inbound down. Empty fallback arrays are now normalized away on add, update, config build and node push; non-empty ones are refused with decryption, enabling decryption over stored fallback rules is refused, and a legacy conflicting row holds back the running core instead of stopping it. The form, API docs and locales follow. Closes #7001. --- .github/workflows/ci.yml | 6 +- .../docs/en/reference/api/inbounds.mdx | 46 ++- docs/public/openapi.json | 14 +- frontend/public/openapi.json | 14 +- frontend/src/pages/api-docs/endpoints.ts | 10 +- .../src/pages/inbounds/form/FallbacksCard.tsx | 7 +- .../pages/inbounds/form/InboundFormModal.tsx | 65 +++- .../inbounds/form/useInboundFallbacks.ts | 10 + frontend/src/test/inbound-form-modal.test.tsx | 142 +++++++- frontend/src/test/setup.components.ts | 5 +- .../controller/inbound_vless_fallback_test.go | 119 +++++++ internal/web/service/fallback.go | 12 +- internal/web/service/inbound.go | 19 +- internal/web/service/inbound_protocol.go | 3 + .../web/service/inbound_vless_fallback.go | 78 +++++ .../service/inbound_vless_fallback_test.go | 317 ++++++++++++++++++ internal/web/service/xray.go | 9 + internal/web/translation/ar-EG.json | 4 +- internal/web/translation/en-US.json | 4 +- internal/web/translation/es-ES.json | 4 +- internal/web/translation/fa-IR.json | 4 +- internal/web/translation/id-ID.json | 4 +- internal/web/translation/ja-JP.json | 4 +- internal/web/translation/pt-BR.json | 4 +- internal/web/translation/ru-RU.json | 4 +- internal/web/translation/sk-SK.json | 4 +- internal/web/translation/tr-TR.json | 4 +- internal/web/translation/uk-UA.json | 4 +- internal/web/translation/vi-VN.json | 4 +- internal/web/translation/zh-CN.json | 4 +- internal/web/translation/zh-TW.json | 4 +- 31 files changed, 853 insertions(+), 79 deletions(-) create mode 100644 internal/web/controller/inbound_vless_fallback_test.go create mode 100644 internal/web/service/inbound_vless_fallback.go create mode 100644 internal/web/service/inbound_vless_fallback_test.go diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c2d4a6deb..8d82bb8cb 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -75,12 +75,12 @@ jobs: - name: PostgreSQL durable-first tests run: | set -o pipefail - go test ./internal/web/service -run 'PostgresCommitFailure|^TestSyncInboundClientsOnPostgres$|^TestSetClientLimitHwidIsSerializedWithSyncInbound$' -count=1 -v | tee /tmp/postgres-durable-first.log + go test ./internal/web/service -run 'PostgresCommitFailure|^TestSyncInboundClientsOnPostgres$|^TestSetClientLimitHwidIsSerializedWithSyncInbound$|^TestVlessManagedFallbackConflictPostgres$' -count=1 -v | tee /tmp/postgres-durable-first.log # Count passes rather than assert no SKIP: a renamed or deleted test # prints "no tests to run" and exits 0, leaving the step green for nothing. passed=$(grep -c -- '^--- PASS' /tmp/postgres-durable-first.log || true) - if [ "$passed" -lt 4 ]; then - echo "expected at least 4 passing durable-first tests, got $passed" >&2 + if [ "$passed" -lt 5 ]; then + echo "expected at least 5 passing durable-first tests, got $passed" >&2 exit 1 fi diff --git a/docs/content/docs/en/reference/api/inbounds.mdx b/docs/content/docs/en/reference/api/inbounds.mdx index 5eae788b1..4eefa83b1 100644 --- a/docs/content/docs/en/reference/api/inbounds.mdx +++ b/docs/content/docs/en/reference/api/inbounds.mdx @@ -56,8 +56,10 @@ _openapi: 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' + objects (preferred) or as JSON-encoded strings (legacy). VLESS empty + fallback arrays are normalized away; nonempty fallbacks cannot be + combined with active server decryption. + 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-vless-empty-fallback-arrays-are-normalized-away-nonempty-fallbacks-cannot-be-combined-with-active-server-decryption' - 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' @@ -70,8 +72,10 @@ _openapi: title: 'Replace an inbound’s configuration. Body shape mirrors /add, but the inbound keeps its stored client list and enable flag: settings.clients and enable in the body are ignored. Manage clients through the - /panel/api/clients endpoints and toggle the inbound with /setEnable.' - url: '#replace-an-inbounds-configuration-body-shape-mirrors-add-but-the-inbound-keeps-its-stored-client-list-and-enable-flag-settingsclients-and-enable-in-the-body-are-ignored-manage-clients-through-the-panelapiclients-endpoints-and-toggle-the-inbound-with-setenable' + /panel/api/clients endpoints and toggle the inbound with /setEnable. + Remove effective fallback rules before enabling VLESS server + decryption.' + url: '#replace-an-inbounds-configuration-body-shape-mirrors-add-but-the-inbound-keeps-its-stored-client-list-and-enable-flag-settingsclients-and-enable-in-the-body-are-ignored-manage-clients-through-the-panelapiclients-endpoints-and-toggle-the-inbound-with-setenable-remove-effective-fallback-rules-before-enabling-vless-server-decryption' - depth: 2 title: Toggle only the enable flag without serialising the whole settings JSON. Recommended for UI switches on large inbounds. @@ -96,8 +100,9 @@ _openapi: 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). - 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' + The body uses form encoding with a single "data" field. VLESS fallback + validation is identical to /add. + 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-vless-fallback-validation-is-identical-to-add' - depth: 2 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 @@ -113,8 +118,10 @@ _openapi: 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. - Triggers an Xray restart. - url: '#replace-the-entire-fallback-list-for-a-master-inbound-body-is-json-triggers-an-xray-restart' + Nonempty lists require TCP with TLS/REALITY and no active VLESS server + decryption. An empty list can remove rules even from an incompatible + inbound. Triggers an Xray restart. + url: '#replace-the-entire-fallback-list-for-a-master-inbound-body-is-json-nonempty-lists-require-tcp-with-tlsreality-and-no-active-vless-server-decryption-an-empty-list-can-remove-rules-even-from-an-incompatible-inbound-triggers-an-xray-restart' structuredData: headings: - content: List every inbound owned by the authenticated user, including each @@ -157,8 +164,10 @@ _openapi: - 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 + JSON objects (preferred) or as JSON-encoded strings (legacy). VLESS + empty fallback arrays are normalized away; nonempty fallbacks cannot + be combined with active server decryption. + 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-vless-empty-fallback-arrays-are-normalized-away-nonempty-fallbacks-cannot-be-combined-with-active-server-decryption - 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; @@ -168,8 +177,10 @@ _openapi: - content: 'Replace an inbound’s configuration. Body shape mirrors /add, but the inbound keeps its stored client list and enable flag: settings.clients and enable in the body are ignored. Manage clients through the - /panel/api/clients endpoints and toggle the inbound with /setEnable.' - id: replace-an-inbounds-configuration-body-shape-mirrors-add-but-the-inbound-keeps-its-stored-client-list-and-enable-flag-settingsclients-and-enable-in-the-body-are-ignored-manage-clients-through-the-panelapiclients-endpoints-and-toggle-the-inbound-with-setenable + /panel/api/clients endpoints and toggle the inbound with /setEnable. + Remove effective fallback rules before enabling VLESS server + decryption.' + id: replace-an-inbounds-configuration-body-shape-mirrors-add-but-the-inbound-keeps-its-stored-client-list-and-enable-flag-settingsclients-and-enable-in-the-body-are-ignored-manage-clients-through-the-panelapiclients-endpoints-and-toggle-the-inbound-with-setenable-remove-effective-fallback-rules-before-enabling-vless-server-decryption - 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 @@ -188,8 +199,9 @@ _openapi: 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 + The body uses form encoding with a single "data" field. VLESS fallback + validation is identical to /add. + 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-vless-fallback-validation-is-identical-to-add - 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 @@ -202,8 +214,10 @@ _openapi: 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. - Triggers an Xray restart. - id: replace-the-entire-fallback-list-for-a-master-inbound-body-is-json-triggers-an-xray-restart + Nonempty lists require TCP with TLS/REALITY and no active VLESS server + decryption. An empty list can remove rules even from an incompatible + inbound. Triggers an Xray restart. + id: replace-the-entire-fallback-list-for-a-master-inbound-body-is-json-nonempty-lists-require-tcp-with-tlsreality-and-no-active-vless-server-decryption-an-empty-list-can-remove-rules-even-from-an-incompatible-inbound-triggers-an-xray-restart contents: [] --- diff --git a/docs/public/openapi.json b/docs/public/openapi.json index 5b2aa2488..c999960f5 100644 --- a/docs/public/openapi.json +++ b/docs/public/openapi.json @@ -5651,7 +5651,7 @@ "tags": [ "Inbounds" ], - "summary": "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).", + "summary": "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). VLESS empty fallback arrays are normalized away; nonempty fallbacks cannot be combined with active server decryption.", "operationId": "post_panel_api_inbounds_add", "requestBody": { "required": true, @@ -5675,8 +5675,7 @@ "email": "user1" } ], - "decryption": "none", - "fallbacks": [] + "decryption": "none" }, "streamSettings": { "network": "tcp", @@ -5847,7 +5846,7 @@ "tags": [ "Inbounds" ], - "summary": "Replace an inbound’s configuration. Body shape mirrors /add, but the inbound keeps its stored client list and enable flag: settings.clients and enable in the body are ignored. Manage clients through the /panel/api/clients endpoints and toggle the inbound with /setEnable.", + "summary": "Replace an inbound’s configuration. Body shape mirrors /add, but the inbound keeps its stored client list and enable flag: settings.clients and enable in the body are ignored. Manage clients through the /panel/api/clients endpoints and toggle the inbound with /setEnable. Remove effective fallback rules before enabling VLESS server decryption.", "operationId": "post_panel_api_inbounds_update_id", "parameters": [ { @@ -5882,8 +5881,7 @@ "email": "user1" } ], - "decryption": "none", - "fallbacks": [] + "decryption": "none" }, "streamSettings": { "network": "tcp", @@ -6158,7 +6156,7 @@ "tags": [ "Inbounds" ], - "summary": "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.", + "summary": "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. VLESS fallback validation is identical to /add.", "operationId": "post_panel_api_inbounds_import", "requestBody": { "required": true, @@ -6332,7 +6330,7 @@ "tags": [ "Inbounds" ], - "summary": "Replace the entire fallback list for a master inbound. Body is JSON. Triggers an Xray restart.", + "summary": "Replace the entire fallback list for a master inbound. Body is JSON. Nonempty lists require TCP with TLS/REALITY and no active VLESS server decryption. An empty list can remove rules even from an incompatible inbound. Triggers an Xray restart.", "operationId": "post_panel_api_inbounds_id_fallbacks", "parameters": [ { diff --git a/frontend/public/openapi.json b/frontend/public/openapi.json index 5b2aa2488..c999960f5 100644 --- a/frontend/public/openapi.json +++ b/frontend/public/openapi.json @@ -5651,7 +5651,7 @@ "tags": [ "Inbounds" ], - "summary": "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).", + "summary": "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). VLESS empty fallback arrays are normalized away; nonempty fallbacks cannot be combined with active server decryption.", "operationId": "post_panel_api_inbounds_add", "requestBody": { "required": true, @@ -5675,8 +5675,7 @@ "email": "user1" } ], - "decryption": "none", - "fallbacks": [] + "decryption": "none" }, "streamSettings": { "network": "tcp", @@ -5847,7 +5846,7 @@ "tags": [ "Inbounds" ], - "summary": "Replace an inbound’s configuration. Body shape mirrors /add, but the inbound keeps its stored client list and enable flag: settings.clients and enable in the body are ignored. Manage clients through the /panel/api/clients endpoints and toggle the inbound with /setEnable.", + "summary": "Replace an inbound’s configuration. Body shape mirrors /add, but the inbound keeps its stored client list and enable flag: settings.clients and enable in the body are ignored. Manage clients through the /panel/api/clients endpoints and toggle the inbound with /setEnable. Remove effective fallback rules before enabling VLESS server decryption.", "operationId": "post_panel_api_inbounds_update_id", "parameters": [ { @@ -5882,8 +5881,7 @@ "email": "user1" } ], - "decryption": "none", - "fallbacks": [] + "decryption": "none" }, "streamSettings": { "network": "tcp", @@ -6158,7 +6156,7 @@ "tags": [ "Inbounds" ], - "summary": "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.", + "summary": "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. VLESS fallback validation is identical to /add.", "operationId": "post_panel_api_inbounds_import", "requestBody": { "required": true, @@ -6332,7 +6330,7 @@ "tags": [ "Inbounds" ], - "summary": "Replace the entire fallback list for a master inbound. Body is JSON. Triggers an Xray restart.", + "summary": "Replace the entire fallback list for a master inbound. Body is JSON. Nonempty lists require TCP with TLS/REALITY and no active VLESS server decryption. An empty list can remove rules even from an incompatible inbound. Triggers an Xray restart.", "operationId": "post_panel_api_inbounds_id_fallbacks", "parameters": [ { diff --git a/frontend/src/pages/api-docs/endpoints.ts b/frontend/src/pages/api-docs/endpoints.ts index 7e2fb34af..3f5b44bfa 100644 --- a/frontend/src/pages/api-docs/endpoints.ts +++ b/frontend/src/pages/api-docs/endpoints.ts @@ -67,7 +67,7 @@ export interface Section { // /inbounds/update replaces the whole row, so it takes the same payload as /add. const inboundBody = - '{\n "enable": true,\n "remark": "VLESS-443",\n "listen": "",\n "port": 443,\n "protocol": "vless",\n "expiryTime": 0,\n "total": 0,\n "settings": {\n "clients": [{ "id": "...", "email": "user1" }],\n "decryption": "none",\n "fallbacks": []\n },\n "streamSettings": {\n "network": "tcp",\n "security": "reality",\n "realitySettings": { "show": false, "dest": "..." }\n },\n "sniffing": {\n "enabled": true,\n "destOverride": ["http", "tls"]\n }\n}'; + '{\n "enable": true,\n "remark": "VLESS-443",\n "listen": "",\n "port": 443,\n "protocol": "vless",\n "expiryTime": 0,\n "total": 0,\n "settings": {\n "clients": [{ "id": "...", "email": "user1" }],\n "decryption": "none"\n },\n "streamSettings": {\n "network": "tcp",\n "security": "reality",\n "realitySettings": { "show": false, "dest": "..." }\n },\n "sniffing": {\n "enabled": true,\n "destOverride": ["http", "tls"]\n }\n}'; const outboundSubscriptionBodyParams: EndpointParam[] = [ { @@ -309,7 +309,7 @@ export const sections: readonly Section[] = [ method: 'POST', path: '/panel/api/inbounds/add', summary: - 'Create a new inbound. Send the full inbound payload (protocol, port, settings, streamSettings, sniffing, remark, expiryTime, total, enable). settings, streamSettings, and sniffing may be sent as nested JSON objects (preferred) or as JSON-encoded strings (legacy).', + 'Create a new inbound. Send the full inbound payload (protocol, port, settings, streamSettings, sniffing, remark, expiryTime, total, enable). settings, streamSettings, and sniffing may be sent as nested JSON objects (preferred) or as JSON-encoded strings (legacy). VLESS empty fallback arrays are normalized away; nonempty fallbacks cannot be combined with active server decryption.', body: inboundBody, errorResponse: '{\n "success": false,\n "msg": "Port 443 is already in use"\n}', }, @@ -332,7 +332,7 @@ export const sections: readonly Section[] = [ method: 'POST', path: '/panel/api/inbounds/update/:id', summary: - 'Replace an inbound’s configuration. Body shape mirrors /add, but the inbound keeps its stored client list and enable flag: settings.clients and enable in the body are ignored. Manage clients through the /panel/api/clients endpoints and toggle the inbound with /setEnable.', + 'Replace an inbound’s configuration. Body shape mirrors /add, but the inbound keeps its stored client list and enable flag: settings.clients and enable in the body are ignored. Manage clients through the /panel/api/clients endpoints and toggle the inbound with /setEnable. Remove effective fallback rules before enabling VLESS server decryption.', params: [{ name: 'id', in: 'path', type: 'number', desc: 'Inbound ID.' }], body: inboundBody, }, @@ -377,7 +377,7 @@ export const sections: readonly Section[] = [ method: 'POST', path: '/panel/api/inbounds/import', summary: - '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.', + '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. VLESS fallback validation is identical to /add.', params: [ { name: 'data', @@ -422,7 +422,7 @@ export const sections: readonly Section[] = [ method: 'POST', path: '/panel/api/inbounds/:id/fallbacks', summary: - 'Replace the entire fallback list for a master inbound. Body is JSON. Triggers an Xray restart.', + 'Replace the entire fallback list for a master inbound. Body is JSON. Nonempty lists require TCP with TLS/REALITY and no active VLESS server decryption. An empty list can remove rules even from an incompatible inbound. Triggers an Xray restart.', params: [ { name: 'id', in: 'path', type: 'number', desc: 'Master inbound ID.' }, { diff --git a/frontend/src/pages/inbounds/form/FallbacksCard.tsx b/frontend/src/pages/inbounds/form/FallbacksCard.tsx index 2e7dc6de3..b75b8c794 100644 --- a/frontend/src/pages/inbounds/form/FallbacksCard.tsx +++ b/frontend/src/pages/inbounds/form/FallbacksCard.tsx @@ -10,6 +10,7 @@ import { import type { FallbackRow } from '@/schemas/forms/inbound-form'; interface FallbacksCardProps { + canAdd?: boolean; fallbacks: FallbackRow[]; fallbackChildOptions: { label: string; value: number }[]; addFallback: () => void; @@ -20,6 +21,7 @@ interface FallbacksCardProps { } export default function FallbacksCard({ + canAdd = true, fallbacks, fallbackChildOptions, addFallback, @@ -38,6 +40,7 @@ export default function FallbacksCard({ size="small" icon={} onClick={addFallback} + disabled={!canAdd} > {t('pages.inbounds.fallbacks.add') || 'Add fallback'} @@ -45,7 +48,9 @@ export default function FallbacksCard({ size="small" onClick={addAllFallbacks} disabled={ - fallbackChildOptions.length === 0 || fallbacks.length >= fallbackChildOptions.length + !canAdd || + fallbackChildOptions.length === 0 || + fallbacks.length >= fallbackChildOptions.length } title={t('pages.inbounds.form.addAllFallbackTooltip')} > diff --git a/frontend/src/pages/inbounds/form/InboundFormModal.tsx b/frontend/src/pages/inbounds/form/InboundFormModal.tsx index bc7c58f46..c8f99018f 100644 --- a/frontend/src/pages/inbounds/form/InboundFormModal.tsx +++ b/frontend/src/pages/inbounds/form/InboundFormModal.tsx @@ -226,11 +226,14 @@ export default function InboundFormModal({ const getV = methods.getValues as unknown as (name?: string) => unknown; const control = methods.control; const [saving, setSaving] = useState(false); + const savedInboundId = useRef(null); const [scanning, setScanning] = useState(false); const [scanResult, setScanResult] = useState(null); const [activeTab, setActiveTab] = useState('basic'); const { fallbacks, + hasStoredFallbacks, + fallbacksLoaded, fallbackChildOptions, loadFallbacks, saveFallbacks, @@ -250,6 +253,7 @@ export default function InboundFormModal({ * always empty. Offer it only then; `listen`/`custom` work for local inbounds. */ const nodeShareOptionAvailable = selectableNodes.length > 0 && isNodeEligible; + const vlessDecryption = useWatch({ control, name: 'settings.decryption' }) ?? ''; const vlessEncryption = useWatch({ control, name: 'settings.encryption' }) ?? ''; const ssMethod = useWatch({ control, name: 'settings.method' }); const isSSWith2022 = isSS2022({ @@ -295,7 +299,10 @@ export default function InboundFormModal({ streamSettings: { network }, settings: { network: wSsNetwork, allowedNetwork: wTunnelNetwork, udp: mixedUdpOn }, }); + const hasVlessDecryption = + protocol === Protocols.VLESS && vlessDecryption !== '' && vlessDecryption !== 'none'; const isFallbackHost = + !hasVlessDecryption && (protocol === Protocols.VLESS || protocol === Protocols.TROJAN) && network === 'tcp' && (security === 'tls' || security === 'reality'); @@ -437,6 +444,7 @@ export default function InboundFormModal({ ? withoutClients(rawInboundToFormValues(dbInbound)) : buildAddModeValues(); methods.reset(initial); + savedInboundId.current = null; setScanResult(null); setActiveTab('basic'); const initialTag = (initial.tag ?? '') as string; @@ -573,19 +581,46 @@ export default function InboundFormModal({ ); return; } + if (saving) return; + if (!fallbacksLoaded) { + messageApi.error(t('pages.inbounds.fallbacks.saveFailed')); + return; + } + if (!isFallbackHost && fallbacks.length > 0) { + setActiveTab('protocol'); + messageApi.error( + t( + hasVlessDecryption + ? 'pages.inbounds.fallbacks.decryptionConflict' + : 'pages.inbounds.fallbacks.needsTls', + ), + ); + return; + } setSaving(true); try { - const payload = formValuesToWirePayload(parsed.data, { omitClients: mode === 'edit' }); - const url = - mode === 'edit' && dbInbound - ? `/panel/api/inbounds/update/${dbInbound.id}` - : '/panel/api/inbounds/add'; + // Persist an explicit removal before the backend checks the new decryption setting. + if (!isFallbackHost && hasStoredFallbacks && dbInbound) { + if (!(await saveFallbacks(dbInbound.id))) { + messageApi.error(t('pages.inbounds.fallbacks.saveFailed')); + return; + } + } + const existingId = mode === 'edit' ? dbInbound?.id : savedInboundId.current; + const payload = formValuesToWirePayload(parsed.data, { omitClients: !!existingId }); + const url = existingId + ? `/panel/api/inbounds/update/${existingId}` + : '/panel/api/inbounds/add'; const msg = await HttpUtil.post(url, payload); if (msg?.success) { if (isFallbackHost) { const obj = msg.obj as { id?: number; Id?: number } | null; - const masterId = mode === 'edit' ? dbInbound!.id : (obj?.id ?? obj?.Id ?? 0); - if (masterId) await saveFallbacks(masterId); + const masterId = existingId ?? obj?.id ?? obj?.Id ?? 0; + if (masterId) savedInboundId.current = masterId; + if (masterId && !(await saveFallbacks(masterId))) { + messageApi.error(t('pages.inbounds.fallbacks.saveFailed')); + return; + } } onSaved(); onClose(); @@ -783,6 +818,7 @@ export default function InboundFormModal({ const fallbacksCard = ( )} - {isFallbackHost && fallbacksCard} + {(isFallbackHost || fallbacks.length > 0) && fallbacksCard} + {hasVlessDecryption && ( + + )} {(protocol === Protocols.VLESS || protocol === Protocols.TROJAN) && network === 'tcp' && + !hasVlessDecryption && !isFallbackHost && ( 0 ? [ { key: 'protocol', diff --git a/frontend/src/pages/inbounds/form/useInboundFallbacks.ts b/frontend/src/pages/inbounds/form/useInboundFallbacks.ts index ff3859d04..e07e373ee 100644 --- a/frontend/src/pages/inbounds/form/useInboundFallbacks.ts +++ b/frontend/src/pages/inbounds/form/useInboundFallbacks.ts @@ -10,6 +10,8 @@ import { coerceInboundJsonField, type DBInbound } from '@/models/dbinbound'; export function useInboundFallbacks(dbInbound: DBInbound | null, dbInbounds: DBInbound[]) { const fallbackKeyRef = useRef(0); const [fallbacks, setFallbacks] = useState([]); + const [hasStoredFallbacks, setHasStoredFallbacks] = useState(false); + const [fallbacksLoaded, setFallbacksLoaded] = useState(false); const fallbackChildOptions = (dbInbounds || []) .filter((ib) => ib.id !== dbInbound?.id) @@ -19,7 +21,10 @@ export function useInboundFallbacks(dbInbound: DBInbound | null, dbInbounds: DBI })); const loadFallbacks = async (masterId: number | null) => { + setFallbacksLoaded(false); + setHasStoredFallbacks(false); if (!masterId) { + setFallbacksLoaded(true); setFallbacks([]); return; } @@ -28,6 +33,8 @@ export function useInboundFallbacks(dbInbound: DBInbound | null, dbInbounds: DBI setFallbacks([]); return; } + setFallbacksLoaded(true); + setHasStoredFallbacks(msg.obj.length > 0); setFallbacks( ( msg.obj as { @@ -68,6 +75,7 @@ export function useInboundFallbacks(dbInbound: DBInbound | null, dbInbounds: DBI const msg = await HttpUtil.post(`/panel/api/inbounds/${masterId}/fallbacks`, payload, { headers: { 'Content-Type': 'application/json' }, }); + if (msg?.success) setHasStoredFallbacks(payload.fallbacks.length > 0); return !!msg?.success; }; @@ -181,6 +189,8 @@ export function useInboundFallbacks(dbInbound: DBInbound | null, dbInbounds: DBI return { fallbacks, + hasStoredFallbacks, + fallbacksLoaded, fallbackChildOptions, loadFallbacks, saveFallbacks, diff --git a/frontend/src/test/inbound-form-modal.test.tsx b/frontend/src/test/inbound-form-modal.test.tsx index 29c6ae1f1..047981225 100644 --- a/frontend/src/test/inbound-form-modal.test.tsx +++ b/frontend/src/test/inbound-form-modal.test.tsx @@ -99,7 +99,7 @@ function cloneLikeVlessInbound(target: string) { }); } -function renderCloneLikeEdit(dbInbound: DBInbound) { +async function renderCloneLikeEdit(dbInbound: DBInbound) { renderWithProviders( {}} />, ); + await act(async () => {}); } describe('InboundFormModal', () => { @@ -278,7 +279,7 @@ describe('InboundFormModal', () => { const post = vi.mocked(HttpUtil.post); post.mockClear(); messageError.mockClear(); - renderCloneLikeEdit(cloneLikeVlessInbound('example.com')); + await renderCloneLikeEdit(cloneLikeVlessInbound('example.com')); fireEvent.click(primaryButton()); @@ -323,7 +324,7 @@ describe('InboundFormModal', () => { it('submits a valid clone-like Reality inbound', async () => { const post = vi.mocked(HttpUtil.post); post.mockClear(); - renderCloneLikeEdit(cloneLikeVlessInbound('example.com:443')); + await renderCloneLikeEdit(cloneLikeVlessInbound('example.com:443')); fireEvent.click(primaryButton()); @@ -340,7 +341,7 @@ describe('InboundFormModal', () => { it('switches a REALITY inbound to an XDRIVE stream with security none', async () => { const post = vi.mocked(HttpUtil.post); post.mockClear(); - renderCloneLikeEdit(cloneLikeVlessInbound('example.com:443')); + await renderCloneLikeEdit(cloneLikeVlessInbound('example.com:443')); fireEvent.click(screen.getByRole('tab', { name: 'Stream' })); const transmission = Array.from(document.querySelectorAll('.ant-form-item')).find( @@ -448,7 +449,7 @@ describe('InboundFormModal', () => { shareAddrStrategy: 'listen', shareAddr: '', }); - renderCloneLikeEdit(hysteria); + await renderCloneLikeEdit(hysteria); // Form watches flush in a macrotask; save only once the hop fields have emitted. await screen.findByDisplayValue('20000-30000'); await act(async () => { @@ -501,7 +502,7 @@ describe('InboundFormModal', () => { shareAddrStrategy: 'listen', shareAddr: '', }); - renderCloneLikeEdit(hysteria); + await renderCloneLikeEdit(hysteria); await screen.findByText('geoip:cn'); await act(async () => { await new Promise((r) => setTimeout(r, 0)); @@ -541,7 +542,7 @@ describe('InboundFormModal', () => { shareAddrStrategy: 'listen', shareAddr: '', }); - renderCloneLikeEdit(tun); + await renderCloneLikeEdit(tun); fireEvent.change(document.getElementById('tun-desc')!, { target: { value: 'MyTun' } }); fireEvent.click(document.getElementById('tun-auto-dns-gateway')!); @@ -571,7 +572,7 @@ describe('InboundFormModal', () => { clients: [{ email: 'legacy', id: '' }], }, }); - renderCloneLikeEdit(legacy); + await renderCloneLikeEdit(legacy); fireEvent.click(primaryButton()); @@ -580,11 +581,132 @@ describe('InboundFormModal', () => { expect(JSON.parse(payload.settings)).not.toHaveProperty('clients'); }); - it('offers the enable switch when adding an inbound but not when editing one', () => { + it('offers the enable switch when adding an inbound but not when editing one', async () => { renderModal(); expect(document.getElementById('inbound-enable')).not.toBeNull(); cleanup(); - renderCloneLikeEdit(cloneLikeVlessInbound('example.com:443')); + await renderCloneLikeEdit(cloneLikeVlessInbound('example.com:443')); expect(document.getElementById('inbound-enable')).toBeNull(); }); }); + +describe('VLESS fallback safety', () => { + it('disables adding fallbacks with server decryption and keeps removal available', async () => { + vi.mocked(HttpUtil.get).mockResolvedValueOnce({ + success: true, + msg: '', + obj: [{ dest: '8080' }], + }); + const inbound = cloneLikeVlessInbound('example.com:443'); + inbound.settings = { + clients: [], + decryption: 'mlkem768x25519plus.native.600s.key', + encryption: 'none', + }; + await renderCloneLikeEdit(inbound); + fireEvent.click(screen.getByRole('tab', { name: 'Protocol' })); + await waitFor(() => expect(screen.getByDisplayValue('8080')).toBeTruthy()); + expect( + (screen.getByRole('button', { name: /Add fallback$/i }) as HTMLButtonElement).disabled, + ).toBe(true); + expect(screen.getByRole('button', { name: 'Delete' })).toBeTruthy(); + expect( + screen.getByText(/Remove and save fallback rules before enabling VLESS decryption/), + ).toBeTruthy(); + }); + it('uses server decryption rather than outbound encryption for eligibility', async () => { + const inbound = cloneLikeVlessInbound('example.com:443'); + inbound.settings = { clients: [], decryption: 'none', encryption: 'link-only-key' }; + await renderCloneLikeEdit(inbound); + fireEvent.click(screen.getByRole('tab', { name: 'Protocol' })); + await waitFor(() => + expect( + (screen.getByRole('button', { name: /Add fallback$/i }) as HTMLButtonElement).disabled, + ).toBe(false), + ); + }); + + it('retains unsaved fallback rows when decryption becomes incompatible', async () => { + messageError.mockClear(); + const post = vi.mocked(HttpUtil.post); + post.mockClear(); + await renderCloneLikeEdit(cloneLikeVlessInbound('example.com:443')); + fireEvent.click(screen.getByRole('tab', { name: 'Protocol' })); + fireEvent.click(screen.getByRole('button', { name: /Add fallback$/i })); + fireEvent.change(screen.getByLabelText('Dest'), { target: { value: '8080' } }); + fireEvent.change(screen.getByLabelText('Decryption'), { + target: { value: 'mlkem768x25519plus.native.600s.key' }, + }); + fireEvent.click(primaryButton()); + await waitFor(() => + expect(messageError).toHaveBeenCalledWith( + expect.stringContaining('Remove and save fallback rules'), + ), + ); + expect(screen.getByDisplayValue('8080')).toBeTruthy(); + expect(post).not.toHaveBeenCalled(); + }); + + it('removes legacy managed rules before saving active decryption', async () => { + vi.mocked(HttpUtil.get).mockResolvedValueOnce({ + success: true, + msg: '', + obj: [{ dest: '8080' }], + }); + const post = vi.mocked(HttpUtil.post); + post.mockClear(); + const inbound = cloneLikeVlessInbound('example.com:443'); + inbound.settings = { + clients: [], + decryption: 'mlkem768x25519plus.native.600s.key', + encryption: 'none', + }; + await renderCloneLikeEdit(inbound); + fireEvent.click(screen.getByRole('tab', { name: 'Protocol' })); + await waitFor(() => expect(screen.getByDisplayValue('8080')).toBeTruthy()); + fireEvent.click(screen.getByRole('button', { name: 'Delete' })); + fireEvent.click(primaryButton()); + await waitFor(() => + expect(post.mock.calls.map(([url]) => url)).toEqual([ + '/panel/api/inbounds/42/fallbacks', + '/panel/api/inbounds/update/42', + ]), + ); + expect(post.mock.calls[0][1]).toEqual({ fallbacks: [] }); + }); + + it('keeps the draft open when saving fallback rules fails', async () => { + messageError.mockClear(); + const post = vi.mocked(HttpUtil.post); + post + .mockResolvedValueOnce({ success: true, msg: '', obj: { id: 42 } }) + .mockResolvedValueOnce({ success: false, msg: 'save failed', obj: null }); + const onClose = vi.fn(); + const onSaved = vi.fn(); + const inbound = cloneLikeVlessInbound('example.com:443'); + renderWithProviders( + , + ); + await act(async () => {}); + fireEvent.click(screen.getByRole('tab', { name: 'Protocol' })); + fireEvent.click(screen.getByRole('button', { name: /Add fallback$/i })); + fireEvent.change(screen.getByLabelText('Dest'), { target: { value: '8080' } }); + fireEvent.click(primaryButton()); + await waitFor(() => + expect(messageError).toHaveBeenCalledWith( + expect.stringContaining('Could not load or save fallback rules'), + ), + ); + expect(onClose).not.toHaveBeenCalled(); + expect(onSaved).not.toHaveBeenCalled(); + expect(screen.getByDisplayValue('8080')).toBeTruthy(); + }); +}); diff --git a/frontend/src/test/setup.components.ts b/frontend/src/test/setup.components.ts index acb7cdf15..05d280e8a 100644 --- a/frontend/src/test/setup.components.ts +++ b/frontend/src/test/setup.components.ts @@ -110,6 +110,9 @@ import { HttpUtil, Msg } from '@/utils'; vi.spyOn(HttpUtil, 'post').mockResolvedValue({ success: true, obj: {} } as any); // Array-returning endpoints must resolve to [] so Zod list schemas validate. vi.spyOn(HttpUtil, 'get').mockImplementation(async (url: string) => { - const isList = url.includes('/panel/api/inbounds/options') || url.includes('/panel/api/hosts/'); + const isList = + url.includes('/panel/api/inbounds/options') || + url.includes('/panel/api/hosts/') || + url.endsWith('/fallbacks'); return new Msg(true, '', isList ? [] : {}); }); diff --git a/internal/web/controller/inbound_vless_fallback_test.go b/internal/web/controller/inbound_vless_fallback_test.go new file mode 100644 index 000000000..7e8dcb074 --- /dev/null +++ b/internal/web/controller/inbound_vless_fallback_test.go @@ -0,0 +1,119 @@ +package controller + +import ( + "encoding/base64" + "encoding/json" + "net/http" + "net/http/httptest" + "net/url" + "path/filepath" + "strconv" + "strings" + "testing" + + "github.com/gin-gonic/gin" + "github.com/xtls/xray-core/infra/conf" + + "github.com/mhsanaei/3x-ui/v3/internal/database" + "github.com/mhsanaei/3x-ui/v3/internal/database/dbtest" + "github.com/mhsanaei/3x-ui/v3/internal/database/model" + "github.com/mhsanaei/3x-ui/v3/internal/util/crypto" + "github.com/mhsanaei/3x-ui/v3/internal/web/runtime" +) + +func TestVlessFallbackHTTPAndImportValidation(t *testing.T) { + gin.SetMode(gin.TestMode) + folder := t.TempDir() + t.Setenv("XUI_DB_FOLDER", folder) + dbtest.InitDB(t, filepath.Join(folder, "x-ui.db")) + previous := runtime.GetManager() + runtime.SetManager(runtime.NewManager(runtime.LocalDeps{APIPort: func() int { return 0 }, SetNeedRestart: func() {}})) + t.Cleanup(func() { runtime.SetManager(previous) }) + for _, scope := range []string{model.ApiScopeAdmin, model.ApiScopeNodeSync} { + if err := database.GetDB().Create(&model.ApiToken{Name: scope, Token: crypto.HashTokenSHA256(scope), Enabled: true, Scope: scope}).Error; err != nil { + t.Fatal(err) + } + } + router := gin.New() + auth := &APIController{} + group := router.Group("/panel/api") + group.Use(auth.checkAPIAuth, auth.enforceTokenScope) + NewInboundController(group.Group("/inbounds")) + for _, scope := range []string{model.ApiScopeAdmin, model.ApiScopeNodeSync} { + for _, route := range []string{"add", "update", "import"} { + if scope == model.ApiScopeNodeSync && route == "import" { + continue + } + for _, invalid := range []bool{false, true} { + t.Run(scope+"/"+route+"/"+strconv.FormatBool(invalid), func(t *testing.T) { + raw := `[]` + if invalid { + raw = `[{"dest":8080}]` + } + inbound := &model.Inbound{Protocol: model.VLESS, Port: 32110, Settings: `{"clients":[],"decryption":"mlkem768x25519plus.native.600s.` + base64.RawURLEncoding.EncodeToString(make([]byte, 32)) + `","fallbacks":` + raw + `}`, StreamSettings: `{"network":"tcp","security":"none"}`} + db := database.GetDB() + if err := db.Where("1 = 1").Delete(&model.Inbound{}).Error; err != nil { + t.Fatal(err) + } + path := "/panel/api/inbounds/" + route + if route == "update" { + stored := *inbound + stored.Settings = `{"clients":[],"decryption":"none"}` + if err := db.Create(&stored).Error; err != nil { + t.Fatal(err) + } + inbound.Id = stored.Id + path += "/" + strconv.Itoa(stored.Id) + } + form := url.Values{"protocol": {"vless"}, "port": {"32110"}, "settings": {inbound.Settings}, "streamSettings": {inbound.StreamSettings}} + if route == "import" { + data, _ := json.Marshal(inbound) + form = url.Values{"data": {string(data)}} + } + request := httptest.NewRequest(http.MethodPost, path, strings.NewReader(form.Encode())) + request.Header.Set("Content-Type", "application/x-www-form-urlencoded") + request.Header.Set("Authorization", "Bearer "+scope) + response := httptest.NewRecorder() + router.ServeHTTP(response, request) + var result struct { + Success bool `json:"success"` + Msg string `json:"msg"` + } + if err := json.Unmarshal(response.Body.Bytes(), &result); err != nil { + t.Fatal(err) + } + if result.Success == invalid { + t.Fatalf("unexpected response: %s", response.Body) + } + var rows []model.Inbound + if err := db.Find(&rows).Error; err != nil { + t.Fatal(err) + } + if invalid { + if !strings.Contains(strings.ToLower(result.Msg), "fallback") { + t.Fatalf("missing error: %s", response.Body) + } + if route == "update" { + if len(rows) != 1 || rows[0].Settings != `{"clients":[],"decryption":"none"}` { + t.Fatalf("failed update persisted: %+v", rows) + } + } else if len(rows) != 0 { + t.Fatal("failed add/import persisted") + } + return + } + if len(rows) != 1 { + t.Fatalf("saved rows: %d", len(rows)) + } + var core conf.VLessInboundConfig + if err := json.Unmarshal([]byte(rows[0].Settings), &core); err != nil { + t.Fatal(err) + } + if _, err := core.Build(); err != nil { + t.Fatalf("saved API config rejected by Xray: %v", err) + } + }) + } + } + } +} diff --git a/internal/web/service/fallback.go b/internal/web/service/fallback.go index 01e929b1e..4995c87af 100644 --- a/internal/web/service/fallback.go +++ b/internal/web/service/fallback.go @@ -59,8 +59,16 @@ func (s *FallbackService) GetParentForChild(childId int) (*model.InboundFallback // SetByMaster replaces the master's entire fallback list atomically. func (s *FallbackService) SetByMaster(masterId int, items []FallbackInput) error { - db := database.GetDB() - return db.Transaction(func(tx *gorm.DB) error { + return runSerializedTx(func(tx *gorm.DB) error { + if len(items) > 0 { + var master model.Inbound + if err := tx.First(&master, masterId).Error; err != nil { + return err + } + if !inboundCanHostFallbacks(&master) { + return fmt.Errorf("inbound %d cannot host fallbacks: use TCP with TLS/REALITY and disable VLESS decryption first", masterId) + } + } if err := tx.Where("master_id = ?", masterId).Delete(&model.InboundFallback{}).Error; err != nil { return err } diff --git a/internal/web/service/inbound.go b/internal/web/service/inbound.go index faf799701..3089c40db 100644 --- a/internal/web/service/inbound.go +++ b/internal/web/service/inbound.go @@ -1297,6 +1297,9 @@ func (s *InboundService) normalizeMtprotoXrayPort(inbound *model.Inbound, oldSet func (s *InboundService) AddInbound(inbound *model.Inbound) (*model.Inbound, bool, error) { defer InvalidateSubData() inbound.Id = 0 + if err := normalizeVlessFallbacks(inbound); err != nil { + return nil, false, err + } if err := normalizeTuicSettings(inbound); err != nil { return inbound, false, err } @@ -2055,6 +2058,9 @@ func (s *InboundService) UpdateInbound(inbound *model.Inbound) (*model.Inbound, // Restore the stored NodeID before any host-scoped check so a node inbound // stays scoped to its own node (the payload's nodeId is unreliable, often absent). inbound.NodeID = oldInbound.NodeID + if err := normalizeVlessFallbacks(inbound); err != nil { + return nil, false, err + } if err := normalizeTuicSettings(inbound); err != nil { return inbound, false, err } @@ -2126,6 +2132,9 @@ func (s *InboundService) UpdateInbound(inbound *model.Inbound) (*model.Inbound, if err := s.validateUpdatedInboundClients(inbound, stored); err != nil { return err } + if err := validateVlessManagedFallbacks(tx, inbound); err != nil { + return err + } conflict, cErr := checkPortConflictTx(tx, inbound, inbound.Id) if cErr != nil { return cErr @@ -2433,12 +2442,18 @@ func (s *InboundService) buildInboundForNodePush(tx *gorm.DB, inbound *model.Inb } built := *inbound + if err := normalizeVlessFallbacks(&built); err != nil { + return nil, err + } + if err := validateVlessManagedFallbacks(tx, &built); err != nil { + return nil, err + } settings := map[string]any{} - if err := json.Unmarshal([]byte(inbound.Settings), &settings); err != nil { + if err := json.Unmarshal([]byte(built.Settings), &settings); err != nil { return nil, err } - if !inboundCanHostFallbacks(inbound) { + if !inboundCanHostFallbacks(&built) { return &built, nil } fallbacks, err := s.fallbackService.BuildFallbacksJSON(tx, inbound.Id) diff --git a/internal/web/service/inbound_protocol.go b/internal/web/service/inbound_protocol.go index aa82a6564..c43c52b5d 100644 --- a/internal/web/service/inbound_protocol.go +++ b/internal/web/service/inbound_protocol.go @@ -140,6 +140,9 @@ func inboundCanHostFallbacks(ib *model.Inbound) bool { if ib.Protocol != model.VLESS && ib.Protocol != model.Trojan { return false } + if ib.Protocol == model.VLESS && vlessDecryptionEnabled(ib.Settings) { + return false + } return streamSupportsFallbacks(ib.StreamSettings) } diff --git a/internal/web/service/inbound_vless_fallback.go b/internal/web/service/inbound_vless_fallback.go new file mode 100644 index 000000000..979e0e2f4 --- /dev/null +++ b/internal/web/service/inbound_vless_fallback.go @@ -0,0 +1,78 @@ +package service + +import ( + "bytes" + "encoding/json" + "fmt" + + "github.com/xtls/xray-core/infra/conf" + "gorm.io/gorm" + + "github.com/mhsanaei/3x-ui/v3/internal/database/model" +) + +func vlessDecryptionEnabled(settings string) bool { + var s struct { + Decryption string `json:"decryption"` + } + return json.Unmarshal([]byte(settings), &s) == nil && vlessEncValueSet(s.Decryption) +} + +// normalizeVlessFallbacks also protects API/import callers that bypass the form adapter. +func normalizeVlessFallbacks(inbound *model.Inbound) error { + if inbound.Protocol != model.VLESS { + return nil + } + var settings map[string]json.RawMessage + if err := json.Unmarshal([]byte(inbound.Settings), &settings); err != nil { + return fmt.Errorf("inbound %d: invalid VLESS settings: %w", inbound.Id, err) + } + raw, exists := settings["fallbacks"] + if !exists || bytes.Equal(bytes.TrimSpace(raw), []byte("null")) { + return nil + } + var fallbacks []*conf.VLessInboundFallback + if err := json.Unmarshal(raw, &fallbacks); err != nil { + return fmt.Errorf("inbound %d: invalid VLESS fallbacks: %w", inbound.Id, err) + } + if len(fallbacks) == 0 { + delete(settings, "fallbacks") + normalized, err := json.Marshal(settings) + if err != nil { + return err + } + inbound.Settings = string(normalized) + return nil + } + if vlessDecryptionEnabled(inbound.Settings) { + return vlessFallbackConflict(inbound.Id) + } + for _, fallback := range fallbacks { + if fallback == nil { + return fmt.Errorf("inbound %d: VLESS fallback entries must be objects", inbound.Id) + } + } + candidate := conf.VLessInboundConfig{Decryption: "none", Fallbacks: fallbacks} + if _, err := candidate.Build(); err != nil { + return fmt.Errorf("inbound %d: invalid VLESS fallbacks: %w", inbound.Id, err) + } + return nil +} + +func vlessFallbackConflict(id int) error { + return fmt.Errorf("inbound %d: VLESS fallbacks cannot be used with active server decryption; remove fallback rules before enabling decryption", id) +} + +func validateVlessManagedFallbacks(tx *gorm.DB, inbound *model.Inbound) error { + if inbound.Protocol != model.VLESS || !vlessDecryptionEnabled(inbound.Settings) || inbound.Id == 0 { + return nil + } + fallbacks, err := (&FallbackService{}).BuildFallbacksJSON(tx, inbound.Id) + if err != nil { + return err + } + if len(fallbacks) > 0 { + return vlessFallbackConflict(inbound.Id) + } + return nil +} diff --git a/internal/web/service/inbound_vless_fallback_test.go b/internal/web/service/inbound_vless_fallback_test.go new file mode 100644 index 000000000..89ba0b6d8 --- /dev/null +++ b/internal/web/service/inbound_vless_fallback_test.go @@ -0,0 +1,317 @@ +package service + +import ( + "encoding/base64" + "encoding/json" + "reflect" + "strings" + "testing" + + "github.com/xtls/xray-core/infra/conf" + + "github.com/mhsanaei/3x-ui/v3/internal/database" + "github.com/mhsanaei/3x-ui/v3/internal/database/model" +) + +func fallbackTestDecryption() string { + return "mlkem768x25519plus.native.600s." + base64.RawURLEncoding.EncodeToString(make([]byte, 32)) +} + +func TestVlessFallbackCRUDProtectsCoreConfig(t *testing.T) { + for _, operation := range []string{"add", "update"} { + for _, tc := range []struct { + name, fallback string + rejected bool + }{ + {"empty", `,"fallbacks":[]`, false}, + {"null", `,"fallbacks":null`, false}, + {"absent", "", false}, + {"conflicting", `,"fallbacks":[{"dest":8080}]`, true}, + {"malformed object", `,"fallbacks":{}`, true}, + {"malformed entry", `,"fallbacks":[null]`, true}, + } { + t.Run(operation+"/"+tc.name, func(t *testing.T) { + setupConflictDB(t) + useTestRuntimeManager(t).SetLocalRuntimeOverride(&fakeNodeRuntime{}) + inbound := &model.Inbound{UserId: 1, Port: 32101, Protocol: model.VLESS, Settings: `{"clients":[],"decryption":"none"}`, StreamSettings: `{"network":"tcp","security":"none"}`} + if operation == "update" { + if err := database.GetDB().Create(inbound).Error; err != nil { + t.Fatal(err) + } + } + inbound.Settings = `{"clients":[],"decryption":"` + fallbackTestDecryption() + `","encryption":"link-key","testseed":[1,2,3,4]` + tc.fallback + `}` + svc := &InboundService{} + var err error + if operation == "add" { + _, _, err = svc.AddInbound(inbound) + } else { + _, _, err = svc.UpdateInbound(inbound) + } + if tc.rejected { + if err == nil || !strings.Contains(strings.ToLower(err.Error()), "fallback") { + t.Fatalf("error = %v, want an actionable fallback validation error", err) + } + var rows []model.Inbound + if err := database.GetDB().Find(&rows).Error; err != nil { + t.Fatal(err) + } + if operation == "add" && len(rows) != 0 || operation == "update" && (len(rows) != 1 || rows[0].Settings != `{"clients":[],"decryption":"none"}`) { + t.Fatalf("invalid request changed the database: %+v", rows) + } + return + } + if err != nil { + t.Fatal(err) + } + var stored model.Inbound + if err := database.GetDB().First(&stored, inbound.Id).Error; err != nil { + t.Fatal(err) + } + var core conf.VLessInboundConfig + if err := json.Unmarshal([]byte(stored.Settings), &core); err != nil { + t.Fatal(err) + } + if _, err := core.Build(); err != nil { + t.Fatalf("saved configuration rejected by Xray: %v; settings=%s", err, stored.Settings) + } + var preserved map[string]json.RawMessage + _ = json.Unmarshal([]byte(stored.Settings), &preserved) + var seed []int + _ = json.Unmarshal(preserved["testseed"], &seed) + if !reflect.DeepEqual(seed, []int{1, 2, 3, 4}) || core.Decryption != fallbackTestDecryption() { + t.Fatalf("unrelated VLESS settings changed: %s", stored.Settings) + } + }) + } + } +} + +func TestVlessManagedFallbackConflictDoesNotChangeRulesOrInbound(t *testing.T) { + setupConflictDB(t) + testVlessManagedFallbackConflict(t) +} + +func TestVlessManagedFallbackConflictPostgres(t *testing.T) { + durablePostgresDB(t) + testVlessManagedFallbackConflict(t) +} + +func testVlessManagedFallbackConflict(t *testing.T) { + t.Helper() + useTestRuntimeManager(t).SetLocalRuntimeOverride(&fakeNodeRuntime{}) + db := database.GetDB() + master := &model.Inbound{UserId: 1, Port: 32102, Protocol: model.VLESS, Settings: `{"clients":[],"decryption":"none"}`, StreamSettings: `{"network":"tcp","security":"tls"}`} + if err := db.Create(master).Error; err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + _ = db.Where("master_id = ?", master.Id).Delete(&model.InboundFallback{}).Error + _ = db.Delete(master).Error + }) + original := model.InboundFallback{MasterId: master.Id, Dest: "8080"} + if err := db.Create(&original).Error; err != nil { + t.Fatal(err) + } + update := *master + update.Settings = `{"clients":[],"decryption":"` + fallbackTestDecryption() + `"}` + if _, _, err := (&InboundService{}).UpdateInbound(&update); err == nil || !strings.Contains(strings.ToLower(err.Error()), "fallback") { + t.Fatalf("UpdateInbound error = %v, want fallback conflict", err) + } + var stored model.Inbound + if err := db.First(&stored, master.Id).Error; err != nil || stored.Settings != master.Settings { + t.Fatalf("rejected encryption update changed inbound: %s, %v", stored.Settings, err) + } + // Stand in for a legacy/imported conflict without going through CRUD. + if err := db.Model(master).Update("settings", update.Settings).Error; err != nil { + t.Fatal(err) + } + svc := &FallbackService{} + if err := svc.SetByMaster(master.Id, []FallbackInput{{Dest: "9090"}}); err == nil || !strings.Contains(strings.ToLower(err.Error()), "fallback") { + t.Fatalf("SetByMaster error = %v, want incompatible master", err) + } + rows, err := svc.GetByMaster(master.Id) + if err != nil || len(rows) != 1 || rows[0].Dest != "8080" || rows[0].Id != original.Id { + t.Fatalf("rejected replacement changed fallback rules: %+v, %v", rows, err) + } + if err := svc.SetByMaster(master.Id, nil); err != nil { + t.Fatalf("removing conflicting rules must remain possible: %v", err) + } + if _, _, err := (&InboundService{}).UpdateInbound(&update); err != nil { + t.Fatalf("encryption must work after explicit rule removal: %v", err) + } +} + +func TestVlessLegacyFallbacksAreValidatedOnEveryRuntimePath(t *testing.T) { + for _, managed := range []bool{false, true} { + for _, nonempty := range []bool{false, true} { + if managed && !nonempty { + continue + } + for _, path := range []string{"node", "local", "config"} { + t.Run(path+"/"+map[bool]string{false: "empty", true: "nonempty"}[nonempty]+map[bool]string{false: "raw", true: "managed"}[managed], func(t *testing.T) { + setupConflictDB(t) + fb := `[]` + if nonempty && !managed { + fb = `[{"dest":8080}]` + } + ib := &model.Inbound{UserId: 1, Enable: true, Tag: "legacy", Port: 32103, Protocol: model.VLESS, Settings: `{"clients":[],"decryption":"` + fallbackTestDecryption() + `","fallbacks":` + fb + `}`, StreamSettings: `{"network":"tcp","security":"none"}`} + db := database.GetDB() + if err := db.Create(ib).Error; err != nil { + t.Fatal(err) + } + if managed { + if err := db.Create(&model.InboundFallback{MasterId: ib.Id, Dest: "8080"}).Error; err != nil { + t.Fatal(err) + } + } + svc := &InboundService{} + var built *model.Inbound + var err error + switch path { + case "node": + built, err = svc.buildInboundForNodePush(db, ib) + case "local": + built, err = svc.buildInboundForLocalRuntime(db, ib) + case "config": + cfg, cfgErr := (&XrayService{}).GetXrayConfig() + err = cfgErr + if err == nil { + for _, inbound := range cfg.InboundConfigs { + if inbound.Tag == "legacy" { + built = &model.Inbound{Settings: string(inbound.Settings)} + } + } + } + } + if nonempty { + if err == nil || !strings.Contains(strings.ToLower(err.Error()), "fallback") { + t.Fatalf("legacy conflict error = %v", err) + } + return + } + if err != nil || built == nil { + t.Fatalf("legacy empty repair = %v, %v", built, err) + } + var core conf.VLessInboundConfig + if err := json.Unmarshal([]byte(built.Settings), &core); err != nil { + t.Fatal(err) + } + if _, err := core.Build(); err != nil { + t.Fatalf("legacy config still crashes Xray: %v", err) + } + }) + } + } + } +} + +func TestVlessFallbackNormalizationPreservesSupportedConfigurations(t *testing.T) { + for _, tc := range []struct { + name string + protocol model.Protocol + settings string + reject bool + }{ + {"plain VLESS", model.VLESS, `{"clients":[],"decryption":"none","fallbacks":[{"dest":8080}]}`, false}, + {"outbound encryption only", model.VLESS, `{"clients":[],"decryption":"none","encryption":"link-only-key","fallbacks":[{"dest":8080}]}`, false}, + {"Trojan", model.Trojan, `{"clients":[],"fallbacks":[{"dest":8080}]}`, false}, + {"malformed plain VLESS", model.VLESS, `{"clients":[],"decryption":"none","fallbacks":[null]}`, true}, + {"invalid fallback destination", model.VLESS, `{"clients":[],"decryption":"none","fallbacks":[{"dest":{}}]}`, true}, + {"invalid fallback path", model.VLESS, `{"clients":[],"decryption":"none","fallbacks":[{"dest":8080,"path":"invalid"}]}`, true}, + } { + t.Run(tc.name, func(t *testing.T) { + inbound := &model.Inbound{Protocol: tc.protocol, Settings: tc.settings, StreamSettings: `{"network":"tcp","security":"tls"}`} + err := normalizeVlessFallbacks(inbound) + if (err != nil) != tc.reject { + t.Fatalf("validation error: %v", err) + } + if inbound.Settings != tc.settings { + t.Fatalf("normalizer modified supported/rejected settings: %s", inbound.Settings) + } + if tc.reject { + return + } + if !inboundCanHostFallbacks(inbound) { + t.Fatal("supported master became ineligible") + } + var buildErr error + if tc.protocol == model.VLESS { + var core conf.VLessInboundConfig + _ = json.Unmarshal([]byte(inbound.Settings), &core) + _, buildErr = core.Build() + } else { + var core conf.TrojanServerConfig + _ = json.Unmarshal([]byte(inbound.Settings), &core) + _, buildErr = core.Build() + } + if buildErr != nil { + t.Fatal(buildErr) + } + }) + } + inbound := &model.Inbound{Protocol: model.VLESS, Settings: `{"decryption":"` + fallbackTestDecryption() + `","fallbacks":[],"unknown":9007199254740993}`} + if err := normalizeVlessFallbacks(inbound); err != nil { + t.Fatal(err) + } + once := inbound.Settings + if err := normalizeVlessFallbacks(inbound); err != nil || once != inbound.Settings { + t.Fatalf("not idempotent: %s; %v", inbound.Settings, err) + } + if !strings.Contains(inbound.Settings, "9007199254740993") { + t.Fatal("normalization rounded an unrelated number") + } +} + +func TestVlessInvalidCandidateKeepsHealthyCore(t *testing.T) { + setupConflictDB(t) + process, _ := startBalancerTestXray(t, balancerTestConfig{outbounds: allBalancerTestOutbounds}) + inbound := &model.Inbound{Enable: true, Protocol: model.VLESS, Port: 32119, Settings: `{"clients":[],"decryption":"` + fallbackTestDecryption() + `","fallbacks":[{"dest":8080}]}`, StreamSettings: `{"network":"tcp","security":"none"}`} + if err := database.GetDB().Create(inbound).Error; err != nil { + t.Fatal(err) + } + for _, force := range []bool{false, true} { + err := (&XrayService{}).RestartXray(force) + if err == nil || !strings.Contains(err.Error(), "fallback") { + t.Fatalf("restart error: %v", err) + } + if currentXrayProcess() != process || !process.IsRunning() { + t.Fatal("invalid config replaced healthy Xray") + } + if _, err := liveBalancerInfo(process, "b1"); err != nil { + t.Fatalf("healthy core stopped serving its API: %v", err) + } + if !strings.Contains((&XrayService{}).GetHeldBackConfig(), "fallback") { + t.Fatal("refused config is invisible in status") + } + } +} + +func TestVlessDecryptionAndManagedFallbackWritesSerialize(t *testing.T) { + setupConflictDB(t) + useTestRuntimeManager(t).SetLocalRuntimeOverride(&fakeNodeRuntime{}) + master := &model.Inbound{UserId: 1, Protocol: model.VLESS, Port: 32120, Settings: `{"clients":[],"decryption":"none"}`, StreamSettings: `{"network":"tcp","security":"tls"}`} + if err := database.GetDB().Create(master).Error; err != nil { + t.Fatal(err) + } + update := *master + update.Settings = `{"clients":[],"decryption":"` + fallbackTestDecryption() + `"}` + start := make(chan struct{}) + results := make(chan error, 2) + go func() { <-start; _, _, err := (&InboundService{}).UpdateInbound(&update); results <- err }() + go func() { + <-start + results <- (&FallbackService{}).SetByMaster(master.Id, []FallbackInput{{Dest: "8080"}}) + }() + close(start) + first, second := <-results, <-results + if (first == nil) == (second == nil) { + t.Fatalf("exactly one conflicting write must succeed: %v; %v", first, second) + } + var stored model.Inbound + if err := database.GetDB().First(&stored, master.Id).Error; err != nil { + t.Fatal(err) + } + if err := validateVlessManagedFallbacks(nil, &stored); err != nil { + t.Fatalf("concurrent writes persisted incompatible state: %v", err) + } +} diff --git a/internal/web/service/xray.go b/internal/web/service/xray.go index 27674e56f..02760b311 100644 --- a/internal/web/service/xray.go +++ b/internal/web/service/xray.go @@ -214,6 +214,12 @@ func (s *XrayService) GetXrayConfig() (*xray.Config, error) { if inbound.Protocol == model.MTProto || inbound.Protocol == model.AmneziaWG || inbound.Protocol == model.TUIC { continue } + if err := normalizeVlessFallbacks(inbound); err != nil { + return nil, err + } + if err := validateVlessManagedFallbacks(nil, inbound); err != nil { + return nil, err + } settings := map[string]any{} _ = json.Unmarshal([]byte(inbound.Settings), &settings) flowAllowed := !inbound.DisableFlow && @@ -1503,6 +1509,9 @@ func (s *XrayService) RestartXray(isForce bool) error { xrayConfig, err := s.GetXrayConfig() if err != nil { + if process := currentXrayProcess(); process != nil && process.IsRunning() { + xrayState.holdBack(fmt.Sprintf("config refused: %v", err)) + } return err } diff --git a/internal/web/translation/ar-EG.json b/internal/web/translation/ar-EG.json index f96c53bc9..fb570a464 100644 --- a/internal/web/translation/ar-EG.json +++ b/internal/web/translation/ar-EG.json @@ -312,7 +312,9 @@ "pickInbound": "اختر inbound", "matchAny": "أي", "destPlaceholder": "تلقائي (listen:port للفرع)", - "needsTls": "تصبح الـ Fallbacks متاحة بعد اختيار TLS أو Reality في تبويب الأمان (فقط VLESS/Trojan عبر RAW)." + "needsTls": "تصبح الـ Fallbacks متاحة بعد اختيار TLS أو Reality في تبويب الأمان (فقط VLESS/Trojan عبر RAW).", + "decryptionConflict": "لا يمكن استخدام fallbacks مع فك تشفير VLESS النشط على الخادم. احذف قواعد fallback واحفظها قبل تفعيل فك التشفير، أو عطّل فك التشفير.", + "saveFailed": "تعذر تحميل قواعد fallback أو حفظها. بقيت المسودة مفتوحة؛ أعد المحاولة بعد مراجعة الخطأ." }, "protocol": "بروتوكول", "port": "المنفذ", diff --git a/internal/web/translation/en-US.json b/internal/web/translation/en-US.json index fa355d30f..a11d6d4a0 100644 --- a/internal/web/translation/en-US.json +++ b/internal/web/translation/en-US.json @@ -312,7 +312,9 @@ "pickInbound": "Pick an inbound", "matchAny": "any", "destPlaceholder": "auto (child listen:port)", - "needsTls": "Fallbacks become available once Security is set to TLS or Reality on the Security tab (VLESS/Trojan over RAW only)." + "needsTls": "Fallbacks become available once Security is set to TLS or Reality on the Security tab (VLESS/Trojan over RAW only).", + "decryptionConflict": "VLESS fallbacks cannot be used with active server decryption. Remove and save fallback rules before enabling VLESS decryption, or disable decryption.", + "saveFailed": "Could not load or save fallback rules. Your draft remains open; retry after checking the error." }, "protocol": "Protocol", "port": "Port", diff --git a/internal/web/translation/es-ES.json b/internal/web/translation/es-ES.json index 70d4f9130..3bb0f0ce2 100644 --- a/internal/web/translation/es-ES.json +++ b/internal/web/translation/es-ES.json @@ -312,7 +312,9 @@ "pickInbound": "Selecciona un inbound", "matchAny": "cualquiera", "destPlaceholder": "automático (listen:puerto del hijo)", - "needsTls": "Los fallbacks estarán disponibles al seleccionar TLS o Reality en la pestaña de Seguridad (solo VLESS/Trojan sobre RAW)." + "needsTls": "Los fallbacks estarán disponibles al seleccionar TLS o Reality en la pestaña de Seguridad (solo VLESS/Trojan sobre RAW).", + "decryptionConflict": "Los fallbacks no son compatibles con el descifrado VLESS activo del servidor. Elimina y guarda las reglas antes de activarlo, o desactiva el descifrado.", + "saveFailed": "No se pudieron cargar o guardar las reglas de fallback. El borrador sigue abierto; revisa el error y vuelve a intentarlo." }, "protocol": "Protocolo", "port": "Puerto", diff --git a/internal/web/translation/fa-IR.json b/internal/web/translation/fa-IR.json index f0fc02513..957fb8c2e 100644 --- a/internal/web/translation/fa-IR.json +++ b/internal/web/translation/fa-IR.json @@ -312,7 +312,9 @@ "pickInbound": "یک اینباند انتخاب کنید", "matchAny": "همه", "destPlaceholder": "خودکار (listen:port فرزند)", - "needsTls": "فال‌بک‌ها پس از انتخاب TLS یا Reality در برگه‌ی امنیت در دسترس می‌شوند (فقط VLESS/Trojan روی RAW)." + "needsTls": "فال‌بک‌ها پس از انتخاب TLS یا Reality در برگه‌ی امنیت در دسترس می‌شوند (فقط VLESS/Trojan روی RAW).", + "decryptionConflict": "Fallback با decryption فعال VLESS سازگار نیست. پیش از فعال‌کردن decryption، قوانین fallback را حذف و ذخیره کنید یا decryption را غیرفعال کنید.", + "saveFailed": "بارگیری یا ذخیره قوانین fallback انجام نشد. تغییرات فرم حفظ شده‌اند؛ پس از بررسی خطا دوباره تلاش کنید." }, "protocol": "پروتکل", "port": "پورت", diff --git a/internal/web/translation/id-ID.json b/internal/web/translation/id-ID.json index 8b62115c8..e14b66968 100644 --- a/internal/web/translation/id-ID.json +++ b/internal/web/translation/id-ID.json @@ -312,7 +312,9 @@ "pickInbound": "Pilih inbound", "matchAny": "apa pun", "destPlaceholder": "otomatis (listen:port child)", - "needsTls": "Fallback tersedia setelah memilih TLS atau Reality di tab Keamanan (hanya VLESS/Trojan melalui RAW)." + "needsTls": "Fallback tersedia setelah memilih TLS atau Reality di tab Keamanan (hanya VLESS/Trojan melalui RAW).", + "decryptionConflict": "Fallback tidak dapat digunakan dengan dekripsi VLESS server yang aktif. Hapus dan simpan aturan fallback sebelum mengaktifkan dekripsi, atau nonaktifkan dekripsi.", + "saveFailed": "Aturan fallback gagal dimuat atau disimpan. Draf tetap terbuka; periksa kesalahan lalu coba lagi." }, "protocol": "Protokol", "port": "Port", diff --git a/internal/web/translation/ja-JP.json b/internal/web/translation/ja-JP.json index 5c3dcf445..f238906b5 100644 --- a/internal/web/translation/ja-JP.json +++ b/internal/web/translation/ja-JP.json @@ -312,7 +312,9 @@ "pickInbound": "インバウンドを選択", "matchAny": "任意", "destPlaceholder": "自動(子の listen:port)", - "needsTls": "フォールバックは、セキュリティタブで TLS または Reality を選択すると設定できます(RAW 上の VLESS/Trojan のみ)。" + "needsTls": "フォールバックは、セキュリティタブで TLS または Reality を選択すると設定できます(RAW 上の VLESS/Trojan のみ)。", + "decryptionConflict": "VLESS のサーバー復号が有効な場合、フォールバックは使用できません。復号を有効にする前にフォールバックルールを削除して保存するか、復号を無効にしてください。", + "saveFailed": "フォールバックルールを読み込みまたは保存できませんでした。下書きは開いたままです。エラーを確認して再試行してください。" }, "protocol": "プロトコル", "port": "ポート", diff --git a/internal/web/translation/pt-BR.json b/internal/web/translation/pt-BR.json index 425c5e29d..b014a82e7 100644 --- a/internal/web/translation/pt-BR.json +++ b/internal/web/translation/pt-BR.json @@ -312,7 +312,9 @@ "pickInbound": "Escolha um inbound", "matchAny": "qualquer", "destPlaceholder": "automático (listen:porta do filho)", - "needsTls": "Os fallbacks ficam disponíveis após selecionar TLS ou Reality na aba Segurança (apenas VLESS/Trojan sobre RAW)." + "needsTls": "Os fallbacks ficam disponíveis após selecionar TLS ou Reality na aba Segurança (apenas VLESS/Trojan sobre RAW).", + "decryptionConflict": "Fallbacks não são compatíveis com a descriptografia VLESS ativa no servidor. Remova e salve as regras antes de ativá-la ou desative a descriptografia.", + "saveFailed": "Não foi possível carregar ou salvar as regras de fallback. O rascunho continua aberto; verifique o erro e tente novamente." }, "protocol": "Protocolo", "port": "Porta", diff --git a/internal/web/translation/ru-RU.json b/internal/web/translation/ru-RU.json index e5ca299be..86adf35cd 100644 --- a/internal/web/translation/ru-RU.json +++ b/internal/web/translation/ru-RU.json @@ -312,7 +312,9 @@ "pickInbound": "Выберите инбаунд", "matchAny": "любой", "destPlaceholder": "авто (listen:порт дочернего)", - "needsTls": "Fallbacks станут доступны после выбора TLS или Reality на вкладке «Безопасность» (только VLESS/Trojan поверх RAW)." + "needsTls": "Fallbacks станут доступны после выбора TLS или Reality на вкладке «Безопасность» (только VLESS/Trojan поверх RAW).", + "decryptionConflict": "Fallback несовместим с активной серверной расшифровкой VLESS. Перед её включением удалите и сохраните правила fallback либо отключите расшифровку.", + "saveFailed": "Не удалось загрузить или сохранить правила fallback. Черновик остаётся открытым; проверьте ошибку и повторите попытку." }, "protocol": "Протокол", "port": "Порт", diff --git a/internal/web/translation/sk-SK.json b/internal/web/translation/sk-SK.json index d23b83e1e..c21a2c7ca 100644 --- a/internal/web/translation/sk-SK.json +++ b/internal/web/translation/sk-SK.json @@ -312,7 +312,9 @@ "pickInbound": "Vyberte vstup", "matchAny": "ľubovoľné", "destPlaceholder": "automaticky (adresa:port vnoreného vstupu)", - "needsTls": "Náhradné ciele sú dostupné po výbere TLS alebo Reality na karte Bezpečnosť (iba VLESS/Trojan cez RAW)." + "needsTls": "Náhradné ciele sú dostupné po výbere TLS alebo Reality na karte Bezpečnosť (iba VLESS/Trojan cez RAW).", + "decryptionConflict": "Fallback nemožno použiť s aktívnym serverovým dešifrovaním VLESS. Pred jeho zapnutím odstráňte a uložte pravidlá fallback alebo vypnite dešifrovanie.", + "saveFailed": "Pravidlá fallback sa nepodarilo načítať alebo uložiť. Koncept zostáva otvorený; skontrolujte chybu a skúste to znova." }, "protocol": "Protokol", "port": "Port", diff --git a/internal/web/translation/tr-TR.json b/internal/web/translation/tr-TR.json index 196fb7d7e..99c1183be 100644 --- a/internal/web/translation/tr-TR.json +++ b/internal/web/translation/tr-TR.json @@ -312,7 +312,9 @@ "pickInbound": "Bir Gelen Bağlantı Seç", "matchAny": "herhangi", "destPlaceholder": "otomatik (child listen:port)", - "needsTls": "Geri düşüşler (fallback), Güvenlik sekmesinde TLS veya Reality seçildiğinde kullanılabilir olur (yalnızca RAW üzerinde VLESS/Trojan)." + "needsTls": "Geri düşüşler (fallback), Güvenlik sekmesinde TLS veya Reality seçildiğinde kullanılabilir olur (yalnızca RAW üzerinde VLESS/Trojan).", + "decryptionConflict": "Fallback, etkin sunucu tarafı VLESS şifre çözme ile kullanılamaz. Şifre çözmeyi etkinleştirmeden önce fallback kurallarını silip kaydedin veya şifre çözmeyi kapatın.", + "saveFailed": "Fallback kuralları yüklenemedi veya kaydedilemedi. Taslak açık kaldı; hatayı kontrol edip tekrar deneyin." }, "protocol": "Protokol", "port": "Port", diff --git a/internal/web/translation/uk-UA.json b/internal/web/translation/uk-UA.json index ceeac7d12..690efd610 100644 --- a/internal/web/translation/uk-UA.json +++ b/internal/web/translation/uk-UA.json @@ -312,7 +312,9 @@ "pickInbound": "Оберіть інбаунд", "matchAny": "будь-який", "destPlaceholder": "авто (listen:порт дочірнього)", - "needsTls": "Fallbacks стануть доступні після вибору TLS або Reality на вкладці «Безпека» (лише VLESS/Trojan поверх RAW)." + "needsTls": "Fallbacks стануть доступні після вибору TLS або Reality на вкладці «Безпека» (лише VLESS/Trojan поверх RAW).", + "decryptionConflict": "Fallback несумісний з активним серверним розшифруванням VLESS. Перед його ввімкненням видаліть і збережіть правила fallback або вимкніть розшифрування.", + "saveFailed": "Не вдалося завантажити або зберегти правила fallback. Чернетка залишається відкритою; перевірте помилку та повторіть спробу." }, "protocol": "Протокол", "port": "Порт", diff --git a/internal/web/translation/vi-VN.json b/internal/web/translation/vi-VN.json index 2534daadd..54bf57deb 100644 --- a/internal/web/translation/vi-VN.json +++ b/internal/web/translation/vi-VN.json @@ -312,7 +312,9 @@ "pickInbound": "Chọn một inbound", "matchAny": "bất kỳ", "destPlaceholder": "tự động (listen:port của child)", - "needsTls": "Fallback khả dụng sau khi chọn TLS hoặc Reality trong thẻ Bảo mật (chỉ VLESS/Trojan trên RAW)." + "needsTls": "Fallback khả dụng sau khi chọn TLS hoặc Reality trong thẻ Bảo mật (chỉ VLESS/Trojan trên RAW).", + "decryptionConflict": "Không thể dùng fallback khi bật giải mã VLESS phía máy chủ. Hãy xóa và lưu các quy tắc fallback trước khi bật giải mã, hoặc tắt giải mã.", + "saveFailed": "Không thể tải hoặc lưu quy tắc fallback. Bản nháp vẫn mở; hãy kiểm tra lỗi rồi thử lại." }, "protocol": "Giao thức", "port": "Cổng", diff --git a/internal/web/translation/zh-CN.json b/internal/web/translation/zh-CN.json index 5e298d3d8..ef97d0351 100644 --- a/internal/web/translation/zh-CN.json +++ b/internal/web/translation/zh-CN.json @@ -312,7 +312,9 @@ "pickInbound": "选择一个入站", "matchAny": "任意", "destPlaceholder": "自动(子入站 listen:port)", - "needsTls": "在“安全”标签页选择 TLS 或 Reality 后即可配置回落(仅限 RAW 上的 VLESS/Trojan)。" + "needsTls": "在“安全”标签页选择 TLS 或 Reality 后即可配置回落(仅限 RAW 上的 VLESS/Trojan)。", + "decryptionConflict": "VLESS 服务端解密启用时不能使用回落。请先删除并保存回落规则再启用解密,或关闭解密。", + "saveFailed": "无法加载或保存回落规则。草稿保持打开;请检查错误后重试。" }, "protocol": "协议", "port": "端口", diff --git a/internal/web/translation/zh-TW.json b/internal/web/translation/zh-TW.json index 2e905f283..2a49cc8cb 100644 --- a/internal/web/translation/zh-TW.json +++ b/internal/web/translation/zh-TW.json @@ -312,7 +312,9 @@ "pickInbound": "選擇一個入站", "matchAny": "任何", "destPlaceholder": "自動(子入站 listen:port)", - "needsTls": "在「安全」分頁選擇 TLS 或 Reality 後即可設定回落(僅限 RAW 上的 VLESS/Trojan)。" + "needsTls": "在「安全」分頁選擇 TLS 或 Reality 後即可設定回落(僅限 RAW 上的 VLESS/Trojan)。", + "decryptionConflict": "VLESS 伺服器端解密啟用時無法使用回落。請先刪除並儲存回落規則再啟用解密,或關閉解密。", + "saveFailed": "無法載入或儲存回落規則。草稿保持開啟;請檢查錯誤後重試。" }, "protocol": "協議", "port": "連接埠",