mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-11-06 05:05:01 +03:00
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:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user