mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-08-19 17:40:59 +00:00
feat(reality): add live REALITY target scanner with IP/CIDR discovery
Replace the static reality-targets list with a server-side TLS 1.3 probe that checks TLS 1.3 + HTTP/2 + X25519 + a trusted certificate. - Single-domain validate auto-fills target and serverNames from the cert SAN - Discovery scans an IP/CIDR without SNI to find new targets from their certificates, deduped and ranked by feasibility then latency, private-IP guarded via netsafe - New endpoints scanRealityTarget and scanRealityTargets with RealityScanResult, plus openapigen and api-docs entries - Add scanner strings to all 13 locales - Replace deprecated AntD Alert message prop with title across the panel
This commit is contained in:
@@ -0,0 +1,174 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Button, Input, Modal, Space, Table, Tag, Tooltip, Typography } from 'antd';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
|
||||
import type { RealityScanResult } from '@/generated/types';
|
||||
|
||||
interface RealityTargetScannerModalProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
scanRealityCandidates: (targets?: string) => Promise<RealityScanResult[]>;
|
||||
onPick: (result: RealityScanResult) => void;
|
||||
}
|
||||
|
||||
export default function RealityTargetScannerModal({
|
||||
open,
|
||||
onClose,
|
||||
scanRealityCandidates,
|
||||
onPick,
|
||||
}: RealityTargetScannerModalProps) {
|
||||
const { t } = useTranslation();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [query, setQuery] = useState('');
|
||||
const [results, setResults] = useState<RealityScanResult[]>([]);
|
||||
const scanRef = useRef(scanRealityCandidates);
|
||||
scanRef.current = scanRealityCandidates;
|
||||
|
||||
const runScan = useCallback(async (targets?: string) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
setResults(await scanRef.current(targets));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
setResults([]);
|
||||
runScan();
|
||||
}, [open, runScan]);
|
||||
|
||||
const columns: ColumnsType<RealityScanResult> = [
|
||||
{
|
||||
title: t('pages.inbounds.form.target'),
|
||||
dataIndex: 'target',
|
||||
key: 'target',
|
||||
width: 200,
|
||||
render: (target: string, row) => (
|
||||
<Tooltip title={row.ip ? `${target} — ${row.ip}` : target}>
|
||||
<div style={{ lineHeight: 1.25 }}>
|
||||
<div style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{target}</div>
|
||||
{row.ip ? <div style={{ color: '#999', fontSize: 12 }}>{row.ip}</div> : null}
|
||||
</div>
|
||||
</Tooltip>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: t('pages.inbounds.form.scanStatus'),
|
||||
dataIndex: 'feasible',
|
||||
key: 'feasible',
|
||||
width: 95,
|
||||
render: (feasible: boolean, row) =>
|
||||
feasible ? (
|
||||
<Tag color="success">{t('pages.inbounds.form.scanFeasible')}</Tag>
|
||||
) : (
|
||||
<Tooltip title={row.reason}>
|
||||
<Tag color="warning">{t('pages.inbounds.form.scanNotFeasible')}</Tag>
|
||||
</Tooltip>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: 'TLS',
|
||||
dataIndex: 'tlsVersion',
|
||||
key: 'tlsVersion',
|
||||
width: 60,
|
||||
render: (v: string) => v || '—',
|
||||
},
|
||||
{
|
||||
title: 'ALPN',
|
||||
dataIndex: 'alpn',
|
||||
key: 'alpn',
|
||||
width: 75,
|
||||
render: (v: string) => v || '—',
|
||||
},
|
||||
{
|
||||
title: t('pages.inbounds.form.scanCurve'),
|
||||
dataIndex: 'curveID',
|
||||
key: 'curveID',
|
||||
width: 130,
|
||||
render: (v: string) => v || '—',
|
||||
},
|
||||
{
|
||||
title: t('pages.inbounds.form.scanCert'),
|
||||
dataIndex: 'certSubject',
|
||||
key: 'certSubject',
|
||||
width: 160,
|
||||
ellipsis: true,
|
||||
render: (_: string, row) =>
|
||||
row.certValid ? (
|
||||
<Tooltip title={`${row.certSubject} (${row.certIssuer})`}>
|
||||
<span>{row.certSubject || '—'}</span>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<Tag>{t('pages.inbounds.form.scanCertInvalid')}</Tag>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: t('pages.inbounds.form.scanLatency'),
|
||||
dataIndex: 'latencyMs',
|
||||
key: 'latencyMs',
|
||||
width: 85,
|
||||
render: (v: number) => (v > 0 ? `${v} ms` : '—'),
|
||||
},
|
||||
{
|
||||
title: '',
|
||||
key: 'action',
|
||||
width: 64,
|
||||
render: (_, row) => (
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
onClick={() => {
|
||||
onPick(row);
|
||||
onClose();
|
||||
}}
|
||||
>
|
||||
{t('pages.inbounds.form.scanUse')}
|
||||
</Button>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={open}
|
||||
onCancel={onClose}
|
||||
footer={[
|
||||
<Button key="rescan" onClick={() => runScan(query.trim() || undefined)} loading={loading}>
|
||||
{t('pages.inbounds.form.scanRescan')}
|
||||
</Button>,
|
||||
<Button key="close" type="primary" onClick={onClose}>
|
||||
{t('close')}
|
||||
</Button>,
|
||||
]}
|
||||
title={t('pages.inbounds.form.scanModalTitle')}
|
||||
width={960}
|
||||
>
|
||||
<Space orientation="vertical" size="small" style={{ width: '100%' }}>
|
||||
<Typography.Paragraph type="secondary" style={{ marginBottom: 0 }}>
|
||||
{t('pages.inbounds.form.scanModalDesc')}
|
||||
</Typography.Paragraph>
|
||||
<Input.Search
|
||||
allowClear
|
||||
enterButton={t('pages.inbounds.form.scan')}
|
||||
loading={loading}
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
onSearch={() => runScan(query.trim() || undefined)}
|
||||
placeholder={t('pages.inbounds.form.scanDiscoverPlaceholder')}
|
||||
/>
|
||||
<Table<RealityScanResult>
|
||||
size="small"
|
||||
rowKey="target"
|
||||
loading={loading}
|
||||
columns={columns}
|
||||
dataSource={results}
|
||||
pagination={false}
|
||||
scroll={{ y: 360 }}
|
||||
/>
|
||||
</Space>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -1,13 +1,20 @@
|
||||
import { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Button, Collapse, Divider, Form, Input, InputNumber, Select, Space, Switch } from 'antd';
|
||||
import { ReloadOutlined } from '@ant-design/icons';
|
||||
import { Alert, Button, Collapse, Descriptions, Divider, Form, Input, InputNumber, Select, Space, Switch } from 'antd';
|
||||
import { RadarChartOutlined, ReloadOutlined, SearchOutlined } from '@ant-design/icons';
|
||||
|
||||
import { UTLS_FINGERPRINT } from '@/schemas/primitives';
|
||||
import { validateRealityTarget } from '@/lib/xray/stream-wire-normalize';
|
||||
import type { RealityScanResult } from '@/generated/types';
|
||||
import RealityTargetScannerModal from './RealityTargetScannerModal';
|
||||
|
||||
interface RealityFormProps {
|
||||
saving: boolean;
|
||||
randomizeRealityTarget: () => void;
|
||||
scanning: boolean;
|
||||
scanResult: RealityScanResult | null;
|
||||
scanRealityTarget: () => void;
|
||||
scanRealityCandidates: (targets?: string) => Promise<RealityScanResult[]>;
|
||||
applyRealityScanResult: (result: RealityScanResult) => void;
|
||||
randomizeShortIds: () => void;
|
||||
genRealityKeypair: () => void;
|
||||
clearRealityKeypair: () => void;
|
||||
@@ -17,7 +24,11 @@ interface RealityFormProps {
|
||||
|
||||
export default function RealityForm({
|
||||
saving,
|
||||
randomizeRealityTarget,
|
||||
scanning,
|
||||
scanResult,
|
||||
scanRealityTarget,
|
||||
scanRealityCandidates,
|
||||
applyRealityScanResult,
|
||||
randomizeShortIds,
|
||||
genRealityKeypair,
|
||||
clearRealityKeypair,
|
||||
@@ -25,6 +36,7 @@ export default function RealityForm({
|
||||
clearMldsa65,
|
||||
}: RealityFormProps) {
|
||||
const { t } = useTranslation();
|
||||
const [scannerOpen, setScannerOpen] = useState(false);
|
||||
return (
|
||||
<>
|
||||
<Form.Item
|
||||
@@ -49,7 +61,7 @@ export default function RealityForm({
|
||||
label={t('pages.inbounds.form.target')}
|
||||
tooltip={t('pages.inbounds.form.realityTargetHint')}
|
||||
>
|
||||
<Space.Compact block>
|
||||
<Space.Compact block style={{ display: 'flex' }}>
|
||||
<Form.Item
|
||||
name={['streamSettings', 'realitySettings', 'target']}
|
||||
noStyle
|
||||
@@ -62,21 +74,48 @@ export default function RealityForm({
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Input style={{ width: 'calc(100% - 32px)' }} placeholder="example.com:443" />
|
||||
<Input style={{ flex: 1 }} placeholder="example.com:443" />
|
||||
</Form.Item>
|
||||
<Button icon={<ReloadOutlined />} onClick={randomizeRealityTarget} />
|
||||
<Button icon={<RadarChartOutlined />} loading={scanning} onClick={scanRealityTarget}>
|
||||
{t('pages.inbounds.form.scan')}
|
||||
</Button>
|
||||
<Button icon={<SearchOutlined />} onClick={() => setScannerOpen(true)}>
|
||||
{t('pages.inbounds.form.findTargets')}
|
||||
</Button>
|
||||
</Space.Compact>
|
||||
</Form.Item>
|
||||
<Form.Item label="SNI">
|
||||
<Space.Compact block style={{ display: 'flex' }}>
|
||||
<Form.Item
|
||||
name={['streamSettings', 'realitySettings', 'serverNames']}
|
||||
noStyle
|
||||
>
|
||||
<Select mode="tags" tokenSeparators={[',']} style={{ flex: 1 }} />
|
||||
</Form.Item>
|
||||
<Button icon={<ReloadOutlined />} onClick={randomizeRealityTarget} />
|
||||
</Space.Compact>
|
||||
{scanResult && (
|
||||
<Form.Item label=" " colon={false}>
|
||||
<Alert
|
||||
type={scanResult.feasible ? 'success' : 'warning'}
|
||||
showIcon
|
||||
title={
|
||||
scanResult.feasible
|
||||
? t('pages.inbounds.form.scanFeasible')
|
||||
: scanResult.reason || t('pages.inbounds.form.scanNotFeasible')
|
||||
}
|
||||
description={
|
||||
<Descriptions size="small" column={1}>
|
||||
<Descriptions.Item label="TLS">{scanResult.tlsVersion || '—'}</Descriptions.Item>
|
||||
<Descriptions.Item label="ALPN">{scanResult.alpn || '—'}</Descriptions.Item>
|
||||
<Descriptions.Item label={t('pages.inbounds.form.scanCurve')}>
|
||||
{scanResult.curveID || '—'}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label={t('pages.inbounds.form.scanCert')}>
|
||||
{scanResult.certValid
|
||||
? `${scanResult.certSubject} (${scanResult.certIssuer})`
|
||||
: t('pages.inbounds.form.scanCertInvalid')}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label={t('pages.inbounds.form.scanLatency')}>
|
||||
{scanResult.latencyMs > 0 ? `${scanResult.latencyMs} ms` : '—'}
|
||||
</Descriptions.Item>
|
||||
</Descriptions>
|
||||
}
|
||||
/>
|
||||
</Form.Item>
|
||||
)}
|
||||
<Form.Item label="SNI" name={['streamSettings', 'realitySettings', 'serverNames']}>
|
||||
<Select mode="tags" tokenSeparators={[',']} style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name={['streamSettings', 'realitySettings', 'maxTimediff']}
|
||||
@@ -201,6 +240,12 @@ export default function RealityForm({
|
||||
},
|
||||
]}
|
||||
/>
|
||||
<RealityTargetScannerModal
|
||||
open={scannerOpen}
|
||||
onClose={() => setScannerOpen(false)}
|
||||
scanRealityCandidates={scanRealityCandidates}
|
||||
onPick={applyRealityScanResult}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user