mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-08-20 18:11:00 +00:00
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 <noreply@anthropic.com>
This commit is contained in:
@@ -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": [
|
||||
""
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user