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.
This commit is contained in:
Hamed
2026-11-03 18:22:30 +03:30
committed by GitHub
parent fee7b1621d
commit 19f2f21d81
31 changed files with 853 additions and 79 deletions
+3 -3
View File
@@ -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
+30 -16
View File
@@ -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: []
---
+6 -8
View File
@@ -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": [
{
+6 -8
View File
@@ -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": [
{
+5 -5
View File
@@ -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.' },
{
@@ -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={<PlusOutlined aria-hidden />}
onClick={addFallback}
disabled={!canAdd}
>
{t('pages.inbounds.fallbacks.add') || 'Add fallback'}
</Button>
@@ -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')}
>
@@ -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<number | null>(null);
const [scanning, setScanning] = useState(false);
const [scanResult, setScanResult] = useState<RealityScanResult | null>(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 = (
<FallbacksCard
canAdd={isFallbackHost && fallbacksLoaded}
fallbacks={fallbacks}
fallbackChildOptions={fallbackChildOptions}
addFallback={addFallback}
@@ -834,9 +870,18 @@ export default function InboundFormModal({
/>
)}
{isFallbackHost && fallbacksCard}
{(isFallbackHost || fallbacks.length > 0) && fallbacksCard}
{hasVlessDecryption && (
<Alert
className="mt-12"
type="warning"
showIcon
title={t('pages.inbounds.fallbacks.decryptionConflict')}
/>
)}
{(protocol === Protocols.VLESS || protocol === Protocols.TROJAN) &&
network === 'tcp' &&
!hasVlessDecryption &&
!isFallbackHost && (
<Alert
className="mt-12"
@@ -1175,7 +1220,9 @@ export default function InboundFormModal({
Protocols.TUIC,
Protocols.MASQUE,
] as string[]
).includes(protocol) || isFallbackHost
).includes(protocol) ||
isFallbackHost ||
fallbacks.length > 0
? [
{
key: 'protocol',
@@ -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<FallbackRow[]>([]);
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,
+132 -10
View File
@@ -99,7 +99,7 @@ function cloneLikeVlessInbound(target: string) {
});
}
function renderCloneLikeEdit(dbInbound: DBInbound) {
async function renderCloneLikeEdit(dbInbound: DBInbound) {
renderWithProviders(
<InboundFormModal
open
@@ -111,6 +111,7 @@ function renderCloneLikeEdit(dbInbound: DBInbound) {
onSaved={() => {}}
/>,
);
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(
<InboundFormModal
open
mode="edit"
dbInbound={inbound}
dbInbounds={[inbound]}
availableNodes={[]}
onClose={onClose}
onSaved={onSaved}
/>,
);
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();
});
});
+4 -1
View File
@@ -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 ? [] : {});
});
@@ -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)
}
})
}
}
}
}
+10 -2
View File
@@ -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
}
+17 -2
View File
@@ -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)
+3
View File
@@ -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)
}
@@ -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
}
@@ -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)
}
}
+9
View File
@@ -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
}
+3 -1
View File
@@ -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": "المنفذ",
+3 -1
View File
@@ -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",
+3 -1
View File
@@ -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",
+3 -1
View File
@@ -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": "پورت",
+3 -1
View File
@@ -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",
+3 -1
View File
@@ -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": "ポート",
+3 -1
View File
@@ -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",
+3 -1
View File
@@ -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": "Порт",
+3 -1
View File
@@ -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",
+3 -1
View File
@@ -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",
+3 -1
View File
@@ -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": "Порт",
+3 -1
View File
@@ -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",
+3 -1
View File
@@ -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": "端口",
+3 -1
View File
@@ -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": "連接埠",