diff --git a/frontend/public/openapi.json b/frontend/public/openapi.json index 14dc072c2..f90253e25 100644 --- a/frontend/public/openapi.json +++ b/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": [ "" diff --git a/frontend/src/generated/examples.ts b/frontend/src/generated/examples.ts index 7e2cf8f26..aed7c520f 100644 --- a/frontend/src/generated/examples.ts +++ b/frontend/src/generated/examples.ts @@ -701,6 +701,7 @@ export const EXAMPLES: Record = { }, "RealityScanResult": { "alpn": "h2", + "certChainValid": true, "certIssuer": "Google Trust Services", "certSubject": "cloudflare.com", "certValid": true, @@ -712,6 +713,7 @@ export const EXAMPLES: Record = { "latencyMs": 180, "notAfter": "2026-08-01T00:00:00Z", "port": 443, + "privateTarget": false, "reason": "", "serverNames": [ "" diff --git a/frontend/src/generated/schemas.ts b/frontend/src/generated/schemas.ts index 5fdc82510..06efbc130 100644 --- a/frontend/src/generated/schemas.ts +++ b/frontend/src/generated/schemas.ts @@ -2941,6 +2941,11 @@ export const SCHEMAS: Record = { "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 = { "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 = { }, "required": [ "alpn", + "certChainValid", "certIssuer", "certSubject", "certValid", @@ -3024,6 +3035,7 @@ export const SCHEMAS: Record = { "latencyMs", "notAfter", "port", + "privateTarget", "reason", "serverNames", "target", diff --git a/frontend/src/generated/types.ts b/frontend/src/generated/types.ts index c86a570da..0eb091259 100644 --- a/frontend/src/generated/types.ts +++ b/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; diff --git a/frontend/src/generated/zod.ts b/frontend/src/generated/zod.ts index 7a03ec69e..41fa21b58 100644 --- a/frontend/src/generated/zod.ts +++ b/frontend/src/generated/zod.ts @@ -717,6 +717,7 @@ export type ProbeResultUI = z.infer; 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(), diff --git a/frontend/src/pages/api-docs/endpoints.ts b/frontend/src/pages/api-docs/endpoints.ts index 2decc2658..ef421d9dd 100644 --- a/frontend/src/pages/api-docs/endpoints.ts +++ b/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', diff --git a/frontend/src/pages/inbounds/form/InboundFormModal.tsx b/frontend/src/pages/inbounds/form/InboundFormModal.tsx index 34bca1da0..7da4d2041 100644 --- a/frontend/src/pages/inbounds/form/InboundFormModal.tsx +++ b/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({ 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} void; + scanRealityTarget: (allowPrivate?: boolean) => void; scanRealityCandidates: (targets?: string) => Promise; - 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({ > -