Kaynağa Gözat

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 13 saat önce
ebeveyn
işleme
6964d84742
36 değiştirilmiş dosya ile 1489 ekleme ve 63 silme
  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",
 			),
 		},
 		{