Files
3x-ui/frontend/src/pages/inbounds/form/FallbacksCard.tsx
T
Hamed 19f2f21d81 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.
2026-11-03 15:52:30 +01:00

175 lines
5.7 KiB
TypeScript

import { useTranslation } from 'react-i18next';
import { Button, Card, Col, Empty, Input, InputNumber, Row, Select, Space } from 'antd';
import {
ArrowDownOutlined,
ArrowUpOutlined,
DeleteOutlined,
PlusOutlined,
} from '@ant-design/icons';
import type { FallbackRow } from '@/schemas/forms/inbound-form';
interface FallbacksCardProps {
canAdd?: boolean;
fallbacks: FallbackRow[];
fallbackChildOptions: { label: string; value: number }[];
addFallback: () => void;
updateFallback: (rowKey: string, patch: Partial<FallbackRow>) => void;
removeFallback: (idx: number) => void;
moveFallback: (idx: number, direction: -1 | 1) => void;
addAllFallbacks: () => void;
}
export default function FallbacksCard({
canAdd = true,
fallbacks,
fallbackChildOptions,
addFallback,
updateFallback,
removeFallback,
moveFallback,
addAllFallbacks,
}: FallbacksCardProps) {
const { t } = useTranslation();
const addButtons = (
<Space size={8} wrap>
<Button
type="primary"
ghost
size="small"
icon={<PlusOutlined aria-hidden />}
onClick={addFallback}
disabled={!canAdd}
>
{t('pages.inbounds.fallbacks.add') || 'Add fallback'}
</Button>
<Button
size="small"
onClick={addAllFallbacks}
disabled={
!canAdd ||
fallbackChildOptions.length === 0 ||
fallbacks.length >= fallbackChildOptions.length
}
title={t('pages.inbounds.form.addAllFallbackTooltip')}
>
{t('pages.inbounds.form.addAll')}
</Button>
</Space>
);
return (
<Card
size="small"
className="mt-12"
title={t('pages.inbounds.fallbacks.title') || 'Fallbacks'}
extra={addButtons}
>
{fallbacks.length === 0 ? (
<Empty
image={Empty.PRESENTED_IMAGE_SIMPLE}
styles={{ image: { height: 36 } }}
description={t('pages.inbounds.fallbacks.empty') || 'No fallbacks yet'}
style={{ margin: '4px 0 12px' }}
/>
) : (
fallbacks.map((record, idx) => (
<Card
key={record.rowKey}
type="inner"
size="small"
style={{ marginBottom: 8 }}
styles={{ body: { padding: 12 } }}
>
<Space.Compact block style={{ marginBottom: 8 }}>
<Select
aria-label={t('pages.inbounds.fallbacks.pickInbound')}
value={record.childId}
options={fallbackChildOptions}
placeholder={t('pages.inbounds.fallbacks.pickInbound') || 'Pick an inbound'}
allowClear
showSearch={{
filterOption: (input, option) =>
((option?.label as string) || '').toLowerCase().includes(input.toLowerCase()),
}}
style={{ width: '100%' }}
onChange={(v) => updateFallback(record.rowKey, { childId: v ?? null })}
/>
<Button
aria-label={t('pages.inbounds.form.moveUp')}
disabled={idx === 0}
onClick={() => moveFallback(idx, -1)}
title={t('pages.inbounds.form.moveUp')}
icon={<ArrowUpOutlined aria-hidden />}
/>
<Button
aria-label={t('pages.inbounds.form.moveDown')}
disabled={idx === fallbacks.length - 1}
onClick={() => moveFallback(idx, 1)}
title={t('pages.inbounds.form.moveDown')}
icon={<ArrowDownOutlined aria-hidden />}
/>
<Button
aria-label={t('delete')}
danger
onClick={() => removeFallback(idx)}
icon={<DeleteOutlined aria-hidden />}
/>
</Space.Compact>
<Row gutter={[8, 8]}>
<Col xs={24} sm={12}>
<Input
aria-label="SNI"
prefix="SNI"
placeholder={t('pages.inbounds.fallbacks.matchAny') || 'any'}
value={record.name}
onChange={(e) => updateFallback(record.rowKey, { name: e.target.value })}
/>
</Col>
<Col xs={24} sm={12}>
<Input
aria-label="ALPN"
prefix="ALPN"
placeholder={t('pages.inbounds.fallbacks.matchAny') || 'any'}
value={record.alpn}
onChange={(e) => updateFallback(record.rowKey, { alpn: e.target.value })}
/>
</Col>
<Col xs={24} sm={12}>
<Input
aria-label="Path"
prefix="Path"
placeholder="/"
value={record.path}
onChange={(e) => updateFallback(record.rowKey, { path: e.target.value })}
/>
</Col>
<Col xs={24} sm={12}>
<Input
aria-label="Dest"
prefix="Dest"
placeholder={t('pages.inbounds.fallbacks.destPlaceholder') || 'auto'}
value={record.dest}
onChange={(e) => updateFallback(record.rowKey, { dest: e.target.value })}
/>
</Col>
<Col xs={24} sm={12}>
<InputNumber
aria-label="xver"
prefix="xver"
min={0}
max={2}
style={{ width: '100%' }}
value={record.xver}
onChange={(v) => updateFallback(record.rowKey, { xver: Number(v) || 0 })}
/>
</Col>
</Row>
</Card>
))
)}
</Card>
);
}