Browse Source

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
MHSanaei 16 giờ trước cách đây
mục cha
commit
6964d84742
36 tập tin đã thay đổi với 1489 bổ sung63 xóa
  1. 1 1
      frontend/README.md
  2. 237 0
      frontend/public/openapi.json
  3. 22 0
      frontend/src/generated/examples.ts
  4. 98 0
      frontend/src/generated/schemas.ts
  5. 21 0
      frontend/src/generated/types.ts
  6. 22 0
      frontend/src/generated/zod.ts
  7. 0 23
      frontend/src/models/reality-targets.ts
  8. 21 0
      frontend/src/pages/api-docs/endpoints.ts
  9. 1 1
      frontend/src/pages/clients/BulkAttachInboundsModal.tsx
  10. 1 1
      frontend/src/pages/clients/BulkDetachInboundsModal.tsx
  11. 1 1
      frontend/src/pages/groups/GroupAddClientsModal.tsx
  12. 1 1
      frontend/src/pages/inbounds/clients/AttachClientsModal.tsx
  13. 1 1
      frontend/src/pages/inbounds/clients/AttachExistingClientsModal.tsx
  14. 13 3
      frontend/src/pages/inbounds/form/InboundFormModal.tsx
  15. 174 0
      frontend/src/pages/inbounds/form/security/RealityTargetScannerModal.tsx
  16. 62 17
      frontend/src/pages/inbounds/form/security/reality.tsx
  17. 58 12
      frontend/src/pages/inbounds/form/useSecurityActions.ts
  18. 1 1
      frontend/src/pages/nodes/NodesPage.tsx
  19. 5 1
      frontend/src/test/inbound-form-blocks.test.tsx
  20. 25 0
      internal/web/controller/server.go
  21. 391 0
      internal/web/service/reality_scan.go
  22. 111 0
      internal/web/service/reality_scan_test.go
  23. 17 0
      internal/web/translation/ar-EG.json
  24. 17 0
      internal/web/translation/en-US.json
  25. 17 0
      internal/web/translation/es-ES.json
  26. 17 0
      internal/web/translation/fa-IR.json
  27. 17 0
      internal/web/translation/id-ID.json
  28. 17 0
      internal/web/translation/ja-JP.json
  29. 17 0
      internal/web/translation/pt-BR.json
  30. 17 0
      internal/web/translation/ru-RU.json
  31. 17 0
      internal/web/translation/tr-TR.json
  32. 17 0
      internal/web/translation/uk-UA.json
  33. 17 0
      internal/web/translation/vi-VN.json
  34. 17 0
      internal/web/translation/zh-CN.json
  35. 17 0
      internal/web/translation/zh-TW.json
  36. 1 0
      tools/openapigen/main.go

+ 1 - 1
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

+ 237 - 0
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": [

+ 22 - 0
frontend/src/generated/examples.ts

@@ -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": "",

+ 98 - 0
frontend/src/generated/schemas.ts

@@ -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": {

+ 21 - 0
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;

+ 22 - 0
frontend/src/generated/zod.ts

@@ -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(),

+ 0 - 23
frontend/src/models/reality-targets.ts

@@ -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,
-  };
-}

+ 21 - 0
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',

+ 1 - 1
frontend/src/pages/clients/BulkAttachInboundsModal.tsx

@@ -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

+ 1 - 1
frontend/src/pages/clients/BulkDetachInboundsModal.tsx

@@ -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

+ 1 - 1
frontend/src/pages/groups/GroupAddClientsModal.tsx

@@ -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"

+ 1 - 1
frontend/src/pages/inbounds/clients/AttachClientsModal.tsx

@@ -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"

+ 1 - 1
frontend/src/pages/inbounds/clients/AttachExistingClientsModal.tsx

@@ -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%' }}>

+ 13 - 3
frontend/src/pages/inbounds/form/InboundFormModal.tsx

@@ -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}

+ 174 - 0
frontend/src/pages/inbounds/form/security/RealityTargetScannerModal.tsx

@@ -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>
+  );
+}

+ 62 - 17
frontend/src/pages/inbounds/form/security/reality.tsx

@@ -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}
+      />
     </>
   );
 }

+ 58 - 12
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<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,

+ 1 - 1
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')}
         />
       )}
     </div>

+ 5 - 1
frontend/src/test/inbound-form-blocks.test.tsx

@@ -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}

+ 25 - 0
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()

+ 391 - 0
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
+	})
+}

+ 111 - 0
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)
+		}
+	}
+}

+ 17 - 0
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",

+ 17 - 0
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",

+ 17 - 0
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",

+ 17 - 0
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",

+ 17 - 0
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",

+ 17 - 0
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",

+ 17 - 0
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",

+ 17 - 0
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",

+ 17 - 0
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",

+ 17 - 0
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",

+ 17 - 0
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",

+ 17 - 0
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",

+ 17 - 0
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",

+ 1 - 0
tools/openapigen/main.go

@@ -78,6 +78,7 @@ func run(root, outDir string) error {
 			StructAllow: setOf(
 				"InboundOption",
 				"ProbeResultUI",
+				"RealityScanResult",
 			),
 		},
 		{