Files
3x-ui/frontend/src/pages/inbounds/form/useSecurityActions.ts
T
Leslie Alexander 66234315e4 fix(panel): default TLS ALPN to http/1.1 for WebSocket transport (#6787)
* fix(panel): default TLS ALPN to http/1.1 for WebSocket transport

WebSocket bootstraps over HTTP/1.1: leaving ALPN at the schema default
['h2','http/1.1'] makes the server negotiate h2 and the WS handshake
fails. See issue #6782.

* style(frontend): keep comments within the two-line limit

* style(frontend): keep comments within the two-line limit
2026-10-11 07:17:05 +02:00

395 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 (allowPrivate = false) => {
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, allowPrivate },
{ silent: true },
);
// The SSRF guard refuses a LAN/loopback endpoint until the operator confirms it.
const blocked = (msg?.obj as { privateTarget?: boolean } | null | undefined)?.privateTarget;
if (!msg?.success && blocked && !allowPrivate) {
modal.confirm({
title: t('pages.inbounds.form.scanPrivateConfirmTitle'),
content: t('pages.inbounds.form.scanPrivateConfirmContent', { target }),
okText: t('confirm'),
cancelText: t('cancel'),
onOk: () => pinFromRemote(true),
});
return;
}
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') {
const network = (current.network as string | undefined) ?? '';
cleaned.tlsSettings = createTlsSettingsWithDefaultCert(network);
}
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,
};
}