mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-11-11 07:34:59 +03:00
708a69acde
* 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>
364 lines
14 KiB
TypeScript
364 lines
14 KiB
TypeScript
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';
|
|
import { RealityStreamSettingsSchema } from '@/schemas/protocols/security/reality';
|
|
import type { InboundFormValues } from '@/schemas/forms/inbound-form';
|
|
import type { RealityScanResult } from '@/generated/types';
|
|
|
|
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 —
|
|
* 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({ 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;
|
|
|
|
const genRealityKeypair = async () => {
|
|
setSaving(true);
|
|
try {
|
|
const msg = await HttpUtil.get('/panel/api/server/getNewX25519Cert');
|
|
if (msg?.success) {
|
|
const obj = msg.obj as { privateKey: string; publicKey: string };
|
|
setValue('streamSettings.realitySettings.privateKey', obj.privateKey);
|
|
setValue('streamSettings.realitySettings.settings.publicKey', obj.publicKey);
|
|
}
|
|
} finally {
|
|
setSaving(false);
|
|
}
|
|
};
|
|
|
|
const clearRealityKeypair = () => {
|
|
setValue('streamSettings.realitySettings.privateKey', '');
|
|
setValue('streamSettings.realitySettings.settings.publicKey', '');
|
|
};
|
|
|
|
const genMldsa65 = async () => {
|
|
setSaving(true);
|
|
try {
|
|
const msg = await HttpUtil.get('/panel/api/server/getNewmldsa65');
|
|
if (msg?.success) {
|
|
const obj = msg.obj as { seed: string; verify: string };
|
|
setValue('streamSettings.realitySettings.mldsa65Seed', obj.seed);
|
|
setValue('streamSettings.realitySettings.settings.mldsa65Verify', obj.verify);
|
|
}
|
|
} finally {
|
|
setSaving(false);
|
|
}
|
|
};
|
|
|
|
const clearMldsa65 = () => {
|
|
setValue('streamSettings.realitySettings.mldsa65Seed', '');
|
|
setValue('streamSettings.realitySettings.settings.mldsa65Verify', '');
|
|
};
|
|
|
|
/*
|
|
* 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);
|
|
/*
|
|
* 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 (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, sni, xver, allowPrivate },
|
|
{ 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);
|
|
/*
|
|
* 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);
|
|
}
|
|
};
|
|
|
|
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 = () => {
|
|
setValue(
|
|
'streamSettings.realitySettings.shortIds',
|
|
RandomUtil.randomShortIds().split(',').map((s) => s.trim()).filter(Boolean),
|
|
);
|
|
};
|
|
|
|
const randomizeSpiderX = () => {
|
|
setValue(
|
|
'streamSettings.realitySettings.settings.spiderX',
|
|
`/${RandomUtil.randomSeq(15)}`,
|
|
);
|
|
};
|
|
|
|
const getNewEchCert = async () => {
|
|
const sni = getValues('streamSettings.tlsSettings.serverName');
|
|
setSaving(true);
|
|
try {
|
|
const msg = await HttpUtil.post('/panel/api/server/getNewEchCert', { sni });
|
|
if (msg?.success) {
|
|
const obj = msg.obj as { echServerKeys: string; echConfigList: string };
|
|
setValue('streamSettings.tlsSettings.echServerKeys', obj.echServerKeys);
|
|
setValue('streamSettings.tlsSettings.settings.echConfigList', obj.echConfigList);
|
|
}
|
|
} finally {
|
|
setSaving(false);
|
|
}
|
|
};
|
|
|
|
const clearEchCert = () => {
|
|
setValue('streamSettings.tlsSettings.echServerKeys', '');
|
|
setValue('streamSettings.tlsSettings.settings.echConfigList', '');
|
|
};
|
|
|
|
/*
|
|
* Fill the pinned-cert field from the inbound's own certificate: read the
|
|
* first configured cert (file path or inline content) and ask the server for
|
|
* its hex SHA-256, then merge the hash(es) into pinnedPeerCertSha256.
|
|
*/
|
|
const pinFromCert = async () => {
|
|
const certs = (getValues('streamSettings.tlsSettings.certificates') ?? []) as Array<{
|
|
certificateFile?: string;
|
|
certificate?: string[];
|
|
}>;
|
|
const first = certs[0];
|
|
const certFile = first?.certificateFile?.trim() ?? '';
|
|
const certContent = Array.isArray(first?.certificate) ? first.certificate.join('\n').trim() : '';
|
|
if (!certFile && !certContent) {
|
|
messageApi.warning(t('pages.inbounds.setDefaultCertEmpty'));
|
|
return;
|
|
}
|
|
setSaving(true);
|
|
try {
|
|
const msg = await HttpUtil.post('/panel/api/server/getCertHash', { certFile, certContent });
|
|
if (!msg?.success) {
|
|
messageApi.warning(msg?.msg || t('pages.inbounds.setDefaultCertEmpty'));
|
|
return;
|
|
}
|
|
const hashes = (msg.obj as string[] | undefined) ?? [];
|
|
if (hashes.length === 0) return;
|
|
const current = (getValues(
|
|
'streamSettings.tlsSettings.settings.pinnedPeerCertSha256',
|
|
) as string[] | undefined) ?? [];
|
|
const merged = Array.from(new Set([...current, ...hashes]));
|
|
setValue('streamSettings.tlsSettings.settings.pinnedPeerCertSha256', merged);
|
|
} finally {
|
|
setSaving(false);
|
|
}
|
|
};
|
|
|
|
/*
|
|
* Fill the pinned-cert field by pinging the configured SNI: fetches the live
|
|
* remote certificate hash via `xray tls ping`. Useful when the panel doesn't
|
|
* hold the cert file (a CDN front / external endpoint).
|
|
*/
|
|
const pinFromRemote = async () => {
|
|
const server = ((getValues('streamSettings.tlsSettings.serverName') as string | undefined) ?? '').trim();
|
|
if (!server) {
|
|
messageApi.warning(t('pages.inbounds.form.pinFromRemoteNoSni'));
|
|
return;
|
|
}
|
|
/*
|
|
* `xray tls ping` defaults to :443, but a self-hosted inbound rarely
|
|
* listens there. Append the inbound's own port (unless the SNI already
|
|
* carries one) so the ping reaches the actual TLS endpoint.
|
|
*/
|
|
const port = getValues('port') as number | undefined;
|
|
const target = /:\d+$/.test(server) || !port ? server : `${server}:${port}`;
|
|
setSaving(true);
|
|
try {
|
|
const msg = await HttpUtil.post('/panel/api/server/getRemoteCertHash', { server: target });
|
|
if (!msg?.success) {
|
|
messageApi.warning(msg?.msg || t('pages.inbounds.form.pinFromRemoteFailed'));
|
|
return;
|
|
}
|
|
const hashes = (msg.obj as string[] | undefined) ?? [];
|
|
if (hashes.length === 0) return;
|
|
const current = (getValues(
|
|
'streamSettings.tlsSettings.settings.pinnedPeerCertSha256',
|
|
) as string[] | undefined) ?? [];
|
|
const merged = Array.from(new Set([...current, ...hashes]));
|
|
setValue('streamSettings.tlsSettings.settings.pinnedPeerCertSha256', merged);
|
|
} finally {
|
|
setSaving(false);
|
|
}
|
|
};
|
|
|
|
const setCertFromPanel = async (certName: number) => {
|
|
setSaving(true);
|
|
try {
|
|
/*
|
|
* Node-assigned inbounds run on the node, so their cert files must be the
|
|
* node's own paths (fetched through the central panel), not this panel's.
|
|
*/
|
|
const msg = typeof nodeId === 'number'
|
|
? await HttpUtil.get(`/panel/api/nodes/webCert/${nodeId}`, undefined, { silent: true })
|
|
: await HttpUtil.post('/panel/api/setting/all', undefined, { silent: true });
|
|
if (!msg?.success) {
|
|
messageApi.warning(msg?.msg || t('pages.inbounds.setDefaultCertEmpty'));
|
|
return;
|
|
}
|
|
const obj = msg.obj as { webCertFile?: string; webKeyFile?: string };
|
|
if (!obj?.webCertFile && !obj?.webKeyFile) {
|
|
messageApi.warning(t('pages.inbounds.setDefaultCertEmpty'));
|
|
return;
|
|
}
|
|
setValue(
|
|
`streamSettings.tlsSettings.certificates.${certName}.certificateFile`,
|
|
obj.webCertFile ?? '',
|
|
);
|
|
setValue(
|
|
`streamSettings.tlsSettings.certificates.${certName}.keyFile`,
|
|
obj.webKeyFile ?? '',
|
|
);
|
|
} finally {
|
|
setSaving(false);
|
|
}
|
|
};
|
|
|
|
const clearCertFiles = (certName: number) => {
|
|
setValue(
|
|
`streamSettings.tlsSettings.certificates.${certName}.certificateFile`,
|
|
'',
|
|
);
|
|
setValue(
|
|
`streamSettings.tlsSettings.certificates.${certName}.keyFile`,
|
|
'',
|
|
);
|
|
};
|
|
|
|
const onSecurityChange = async (next: string) => {
|
|
setScanResult(null);
|
|
const current = (getValues('streamSettings') as Record<string, unknown>) ?? {};
|
|
const cleaned: Record<string, unknown> = { ...current, security: next };
|
|
delete cleaned.tlsSettings;
|
|
delete cleaned.realitySettings;
|
|
if (next === 'tls') {
|
|
cleaned.tlsSettings = createTlsSettingsWithDefaultCert();
|
|
}
|
|
if (next === 'reality') {
|
|
const reality = RealityStreamSettingsSchema.parse({}) as Record<string, unknown>;
|
|
reality.target = '';
|
|
reality.serverNames = [];
|
|
reality.shortIds = RandomUtil.randomShortIds().split(',').map((s) => s.trim()).filter(Boolean);
|
|
cleaned.realitySettings = reality;
|
|
}
|
|
setValue('streamSettings', cleaned);
|
|
if (next === 'reality') {
|
|
randomizeSpiderX();
|
|
try {
|
|
const msg = await HttpUtil.get('/panel/api/server/getNewX25519Cert');
|
|
if (msg?.success) {
|
|
const obj = msg.obj as { privateKey: string; publicKey: string };
|
|
setValue('streamSettings.realitySettings.privateKey', obj.privateKey);
|
|
setValue('streamSettings.realitySettings.settings.publicKey', obj.publicKey);
|
|
}
|
|
} catch {
|
|
/* best-effort: leave keypair fields empty if server call fails */
|
|
}
|
|
}
|
|
};
|
|
|
|
return {
|
|
genRealityKeypair,
|
|
clearRealityKeypair,
|
|
genMldsa65,
|
|
clearMldsa65,
|
|
scanRealityTarget,
|
|
scanRealityCandidates,
|
|
applyRealityScanResult,
|
|
randomizeShortIds,
|
|
randomizeSpiderX,
|
|
getNewEchCert,
|
|
clearEchCert,
|
|
pinFromCert,
|
|
pinFromRemote,
|
|
setCertFromPanel,
|
|
clearCertFiles,
|
|
onSecurityChange,
|
|
};
|
|
}
|