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
+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,