소스 검색

fix(reality): make the REALITY target check usable on a private network (#6242)

* fix(reality): make the REALITY target check usable on a private network

The probe dials through netsafe.SSRFGuardedDialContext, so a fronting service
reachable only inside the deployment (a Docker service name, a LAN address)
always failed with "blocked private/internal address": the inbound itself
works, because the guard sits in the probe path only, so the panel reported a
red verdict on a healthy configuration. Instead of a panel-wide setting that
lifts the guard for good, the guard is now lifted per probe and only after the
operator confirms the local-network warning in a modal; the verdict keeps
privateTarget set, so a passing local check stays a warning rather than a
green success.

The probe also sent the target host as SNI. Clients dial the target but send a
name from serverNames, so a fronting proxy answered with its default
certificate — a Traefik front reached as "traefik" reported "certificate is
valid for <hash>.traefik.default, not traefik" on a deployment whose clients
get a valid chain. The panel now sends the first configured serverName as SNI
and the certificate is verified against it; empty serverNames keeps the old
fallback. The reported target stays the dialled address, so a passing check no
longer rewrites the target field with the SNI host.

The result panel reports what was actually seen: the SNI used, the certificate
subject/issuer and its expiry stay visible when the chain is untrusted (with
"Not trusted" appended) instead of being replaced by that verdict alone.
Certificate names are copied into the SNI field only when the chain verified —
the names on a proxy's default certificate would otherwise become the SNI of
the next check.

The bulk/CIDR scanner keeps the guard unconditionally: honouring the opt-in
there would turn it into an internal network scanner.

* fix(reality): recover from a stale SNI and report a refused address reliably

Review follow-up on the REALITY target check.

The probe sends the stored serverNames as SNI, and the panel only wrote names
back when the whole chain verified, so switching Target while the SNI field
still held the previous target's names failed every rescan: the new target's
real names came back from the probe but were discarded with the verdict. The
certificate is now checked in two steps — chain first, then the name — and a
trusted chain presented for other names is enough for the panel to offer those
names, so the next scan passes. Picking a row in the bulk scanner replaces the
names outright, since keeping the previous target's SNI leaves a REALITY config
that cannot work.

SSRFGuardedDialContext kept the refusal only in lastErr, so on a dual-stack
name a refused private address followed by a failing public one lost the
sentinel and the panel silently skipped the confirmation. The refusal is now
tracked separately and reported alongside the last dial error.

Honouring the opt-in is logged with the target and the resolved address, since
it bypasses the SSRF guard on an authenticated endpoint. The read-only SNI row
in the result is labelled "SNI used" so it no longer collides with the SNI
field below it, and the comment blocks are back within the 2-line limit.

---------

Co-authored-by: Claude <[email protected]>
shustovTE 14 시간 전
부모
커밋
708a69acde

+ 17 - 1
frontend/public/openapi.json

@@ -2967,6 +2967,11 @@
             "example": "h2",
             "type": "string"
           },
+          "certChainValid": {
+            "description": "CertChainValid ignores the name: a trusted chain presented for other names\nstill has serverNames the panel can offer instead of the failing SNI.",
+            "example": true,
+            "type": "boolean"
+          },
           "certIssuer": {
             "example": "Google Trust Services",
             "type": "string"
@@ -3011,6 +3016,11 @@
             "example": 443,
             "type": "integer"
           },
+          "privateTarget": {
+            "description": "PrivateTarget marks a target that resolves to a loopback/private/link-local\naddress: blocked before the probe unless the caller opted in, then flagged.",
+            "example": false,
+            "type": "boolean"
+          },
           "reason": {
             "type": "string"
           },
