mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-07-14 16:46:07 +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:
@@ -463,6 +463,28 @@ export const EXAMPLES: Record<string, unknown> = {
|
||||
"xrayState": "",
|
||||
"xrayVersion": "25.10.31"
|
||||
},
|
||||
"RealityScanResult": {
|
||||
"alpn": "h2",
|
||||
"certIssuer": "Google Trust Services",
|
||||
"certSubject": "cloudflare.com",
|
||||
"certValid": true,
|
||||
"curveID": "X25519",
|
||||
"feasible": true,
|
||||
"h2": true,
|
||||
"host": "www.cloudflare.com",
|
||||
"ip": "104.16.124.96",
|
||||
"latencyMs": 180,
|
||||
"notAfter": "2026-08-01T00:00:00Z",
|
||||
"port": 443,
|
||||
"reason": "",
|
||||
"serverNames": [
|
||||
""
|
||||
],
|
||||
"target": "www.cloudflare.com:443",
|
||||
"tls13": true,
|
||||
"tlsVersion": "1.3",
|
||||
"x25519": true
|
||||
},
|
||||
"Setting": {
|
||||
"id": 0,
|
||||
"key": "",
|
||||
|
||||
@@ -2120,6 +2120,104 @@ export const SCHEMAS: Record<string, unknown> = {
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"RealityScanResult": {
|
||||
"properties": {
|
||||
"alpn": {
|
||||
"example": "h2",
|
||||
"type": "string"
|
||||
},
|
||||
"certIssuer": {
|
||||
"example": "Google Trust Services",
|
||||
"type": "string"
|
||||
},
|
||||
"certSubject": {
|
||||
"example": "cloudflare.com",
|
||||
"type": "string"
|
||||
},
|
||||
"certValid": {
|
||||
"example": true,
|
||||
"type": "boolean"
|
||||
},
|
||||
"curveID": {
|
||||
"example": "X25519",
|
||||
"type": "string"
|
||||
},
|
||||
"feasible": {
|
||||
"example": true,
|
||||
"type": "boolean"
|
||||
},
|
||||
"h2": {
|
||||
"example": true,
|
||||
"type": "boolean"
|
||||
},
|
||||
"host": {
|
||||
"example": "www.cloudflare.com",
|
||||
"type": "string"
|
||||
},
|
||||
"ip": {
|
||||
"example": "104.16.124.96",
|
||||
"type": "string"
|
||||
},
|
||||
"latencyMs": {
|
||||
"example": 180,
|
||||
"type": "integer"
|
||||
},
|
||||
"notAfter": {
|
||||
"example": "2026-08-01T00:00:00Z",
|
||||
"type": "string"
|
||||
},
|
||||
"port": {
|
||||
"example": 443,
|
||||
"type": "integer"
|
||||
},
|
||||
"reason": {
|
||||
"type": "string"
|
||||
},
|
||||
"serverNames": {
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"target": {
|
||||
"example": "www.cloudflare.com:443",
|
||||
"type": "string"
|
||||
},
|
||||
"tls13": {
|
||||
"example": true,
|
||||
"type": "boolean"
|
||||
},
|
||||
"tlsVersion": {
|
||||
"example": "1.3",
|
||||
"type": "string"
|
||||
},
|
||||
"x25519": {
|
||||
"example": true,
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"alpn",
|
||||
"certIssuer",
|
||||
"certSubject",
|
||||
"certValid",
|
||||
"curveID",
|
||||
"feasible",
|
||||
"h2",
|
||||
"host",
|
||||
"ip",
|
||||
"latencyMs",
|
||||
"notAfter",
|
||||
"port",
|
||||
"reason",
|
||||
"serverNames",
|
||||
"target",
|
||||
"tls13",
|
||||
"tlsVersion",
|
||||
"x25519"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"Setting": {
|
||||
"description": "Setting stores key-value configuration settings for the 3x-ui panel.",
|
||||
"properties": {
|
||||
|
||||
@@ -462,6 +462,27 @@ export interface ProbeResultUI {
|
||||
xrayVersion: string;
|
||||
}
|
||||
|
||||
export interface RealityScanResult {
|
||||
alpn: string;
|
||||
certIssuer: string;
|
||||
certSubject: string;
|
||||
certValid: boolean;
|
||||
curveID: string;
|
||||
feasible: boolean;
|
||||
h2: boolean;
|
||||
host: string;
|
||||
ip: string;
|
||||
latencyMs: number;
|
||||
notAfter: string;
|
||||
port: number;
|
||||
reason: string;
|
||||
serverNames: string[];
|
||||
target: string;
|
||||
tls13: boolean;
|
||||
tlsVersion: string;
|
||||
x25519: boolean;
|
||||
}
|
||||
|
||||
export interface Setting {
|
||||
id: number;
|
||||
key: string;
|
||||
|
||||
@@ -494,6 +494,28 @@ export const ProbeResultUISchema = z.object({
|
||||
});
|
||||
export type ProbeResultUI = z.infer<typeof ProbeResultUISchema>;
|
||||
|
||||
export const RealityScanResultSchema = z.object({
|
||||
alpn: z.string(),
|
||||
certIssuer: z.string(),
|
||||
certSubject: z.string(),
|
||||
certValid: z.boolean(),
|
||||
curveID: z.string(),
|
||||
feasible: z.boolean(),
|
||||
h2: z.boolean(),
|
||||
host: z.string(),
|
||||
ip: z.string(),
|
||||
latencyMs: z.number().int(),
|
||||
notAfter: z.string(),
|
||||
port: z.number().int(),
|
||||
reason: z.string(),
|
||||
serverNames: z.array(z.string()),
|
||||
target: z.string(),
|
||||
tls13: z.boolean(),
|
||||
tlsVersion: z.string(),
|
||||
x25519: z.boolean(),
|
||||
});
|
||||
export type RealityScanResult = z.infer<typeof RealityScanResultSchema>;
|
||||
|
||||
export const SettingSchema = z.object({
|
||||
id: z.number().int(),
|
||||
key: z.string(),
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
export interface RealityTarget {
|
||||
target: string;
|
||||
sni: string;
|
||||
}
|
||||
|
||||
export const REALITY_TARGETS: readonly RealityTarget[] = [
|
||||
{ target: 'www.amazon.com:443', sni: 'www.amazon.com' },
|
||||
{ target: 'aws.amazon.com:443', sni: 'aws.amazon.com' },
|
||||
{ target: 'www.oracle.com:443', sni: 'www.oracle.com' },
|
||||
{ target: 'www.nvidia.com:443', sni: 'www.nvidia.com' },
|
||||
{ target: 'www.amd.com:443', sni: 'www.amd.com' },
|
||||
{ target: 'www.intel.com:443', sni: 'www.intel.com' },
|
||||
{ target: 'www.sony.com:443', sni: 'www.sony.com' },
|
||||
];
|
||||
|
||||
export function getRandomRealityTarget(): RealityTarget {
|
||||
const randomIndex = Math.floor(Math.random() * REALITY_TARGETS.length);
|
||||
const selected = REALITY_TARGETS[randomIndex];
|
||||
return {
|
||||
target: selected.target,
|
||||
sni: selected.sni,
|
||||
};
|
||||
}
|
||||
@@ -489,6 +489,27 @@ export const sections: readonly Section[] = [
|
||||
body: 'server=cloudflare-dns.com',
|
||||
response: '{\n "success": true,\n "obj": [\n "e8e2d3..."\n ]\n}',
|
||||
},
|
||||
{
|
||||
method: 'POST',
|
||||
path: '/panel/api/server/scanRealityTarget',
|
||||
summary: 'Run a live TLS 1.3 probe against a candidate REALITY target and return a feasibility verdict (TLS 1.3 + h2 + X25519 + trusted certificate) plus the certificate SAN DNS names.',
|
||||
params: [
|
||||
{ name: 'target', in: 'body (form)', type: 'string', desc: 'Candidate target as host or host:port (default port 443), e.g. www.cloudflare.com:443.' },
|
||||
],
|
||||
body: 'target=www.cloudflare.com:443',
|
||||
responseSchema: 'RealityScanResult',
|
||||
},
|
||||
{
|
||||
method: 'POST',
|
||||
path: '/panel/api/server/scanRealityTargets',
|
||||
summary: 'Probe/discover REALITY targets and return each verdict ranked by feasibility then latency. Each comma-separated token may be a domain (validated with SNI), a bare IP, or a CIDR range (discovered without SNI by reading the certificate domain). When empty, a built-in seed list is probed.',
|
||||
params: [
|
||||
{ name: 'targets', in: 'body (form)', type: 'string', optional: true, desc: 'Optional comma-separated tokens: domain[:port], IP[:port], or CIDR (e.g. 104.16.0.0/24). When omitted, a built-in seed list is probed.' },
|
||||
],
|
||||
body: 'targets=104.16.0.0/24,www.apple.com:443',
|
||||
responseSchema: 'RealityScanResult',
|
||||
responseSchemaArray: true,
|
||||
},
|
||||
{
|
||||
method: 'GET',
|
||||
path: '/panel/api/server/clientIps',
|
||||
|
||||
@@ -81,7 +81,7 @@ export default function BulkAttachInboundsModal({
|
||||
{t('pages.clients.attachToInboundsDesc', { count })}
|
||||
</Typography.Paragraph>
|
||||
{targetOptions.length === 0 ? (
|
||||
<Alert type="info" showIcon message={t('pages.clients.attachToInboundsNoTargets')} />
|
||||
<Alert type="info" showIcon title={t('pages.clients.attachToInboundsNoTargets')} />
|
||||
) : (
|
||||
<>
|
||||
<SelectAllClearButtons
|
||||
|
||||
@@ -81,7 +81,7 @@ export default function BulkDetachInboundsModal({
|
||||
{t('pages.clients.detachFromInboundsDesc', { count })}
|
||||
</Typography.Paragraph>
|
||||
{targetOptions.length === 0 ? (
|
||||
<Alert type="info" showIcon message={t('pages.clients.detachFromInboundsNoTargets')} />
|
||||
<Alert type="info" showIcon title={t('pages.clients.detachFromInboundsNoTargets')} />
|
||||
) : (
|
||||
<>
|
||||
<SelectAllClearButtons
|
||||
|
||||
@@ -139,7 +139,7 @@ export default function GroupAddClientsModal({
|
||||
</Typography.Text>
|
||||
</Space>
|
||||
{rows.length === 0 ? (
|
||||
<Alert type="info" showIcon message={t('pages.groups.addToGroupEmpty')} />
|
||||
<Alert type="info" showIcon title={t('pages.groups.addToGroupEmpty')} />
|
||||
) : (
|
||||
<Table<ClientRow>
|
||||
size="small"
|
||||
|
||||
@@ -192,7 +192,7 @@ export default function AttachClientsModal({
|
||||
</Space>
|
||||
|
||||
{targetOptions.length === 0 ? (
|
||||
<Alert type="info" showIcon message={t('pages.inbounds.attachClientsNoTargets')} />
|
||||
<Alert type="info" showIcon title={t('pages.inbounds.attachClientsNoTargets')} />
|
||||
) : (
|
||||
<Select
|
||||
mode="multiple"
|
||||
|
||||
@@ -180,7 +180,7 @@ export default function AttachExistingClientsModal({
|
||||
</Typography.Paragraph>
|
||||
|
||||
{noClients ? (
|
||||
<Alert type="info" showIcon message={t('pages.inbounds.attachExistingNoClients')} />
|
||||
<Alert type="info" showIcon title={t('pages.inbounds.attachExistingNoClients')} />
|
||||
) : (
|
||||
<Spin spinning={loading}>
|
||||
<Space orientation="vertical" size="small" style={{ width: '100%' }}>
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
} from 'antd';
|
||||
|
||||
import { HttpUtil, NumberFormatter, RandomUtil, SizeFormatter, Wireguard } from '@/utils';
|
||||
import type { RealityScanResult } from '@/generated/types';
|
||||
import {
|
||||
rawInboundToFormValues,
|
||||
formValuesToWirePayload,
|
||||
@@ -174,6 +175,8 @@ export default function InboundFormModal({
|
||||
const [messageApi, messageContextHolder] = message.useMessage();
|
||||
const [form] = Form.useForm<InboundFormValues>();
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [scanning, setScanning] = useState(false);
|
||||
const [scanResult, setScanResult] = useState<RealityScanResult | null>(null);
|
||||
const {
|
||||
fallbacks,
|
||||
fallbackChildOptions,
|
||||
@@ -241,7 +244,9 @@ export default function InboundFormModal({
|
||||
clearRealityKeypair,
|
||||
genMldsa65,
|
||||
clearMldsa65,
|
||||
randomizeRealityTarget,
|
||||
scanRealityTarget,
|
||||
scanRealityCandidates,
|
||||
applyRealityScanResult,
|
||||
randomizeShortIds,
|
||||
getNewEchCert,
|
||||
clearEchCert,
|
||||
@@ -250,7 +255,7 @@ export default function InboundFormModal({
|
||||
setCertFromPanel,
|
||||
clearCertFiles,
|
||||
onSecurityChange,
|
||||
} = useSecurityActions({ form, setSaving, messageApi, nodeId: typeof wNodeId === 'number' ? wNodeId : null });
|
||||
} = useSecurityActions({ form, setSaving, messageApi, nodeId: typeof wNodeId === 'number' ? wNodeId : null, setScanResult, setScanning });
|
||||
|
||||
|
||||
const toggleSockopt = (on: boolean) => {
|
||||
@@ -347,6 +352,7 @@ export default function InboundFormModal({
|
||||
: buildAddModeValues();
|
||||
form.resetFields();
|
||||
form.setFieldsValue(initial);
|
||||
setScanResult(null);
|
||||
const initialTag = (initial.tag ?? '') as string;
|
||||
autoTagRef.current = isAutoInboundTag(initialTag, {
|
||||
port: initial.port ?? 0,
|
||||
@@ -890,7 +896,11 @@ export default function InboundFormModal({
|
||||
{security === 'reality' && (
|
||||
<RealityForm
|
||||
saving={saving}
|
||||
randomizeRealityTarget={randomizeRealityTarget}
|
||||
scanning={scanning}
|
||||
scanResult={scanResult}
|
||||
scanRealityTarget={scanRealityTarget}
|
||||
scanRealityCandidates={scanRealityCandidates}
|
||||
applyRealityScanResult={applyRealityScanResult}
|
||||
randomizeShortIds={randomizeShortIds}
|
||||
genRealityKeypair={genRealityKeypair}
|
||||
clearRealityKeypair={clearRealityKeypair}
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,10 +4,10 @@ import type { FormInstance } from 'antd';
|
||||
import type { MessageInstance } from 'antd/es/message/interface';
|
||||
|
||||
import { HttpUtil, RandomUtil } from '@/utils';
|
||||
import { getRandomRealityTarget } from '@/models/reality-targets';
|
||||
import { createTlsSettingsWithDefaultCert } from '@/lib/xray/inbound-tls-defaults';
|
||||
import { RealityStreamSettingsSchema } from '@/schemas/protocols/security/reality';
|
||||
import type { InboundFormValues } from '@/schemas/forms/inbound-form';
|
||||
import type { RealityScanResult } from '@/generated/types';
|
||||
|
||||
interface UseSecurityActionsArgs {
|
||||
form: FormInstance<InboundFormValues>;
|
||||
@@ -17,13 +17,15 @@ interface UseSecurityActionsArgs {
|
||||
// Panel" must read the node's own cert paths for a node-assigned inbound —
|
||||
// the central panel's paths don't exist on the node. See issue #4854.
|
||||
nodeId: number | null;
|
||||
setScanResult: Dispatch<SetStateAction<RealityScanResult | null>>;
|
||||
setScanning: Dispatch<SetStateAction<boolean>>;
|
||||
}
|
||||
|
||||
// Server-side TLS / Reality key + certificate generation handlers for the
|
||||
// inbound modal's security tab. Each talks to a /panel server endpoint and
|
||||
// writes the result back into the form. Lifted out of InboundFormModal so
|
||||
// the modal body stays focused on orchestration.
|
||||
export function useSecurityActions({ form, setSaving, messageApi, nodeId }: UseSecurityActionsArgs) {
|
||||
export function useSecurityActions({ form, setSaving, messageApi, nodeId, setScanResult, setScanning }: UseSecurityActionsArgs) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const genRealityKeypair = async () => {
|
||||
@@ -64,13 +66,55 @@ export function useSecurityActions({ form, setSaving, messageApi, nodeId }: UseS
|
||||
form.setFieldValue(['streamSettings', 'realitySettings', 'settings', 'mldsa65Verify'], '');
|
||||
};
|
||||
|
||||
const randomizeRealityTarget = () => {
|
||||
const tgt = getRandomRealityTarget() as { target: string; sni: string };
|
||||
form.setFieldValue(['streamSettings', 'realitySettings', 'target'], tgt.target);
|
||||
form.setFieldValue(
|
||||
['streamSettings', 'realitySettings', 'serverNames'],
|
||||
tgt.sni.split(',').map((s) => s.trim()).filter(Boolean),
|
||||
const applyRealityScanResult = (r: RealityScanResult) => {
|
||||
setScanResult(r);
|
||||
form.setFieldValue(['streamSettings', 'realitySettings', 'target'], r.target);
|
||||
if (r.serverNames?.length) {
|
||||
form.setFieldValue(['streamSettings', 'realitySettings', 'serverNames'], r.serverNames);
|
||||
}
|
||||
};
|
||||
|
||||
const scanRealityTarget = async () => {
|
||||
const target = ((form.getFieldValue(['streamSettings', 'realitySettings', 'target']) as string | undefined) ?? '').trim();
|
||||
if (!target) {
|
||||
messageApi.warning(t('pages.inbounds.form.realityTargetRequired'));
|
||||
return;
|
||||
}
|
||||
setScanning(true);
|
||||
try {
|
||||
const msg = await HttpUtil.post<RealityScanResult>(
|
||||
'/panel/api/server/scanRealityTarget',
|
||||
{ target },
|
||||
{ silent: true },
|
||||
);
|
||||
if (!msg?.success || !msg.obj) {
|
||||
setScanResult(null);
|
||||
messageApi.error(msg?.msg || t('pages.inbounds.toasts.scanRealityTargetError'));
|
||||
return;
|
||||
}
|
||||
const r = msg.obj;
|
||||
applyRealityScanResult(r);
|
||||
if (r.feasible) {
|
||||
messageApi.success(t('pages.inbounds.toasts.scanRealityTargetFeasible'));
|
||||
} else {
|
||||
messageApi.warning(r.reason || t('pages.inbounds.toasts.scanRealityTargetNotFeasible'));
|
||||
}
|
||||
} finally {
|
||||
setScanning(false);
|
||||
}
|
||||
};
|
||||
|
||||
const scanRealityCandidates = async (targets?: string): Promise<RealityScanResult[]> => {
|
||||
const msg = await HttpUtil.post<RealityScanResult[]>(
|
||||
'/panel/api/server/scanRealityTargets',
|
||||
targets ? { targets } : {},
|
||||
{ silent: true },
|
||||
);
|
||||
if (!msg?.success || !Array.isArray(msg.obj)) {
|
||||
messageApi.error(msg?.msg || t('pages.inbounds.toasts.scanRealityTargetError'));
|
||||
return [];
|
||||
}
|
||||
return msg.obj;
|
||||
};
|
||||
|
||||
const randomizeShortIds = () => {
|
||||
@@ -209,6 +253,7 @@ export function useSecurityActions({ form, setSaving, messageApi, nodeId }: UseS
|
||||
};
|
||||
|
||||
const onSecurityChange = async (next: string) => {
|
||||
setScanResult(null);
|
||||
const current = (form.getFieldValue('streamSettings') as Record<string, unknown>) ?? {};
|
||||
const cleaned: Record<string, unknown> = { ...current, security: next };
|
||||
delete cleaned.tlsSettings;
|
||||
@@ -218,9 +263,8 @@ export function useSecurityActions({ form, setSaving, messageApi, nodeId }: UseS
|
||||
}
|
||||
if (next === 'reality') {
|
||||
const reality = RealityStreamSettingsSchema.parse({}) as Record<string, unknown>;
|
||||
const tgt = getRandomRealityTarget() as { target: string; sni: string };
|
||||
reality.target = tgt.target;
|
||||
reality.serverNames = tgt.sni.split(',').map((s) => s.trim()).filter(Boolean);
|
||||
reality.target = '';
|
||||
reality.serverNames = [];
|
||||
reality.shortIds = RandomUtil.randomShortIds().split(',').map((s) => s.trim()).filter(Boolean);
|
||||
cleaned.realitySettings = reality;
|
||||
}
|
||||
@@ -244,7 +288,9 @@ export function useSecurityActions({ form, setSaving, messageApi, nodeId }: UseS
|
||||
clearRealityKeypair,
|
||||
genMldsa65,
|
||||
clearMldsa65,
|
||||
randomizeRealityTarget,
|
||||
scanRealityTarget,
|
||||
scanRealityCandidates,
|
||||
applyRealityScanResult,
|
||||
randomizeShortIds,
|
||||
getNewEchCert,
|
||||
clearEchCert,
|
||||
|
||||
@@ -41,7 +41,7 @@ function UpdateChannelChoice({ onChange }: { onChange: (dev: boolean) => void })
|
||||
type="info"
|
||||
showIcon
|
||||
style={{ marginTop: 8 }}
|
||||
message={t('pages.index.devChannelWarning')}
|
||||
title={t('pages.index.devChannelWarning')}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -98,7 +98,11 @@ describe('inbound security forms', () => {
|
||||
renderInForm(() => (
|
||||
<RealityForm
|
||||
saving={false}
|
||||
randomizeRealityTarget={noop}
|
||||
scanning={false}
|
||||
scanResult={null}
|
||||
scanRealityTarget={noop}
|
||||
scanRealityCandidates={async () => []}
|
||||
applyRealityScanResult={noop}
|
||||
randomizeShortIds={noop}
|
||||
genRealityKeypair={noop}
|
||||
clearRealityKeypair={noop}
|
||||
|
||||
Reference in New Issue
Block a user