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:
MHSanaei
2026-06-26 22:18:47 +02:00
parent 451263f1db
commit 6964d84742
36 changed files with 1489 additions and 63 deletions
@@ -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}
/>
</>
);
}