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