From 6964d8474210653678c6ffbadeadb5da67eb8e0c Mon Sep 17 00:00:00 2001 From: MHSanaei Date: Fri, 26 Jun 2026 22:18:47 +0200 Subject: [PATCH] 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 --- frontend/README.md | 2 +- frontend/public/openapi.json | 237 +++++++++++ frontend/src/generated/examples.ts | 22 + frontend/src/generated/schemas.ts | 98 +++++ frontend/src/generated/types.ts | 21 + frontend/src/generated/zod.ts | 22 + frontend/src/models/reality-targets.ts | 23 -- frontend/src/pages/api-docs/endpoints.ts | 21 + .../pages/clients/BulkAttachInboundsModal.tsx | 2 +- .../pages/clients/BulkDetachInboundsModal.tsx | 2 +- .../src/pages/groups/GroupAddClientsModal.tsx | 2 +- .../inbounds/clients/AttachClientsModal.tsx | 2 +- .../clients/AttachExistingClientsModal.tsx | 2 +- .../pages/inbounds/form/InboundFormModal.tsx | 16 +- .../security/RealityTargetScannerModal.tsx | 174 ++++++++ .../pages/inbounds/form/security/reality.tsx | 79 +++- .../pages/inbounds/form/useSecurityActions.ts | 70 +++- frontend/src/pages/nodes/NodesPage.tsx | 2 +- .../src/test/inbound-form-blocks.test.tsx | 6 +- internal/web/controller/server.go | 25 ++ internal/web/service/reality_scan.go | 391 ++++++++++++++++++ internal/web/service/reality_scan_test.go | 111 +++++ internal/web/translation/ar-EG.json | 17 + internal/web/translation/en-US.json | 17 + internal/web/translation/es-ES.json | 17 + internal/web/translation/fa-IR.json | 17 + internal/web/translation/id-ID.json | 17 + internal/web/translation/ja-JP.json | 17 + internal/web/translation/pt-BR.json | 17 + internal/web/translation/ru-RU.json | 17 + internal/web/translation/tr-TR.json | 17 + internal/web/translation/uk-UA.json | 17 + internal/web/translation/vi-VN.json | 17 + internal/web/translation/zh-CN.json | 17 + internal/web/translation/zh-TW.json | 17 + tools/openapigen/main.go | 1 + 36 files changed, 1489 insertions(+), 63 deletions(-) delete mode 100644 frontend/src/models/reality-targets.ts create mode 100644 frontend/src/pages/inbounds/form/security/RealityTargetScannerModal.tsx create mode 100644 internal/web/service/reality_scan.go create mode 100644 internal/web/service/reality_scan_test.go diff --git a/frontend/README.md b/frontend/README.md index b10230666..8a5c5b343 100644 --- a/frontend/README.md +++ b/frontend/README.md @@ -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 diff --git a/frontend/public/openapi.json b/frontend/public/openapi.json index 2310f602c..33385c8ad 100644 --- a/frontend/public/openapi.json +++ b/frontend/public/openapi.json @@ -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": [ diff --git a/frontend/src/generated/examples.ts b/frontend/src/generated/examples.ts index b8c1f3d3c..2329d5983 100644 --- a/frontend/src/generated/examples.ts +++ b/frontend/src/generated/examples.ts @@ -463,6 +463,28 @@ export const EXAMPLES: Record = { "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": "", diff --git a/frontend/src/generated/schemas.ts b/frontend/src/generated/schemas.ts index 4ed467ad8..c0d89936f 100644 --- a/frontend/src/generated/schemas.ts +++ b/frontend/src/generated/schemas.ts @@ -2120,6 +2120,104 @@ export const SCHEMAS: Record = { ], "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": { diff --git a/frontend/src/generated/types.ts b/frontend/src/generated/types.ts index 729961e17..f68d17fb9 100644 --- a/frontend/src/generated/types.ts +++ b/frontend/src/generated/types.ts @@ -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; diff --git a/frontend/src/generated/zod.ts b/frontend/src/generated/zod.ts index 92d45296a..bc19547d9 100644 --- a/frontend/src/generated/zod.ts +++ b/frontend/src/generated/zod.ts @@ -494,6 +494,28 @@ export const ProbeResultUISchema = z.object({ }); export type ProbeResultUI = z.infer; +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; + export const SettingSchema = z.object({ id: z.number().int(), key: z.string(), diff --git a/frontend/src/models/reality-targets.ts b/frontend/src/models/reality-targets.ts deleted file mode 100644 index 518c836e0..000000000 --- a/frontend/src/models/reality-targets.ts +++ /dev/null @@ -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, - }; -} diff --git a/frontend/src/pages/api-docs/endpoints.ts b/frontend/src/pages/api-docs/endpoints.ts index 479d2e347..28f377de6 100644 --- a/frontend/src/pages/api-docs/endpoints.ts +++ b/frontend/src/pages/api-docs/endpoints.ts @@ -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', diff --git a/frontend/src/pages/clients/BulkAttachInboundsModal.tsx b/frontend/src/pages/clients/BulkAttachInboundsModal.tsx index 1fd397a3e..f3d4ecf3f 100644 --- a/frontend/src/pages/clients/BulkAttachInboundsModal.tsx +++ b/frontend/src/pages/clients/BulkAttachInboundsModal.tsx @@ -81,7 +81,7 @@ export default function BulkAttachInboundsModal({ {t('pages.clients.attachToInboundsDesc', { count })} {targetOptions.length === 0 ? ( - + ) : ( <> {targetOptions.length === 0 ? ( - + ) : ( <> {rows.length === 0 ? ( - + ) : ( size="small" diff --git a/frontend/src/pages/inbounds/clients/AttachClientsModal.tsx b/frontend/src/pages/inbounds/clients/AttachClientsModal.tsx index a878f2de4..1bdeb21ed 100644 --- a/frontend/src/pages/inbounds/clients/AttachClientsModal.tsx +++ b/frontend/src/pages/inbounds/clients/AttachClientsModal.tsx @@ -192,7 +192,7 @@ export default function AttachClientsModal({ {targetOptions.length === 0 ? ( - + ) : ( + - + - - - - + setScannerOpen(false)} + scanRealityCandidates={scanRealityCandidates} + onPick={applyRealityScanResult} + /> ); } diff --git a/frontend/src/pages/inbounds/form/useSecurityActions.ts b/frontend/src/pages/inbounds/form/useSecurityActions.ts index 980fe6bc8..fe0e8cdf2 100644 --- a/frontend/src/pages/inbounds/form/useSecurityActions.ts +++ b/frontend/src/pages/inbounds/form/useSecurityActions.ts @@ -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; @@ -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>; + setScanning: Dispatch>; } // 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( + '/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 => { + const msg = await HttpUtil.post( + '/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) ?? {}; const cleaned: Record = { ...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; - 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, diff --git a/frontend/src/pages/nodes/NodesPage.tsx b/frontend/src/pages/nodes/NodesPage.tsx index 53c05d0d1..549099700 100644 --- a/frontend/src/pages/nodes/NodesPage.tsx +++ b/frontend/src/pages/nodes/NodesPage.tsx @@ -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')} /> )} diff --git a/frontend/src/test/inbound-form-blocks.test.tsx b/frontend/src/test/inbound-form-blocks.test.tsx index e5a2b1b47..c25047f9c 100644 --- a/frontend/src/test/inbound-form-blocks.test.tsx +++ b/frontend/src/test/inbound-form-blocks.test.tsx @@ -98,7 +98,11 @@ describe('inbound security forms', () => { renderInForm(() => ( []} + applyRealityScanResult={noop} randomizeShortIds={noop} genRealityKeypair={noop} clearRealityKeypair={noop} diff --git a/internal/web/controller/server.go b/internal/web/controller/server.go index 05800c8f2..fae1db3b8 100644 --- a/internal/web/controller/server.go +++ b/internal/web/controller/server.go @@ -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() diff --git a/internal/web/service/reality_scan.go b/internal/web/service/reality_scan.go new file mode 100644 index 000000000..f3965970c --- /dev/null +++ b/internal/web/service/reality_scan.go @@ -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 + }) +} diff --git a/internal/web/service/reality_scan_test.go b/internal/web/service/reality_scan_test.go new file mode 100644 index 000000000..e93c99fea --- /dev/null +++ b/internal/web/service/reality_scan_test.go @@ -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) + } + } +} diff --git a/internal/web/translation/ar-EG.json b/internal/web/translation/ar-EG.json index ce6485231..c56c03307 100644 --- a/internal/web/translation/ar-EG.json +++ b/internal/web/translation/ar-EG.json @@ -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", diff --git a/internal/web/translation/en-US.json b/internal/web/translation/en-US.json index b66a2e28a..c53b0e1e0 100644 --- a/internal/web/translation/en-US.json +++ b/internal/web/translation/en-US.json @@ -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", diff --git a/internal/web/translation/es-ES.json b/internal/web/translation/es-ES.json index addc03760..0eab8e614 100644 --- a/internal/web/translation/es-ES.json +++ b/internal/web/translation/es-ES.json @@ -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", diff --git a/internal/web/translation/fa-IR.json b/internal/web/translation/fa-IR.json index 3d34a72e6..4037c92e3 100644 --- a/internal/web/translation/fa-IR.json +++ b/internal/web/translation/fa-IR.json @@ -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", diff --git a/internal/web/translation/id-ID.json b/internal/web/translation/id-ID.json index 62aec08ba..3d0825f8e 100644 --- a/internal/web/translation/id-ID.json +++ b/internal/web/translation/id-ID.json @@ -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", diff --git a/internal/web/translation/ja-JP.json b/internal/web/translation/ja-JP.json index eaf6b7115..cc8245fe9 100644 --- a/internal/web/translation/ja-JP.json +++ b/internal/web/translation/ja-JP.json @@ -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", diff --git a/internal/web/translation/pt-BR.json b/internal/web/translation/pt-BR.json index e99b49923..5e17c0d21 100644 --- a/internal/web/translation/pt-BR.json +++ b/internal/web/translation/pt-BR.json @@ -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", diff --git a/internal/web/translation/ru-RU.json b/internal/web/translation/ru-RU.json index 4c981c531..4d5cc3b65 100644 --- a/internal/web/translation/ru-RU.json +++ b/internal/web/translation/ru-RU.json @@ -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", diff --git a/internal/web/translation/tr-TR.json b/internal/web/translation/tr-TR.json index 67c0e9fc1..e545d9e97 100644 --- a/internal/web/translation/tr-TR.json +++ b/internal/web/translation/tr-TR.json @@ -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", diff --git a/internal/web/translation/uk-UA.json b/internal/web/translation/uk-UA.json index 2e0536a7c..6aa934f84 100644 --- a/internal/web/translation/uk-UA.json +++ b/internal/web/translation/uk-UA.json @@ -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", diff --git a/internal/web/translation/vi-VN.json b/internal/web/translation/vi-VN.json index b3e8a67f1..91f477a70 100644 --- a/internal/web/translation/vi-VN.json +++ b/internal/web/translation/vi-VN.json @@ -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", diff --git a/internal/web/translation/zh-CN.json b/internal/web/translation/zh-CN.json index 6f3d94c95..6ab1b9adb 100644 --- a/internal/web/translation/zh-CN.json +++ b/internal/web/translation/zh-CN.json @@ -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", diff --git a/internal/web/translation/zh-TW.json b/internal/web/translation/zh-TW.json index 584e0b185..5bd3e2db5 100644 --- a/internal/web/translation/zh-TW.json +++ b/internal/web/translation/zh-TW.json @@ -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", diff --git a/tools/openapigen/main.go b/tools/openapigen/main.go index fbb5d7303..9d5b7ef1a 100644 --- a/tools/openapigen/main.go +++ b/tools/openapigen/main.go @@ -78,6 +78,7 @@ func run(root, outDir string) error { StructAllow: setOf( "InboundOption", "ProbeResultUI", + "RealityScanResult", ), }, {