@@ -3039,6 +3049,7 @@
         },
         "required": [
           "alpn",
+          "certChainValid",
           "certIssuer",
           "certSubject",
           "certValid",
@@ -3050,6 +3061,7 @@
           "latencyMs",
           "notAfter",
           "port",
+          "privateTarget",
           "reason",
           "serverNames",
           "target",
@@ -5738,7 +5750,7 @@
         "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.",
+        "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. A target on a private/loopback address is reported with privateTarget=true and probed only when allowPrivate is set.",
         "operationId": "post_panel_api_server_scanRealityTarget",
         "requestBody": {
           "required": true,
@@ -5773,6 +5785,7 @@
                   "success": true,
                   "obj": {
                     "alpn": "h2",
+                    "certChainValid": true,
                     "certIssuer": "Google Trust Services",
                     "certSubject": "cloudflare.com",
                     "certValid": true,
@@ -5784,6 +5797,7 @@
                     "latencyMs": 180,
                     "notAfter": "2026-08-01T00:00:00Z",
                     "port": 443,
+                    "privateTarget": false,
                     "reason": "",
                     "serverNames": [
                       ""
@@ -5844,6 +5858,7 @@
                   "obj": [
                     {
                       "alpn": "h2",
+                      "certChainValid": true,
                       "certIssuer": "Google Trust Services",
                       "certSubject": "cloudflare.com",
                       "certValid": true,
@@ -5855,6 +5870,7 @@
                       "latencyMs": 180,
                       "notAfter": "2026-08-01T00:00:00Z",
                       "port": 443,
+                      "privateTarget": false,
                       "reason": "",
                       "serverNames": [
                         ""

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

@@ -701,6 +701,7 @@ export const EXAMPLES: Record<string, unknown> = {
   },
   "RealityScanResult": {
     "alpn": "h2",
+    "certChainValid": true,
     "certIssuer": "Google Trust Services",
     "certSubject": "cloudflare.com",
     "certValid": true,
@@ -712,6 +713,7 @@ export const EXAMPLES: Record<string, unknown> = {
     "latencyMs": 180,
     "notAfter": "2026-08-01T00:00:00Z",
     "port": 443,
+    "privateTarget": false,
     "reason": "",
     "serverNames": [
       ""

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

@@ -2941,6 +2941,11 @@ export const SCHEMAS: Record<string, unknown> = {
         "example": "h2",
         "type": "string"
       },
+      "certChainValid": {
+        "description": "CertChainValid ignores the name: a trusted chain presented for other names\nstill has serverNames the panel can offer instead of the failing SNI.",
+        "example": true,
+        "type": "boolean"
+      },
       "certIssuer": {
         "example": "Google Trust Services",
         "type": "string"
@@ -2985,6 +2990,11 @@ export const SCHEMAS: Record<string, unknown> = {
         "example": 443,
         "type": "integer"
       },
+      "privateTarget": {
+        "description": "PrivateTarget marks a target that resolves to a loopback/private/link-local\naddress: blocked before the probe unless the caller opted in, then flagged.",
+        "example": false,
+        "type": "boolean"
+      },
       "reason": {
         "type": "string"
       },
@@ -3013,6 +3023,7 @@ export const SCHEMAS: Record<string, unknown> = {
     },
     "required": [
       "alpn",
+      "certChainValid",
       "certIssuer",
       "certSubject",
       "certValid",
@@ -3024,6 +3035,7 @@ export const SCHEMAS: Record<string, unknown> = {
       "latencyMs",
       "notAfter",
       "port",
+      "privateTarget",
       "reason",
       "serverNames",
       "target",

+ 2 - 0
frontend/src/generated/types.ts

@@ -671,6 +671,7 @@ export interface ProbeResultUI {
 
 export interface RealityScanResult {
   alpn: string;
+  certChainValid: boolean;
   certIssuer: string;
   certSubject: string;
   certValid: boolean;
@@ -682,6 +683,7 @@ export interface RealityScanResult {
   latencyMs: number;
   notAfter: string;
   port: number;
+  privateTarget: boolean;
   reason: string;
   serverNames: string[];
   target: string;

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

@@ -717,6 +717,7 @@ export type ProbeResultUI = z.infer<typeof ProbeResultUISchema>;
 
 export const RealityScanResultSchema = z.object({
   alpn: z.string(),
+  certChainValid: z.boolean(),
   certIssuer: z.string(),
   certSubject: z.string(),
   certValid: z.boolean(),
@@ -728,6 +729,7 @@ export const RealityScanResultSchema = z.object({
   latencyMs: z.number().int(),
   notAfter: z.string(),
   port: z.number().int(),
+  privateTarget: z.boolean(),
   reason: z.string(),
   serverNames: z.array(z.string()),
   target: z.string(),

+ 4 - 1
frontend/src/pages/api-docs/endpoints.ts

@@ -521,9 +521,12 @@ export const sections: readonly Section[] = [
       {
         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.',
+        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. A target on a private/loopback address is reported with privateTarget=true and probed only when allowPrivate is set.',
         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.' },
+          { name: 'sni', in: 'body (form)', type: 'string', optional: true, desc: 'SNI the handshake sends and the certificate is verified against (the inbound serverNames). Defaults to the target host, which a fronting proxy answers with its default certificate.' },
+          { name: 'xver', in: 'body (form)', type: 'number', optional: true, desc: 'PROXY protocol version the target expects (matches the inbound xver). 0 = none.' },
+          { name: 'allowPrivate', in: 'body (form)', type: 'boolean', optional: true, desc: 'Probe a private/internal/loopback target (LAN, Docker service name). Default false (SSRF guard blocks it and the response sets privateTarget=true).' },
         ],
         body: 'target=www.cloudflare.com:443',
         responseSchema: 'RealityScanResult',

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

@@ -223,6 +223,7 @@ export default function InboundFormModal({
 }: InboundFormModalProps) {
   const { t } = useTranslation();
   const [messageApi, messageContextHolder] = message.useMessage();
+  const [modal, modalContextHolder] = Modal.useModal();
   const methods = useForm<InboundFormValues>({ defaultValues: buildAddModeValues() });
   const setV = methods.setValue as unknown as (name: string, value: unknown) => void;
   const getV = methods.getValues as unknown as (name?: string) => unknown;
@@ -317,7 +318,7 @@ export default function InboundFormModal({
     setCertFromPanel,
     clearCertFiles,
     onSecurityChange,
-  } = useSecurityActions({ methods, setSaving, messageApi, nodeId: typeof wNodeId === 'number' ? wNodeId : null, setScanResult, setScanning });
+  } = useSecurityActions({ methods, setSaving, messageApi, modal, nodeId: typeof wNodeId === 'number' ? wNodeId : null, setScanResult, setScanning });
 
 
   const toggleSockopt = (on: boolean) => {
@@ -989,6 +990,7 @@ export default function InboundFormModal({
   return (
     <>
       {messageContextHolder}
+      {modalContextHolder}
       <Modal
         open={open}
         title={title}

+ 43 - 22
frontend/src/pages/inbounds/form/security/reality.tsx

@@ -3,6 +3,7 @@ import { useFormContext } from 'react-hook-form';
 import { useTranslation } from 'react-i18next';
 import { Alert, Button, Collapse, Descriptions, Divider, Form, Input, InputNumber, Select, Space, Switch } from 'antd';
 import { RadarChartOutlined, ReloadOutlined, SearchOutlined } from '@ant-design/icons';
+import dayjs from 'dayjs';
 
 import { FormField } from '@/components/form/rhf';
 import { UTLS_FINGERPRINT } from '@/schemas/primitives';
@@ -18,9 +19,9 @@ interface RealityFormProps {
   saving: boolean;
   scanning: boolean;
   scanResult: RealityScanResult | null;
-  scanRealityTarget: () => void;
+  scanRealityTarget: (allowPrivate?: boolean) => void;
   scanRealityCandidates: (targets?: string) => Promise<RealityScanResult[]>;
-  applyRealityScanResult: (result: RealityScanResult) => void;
+  applyRealityScanResult: (result: RealityScanResult, replaceServerNames?: boolean) => void;
   randomizeShortIds: () => void;
   randomizeSpiderX: () => void;
   genRealityKeypair: () => void;
@@ -46,6 +47,17 @@ export default function RealityForm({
   const { t } = useTranslation();
   const { getFieldState, trigger } = useFormContext();
   const [scannerOpen, setScannerOpen] = useState(false);
+  /*
+   * An untrusted certificate (self-signed fronting service on the LAN) is still
+   * worth reading, so subject/issuer stay visible and only the verdict is added.
+   */
+  const certSummary = (r: RealityScanResult) => {
+    const who = r.certSubject && r.certIssuer
+      ? `${r.certSubject} (${r.certIssuer})`
+      : r.certSubject || r.certIssuer;
+    if (!who) return '—';
+    return r.certValid ? who : `${who} — ${t('pages.inbounds.form.scanCertInvalid')}`;
+  };
   const maxClientVerPath = 'streamSettings.realitySettings.maxClientVer';
   const revalidateMaxClientVer = () => {
     if (getFieldState(maxClientVerPath).error) {
@@ -89,7 +101,7 @@ export default function RealityForm({
           >
             <Input style={{ flex: 1 }} placeholder="example.com:443" />
           </FormField>
-          <Button icon={<RadarChartOutlined />} loading={scanning} onClick={scanRealityTarget}>
+          <Button icon={<RadarChartOutlined />} loading={scanning} onClick={() => scanRealityTarget()}>
             {t('pages.inbounds.form.scan')}
           </Button>
           <Button icon={<SearchOutlined />} onClick={() => setScannerOpen(true)}>
@@ -100,30 +112,39 @@ export default function RealityForm({
       {scanResult && (
         <Form.Item label=" " colon={false}>
           <Alert
-            type={scanResult.feasible ? 'success' : 'warning'}
+            type={scanResult.feasible && !scanResult.privateTarget ? '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>
-            }
+            description={(
+              <>
+                {scanResult.privateTarget && (
+                  <div style={{ marginBottom: 8 }}>{t('pages.inbounds.form.scanPrivateNote')}</div>
+                )}
+                <Descriptions size="small" column={1}>
+                  <Descriptions.Item label={t('pages.inbounds.form.scanSniUsed')}>
+                    {scanResult.host || '—'}
+                  </Descriptions.Item>
+                  <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')}>
+                    {certSummary(scanResult)}
+                  </Descriptions.Item>
+                  <Descriptions.Item label={t('pages.inbounds.form.scanCertExpiry')}>
+                    {scanResult.notAfter ? dayjs(scanResult.notAfter).format('YYYY-MM-DD HH:mm') : '—'}
+                  </Descriptions.Item>
+                  <Descriptions.Item label={t('pages.inbounds.form.scanLatency')}>
+                    {scanResult.latencyMs > 0 ? `${scanResult.latencyMs} ms` : '—'}
+                  </Descriptions.Item>
+                </Descriptions>
+              </>
+            )}
           />
         </Form.Item>
       )}
@@ -282,7 +303,7 @@ export default function RealityForm({
         open={scannerOpen}
         onClose={() => setScannerOpen(false)}
         scanRealityCandidates={scanRealityCandidates}
-        onPick={applyRealityScanResult}
+        onPick={(r) => applyRealityScanResult(r, true)}
       />
     </>
   );

+ 44 - 8
frontend/src/pages/inbounds/form/useSecurityActions.ts

@@ -2,6 +2,7 @@ import type { Dispatch, SetStateAction } from 'react';
 import { useTranslation } from 'react-i18next';
 import type { UseFormReturn } from 'react-hook-form';
 import type { MessageInstance } from 'antd/es/message/interface';
+import type { HookAPI as ModalHookAPI } from 'antd/es/modal/useModal';
 
 import { HttpUtil, RandomUtil } from '@/utils';
 import { createTlsSettingsWithDefaultCert } from '@/lib/xray/inbound-tls-defaults';
@@ -13,6 +14,7 @@ interface UseSecurityActionsArgs {
   methods: UseFormReturn<InboundFormValues>;
   setSaving: Dispatch<SetStateAction<boolean>>;
   messageApi: MessageInstance;
+  modal: ModalHookAPI;
   /*
    * Node the inbound is deployed to (null = central panel). "Set Cert from
    * Panel" must read the node's own cert paths for a node-assigned inbound —
@@ -29,7 +31,7 @@ interface UseSecurityActionsArgs {
  * writes the result back into the form. Lifted out of InboundFormModal so
  * the modal body stays focused on orchestration.
  */
-export function useSecurityActions({ methods, setSaving, messageApi, nodeId, setScanResult, setScanning }: UseSecurityActionsArgs) {
+export function useSecurityActions({ methods, setSaving, messageApi, modal, nodeId, setScanResult, setScanning }: UseSecurityActionsArgs) {
   const { t } = useTranslation();
   const setValue = methods.setValue as unknown as (name: string, value: unknown) => void;
   const getValues = methods.getValues as unknown as (name?: string) => unknown;
@@ -72,26 +74,44 @@ export function useSecurityActions({ methods, setSaving, messageApi, nodeId, set
     setValue('streamSettings.realitySettings.settings.mldsa65Verify', '');
   };
 
-  const applyRealityScanResult = (r: RealityScanResult) => {
+  /*
+   * replaceServerNames is for picking a target wholesale: keeping the previous
+   * target's SNI would leave a REALITY config that cannot work.
+   */
+  const applyRealityScanResult = (r: RealityScanResult, replaceServerNames = false) => {
     setScanResult(r);
     setValue('streamSettings.realitySettings.target', r.target);
-    if (r.serverNames?.length) {
+    /*
+     * Names off an untrusted chain are not usable as SNI; names off a trusted
+     * one are, even when the SNI sent did not match them, which is how a stale
+     * SNI recovers instead of failing every rescan.
+     */
+    if (replaceServerNames) {
+      setValue('streamSettings.realitySettings.serverNames', r.serverNames ?? []);
+    } else if ((r.certValid || r.certChainValid) && r.serverNames?.length) {
       setValue('streamSettings.realitySettings.serverNames', r.serverNames);
     }
   };
 
-  const scanRealityTarget = async () => {
+  const scanRealityTarget = async (allowPrivate = false) => {
     const target = ((getValues('streamSettings.realitySettings.target') as string | undefined) ?? '').trim();
     if (!target) {
       messageApi.warning(t('pages.inbounds.form.realityTargetRequired'));
       return;
     }
     const xver = Number(getValues('streamSettings.realitySettings.xver')) || 0;
+    /*
+     * Clients dial the target but send an SNI from serverNames, so the probe
+     * must too — a fronting proxy answers a bare target name with its default
+     * certificate, which then reads as an untrusted target.
+     */
+    const serverNames = (getValues('streamSettings.realitySettings.serverNames') as string[] | undefined) ?? [];
+    const sni = (serverNames.find((n) => typeof n === 'string' && n.trim() !== '') ?? '').trim();
     setScanning(true);
     try {
       const msg = await HttpUtil.post<RealityScanResult>(
         '/panel/api/server/scanRealityTarget',
-        { target, xver },
+        { target, sni, xver, allowPrivate },
         { silent: true },
       );
       if (!msg?.success || !msg.obj) {
@@ -101,10 +121,26 @@ export function useSecurityActions({ methods, setSaving, messageApi, nodeId, set
       }
       const r = msg.obj;
       applyRealityScanResult(r);
-      if (r.feasible) {
-        messageApi.success(t('pages.inbounds.toasts.scanRealityTargetFeasible'));
-      } else {
+      /*
+       * The SSRF guard refuses a LAN/Docker target until the operator confirms
+       * it; the retry carries the opt-in for this one probe.
+       */
+      if (r.privateTarget && !allowPrivate) {
+        modal.confirm({
+          title: t('pages.inbounds.form.scanPrivateConfirmTitle'),
+          content: t('pages.inbounds.form.scanPrivateConfirmContent', { target: r.target || target }),
+          okText: t('confirm'),
+          cancelText: t('cancel'),
+          onOk: () => scanRealityTarget(true),
+        });
+        return;
+      }
+      if (!r.feasible) {
         messageApi.warning(r.reason || t('pages.inbounds.toasts.scanRealityTargetNotFeasible'));
+      } else if (r.privateTarget) {
+        messageApi.warning(t('pages.inbounds.toasts.scanRealityTargetPrivate'));
+      } else {
+        messageApi.success(t('pages.inbounds.toasts.scanRealityTargetFeasible'));
       }
     } finally {
       setScanning(false);

+ 16 - 2
internal/util/netsafe/netsafe.go

@@ -2,6 +2,7 @@ package netsafe
 
 import (
 	"context"
+	"errors"
 	"fmt"
 	"net"
 	"regexp"
@@ -9,6 +10,11 @@ import (
 	"time"
 )
 
+// ErrPrivateAddressBlocked marks a failed dial where the guard refused at least
+// one resolved address, so a caller offering an opt-in can tell it apart from an
+// ordinary connection failure.
+var ErrPrivateAddressBlocked = errors.New("blocked private/internal address")
+
 func IsBlockedIP(ip net.IP) bool {
 	return ip.IsLoopback() || ip.IsPrivate() || ip.IsLinkLocalUnicast() ||
 		ip.IsLinkLocalMulticast() || ip.IsUnspecified()
@@ -42,10 +48,10 @@ func SSRFGuardedDialContext(ctx context.Context, network, addr string) (net.Conn
 			return nil, err
 		}
 	}
-	var lastErr error
+	var lastErr, blockedErr error
 	for _, ipAddr := range ips {
 		if !allowPrivate && IsBlockedIP(ipAddr.IP) {
-			lastErr = fmt.Errorf("blocked private/internal address %s", ipAddr.IP)
+			blockedErr = fmt.Errorf("%w %s", ErrPrivateAddressBlocked, ipAddr.IP)
 			continue
 		}
 		conn, derr := defaultDialer.DialContext(ctx, network, net.JoinHostPort(ipAddr.IP.String(), port))
@@ -54,6 +60,14 @@ func SSRFGuardedDialContext(ctx context.Context, network, addr string) (net.Conn
 		}
 		lastErr = derr
 	}
+	// A dual-stack name can mix refused and merely unreachable addresses, so the
+	// refusal is reported alongside instead of being lost to the last failure.
+	if blockedErr != nil {
+		if lastErr != nil {
+			return nil, fmt.Errorf("%w; %v", blockedErr, lastErr)
+		}
+		return nil, blockedErr
+	}
 	if lastErr == nil {
 		lastErr = fmt.Errorf("no usable address for %s", host)
 	}

+ 4 - 3
internal/web/controller/server.go

@@ -464,11 +464,12 @@ 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.
+// scanRealityTarget probes the candidate REALITY target with the given sni and
+// returns a feasibility verdict; allowPrivate is the panel's confirmed opt-in.
 func (a *ServerController) scanRealityTarget(c *gin.Context) {
 	xver, _ := strconv.Atoi(c.PostForm("xver"))
-	res, err := a.serverService.ScanRealityTarget(c.PostForm("target"), xver)
+	allowPrivate := c.PostForm("allowPrivate") == "true"
+	res, err := a.serverService.ScanRealityTarget(c.PostForm("target"), c.PostForm("sni"), xver, allowPrivate)
 	if err != nil {
 		jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.scanRealityTargetError"), err)
 		return

+ 65 - 33
internal/web/service/reality_scan.go

@@ -4,6 +4,7 @@ import (
 	"context"
 	"crypto/tls"
 	"crypto/x509"
+	"errors"
 	"fmt"
 	"net"
 	"slices"
@@ -12,6 +13,7 @@ import (
 	"sync"
 	"time"
 
+	"github.com/mhsanaei/3x-ui/v3/internal/logger"
 	"github.com/mhsanaei/3x-ui/v3/internal/util/common"
 	"github.com/mhsanaei/3x-ui/v3/internal/util/netsafe"
 )
@@ -38,24 +40,30 @@ var defaultRealityScanCandidates = []string{
 }
 
 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:""`
+	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"`
+	// PrivateTarget marks a target that resolves to a loopback/private/link-local
+	// address: blocked before the probe unless the caller opted in, then flagged.
+	PrivateTarget bool   `json:"privateTarget" example:"false"`
+	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"`
+	// CertChainValid ignores the name: a trusted chain presented for other names
+	// still has serverNames the panel can offer instead of the failing SNI.
+	CertChainValid bool     `json:"certChainValid" 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 {
@@ -126,6 +134,11 @@ func firstUsableName(leaf *x509.Certificate) string {
 	return ""
 }
 
+func leafVerifies(leaf *x509.Certificate, opts x509.VerifyOptions) bool {
+	_, err := leaf.Verify(opts)
+	return err == nil
+}
+
 func splitRealityTarget(target string) (string, int, error) {
 	target = strings.TrimSpace(target)
 	if target == "" {
@@ -170,30 +183,38 @@ func enumerateCIDR(cidr string, max int) ([]string, error) {
 	return ips, nil
 }
 
-func (s *ServerService) probeRealityAddr(dialHost string, port int, sni string, timeout time.Duration, xver int) *RealityScanResult {
+func (s *ServerService) probeRealityAddr(dialHost string, port int, sni string, timeout time.Duration, xver int, allowPrivate bool) *RealityScanResult {
 	addr := net.JoinHostPort(dialHost, strconv.Itoa(port))
 	res := &RealityScanResult{Port: port}
 	if net.ParseIP(dialHost) != nil {
 		res.IP = dialHost
 	}
+	// Target stays the dialed address (it is what the inbound dials); Host is
+	// the SNI the handshake sent, which may differ for a fronting proxy.
+	res.Host = dialHost
+	res.Target = addr
 	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)
+	ctx, cancel := context.WithTimeout(netsafe.ContextWithAllowPrivate(context.Background(), allowPrivate), timeout)
 	defer cancel()
 
 	start := time.Now()
 	conn, err := netsafe.SSRFGuardedDialContext(ctx, "tcp", addr)
 	if err != nil {
+		res.PrivateTarget = errors.Is(err, netsafe.ErrPrivateAddressBlocked)
 		res.Reason = "connection failed: " + err.Error()
 		return res
 	}
 	defer conn.Close()
+	if remote, ok := conn.RemoteAddr().(*net.TCPAddr); ok {
+		res.PrivateTarget = netsafe.IsBlockedIP(remote.IP)
+		// The opt-in bypasses the SSRF guard, so leave an audit trail of it.
+		if res.PrivateTarget && allowPrivate {
+			logger.Infof("reality scan reached private target %s (%s) with the operator opt-in", addr, remote.IP)
+		}
+	}
 	_ = conn.SetDeadline(time.Now().Add(timeout))
 
 	// A REALITY inbound with xver>=1 fronts a target that speaks the PROXY
@@ -253,13 +274,18 @@ func (s *ServerService) probeRealityAddr(dialHost string, port int, sni string,
 		}
 
 		if verifyHost != "" {
-			opts := x509.VerifyOptions{DNSName: verifyHost, Intermediates: x509.NewCertPool()}
+			opts := x509.VerifyOptions{Intermediates: x509.NewCertPool()}
 			for _, c := range st.PeerCertificates[1:] {
 				opts.Intermediates.AddCert(c)
 			}
-			if _, verr := leaf.Verify(opts); verr == nil {
+			// The chain is checked without the name first: a publicly trusted
+			// certificate for other names still carries usable serverNames.
+			res.CertChainValid = leafVerifies(leaf, opts)
+			opts.DNSName = verifyHost
+			if leafVerifies(leaf, opts) {
 				res.CertValid = true
 			} else {
+				_, verr := leaf.Verify(opts)
 				res.Reason = "certificate not trusted: " + verr.Error()
 			}
 		} else {
@@ -283,16 +309,20 @@ func (s *ServerService) probeRealityAddr(dialHost string, port int, sni string,
 	return res
 }
 
-func (s *ServerService) probeRealityTarget(host string, port int, xver int) *RealityScanResult {
-	return s.probeRealityAddr(host, port, host, realityScanTimeout, xver)
-}
-
-func (s *ServerService) ScanRealityTarget(target string, xver int) (*RealityScanResult, error) {
+// ScanRealityTarget probes one operator-supplied target. An empty sni falls back
+// to the target host; allowPrivate lifts the SSRF guard for this probe only.
+func (s *ServerService) ScanRealityTarget(target string, sni string, xver int, allowPrivate bool) (*RealityScanResult, error) {
 	host, port, err := splitRealityTarget(target)
 	if err != nil {
 		return nil, err
 	}
-	return s.probeRealityTarget(host, port, xver), nil
+	sni = strings.TrimSpace(sni)
+	if sni == "" {
+		sni = host
+	} else if sni, err = netsafe.NormalizeHost(sni); err != nil {
+		return nil, common.NewError("invalid SNI: ", err)
+	}
+	return s.probeRealityAddr(host, port, sni, realityScanTimeout, xver, allowPrivate), nil
 }
 
 func (s *ServerService) ScanRealityTargets(targetsCSV string) ([]*RealityScanResult, error) {
@@ -347,7 +377,9 @@ func (s *ServerService) ScanRealityTargets(targetsCSV string) ([]*RealityScanRes
 		go func(idx int, tk realityProbeTask) {
 			defer wg.Done()
 			defer func() { <-sem }()
-			r := s.probeRealityAddr(tk.dialHost, tk.port, tk.sni, tk.timeout, 0)
+			// The bulk/CIDR scanner never reaches private ranges: the opt-in
+			// there would turn it into an internal network scanner.
+			r := s.probeRealityAddr(tk.dialHost, tk.port, tk.sni, tk.timeout, 0, false)
 			if tk.bulk && r.TLSVersion == "" {
 				return
 			}

+ 2 - 2
internal/web/service/reality_scan_test.go

@@ -78,13 +78,13 @@ func TestSplitRealityTarget(t *testing.T) {
 }
 
 func TestScanRealityTargetInputValidation(t *testing.T) {
-	if _, err := (&ServerService{}).ScanRealityTarget("", 0); err == nil {
+	if _, err := (&ServerService{}).ScanRealityTarget("", "", 0, false); err == nil {
 		t.Error("ScanRealityTarget(empty) expected error, got nil")
 	}
 }
 
 func TestScanRealityTargetBlocksPrivate(t *testing.T) {
-	res, err := (&ServerService{}).ScanRealityTarget("127.0.0.1:443", 0)
+	res, err := (&ServerService{}).ScanRealityTarget("127.0.0.1:443", "", 0, false)
 	if err != nil {
 		t.Fatalf("ScanRealityTarget(loopback) unexpected error: %v", err)
 	}

+ 6 - 0
internal/web/translation/ar-EG.json

@@ -441,6 +441,7 @@
         "scanRealityTargetError": "فشل فحص هدف REALITY.",
         "scanRealityTargetFeasible": "الهدف مناسب — تم ملء الهدف وSNI.",
         "scanRealityTargetNotFeasible": "الهدف قابل للوصول لكنه غير مناسب لـ REALITY.",
+        "scanRealityTargetPrivate": "الهدف يعمل لكنه في شبكة خاصة/محلية.",
         "invalidClientField": "العميل {client}: الحقل {field} — {reason}",
         "invalidField": "{field} — {reason}",
         "moreIssues": "{message}  (+{count} أخرى)"
@@ -612,6 +613,11 @@
         "scanCurve": "تبادل المفاتيح",
         "scanCert": "الشهادة",
         "scanCertInvalid": "غير موثوق",
+        "scanCertExpiry": "انتهاء صلاحية الشهادة",
+        "scanSniUsed": "SNI المستخدم",
+        "scanPrivateNote": "تم الفحص عبر شبكة خاصة/محلية — هذا العنوان غير قابل للوصول من الإنترنت.",
+        "scanPrivateConfirmTitle": "الهدف في شبكة محلية",
+        "scanPrivateConfirmContent": "يشير \"{target}\" إلى عنوان خاص أو محلي. سيتجاوز الفحص حماية SSRF في اللوحة لهذا الاختبار فقط. هل تريد المتابعة؟",
         "scanLatency": "زمن الاستجابة",
         "scanUse": "استخدام",
         "scanRescan": "إعادة الفحص",

+ 6 - 0
internal/web/translation/en-US.json

@@ -441,6 +441,7 @@
         "scanRealityTargetError": "Failed to scan REALITY target.",
         "scanRealityTargetFeasible": "Target is feasible — filled target and SNI.",
         "scanRealityTargetNotFeasible": "Target is reachable but not feasible for REALITY.",
+        "scanRealityTargetPrivate": "Target is reachable but sits on a private/local network.",
         "invalidClientField": "Client {client}: {field} — {reason}",
         "invalidField": "{field} — {reason}",
         "moreIssues": "{message}  (+{count} more)"
@@ -624,6 +625,11 @@
         "scanCurve": "Key Exchange",
         "scanCert": "Certificate",
         "scanCertInvalid": "Not trusted",
+        "scanCertExpiry": "Certificate expires",
+        "scanSniUsed": "SNI used",
+        "scanPrivateNote": "Checked over a private/local network — this address is not reachable from the internet.",
+        "scanPrivateConfirmTitle": "Target on a local network",
+        "scanPrivateConfirmContent": "\"{target}\" resolves to a private or loopback address. The check will bypass the panel SSRF guard for this probe only. Continue?",
         "scanLatency": "Latency",
         "scanUse": "Use",
         "scanRescan": "Rescan",

+ 6 - 0
internal/web/translation/es-ES.json

@@ -441,6 +441,7 @@
         "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.",
+        "scanRealityTargetPrivate": "El destino funciona, pero está en una red privada/local.",
         "invalidClientField": "Cliente {client}: campo {field} — {reason}",
         "invalidField": "{field} — {reason}",
         "moreIssues": "{message}  (+{count} más)"
@@ -633,6 +634,11 @@
         "scanCurve": "Intercambio de claves",
         "scanCert": "Certificado",
         "scanCertInvalid": "No confiable",
+        "scanCertExpiry": "El certificado caduca",
+        "scanSniUsed": "SNI utilizado",
+        "scanPrivateNote": "Comprobado en una red privada/local: esta dirección no es accesible desde internet.",
+        "scanPrivateConfirmTitle": "Destino en una red local",
+        "scanPrivateConfirmContent": "\"{target}\" apunta a una dirección privada o de loopback. La comprobación omitirá la protección SSRF del panel solo para esta prueba. ¿Continuar?",
         "scanLatency": "Latencia",
         "scanUse": "Usar",
         "scanRescan": "Reescanear",

+ 6 - 0
internal/web/translation/fa-IR.json

@@ -441,6 +441,7 @@
         "scanRealityTargetError": "اسکن هدف REALITY ناموفق بود.",
         "scanRealityTargetFeasible": "هدف مناسب است — هدف و SNI پر شد.",
         "scanRealityTargetNotFeasible": "هدف در دسترس است اما برای REALITY مناسب نیست.",
+        "scanRealityTargetPrivate": "هدف کار می‌کند اما در شبکهٔ خصوصی/محلی قرار دارد.",
         "invalidClientField": "کلاینت {client}: فیلد {field} — {reason}",
         "invalidField": "{field} — {reason}",
         "moreIssues": "{message}  (+{count} مورد دیگر)"
@@ -624,6 +625,11 @@
         "scanCurve": "تبادل کلید",
         "scanCert": "گواهی",
         "scanCertInvalid": "نامعتبر",
+        "scanCertExpiry": "انقضای گواهی",
+        "scanSniUsed": "SNI استفاده‌شده",
+        "scanPrivateNote": "بررسی از طریق شبکهٔ خصوصی/محلی انجام شد — این نشانی از اینترنت قابل دسترسی نیست.",
+        "scanPrivateConfirmTitle": "هدف در شبکهٔ محلی",
+        "scanPrivateConfirmContent": "«{target}» به یک نشانی خصوصی یا loopback اشاره می‌کند. بررسی تنها برای همین کاوش، محافظت SSRF پنل را نادیده می‌گیرد. ادامه می‌دهید؟",
         "scanLatency": "تأخیر",
         "scanUse": "استفاده",
         "scanRescan": "اسکن مجدد",

+ 6 - 0
internal/web/translation/id-ID.json

@@ -441,6 +441,7 @@
         "scanRealityTargetError": "Gagal memindai target REALITY.",
         "scanRealityTargetFeasible": "Target layak — target dan SNI terisi.",
         "scanRealityTargetNotFeasible": "Target dapat dijangkau tetapi tidak layak untuk REALITY.",
+        "scanRealityTargetPrivate": "Target dapat dijangkau, tetapi berada di jaringan privat/lokal.",
         "invalidClientField": "Klien {client}: kolom {field} — {reason}",
         "invalidField": "{field} — {reason}",
         "moreIssues": "{message}  (+{count} lainnya)"
@@ -612,6 +613,11 @@
         "scanCurve": "Pertukaran Kunci",
         "scanCert": "Sertifikat",
         "scanCertInvalid": "Tidak tepercaya",
+        "scanCertExpiry": "Sertifikat kedaluwarsa",
+        "scanSniUsed": "SNI yang dipakai",
+        "scanPrivateNote": "Diperiksa melalui jaringan privat/lokal — alamat ini tidak dapat dijangkau dari internet.",
+        "scanPrivateConfirmTitle": "Target di jaringan lokal",
+        "scanPrivateConfirmContent": "\"{target}\" mengarah ke alamat privat atau loopback. Pemeriksaan akan melewati pelindung SSRF panel hanya untuk uji ini. Lanjutkan?",
         "scanLatency": "Latensi",
         "scanUse": "Gunakan",
         "scanRescan": "Pindai ulang",

+ 6 - 0
internal/web/translation/ja-JP.json

@@ -441,6 +441,7 @@
         "scanRealityTargetError": "REALITY ターゲットのスキャンに失敗しました。",
         "scanRealityTargetFeasible": "ターゲットは利用可能です — ターゲットと SNI を入力しました。",
         "scanRealityTargetNotFeasible": "ターゲットには到達できますが、REALITY には利用できません。",
+        "scanRealityTargetPrivate": "ターゲットは利用可能ですが、プライベート/ローカルネットワーク上にあります。",
         "invalidClientField": "クライアント {client}: フィールド {field} — {reason}",
         "invalidField": "{field} — {reason}",
         "moreIssues": "{message}  (他 {count} 件)"
@@ -633,6 +634,11 @@
         "scanCurve": "鍵交換",
         "scanCert": "証明書",
         "scanCertInvalid": "信頼できません",
+        "scanCertExpiry": "証明書の有効期限",
+        "scanSniUsed": "使用した SNI",
+        "scanPrivateNote": "プライベート/ローカルネットワーク経由で確認しました。このアドレスはインターネットからは到達できません。",
+        "scanPrivateConfirmTitle": "ローカルネットワーク上のターゲット",
+        "scanPrivateConfirmContent": "「{target}」はプライベートまたはループバックアドレスに解決されます。このプローブに限りパネルの SSRF 保護をバイパスします。続行しますか?",
         "scanLatency": "レイテンシ",
         "scanUse": "使用",
         "scanRescan": "再スキャン",

+ 6 - 0
internal/web/translation/pt-BR.json

@@ -441,6 +441,7 @@
         "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.",
+        "scanRealityTargetPrivate": "O destino funciona, mas está em uma rede privada/local.",
         "invalidClientField": "Cliente {client}: campo {field} — {reason}",
         "invalidField": "{field} — {reason}",
         "moreIssues": "{message}  (+{count} mais)"
@@ -633,6 +634,11 @@
         "scanCurve": "Troca de chaves",
         "scanCert": "Certificado",
         "scanCertInvalid": "Não confiável",
+        "scanCertExpiry": "Certificado expira",
+        "scanSniUsed": "SNI utilizado",
+        "scanPrivateNote": "Verificado em uma rede privada/local — este endereço não é acessível pela internet.",
+        "scanPrivateConfirmTitle": "Destino em uma rede local",
+        "scanPrivateConfirmContent": "\"{target}\" resolve para um endereço privado ou de loopback. A verificação ignorará a proteção SSRF do painel apenas nesta sondagem. Continuar?",
         "scanLatency": "Latência",
         "scanUse": "Usar",
         "scanRescan": "Reescanear",

+ 6 - 0
internal/web/translation/ru-RU.json

@@ -441,6 +441,7 @@
         "scanRealityTargetError": "Не удалось просканировать цель REALITY.",
         "scanRealityTargetFeasible": "Цель подходит — поля target и SNI заполнены.",
         "scanRealityTargetNotFeasible": "Цель доступна, но не подходит для REALITY.",
+        "scanRealityTargetPrivate": "Цель работает, но находится в приватной (локальной) сети.",
         "invalidClientField": "Клиент {client}: поле {field} — {reason}",
         "invalidField": "{field} — {reason}",
         "moreIssues": "{message}  (+{count} ещё)"
@@ -633,6 +634,11 @@
         "scanCurve": "Обмен ключами",
         "scanCert": "Сертификат",
         "scanCertInvalid": "Не доверенный",
+        "scanCertExpiry": "Сертификат истекает",
+        "scanSniUsed": "Использованный SNI",
+        "scanPrivateNote": "Проверено во внутренней (локальной) сети — этот адрес недоступен из интернета.",
+        "scanPrivateConfirmTitle": "Цель в локальной сети",
+        "scanPrivateConfirmContent": "«{target}» указывает на приватный или локальный адрес. Проверка обойдёт SSRF-защиту панели только для этого запроса. Продолжить?",
         "scanLatency": "Задержка",
         "scanUse": "Выбрать",
         "scanRescan": "Пересканировать",

+ 6 - 0
internal/web/translation/tr-TR.json

@@ -441,6 +441,7 @@
         "scanRealityTargetError": "REALITY hedefi taranamadı.",
         "scanRealityTargetFeasible": "Hedef uygun — hedef ve SNI dolduruldu.",
         "scanRealityTargetNotFeasible": "Hedefe ulaşılabiliyor ancak REALITY için uygun değil.",
+        "scanRealityTargetPrivate": "Hedef çalışıyor ancak özel/yerel bir ağda.",
         "invalidClientField": "Kullanıcı {client}: {field} — {reason}",
         "invalidField": "{field} — {reason}",
         "moreIssues": "{message}  (+{count} tane daha)"
@@ -612,6 +613,11 @@
         "scanCurve": "Anahtar Değişimi",
         "scanCert": "Sertifika",
         "scanCertInvalid": "Güvenilmez",
+        "scanCertExpiry": "Sertifika bitiş tarihi",
+        "scanSniUsed": "Kullanılan SNI",
+        "scanPrivateNote": "Özel/yerel ağ üzerinden kontrol edildi — bu adrese internetten erişilemez.",
+        "scanPrivateConfirmTitle": "Hedef yerel ağda",
+        "scanPrivateConfirmContent": "\"{target}\" özel veya loopback bir adrese çözümleniyor. Kontrol, yalnızca bu deneme için panelin SSRF korumasını atlayacak. Devam edilsin mi?",
         "scanLatency": "Gecikme",
         "scanUse": "Kullan",
         "scanRescan": "Yeniden tara",

+ 6 - 0
internal/web/translation/uk-UA.json

@@ -441,6 +441,7 @@
         "scanRealityTargetError": "Не вдалося просканувати ціль REALITY.",
         "scanRealityTargetFeasible": "Ціль підходить — поля target і SNI заповнено.",
         "scanRealityTargetNotFeasible": "Ціль доступна, але не підходить для REALITY.",
+        "scanRealityTargetPrivate": "Ціль працює, але розташована у приватній (локальній) мережі.",
         "invalidClientField": "Клієнт {client}: поле {field} — {reason}",
         "invalidField": "{field} — {reason}",
         "moreIssues": "{message}  (+{count} ще)"
@@ -612,6 +613,11 @@
         "scanCurve": "Обмін ключами",
         "scanCert": "Сертифікат",
         "scanCertInvalid": "Ненадійний",
+        "scanCertExpiry": "Сертифікат діє до",
+        "scanSniUsed": "Використаний SNI",
+        "scanPrivateNote": "Перевірено у внутрішній (локальній) мережі — ця адреса недоступна з інтернету.",
+        "scanPrivateConfirmTitle": "Ціль у локальній мережі",
+        "scanPrivateConfirmContent": "«{target}» вказує на приватну або локальну адресу. Перевірка обійде SSRF-захист панелі лише для цього запиту. Продовжити?",
         "scanLatency": "Затримка",
         "scanUse": "Обрати",
         "scanRescan": "Пересканувати",

+ 6 - 0
internal/web/translation/vi-VN.json

@@ -441,6 +441,7 @@
         "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.",
+        "scanRealityTargetPrivate": "Đích hoạt động nhưng nằm trong mạng riêng/nội bộ.",
         "invalidClientField": "Khách hàng {client}: trường {field} — {reason}",
         "invalidField": "{field} — {reason}",
         "moreIssues": "{message}  (+{count} lỗi khác)"
@@ -633,6 +634,11 @@
         "scanCurve": "Trao đổi khóa",
         "scanCert": "Chứng chỉ",
         "scanCertInvalid": "Không tin cậy",
+        "scanCertExpiry": "Chứng chỉ hết hạn",
+        "scanSniUsed": "SNI đã dùng",
+        "scanPrivateNote": "Đã kiểm tra qua mạng riêng/nội bộ — địa chỉ này không truy cập được từ internet.",
+        "scanPrivateConfirmTitle": "Đích trong mạng nội bộ",
+        "scanPrivateConfirmContent": "\"{target}\" trỏ tới địa chỉ riêng hoặc loopback. Việc kiểm tra sẽ bỏ qua bảo vệ SSRF của panel chỉ cho lần thăm dò này. Tiếp tục?",
         "scanLatency": "Độ trễ",
         "scanUse": "Dùng",
         "scanRescan": "Quét lại",

+ 6 - 0
internal/web/translation/zh-CN.json

@@ -441,6 +441,7 @@
         "scanRealityTargetError": "扫描 REALITY 目标失败。",
         "scanRealityTargetFeasible": "目标可用 — 已填入目标和 SNI。",
         "scanRealityTargetNotFeasible": "目标可达,但不适用于 REALITY。",
+        "scanRealityTargetPrivate": "目标可用,但位于内网/本地网络中。",
         "invalidClientField": "客户端 {client}:字段 {field} — {reason}",
         "invalidField": "{field} — {reason}",
         "moreIssues": "{message}  (另有 {count} 项)"
@@ -632,6 +633,11 @@
         "scanCurve": "密钥交换",
         "scanCert": "证书",
         "scanCertInvalid": "不受信任",
+        "scanCertExpiry": "证书有效期至",
+        "scanSniUsed": "使用的 SNI",
+        "scanPrivateNote": "已通过内网/本地网络检测 — 该地址无法从互联网访问。",
+        "scanPrivateConfirmTitle": "目标位于本地网络",
+        "scanPrivateConfirmContent": "“{target}”解析到内网或回环地址。本次检测将仅为此探测跳过面板的 SSRF 防护。是否继续?",
         "scanLatency": "延迟",
         "scanUse": "使用",
         "scanRescan": "重新扫描",

+ 6 - 0
internal/web/translation/zh-TW.json

@@ -441,6 +441,7 @@
         "scanRealityTargetError": "掃描 REALITY 目標失敗。",
         "scanRealityTargetFeasible": "目標可用 — 已填入目標與 SNI。",
         "scanRealityTargetNotFeasible": "目標可達,但不適用於 REALITY。",
+        "scanRealityTargetPrivate": "目標可用,但位於內網/本機網路中。",
         "invalidClientField": "用戶端 {client}:欄位 {field} — {reason}",
         "invalidField": "{field} — {reason}",
         "moreIssues": "{message}  (另有 {count} 項)"
@@ -612,6 +613,11 @@
         "scanCurve": "金鑰交換",
         "scanCert": "憑證",
         "scanCertInvalid": "不受信任",
+        "scanCertExpiry": "憑證有效期限",
+        "scanSniUsed": "使用的 SNI",
+        "scanPrivateNote": "已透過內網/本機網路檢測 — 此位址無法從網際網路存取。",
+        "scanPrivateConfirmTitle": "目標位於本機網路",
+        "scanPrivateConfirmContent": "「{target}」解析到內網或回環位址。本次檢測將僅為此探測略過面板的 SSRF 防護。是否繼續?",
         "scanLatency": "延遲",
         "scanUse": "使用",
         "scanRescan": "重新掃描",