mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-06-28 00:24:19 +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:
+1
-1
@@ -100,7 +100,7 @@ frontend/
|
||||
├── generated/ # Code-generated zod + ts types from Go
|
||||
│ # (DO NOT hand-edit — regenerated by gen:zod)
|
||||
├── models/ # Thin legacy types still in transit
|
||||
│ # (DBInbound, Status, AllSetting, reality-targets)
|
||||
│ # (DBInbound, Status, AllSetting)
|
||||
├── styles/ # Shared CSS modules
|
||||
├── test/ # Vitest specs + golden fixtures
|
||||
│ ├── *.test.ts
|
||||
|
||||
@@ -2146,6 +2146,104 @@
|
||||
],
|
||||
"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": {
|
||||
@@ -4637,6 +4735,145 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/panel/api/server/scanRealityTarget": {
|
||||
"post": {
|
||||
"tags": [
|
||||
"Server"
|
||||
],
|
||||
"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.",
|
||||
"operationId": "post_panel_api_server_scanRealityTarget",
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Successful response",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"success": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"msg": {
|
||||
"type": "string"
|
||||
},
|
||||
"obj": {
|
||||
"$ref": "#/components/schemas/RealityScanResult"
|
||||
}
|
||||
}
|
||||
},
|
||||
"example": {
|
||||
"success": true,
|
||||
"obj": {
|
||||
"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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/panel/api/server/scanRealityTargets": {
|
||||
"post": {
|
||||
"tags": [
|
||||
"Server"
|
||||
],
|
||||
"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.",
|
||||
"operationId": "post_panel_api_server_scanRealityTargets",
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Successful response",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"success": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"msg": {
|
||||
"type": "string"
|
||||
},
|
||||
"obj": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/RealityScanResult"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"example": {
|
||||
"success": true,
|
||||
"obj": [
|
||||
{
|
||||
"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
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/panel/api/server/clientIps": {
|
||||
"get": {
|
||||
"tags": [
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -78,6 +78,8 @@ func (a *ServerController) initRouter(g *gin.RouterGroup) {
|
||||
g.POST("/getNewEchCert", a.getNewEchCert)
|
||||
g.POST("/getCertHash", a.getCertHash)
|
||||
g.POST("/getRemoteCertHash", a.getRemoteCertHash)
|
||||
g.POST("/scanRealityTarget", a.scanRealityTarget)
|
||||
g.POST("/scanRealityTargets", a.scanRealityTargets)
|
||||
g.POST("/clientIps", a.setClientIps)
|
||||
}
|
||||
|
||||
@@ -445,6 +447,29 @@ func (a *ServerController) getRemoteCertHash(c *gin.Context) {
|
||||
jsonObj(c, hashes, nil)
|
||||
}
|
||||
|
||||
// scanRealityTarget runs a live TLS 1.3 probe against the candidate REALITY
|
||||
// target and returns a structured feasibility verdict plus the cert SAN names.
|
||||
func (a *ServerController) scanRealityTarget(c *gin.Context) {
|
||||
res, err := a.serverService.ScanRealityTarget(c.PostForm("target"))
|
||||
if err != nil {
|
||||
jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.scanRealityTargetError"), err)
|
||||
return
|
||||
}
|
||||
jsonObj(c, res, nil)
|
||||
}
|
||||
|
||||
// scanRealityTargets probes a batch of candidate REALITY targets (the supplied
|
||||
// comma-separated list, or the built-in seed set when empty) and returns each
|
||||
// verdict ranked by feasibility then latency.
|
||||
func (a *ServerController) scanRealityTargets(c *gin.Context) {
|
||||
res, err := a.serverService.ScanRealityTargets(c.PostForm("targets"))
|
||||
if err != nil {
|
||||
jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.scanRealityTargetError"), err)
|
||||
return
|
||||
}
|
||||
jsonObj(c, res, nil)
|
||||
}
|
||||
|
||||
// getNewVlessEnc generates a new VLESS encryption key.
|
||||
func (a *ServerController) getNewVlessEnc(c *gin.Context) {
|
||||
out, err := a.serverService.GetNewVlessEnc()
|
||||
|
||||
@@ -0,0 +1,391 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"fmt"
|
||||
"net"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/util/common"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/util/netsafe"
|
||||
)
|
||||
|
||||
const (
|
||||
realityScanTimeout = 10 * time.Second
|
||||
realityDiscoverTimeout = 4 * time.Second
|
||||
realityScanConcurrency = 32
|
||||
realityDiscoverMaxIPs = 256
|
||||
realityScanMaxTotal = 512
|
||||
)
|
||||
|
||||
var defaultRealityScanCandidates = []string{
|
||||
"www.cloudflare.com:443",
|
||||
"www.microsoft.com:443",
|
||||
"www.amazon.com:443",
|
||||
"aws.amazon.com:443",
|
||||
"www.samsung.com:443",
|
||||
"www.nvidia.com:443",
|
||||
"www.amd.com:443",
|
||||
"www.intel.com:443",
|
||||
"www.sony.com:443",
|
||||
"dl.google.com:443",
|
||||
}
|
||||
|
||||
type RealityScanResult struct {
|
||||
Target string `json:"target" example:"www.cloudflare.com:443"`
|
||||
Host string `json:"host" example:"www.cloudflare.com"`
|
||||
IP string `json:"ip" example:"104.16.124.96"`
|
||||
Port int `json:"port" example:"443"`
|
||||
Feasible bool `json:"feasible" example:"true"`
|
||||
TLS13 bool `json:"tls13" example:"true"`
|
||||
TLSVersion string `json:"tlsVersion" example:"1.3"`
|
||||
H2 bool `json:"h2" example:"true"`
|
||||
ALPN string `json:"alpn" example:"h2"`
|
||||
X25519 bool `json:"x25519" example:"true"`
|
||||
CurveID string `json:"curveID" example:"X25519"`
|
||||
CertValid bool `json:"certValid" example:"true"`
|
||||
CertSubject string `json:"certSubject" example:"cloudflare.com"`
|
||||
CertIssuer string `json:"certIssuer" example:"Google Trust Services"`
|
||||
NotAfter string `json:"notAfter" example:"2026-08-01T00:00:00Z"`
|
||||
ServerNames []string `json:"serverNames"`
|
||||
LatencyMs int `json:"latencyMs" example:"180"`
|
||||
Reason string `json:"reason" example:""`
|
||||
}
|
||||
|
||||
type realityProbeTask struct {
|
||||
dialHost string
|
||||
port int
|
||||
sni string
|
||||
timeout time.Duration
|
||||
bulk bool
|
||||
}
|
||||
|
||||
func tlsVersionName(v uint16) string {
|
||||
switch v {
|
||||
case tls.VersionTLS13:
|
||||
return "1.3"
|
||||
case tls.VersionTLS12:
|
||||
return "1.2"
|
||||
case tls.VersionTLS11:
|
||||
return "1.1"
|
||||
case tls.VersionTLS10:
|
||||
return "1.0"
|
||||
default:
|
||||
return "unknown"
|
||||
}
|
||||
}
|
||||
|
||||
func realityCurveName(id tls.CurveID) string {
|
||||
switch id {
|
||||
case tls.X25519:
|
||||
return "X25519"
|
||||
case tls.X25519MLKEM768:
|
||||
return "X25519MLKEM768"
|
||||
case tls.CurveP256:
|
||||
return "P-256"
|
||||
case tls.CurveP384:
|
||||
return "P-384"
|
||||
case tls.CurveP521:
|
||||
return "P-521"
|
||||
case 0:
|
||||
return ""
|
||||
default:
|
||||
return fmt.Sprintf("0x%04x", uint16(id))
|
||||
}
|
||||
}
|
||||
|
||||
func filterUsableSANs(dnsNames []string) []string {
|
||||
out := make([]string, 0, len(dnsNames))
|
||||
for _, n := range dnsNames {
|
||||
n = strings.TrimSpace(n)
|
||||
if n == "" || strings.HasPrefix(n, "*.") {
|
||||
continue
|
||||
}
|
||||
out = append(out, n)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func firstUsableName(leaf *x509.Certificate) string {
|
||||
cn := strings.TrimSpace(leaf.Subject.CommonName)
|
||||
if cn != "" && !strings.HasPrefix(cn, "*.") {
|
||||
return cn
|
||||
}
|
||||
for _, n := range leaf.DNSNames {
|
||||
n = strings.TrimSpace(n)
|
||||
if n != "" && !strings.HasPrefix(n, "*.") {
|
||||
return n
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func splitRealityTarget(target string) (string, int, error) {
|
||||
target = strings.TrimSpace(target)
|
||||
if target == "" {
|
||||
return "", 0, common.NewError("target is required")
|
||||
}
|
||||
host, portStr := target, "443"
|
||||
if h, p, err := net.SplitHostPort(target); err == nil {
|
||||
host, portStr = h, p
|
||||
}
|
||||
host, err := netsafe.NormalizeHost(host)
|
||||
if err != nil {
|
||||
return "", 0, common.NewError("invalid target host: ", err)
|
||||
}
|
||||
port, err := strconv.Atoi(portStr)
|
||||
if err != nil || port < 1 || port > 65535 {
|
||||
return "", 0, common.NewError("invalid target port")
|
||||
}
|
||||
return host, port, nil
|
||||
}
|
||||
|
||||
func incIP(ip net.IP) {
|
||||
for j := len(ip) - 1; j >= 0; j-- {
|
||||
ip[j]++
|
||||
if ip[j] > 0 {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func enumerateCIDR(cidr string, max int) ([]string, error) {
|
||||
_, ipnet, err := net.ParseCIDR(strings.TrimSpace(cidr))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ips := make([]string, 0, max)
|
||||
for ip := ipnet.IP.Mask(ipnet.Mask); ipnet.Contains(ip); incIP(ip) {
|
||||
ips = append(ips, ip.String())
|
||||
if len(ips) >= max {
|
||||
break
|
||||
}
|
||||
}
|
||||
return ips, nil
|
||||
}
|
||||
|
||||
func (s *ServerService) probeRealityAddr(dialHost string, port int, sni string, timeout time.Duration) *RealityScanResult {
|
||||
addr := net.JoinHostPort(dialHost, strconv.Itoa(port))
|
||||
res := &RealityScanResult{Port: port}
|
||||
if net.ParseIP(dialHost) != nil {
|
||||
res.IP = dialHost
|
||||
}
|
||||
if sni != "" {
|
||||
res.Host = sni
|
||||
res.Target = net.JoinHostPort(sni, strconv.Itoa(port))
|
||||
} else {
|
||||
res.Host = dialHost
|
||||
res.Target = addr
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), timeout)
|
||||
defer cancel()
|
||||
|
||||
start := time.Now()
|
||||
conn, err := netsafe.SSRFGuardedDialContext(ctx, "tcp", addr)
|
||||
if err != nil {
|
||||
res.Reason = "connection failed: " + err.Error()
|
||||
return res
|
||||
}
|
||||
defer conn.Close()
|
||||
_ = conn.SetDeadline(time.Now().Add(timeout))
|
||||
|
||||
cfg := &tls.Config{
|
||||
ServerName: sni,
|
||||
InsecureSkipVerify: true,
|
||||
NextProtos: []string{"h2", "http/1.1"},
|
||||
CurvePreferences: []tls.CurveID{tls.X25519, tls.X25519MLKEM768},
|
||||
MinVersion: tls.VersionTLS12,
|
||||
}
|
||||
tlsConn := tls.Client(conn, cfg)
|
||||
if err := tlsConn.HandshakeContext(ctx); err != nil {
|
||||
res.Reason = "TLS handshake failed: " + err.Error()
|
||||
return res
|
||||
}
|
||||
res.LatencyMs = int(time.Since(start).Milliseconds())
|
||||
|
||||
st := tlsConn.ConnectionState()
|
||||
res.TLS13 = st.Version == tls.VersionTLS13
|
||||
res.TLSVersion = tlsVersionName(st.Version)
|
||||
res.ALPN = st.NegotiatedProtocol
|
||||
res.H2 = st.NegotiatedProtocol == "h2"
|
||||
res.CurveID = realityCurveName(st.CurveID)
|
||||
res.X25519 = st.CurveID == tls.X25519 || st.CurveID == tls.X25519MLKEM768
|
||||
|
||||
verifyHost := sni
|
||||
if len(st.PeerCertificates) > 0 {
|
||||
leaf := st.PeerCertificates[0]
|
||||
res.CertSubject = leaf.Subject.CommonName
|
||||
if res.CertSubject == "" && len(leaf.DNSNames) > 0 {
|
||||
res.CertSubject = leaf.DNSNames[0]
|
||||
}
|
||||
if len(leaf.Issuer.Organization) > 0 {
|
||||
res.CertIssuer = leaf.Issuer.Organization[0]
|
||||
} else {
|
||||
res.CertIssuer = leaf.Issuer.CommonName
|
||||
}
|
||||
res.NotAfter = leaf.NotAfter.UTC().Format(time.RFC3339)
|
||||
res.ServerNames = filterUsableSANs(leaf.DNSNames)
|
||||
|
||||
if sni == "" {
|
||||
if discovered := firstUsableName(leaf); discovered != "" {
|
||||
res.Host = discovered
|
||||
res.Target = net.JoinHostPort(discovered, strconv.Itoa(port))
|
||||
verifyHost = discovered
|
||||
}
|
||||
}
|
||||
|
||||
if verifyHost != "" {
|
||||
opts := x509.VerifyOptions{DNSName: verifyHost, Intermediates: x509.NewCertPool()}
|
||||
for _, c := range st.PeerCertificates[1:] {
|
||||
opts.Intermediates.AddCert(c)
|
||||
}
|
||||
if _, verr := leaf.Verify(opts); verr == nil {
|
||||
res.CertValid = true
|
||||
} else {
|
||||
res.Reason = "certificate not trusted: " + verr.Error()
|
||||
}
|
||||
} else {
|
||||
res.Reason = "no usable domain in certificate"
|
||||
}
|
||||
} else {
|
||||
res.Reason = "no certificate presented"
|
||||
}
|
||||
|
||||
res.Feasible = res.TLS13 && res.H2 && res.X25519 && res.CertValid
|
||||
if !res.Feasible && res.Reason == "" {
|
||||
switch {
|
||||
case !res.TLS13:
|
||||
res.Reason = "server does not negotiate TLS 1.3"
|
||||
case !res.H2:
|
||||
res.Reason = "server does not negotiate HTTP/2 (h2)"
|
||||
case !res.X25519:
|
||||
res.Reason = "server did not use X25519 key exchange"
|
||||
}
|
||||
}
|
||||
return res
|
||||
}
|
||||
|
||||
func (s *ServerService) probeRealityTarget(host string, port int) *RealityScanResult {
|
||||
return s.probeRealityAddr(host, port, host, realityScanTimeout)
|
||||
}
|
||||
|
||||
func (s *ServerService) ScanRealityTarget(target string) (*RealityScanResult, error) {
|
||||
host, port, err := splitRealityTarget(target)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.probeRealityTarget(host, port), nil
|
||||
}
|
||||
|
||||
func (s *ServerService) ScanRealityTargets(targetsCSV string) ([]*RealityScanResult, error) {
|
||||
var tokens []string
|
||||
for _, raw := range strings.Split(targetsCSV, ",") {
|
||||
if t := strings.TrimSpace(raw); t != "" {
|
||||
tokens = append(tokens, t)
|
||||
}
|
||||
}
|
||||
if len(tokens) == 0 {
|
||||
tokens = append(tokens, defaultRealityScanCandidates...)
|
||||
}
|
||||
|
||||
var tasks []realityProbeTask
|
||||
var invalid []*RealityScanResult
|
||||
for _, token := range tokens {
|
||||
if len(tasks) >= realityScanMaxTotal {
|
||||
break
|
||||
}
|
||||
if strings.Contains(token, "/") {
|
||||
ips, err := enumerateCIDR(token, realityDiscoverMaxIPs)
|
||||
if err != nil {
|
||||
invalid = append(invalid, &RealityScanResult{Target: token, Reason: "invalid CIDR: " + err.Error()})
|
||||
continue
|
||||
}
|
||||
for _, ip := range ips {
|
||||
if len(tasks) >= realityScanMaxTotal {
|
||||
break
|
||||
}
|
||||
tasks = append(tasks, realityProbeTask{dialHost: ip, port: 443, timeout: realityDiscoverTimeout, bulk: true})
|
||||
}
|
||||
continue
|
||||
}
|
||||
host, port, err := splitRealityTarget(token)
|
||||
if err != nil {
|
||||
invalid = append(invalid, &RealityScanResult{Target: token, Reason: err.Error()})
|
||||
continue
|
||||
}
|
||||
if net.ParseIP(host) != nil {
|
||||
tasks = append(tasks, realityProbeTask{dialHost: host, port: port, timeout: realityDiscoverTimeout})
|
||||
} else {
|
||||
tasks = append(tasks, realityProbeTask{dialHost: host, port: port, sni: host, timeout: realityScanTimeout})
|
||||
}
|
||||
}
|
||||
|
||||
probed := make([]*RealityScanResult, len(tasks))
|
||||
sem := make(chan struct{}, realityScanConcurrency)
|
||||
var wg sync.WaitGroup
|
||||
for i, task := range tasks {
|
||||
wg.Add(1)
|
||||
sem <- struct{}{}
|
||||
go func(idx int, tk realityProbeTask) {
|
||||
defer wg.Done()
|
||||
defer func() { <-sem }()
|
||||
r := s.probeRealityAddr(tk.dialHost, tk.port, tk.sni, tk.timeout)
|
||||
if tk.bulk && r.TLSVersion == "" {
|
||||
return
|
||||
}
|
||||
probed[idx] = r
|
||||
}(i, task)
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
results := dedupRealityResults(append(probed, invalid...))
|
||||
sortRealityResults(results)
|
||||
return results, nil
|
||||
}
|
||||
|
||||
func dedupRealityResults(results []*RealityScanResult) []*RealityScanResult {
|
||||
best := make(map[string]*RealityScanResult)
|
||||
order := make([]string, 0, len(results))
|
||||
for _, r := range results {
|
||||
if r == nil {
|
||||
continue
|
||||
}
|
||||
if ex, ok := best[r.Target]; !ok {
|
||||
best[r.Target] = r
|
||||
order = append(order, r.Target)
|
||||
} else if betterRealityResult(r, ex) {
|
||||
best[r.Target] = r
|
||||
}
|
||||
}
|
||||
out := make([]*RealityScanResult, 0, len(order))
|
||||
for _, k := range order {
|
||||
out = append(out, best[k])
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func betterRealityResult(a, b *RealityScanResult) bool {
|
||||
if a.Feasible != b.Feasible {
|
||||
return a.Feasible
|
||||
}
|
||||
return a.LatencyMs > 0 && (b.LatencyMs == 0 || a.LatencyMs < b.LatencyMs)
|
||||
}
|
||||
|
||||
func sortRealityResults(results []*RealityScanResult) {
|
||||
slices.SortStableFunc(results, func(a, b *RealityScanResult) int {
|
||||
if a.Feasible != b.Feasible {
|
||||
if a.Feasible {
|
||||
return -1
|
||||
}
|
||||
return 1
|
||||
}
|
||||
return a.LatencyMs - b.LatencyMs
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestTLSVersionName(t *testing.T) {
|
||||
cases := map[uint16]string{
|
||||
tls.VersionTLS13: "1.3",
|
||||
tls.VersionTLS12: "1.2",
|
||||
tls.VersionTLS11: "1.1",
|
||||
tls.VersionTLS10: "1.0",
|
||||
0: "unknown",
|
||||
}
|
||||
for in, want := range cases {
|
||||
if got := tlsVersionName(in); got != want {
|
||||
t.Errorf("tlsVersionName(%d) = %q, want %q", in, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRealityCurveName(t *testing.T) {
|
||||
cases := map[tls.CurveID]string{
|
||||
tls.X25519: "X25519",
|
||||
tls.X25519MLKEM768: "X25519MLKEM768",
|
||||
tls.CurveP256: "P-256",
|
||||
0: "",
|
||||
}
|
||||
for in, want := range cases {
|
||||
if got := realityCurveName(in); got != want {
|
||||
t.Errorf("realityCurveName(%d) = %q, want %q", in, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestFilterUsableSANs(t *testing.T) {
|
||||
got := filterUsableSANs([]string{"example.com", "*.example.com", "", " a.com "})
|
||||
want := []string{"example.com", "a.com"}
|
||||
if len(got) != len(want) {
|
||||
t.Fatalf("filterUsableSANs = %v, want %v", got, want)
|
||||
}
|
||||
for i := range want {
|
||||
if got[i] != want[i] {
|
||||
t.Errorf("filterUsableSANs[%d] = %q, want %q", i, got[i], want[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSplitRealityTarget(t *testing.T) {
|
||||
okCases := []struct {
|
||||
in string
|
||||
wantHost string
|
||||
wantPort int
|
||||
}{
|
||||
{"example.com", "example.com", 443},
|
||||
{"example.com:8443", "example.com", 8443},
|
||||
{"1.1.1.1:443", "1.1.1.1", 443},
|
||||
}
|
||||
for _, c := range okCases {
|
||||
host, port, err := splitRealityTarget(c.in)
|
||||
if err != nil {
|
||||
t.Errorf("splitRealityTarget(%q) unexpected error: %v", c.in, err)
|
||||
continue
|
||||
}
|
||||
if host != c.wantHost || port != c.wantPort {
|
||||
t.Errorf("splitRealityTarget(%q) = (%q, %d), want (%q, %d)", c.in, host, port, c.wantHost, c.wantPort)
|
||||
}
|
||||
}
|
||||
|
||||
badCases := []string{"", " ", "example.com:0", "example.com:70000", "bad host!"}
|
||||
for _, in := range badCases {
|
||||
if _, _, err := splitRealityTarget(in); err == nil {
|
||||
t.Errorf("splitRealityTarget(%q) expected error, got nil", in)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestScanRealityTargetInputValidation(t *testing.T) {
|
||||
if _, err := (&ServerService{}).ScanRealityTarget(""); err == nil {
|
||||
t.Error("ScanRealityTarget(empty) expected error, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestScanRealityTargetBlocksPrivate(t *testing.T) {
|
||||
res, err := (&ServerService{}).ScanRealityTarget("127.0.0.1:443")
|
||||
if err != nil {
|
||||
t.Fatalf("ScanRealityTarget(loopback) unexpected error: %v", err)
|
||||
}
|
||||
if res.Feasible {
|
||||
t.Error("ScanRealityTarget(loopback) should not be feasible")
|
||||
}
|
||||
if res.Reason == "" {
|
||||
t.Error("ScanRealityTarget(loopback) should set a reason")
|
||||
}
|
||||
}
|
||||
|
||||
func TestScanRealityTargetsHandlesPrivateAndBadInput(t *testing.T) {
|
||||
results, err := (&ServerService{}).ScanRealityTargets("127.0.0.1:443,10.0.0.1:443,bad host!")
|
||||
if err != nil {
|
||||
t.Fatalf("ScanRealityTargets unexpected error: %v", err)
|
||||
}
|
||||
if len(results) != 3 {
|
||||
t.Fatalf("ScanRealityTargets returned %d results, want 3", len(results))
|
||||
}
|
||||
for _, r := range results {
|
||||
if r.Feasible {
|
||||
t.Errorf("result %q unexpectedly feasible", r.Target)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -474,6 +474,9 @@
|
||||
"getNewX25519CertError": "حدث خطأ أثناء الحصول على شهادة X25519.",
|
||||
"getNewmldsa65Error": "حدث خطاء في الحصول على mldsa65.",
|
||||
"getNewVlessEncError": "حدث خطأ أثناء الحصول على VlessEnc.",
|
||||
"scanRealityTargetError": "فشل فحص هدف REALITY.",
|
||||
"scanRealityTargetFeasible": "الهدف مناسب — تم ملء الهدف وSNI.",
|
||||
"scanRealityTargetNotFeasible": "الهدف قابل للوصول لكنه غير مناسب لـ REALITY.",
|
||||
"invalidClientField": "العميل {client}: الحقل {field} — {reason}",
|
||||
"invalidField": "{field} — {reason}",
|
||||
"moreIssues": "{message} (+{count} أخرى)"
|
||||
@@ -623,6 +626,20 @@
|
||||
"realityTargetRequired": "هدف REALITY مطلوب",
|
||||
"realityTargetNeedsPort": "يجب أن يتضمّن هدف REALITY منفذًا (مثل example.com:443)",
|
||||
"realityTargetInvalidPort": "هدف REALITY يحتوي على منفذ غير صالح",
|
||||
"scan": "فحص",
|
||||
"findTargets": "البحث عن أهداف",
|
||||
"scanModalTitle": "ماسح أهداف REALITY",
|
||||
"scanModalDesc": "تحقق من نطاق، أو افحص نطاق IP / CIDR لاكتشاف أهداف REALITY جديدة من شهاداتها. اترك الحقل فارغًا لفحص المرشحين الشائعين.",
|
||||
"scanDiscoverPlaceholder": "IP أو CIDR أو نطاق — اتركه فارغًا للمرشحين الشائعين",
|
||||
"scanStatus": "الحالة",
|
||||
"scanFeasible": "مناسب",
|
||||
"scanNotFeasible": "غير مناسب",
|
||||
"scanCurve": "تبادل المفاتيح",
|
||||
"scanCert": "الشهادة",
|
||||
"scanCertInvalid": "غير موثوق",
|
||||
"scanLatency": "زمن الاستجابة",
|
||||
"scanUse": "استخدام",
|
||||
"scanRescan": "إعادة الفحص",
|
||||
"spiderX": "SpiderX",
|
||||
"getNewCert": "احصل على شهادة جديدة",
|
||||
"mldsa65Seed": "mldsa65 Seed",
|
||||
|
||||
@@ -474,6 +474,9 @@
|
||||
"getNewX25519CertError": "Error while obtaining the X25519 certificate.",
|
||||
"getNewmldsa65Error": "Error while obtaining mldsa65.",
|
||||
"getNewVlessEncError": "Error while obtaining VlessEnc.",
|
||||
"scanRealityTargetError": "Failed to scan REALITY target.",
|
||||
"scanRealityTargetFeasible": "Target is feasible — filled target and SNI.",
|
||||
"scanRealityTargetNotFeasible": "Target is reachable but not feasible for REALITY.",
|
||||
"invalidClientField": "Client {client}: {field} — {reason}",
|
||||
"invalidField": "{field} — {reason}",
|
||||
"moreIssues": "{message} (+{count} more)"
|
||||
@@ -635,6 +638,20 @@
|
||||
"realityTargetRequired": "REALITY target is required",
|
||||
"realityTargetNeedsPort": "REALITY target must include a port (e.g. example.com:443)",
|
||||
"realityTargetInvalidPort": "REALITY target has an invalid port",
|
||||
"scan": "Scan",
|
||||
"findTargets": "Find Targets",
|
||||
"scanModalTitle": "REALITY Target Scanner",
|
||||
"scanModalDesc": "Validate a domain, or scan an IP / CIDR range to discover new REALITY targets from their certificates. Leave the box empty to probe common candidates.",
|
||||
"scanDiscoverPlaceholder": "IP, CIDR, or domain — leave empty for common candidates",
|
||||
"scanStatus": "Status",
|
||||
"scanFeasible": "Feasible",
|
||||
"scanNotFeasible": "Not feasible",
|
||||
"scanCurve": "Key Exchange",
|
||||
"scanCert": "Certificate",
|
||||
"scanCertInvalid": "Not trusted",
|
||||
"scanLatency": "Latency",
|
||||
"scanUse": "Use",
|
||||
"scanRescan": "Rescan",
|
||||
"spiderX": "SpiderX",
|
||||
"getNewCert": "Get New Cert",
|
||||
"mldsa65Seed": "mldsa65 Seed",
|
||||
|
||||
@@ -474,6 +474,9 @@
|
||||
"getNewX25519CertError": "Error al obtener el certificado X25519.",
|
||||
"getNewmldsa65Error": "Error al obtener el certificado mldsa65.",
|
||||
"getNewVlessEncError": "Error al obtener el certificado VlessEnc.",
|
||||
"scanRealityTargetError": "No se pudo escanear el objetivo REALITY.",
|
||||
"scanRealityTargetFeasible": "El objetivo es apto: se rellenaron el objetivo y el SNI.",
|
||||
"scanRealityTargetNotFeasible": "El objetivo es accesible pero no apto para REALITY.",
|
||||
"invalidClientField": "Cliente {client}: campo {field} — {reason}",
|
||||
"invalidField": "{field} — {reason}",
|
||||
"moreIssues": "{message} (+{count} más)"
|
||||
@@ -644,6 +647,20 @@
|
||||
"realityTargetRequired": "El destino REALITY es obligatorio",
|
||||
"realityTargetNeedsPort": "El destino REALITY debe incluir un puerto (p. ej. example.com:443)",
|
||||
"realityTargetInvalidPort": "El destino REALITY tiene un puerto no válido",
|
||||
"scan": "Escanear",
|
||||
"findTargets": "Buscar objetivos",
|
||||
"scanModalTitle": "Escáner de objetivos REALITY",
|
||||
"scanModalDesc": "Valida un dominio o escanea un rango IP / CIDR para descubrir nuevos objetivos REALITY a partir de sus certificados. Deja el campo vacío para probar los candidatos comunes.",
|
||||
"scanDiscoverPlaceholder": "IP, CIDR o dominio — déjalo vacío para candidatos comunes",
|
||||
"scanStatus": "Estado",
|
||||
"scanFeasible": "Apto",
|
||||
"scanNotFeasible": "No apto",
|
||||
"scanCurve": "Intercambio de claves",
|
||||
"scanCert": "Certificado",
|
||||
"scanCertInvalid": "No confiable",
|
||||
"scanLatency": "Latencia",
|
||||
"scanUse": "Usar",
|
||||
"scanRescan": "Reescanear",
|
||||
"spiderX": "SpiderX",
|
||||
"getNewCert": "Obtener nuevo cert",
|
||||
"mldsa65Seed": "mldsa65 Seed",
|
||||
|
||||
@@ -474,6 +474,9 @@
|
||||
"getNewX25519CertError": "خطا در دریافت گواهی X25519.",
|
||||
"getNewmldsa65Error": "خطا در دریافت گواهی mldsa65.",
|
||||
"getNewVlessEncError": "خطا در دریافت گواهی VlessEnc.",
|
||||
"scanRealityTargetError": "اسکن هدف REALITY ناموفق بود.",
|
||||
"scanRealityTargetFeasible": "هدف مناسب است — هدف و SNI پر شد.",
|
||||
"scanRealityTargetNotFeasible": "هدف در دسترس است اما برای REALITY مناسب نیست.",
|
||||
"invalidClientField": "کلاینت {client}: فیلد {field} — {reason}",
|
||||
"invalidField": "{field} — {reason}",
|
||||
"moreIssues": "{message} (+{count} مورد دیگر)"
|
||||
@@ -635,6 +638,20 @@
|
||||
"realityTargetRequired": "هدف REALITY الزامی است",
|
||||
"realityTargetNeedsPort": "هدف REALITY باید شامل پورت باشد (مثلاً example.com:443)",
|
||||
"realityTargetInvalidPort": "پورت هدف REALITY نامعتبر است",
|
||||
"scan": "اسکن",
|
||||
"findTargets": "یافتن هدفها",
|
||||
"scanModalTitle": "اسکنر هدف REALITY",
|
||||
"scanModalDesc": "یک دامنه را اعتبارسنجی کنید، یا یک محدودهی IP/CIDR را اسکن کنید تا هدفهای جدید REALITY از روی گواهیهایشان کشف شوند. برای بررسی کاندیدهای پیشفرض، کادر را خالی بگذارید.",
|
||||
"scanDiscoverPlaceholder": "آیپی، CIDR یا دامنه — برای کاندیدهای پیشفرض خالی بگذارید",
|
||||
"scanStatus": "وضعیت",
|
||||
"scanFeasible": "مناسب",
|
||||
"scanNotFeasible": "نامناسب",
|
||||
"scanCurve": "تبادل کلید",
|
||||
"scanCert": "گواهی",
|
||||
"scanCertInvalid": "نامعتبر",
|
||||
"scanLatency": "تأخیر",
|
||||
"scanUse": "استفاده",
|
||||
"scanRescan": "اسکن مجدد",
|
||||
"spiderX": "SpiderX",
|
||||
"getNewCert": "دریافت گواهی جدید",
|
||||
"mldsa65Seed": "mldsa65 Seed",
|
||||
|
||||
@@ -474,6 +474,9 @@
|
||||
"getNewX25519CertError": "Terjadi kesalahan saat mendapatkan sertifikat X25519.",
|
||||
"getNewmldsa65Error": "Terjadi kesalahan saat mendapatkan sertifikat mldsa65.",
|
||||
"getNewVlessEncError": "Terjadi kesalahan saat mendapatkan sertifikat VlessEnc.",
|
||||
"scanRealityTargetError": "Gagal memindai target REALITY.",
|
||||
"scanRealityTargetFeasible": "Target layak — target dan SNI terisi.",
|
||||
"scanRealityTargetNotFeasible": "Target dapat dijangkau tetapi tidak layak untuk REALITY.",
|
||||
"invalidClientField": "Klien {client}: kolom {field} — {reason}",
|
||||
"invalidField": "{field} — {reason}",
|
||||
"moreIssues": "{message} (+{count} lainnya)"
|
||||
@@ -623,6 +626,20 @@
|
||||
"realityTargetRequired": "Target REALITY wajib diisi",
|
||||
"realityTargetNeedsPort": "Target REALITY harus menyertakan port (mis. example.com:443)",
|
||||
"realityTargetInvalidPort": "Target REALITY memiliki port yang tidak valid",
|
||||
"scan": "Pindai",
|
||||
"findTargets": "Cari Target",
|
||||
"scanModalTitle": "Pemindai Target REALITY",
|
||||
"scanModalDesc": "Validasi domain, atau pindai rentang IP / CIDR untuk menemukan target REALITY baru dari sertifikatnya. Biarkan kosong untuk memeriksa kandidat umum.",
|
||||
"scanDiscoverPlaceholder": "IP, CIDR, atau domain — kosongkan untuk kandidat umum",
|
||||
"scanStatus": "Status",
|
||||
"scanFeasible": "Layak",
|
||||
"scanNotFeasible": "Tidak layak",
|
||||
"scanCurve": "Pertukaran Kunci",
|
||||
"scanCert": "Sertifikat",
|
||||
"scanCertInvalid": "Tidak tepercaya",
|
||||
"scanLatency": "Latensi",
|
||||
"scanUse": "Gunakan",
|
||||
"scanRescan": "Pindai ulang",
|
||||
"spiderX": "SpiderX",
|
||||
"getNewCert": "Dapatkan sertifikat baru",
|
||||
"mldsa65Seed": "mldsa65 Seed",
|
||||
|
||||
@@ -474,6 +474,9 @@
|
||||
"getNewX25519CertError": "X25519証明書の取得中にエラーが発生しました。",
|
||||
"getNewmldsa65Error": "mldsa65証明書の取得中にエラーが発生しました。",
|
||||
"getNewVlessEncError": "VlessEnc証明書の取得中にエラーが発生しました。",
|
||||
"scanRealityTargetError": "REALITY ターゲットのスキャンに失敗しました。",
|
||||
"scanRealityTargetFeasible": "ターゲットは利用可能です — ターゲットと SNI を入力しました。",
|
||||
"scanRealityTargetNotFeasible": "ターゲットには到達できますが、REALITY には利用できません。",
|
||||
"invalidClientField": "クライアント {client}: フィールド {field} — {reason}",
|
||||
"invalidField": "{field} — {reason}",
|
||||
"moreIssues": "{message} (他 {count} 件)"
|
||||
@@ -644,6 +647,20 @@
|
||||
"realityTargetRequired": "REALITY ターゲットは必須です",
|
||||
"realityTargetNeedsPort": "REALITY ターゲットにはポートを含める必要があります(例: example.com:443)",
|
||||
"realityTargetInvalidPort": "REALITY ターゲットのポートが無効です",
|
||||
"scan": "スキャン",
|
||||
"findTargets": "ターゲットを検索",
|
||||
"scanModalTitle": "REALITY ターゲットスキャナー",
|
||||
"scanModalDesc": "ドメインを検証するか、IP / CIDR 範囲をスキャンして証明書から新しい REALITY ターゲットを発見します。空欄のままにすると一般的な候補を検査します。",
|
||||
"scanDiscoverPlaceholder": "IP、CIDR、またはドメイン — 空欄で一般的な候補",
|
||||
"scanStatus": "ステータス",
|
||||
"scanFeasible": "利用可能",
|
||||
"scanNotFeasible": "利用不可",
|
||||
"scanCurve": "鍵交換",
|
||||
"scanCert": "証明書",
|
||||
"scanCertInvalid": "信頼できません",
|
||||
"scanLatency": "レイテンシ",
|
||||
"scanUse": "使用",
|
||||
"scanRescan": "再スキャン",
|
||||
"spiderX": "SpiderX",
|
||||
"getNewCert": "新しい証明書を取得",
|
||||
"mldsa65Seed": "mldsa65 Seed",
|
||||
|
||||
@@ -474,6 +474,9 @@
|
||||
"getNewX25519CertError": "Erro ao obter o certificado X25519.",
|
||||
"getNewmldsa65Error": "Erro ao obter o certificado mldsa65.",
|
||||
"getNewVlessEncError": "Erro ao obter o certificado VlessEnc.",
|
||||
"scanRealityTargetError": "Falha ao escanear o alvo REALITY.",
|
||||
"scanRealityTargetFeasible": "O alvo é viável — alvo e SNI preenchidos.",
|
||||
"scanRealityTargetNotFeasible": "O alvo é acessível, mas não é viável para REALITY.",
|
||||
"invalidClientField": "Cliente {client}: campo {field} — {reason}",
|
||||
"invalidField": "{field} — {reason}",
|
||||
"moreIssues": "{message} (+{count} mais)"
|
||||
@@ -644,6 +647,20 @@
|
||||
"realityTargetRequired": "O alvo REALITY é obrigatório",
|
||||
"realityTargetNeedsPort": "O alvo REALITY deve incluir uma porta (ex.: example.com:443)",
|
||||
"realityTargetInvalidPort": "O alvo REALITY tem uma porta inválida",
|
||||
"scan": "Escanear",
|
||||
"findTargets": "Buscar alvos",
|
||||
"scanModalTitle": "Scanner de alvos REALITY",
|
||||
"scanModalDesc": "Valide um domínio ou escaneie um intervalo IP / CIDR para descobrir novos alvos REALITY a partir dos certificados. Deixe vazio para testar os candidatos comuns.",
|
||||
"scanDiscoverPlaceholder": "IP, CIDR ou domínio — deixe vazio para candidatos comuns",
|
||||
"scanStatus": "Status",
|
||||
"scanFeasible": "Viável",
|
||||
"scanNotFeasible": "Inviável",
|
||||
"scanCurve": "Troca de chaves",
|
||||
"scanCert": "Certificado",
|
||||
"scanCertInvalid": "Não confiável",
|
||||
"scanLatency": "Latência",
|
||||
"scanUse": "Usar",
|
||||
"scanRescan": "Reescanear",
|
||||
"spiderX": "SpiderX",
|
||||
"getNewCert": "Obter novo certificado",
|
||||
"mldsa65Seed": "mldsa65 Seed",
|
||||
|
||||
@@ -474,6 +474,9 @@
|
||||
"getNewX25519CertError": "Ошибка при получении сертификата X25519.",
|
||||
"getNewmldsa65Error": "Ошибка при получении сертификата mldsa65.",
|
||||
"getNewVlessEncError": "Ошибка при получении сертификата VlessEnc.",
|
||||
"scanRealityTargetError": "Не удалось просканировать цель REALITY.",
|
||||
"scanRealityTargetFeasible": "Цель подходит — поля target и SNI заполнены.",
|
||||
"scanRealityTargetNotFeasible": "Цель доступна, но не подходит для REALITY.",
|
||||
"invalidClientField": "Клиент {client}: поле {field} — {reason}",
|
||||
"invalidField": "{field} — {reason}",
|
||||
"moreIssues": "{message} (+{count} ещё)"
|
||||
@@ -644,6 +647,20 @@
|
||||
"realityTargetRequired": "Цель REALITY обязательна",
|
||||
"realityTargetNeedsPort": "Цель REALITY должна содержать порт (например, example.com:443)",
|
||||
"realityTargetInvalidPort": "У цели REALITY указан недопустимый порт",
|
||||
"scan": "Сканировать",
|
||||
"findTargets": "Найти цели",
|
||||
"scanModalTitle": "Сканер целей REALITY",
|
||||
"scanModalDesc": "Проверьте домен или просканируйте диапазон IP / CIDR, чтобы обнаружить новые цели REALITY по их сертификатам. Оставьте поле пустым для проверки обычных кандидатов.",
|
||||
"scanDiscoverPlaceholder": "IP, CIDR или домен — пусто для обычных кандидатов",
|
||||
"scanStatus": "Статус",
|
||||
"scanFeasible": "Подходит",
|
||||
"scanNotFeasible": "Не подходит",
|
||||
"scanCurve": "Обмен ключами",
|
||||
"scanCert": "Сертификат",
|
||||
"scanCertInvalid": "Не доверенный",
|
||||
"scanLatency": "Задержка",
|
||||
"scanUse": "Выбрать",
|
||||
"scanRescan": "Пересканировать",
|
||||
"spiderX": "SpiderX",
|
||||
"getNewCert": "Получить новый сертификат",
|
||||
"mldsa65Seed": "mldsa65 Seed",
|
||||
|
||||
@@ -474,6 +474,9 @@
|
||||
"getNewX25519CertError": "X25519 sertifikası alınırken hata oluştu.",
|
||||
"getNewmldsa65Error": "mldsa65 sertifikası alınırken hata oluştu.",
|
||||
"getNewVlessEncError": "VlessEnc sertifikası alınırken hata oluştu.",
|
||||
"scanRealityTargetError": "REALITY hedefi taranamadı.",
|
||||
"scanRealityTargetFeasible": "Hedef uygun — hedef ve SNI dolduruldu.",
|
||||
"scanRealityTargetNotFeasible": "Hedefe ulaşılabiliyor ancak REALITY için uygun değil.",
|
||||
"invalidClientField": "Kullanıcı {client}: {field} — {reason}",
|
||||
"invalidField": "{field} — {reason}",
|
||||
"moreIssues": "{message} (+{count} tane daha)"
|
||||
@@ -623,6 +626,20 @@
|
||||
"realityTargetRequired": "REALITY hedefi zorunludur",
|
||||
"realityTargetNeedsPort": "REALITY hedefi bir port içermelidir (ör. example.com:443)",
|
||||
"realityTargetInvalidPort": "REALITY hedefinde geçersiz bir port var",
|
||||
"scan": "Tara",
|
||||
"findTargets": "Hedef bul",
|
||||
"scanModalTitle": "REALITY Hedef Tarayıcı",
|
||||
"scanModalDesc": "Bir alan adını doğrulayın veya sertifikalarından yeni REALITY hedefleri keşfetmek için bir IP / CIDR aralığını tarayın. Yaygın adayları taramak için kutuyu boş bırakın.",
|
||||
"scanDiscoverPlaceholder": "IP, CIDR veya alan adı — yaygın adaylar için boş bırakın",
|
||||
"scanStatus": "Durum",
|
||||
"scanFeasible": "Uygun",
|
||||
"scanNotFeasible": "Uygun değil",
|
||||
"scanCurve": "Anahtar Değişimi",
|
||||
"scanCert": "Sertifika",
|
||||
"scanCertInvalid": "Güvenilmez",
|
||||
"scanLatency": "Gecikme",
|
||||
"scanUse": "Kullan",
|
||||
"scanRescan": "Yeniden tara",
|
||||
"spiderX": "SpiderX",
|
||||
"getNewCert": "Yeni Sertifika Al",
|
||||
"mldsa65Seed": "mldsa65 Seed",
|
||||
|
||||
@@ -474,6 +474,9 @@
|
||||
"getNewX25519CertError": "Помилка при отриманні сертифіката X25519.",
|
||||
"getNewmldsa65Error": "Помилка при отриманні сертифіката mldsa65.",
|
||||
"getNewVlessEncError": "Помилка при отриманні сертифіката VlessEnc.",
|
||||
"scanRealityTargetError": "Не вдалося просканувати ціль REALITY.",
|
||||
"scanRealityTargetFeasible": "Ціль підходить — поля target і SNI заповнено.",
|
||||
"scanRealityTargetNotFeasible": "Ціль доступна, але не підходить для REALITY.",
|
||||
"invalidClientField": "Клієнт {client}: поле {field} — {reason}",
|
||||
"invalidField": "{field} — {reason}",
|
||||
"moreIssues": "{message} (+{count} ще)"
|
||||
@@ -623,6 +626,20 @@
|
||||
"realityTargetRequired": "Ціль REALITY обов'язкова",
|
||||
"realityTargetNeedsPort": "Ціль REALITY має містити порт (напр., example.com:443)",
|
||||
"realityTargetInvalidPort": "Ціль REALITY має недійсний порт",
|
||||
"scan": "Сканувати",
|
||||
"findTargets": "Знайти цілі",
|
||||
"scanModalTitle": "Сканер цілей REALITY",
|
||||
"scanModalDesc": "Перевірте домен або проскануйте діапазон IP / CIDR, щоб виявити нові цілі REALITY за їхніми сертифікатами. Залиште поле порожнім для перевірки звичайних кандидатів.",
|
||||
"scanDiscoverPlaceholder": "IP, CIDR або домен — порожнє для звичайних кандидатів",
|
||||
"scanStatus": "Статус",
|
||||
"scanFeasible": "Підходить",
|
||||
"scanNotFeasible": "Не підходить",
|
||||
"scanCurve": "Обмін ключами",
|
||||
"scanCert": "Сертифікат",
|
||||
"scanCertInvalid": "Ненадійний",
|
||||
"scanLatency": "Затримка",
|
||||
"scanUse": "Обрати",
|
||||
"scanRescan": "Пересканувати",
|
||||
"spiderX": "SpiderX",
|
||||
"getNewCert": "Отримати новий сертифікат",
|
||||
"mldsa65Seed": "mldsa65 Seed",
|
||||
|
||||
@@ -474,6 +474,9 @@
|
||||
"getNewX25519CertError": "Lỗi khi lấy chứng chỉ X25519.",
|
||||
"getNewmldsa65Error": "Lỗi khi lấy chứng chỉ mldsa65.",
|
||||
"getNewVlessEncError": "Lỗi khi lấy chứng chỉ VlessEnc.",
|
||||
"scanRealityTargetError": "Quét mục tiêu REALITY thất bại.",
|
||||
"scanRealityTargetFeasible": "Mục tiêu khả dụng — đã điền mục tiêu và SNI.",
|
||||
"scanRealityTargetNotFeasible": "Mục tiêu có thể truy cập nhưng không khả dụng cho REALITY.",
|
||||
"invalidClientField": "Khách hàng {client}: trường {field} — {reason}",
|
||||
"invalidField": "{field} — {reason}",
|
||||
"moreIssues": "{message} (+{count} lỗi khác)"
|
||||
@@ -644,6 +647,20 @@
|
||||
"realityTargetRequired": "Mục tiêu REALITY là bắt buộc",
|
||||
"realityTargetNeedsPort": "Mục tiêu REALITY phải bao gồm cổng (ví dụ example.com:443)",
|
||||
"realityTargetInvalidPort": "Mục tiêu REALITY có cổng không hợp lệ",
|
||||
"scan": "Quét",
|
||||
"findTargets": "Tìm mục tiêu",
|
||||
"scanModalTitle": "Trình quét mục tiêu REALITY",
|
||||
"scanModalDesc": "Xác thực một tên miền, hoặc quét một dải IP / CIDR để khám phá các mục tiêu REALITY mới từ chứng chỉ của chúng. Để trống để quét các ứng viên phổ biến.",
|
||||
"scanDiscoverPlaceholder": "IP, CIDR hoặc tên miền — để trống cho ứng viên phổ biến",
|
||||
"scanStatus": "Trạng thái",
|
||||
"scanFeasible": "Khả dụng",
|
||||
"scanNotFeasible": "Không khả dụng",
|
||||
"scanCurve": "Trao đổi khóa",
|
||||
"scanCert": "Chứng chỉ",
|
||||
"scanCertInvalid": "Không tin cậy",
|
||||
"scanLatency": "Độ trễ",
|
||||
"scanUse": "Dùng",
|
||||
"scanRescan": "Quét lại",
|
||||
"spiderX": "SpiderX",
|
||||
"getNewCert": "Lấy chứng chỉ mới",
|
||||
"mldsa65Seed": "mldsa65 Seed",
|
||||
|
||||
@@ -474,6 +474,9 @@
|
||||
"getNewX25519CertError": "获取X25519证书时出错。",
|
||||
"getNewmldsa65Error": "获取mldsa65证书时出错。",
|
||||
"getNewVlessEncError": "获取VlessEnc证书时出错。",
|
||||
"scanRealityTargetError": "扫描 REALITY 目标失败。",
|
||||
"scanRealityTargetFeasible": "目标可用 — 已填入目标和 SNI。",
|
||||
"scanRealityTargetNotFeasible": "目标可达,但不适用于 REALITY。",
|
||||
"invalidClientField": "客户端 {client}:字段 {field} — {reason}",
|
||||
"invalidField": "{field} — {reason}",
|
||||
"moreIssues": "{message} (另有 {count} 项)"
|
||||
@@ -643,6 +646,20 @@
|
||||
"realityTargetRequired": "REALITY 目标为必填项",
|
||||
"realityTargetNeedsPort": "REALITY 目标必须包含端口(例如 example.com:443)",
|
||||
"realityTargetInvalidPort": "REALITY 目标的端口无效",
|
||||
"scan": "扫描",
|
||||
"findTargets": "查找目标",
|
||||
"scanModalTitle": "REALITY 目标扫描器",
|
||||
"scanModalDesc": "验证某个域名,或扫描 IP / CIDR 范围,从证书中发现新的 REALITY 目标。留空则探测常用候选。",
|
||||
"scanDiscoverPlaceholder": "IP、CIDR 或域名 — 留空使用常用候选",
|
||||
"scanStatus": "状态",
|
||||
"scanFeasible": "可用",
|
||||
"scanNotFeasible": "不可用",
|
||||
"scanCurve": "密钥交换",
|
||||
"scanCert": "证书",
|
||||
"scanCertInvalid": "不受信任",
|
||||
"scanLatency": "延迟",
|
||||
"scanUse": "使用",
|
||||
"scanRescan": "重新扫描",
|
||||
"spiderX": "SpiderX",
|
||||
"getNewCert": "获取新证书",
|
||||
"mldsa65Seed": "mldsa65 Seed",
|
||||
|
||||
@@ -474,6 +474,9 @@
|
||||
"getNewX25519CertError": "取得X25519憑證時發生錯誤。",
|
||||
"getNewmldsa65Error": "取得mldsa65憑證時發生錯誤。",
|
||||
"getNewVlessEncError": "取得VlessEnc憑證時發生錯誤。",
|
||||
"scanRealityTargetError": "掃描 REALITY 目標失敗。",
|
||||
"scanRealityTargetFeasible": "目標可用 — 已填入目標與 SNI。",
|
||||
"scanRealityTargetNotFeasible": "目標可達,但不適用於 REALITY。",
|
||||
"invalidClientField": "用戶端 {client}:欄位 {field} — {reason}",
|
||||
"invalidField": "{field} — {reason}",
|
||||
"moreIssues": "{message} (另有 {count} 項)"
|
||||
@@ -623,6 +626,20 @@
|
||||
"realityTargetRequired": "REALITY 目標為必填項",
|
||||
"realityTargetNeedsPort": "REALITY 目標必須包含連接埠(例如 example.com:443)",
|
||||
"realityTargetInvalidPort": "REALITY 目標的連接埠無效",
|
||||
"scan": "掃描",
|
||||
"findTargets": "尋找目標",
|
||||
"scanModalTitle": "REALITY 目標掃描器",
|
||||
"scanModalDesc": "驗證某個網域,或掃描 IP / CIDR 範圍,從憑證中探索新的 REALITY 目標。留空則探測常用候選。",
|
||||
"scanDiscoverPlaceholder": "IP、CIDR 或網域 — 留空使用常用候選",
|
||||
"scanStatus": "狀態",
|
||||
"scanFeasible": "可用",
|
||||
"scanNotFeasible": "不可用",
|
||||
"scanCurve": "金鑰交換",
|
||||
"scanCert": "憑證",
|
||||
"scanCertInvalid": "不受信任",
|
||||
"scanLatency": "延遲",
|
||||
"scanUse": "使用",
|
||||
"scanRescan": "重新掃描",
|
||||
"spiderX": "SpiderX",
|
||||
"getNewCert": "取得新憑證",
|
||||
"mldsa65Seed": "mldsa65 Seed",
|
||||
|
||||
@@ -78,6 +78,7 @@ func run(root, outDir string) error {
|
||||
StructAllow: setOf(
|
||||
"InboundOption",
|
||||
"ProbeResultUI",
|
||||
"RealityScanResult",
|
||||
),
|
||||
},
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user