mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-11-14 17:15:24 +03:00
6964d84742
Replace the static reality-targets list with a server-side TLS 1.3 probe that checks TLS 1.3 + HTTP/2 + X25519 + a trusted certificate. - Single-domain validate auto-fills target and serverNames from the cert SAN - Discovery scans an IP/CIDR without SNI to find new targets from their certificates, deduped and ranked by feasibility then latency, private-IP guarded via netsafe - New endpoints scanRealityTarget and scanRealityTargets with RealityScanResult, plus openapigen and api-docs entries - Add scanner strings to all 13 locales - Replace deprecated AntD Alert message prop with title across the panel
304 lines
12 KiB
TypeScript
304 lines
12 KiB
TypeScript
import type { Dispatch, SetStateAction } from 'react';
|
|
import { useTranslation } from 'react-i18next';
|
|
import type { FormInstance } from 'antd';
|
|
import type { MessageInstance } from 'antd/es/message/interface';
|
|
|
|
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 {
|
|
form: FormInstance<InboundFormValues>;
|
|
setSaving: Dispatch<SetStateAction<boolean>>;
|
|
messageApi: MessageInstance;
|
|
// 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({ form, setSaving, messageApi, nodeId, setScanResult, setScanning }: UseSecurityActionsArgs) {
|
|
const { t } = useTranslation();
|
|
|
|
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 };
|
|
form.setFieldValue(['streamSettings', 'realitySettings', 'privateKey'], obj.privateKey);
|
|
form.setFieldValue(['streamSettings', 'realitySettings', 'settings', 'publicKey'], obj.publicKey);
|
|
}
|
|
} finally {
|
|
setSaving(false);
|
|
}
|
|
};
|
|
|
|
const clearRealityKeypair = () => {
|
|
form.setFieldValue(['streamSettings', 'realitySettings', 'privateKey'], '');
|
|
form.setFieldValue(['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 };
|
|
form.setFieldValue(['streamSettings', 'realitySettings', 'mldsa65Seed'], obj.seed);
|
|
form.setFieldValue(['streamSettings', 'realitySettings', 'settings', 'mldsa65Verify'], obj.verify);
|
|
}
|
|
} finally {
|
|
setSaving(false);
|
|
}
|
|
};
|
|
|
|
const clearMldsa65 = () => {
|
|
form.setFieldValue(['streamSettings', 'realitySettings', 'mldsa65Seed'], '');
|
|
form.setFieldValue(['streamSettings', 'realitySettings', 'settings', 'mldsa65Verify'], '');
|
|
};
|
|
|
|
const applyRealityScanResult = (r: RealityScanResult) => {
|
|
setScanResult(r);
|
|
form.setFieldValue(['streamSettings', 'realitySettings', 'target'], r.target);
|
|
if (r.serverNames?.length) {
|
|
form.setFieldValue(['streamSettings', 'realitySettings', 'serverNames'], r.serverNames);
|
|
}
|
|
};
|
|
|
|
const scanRealityTarget = async () => {
|
|
const target = ((form.getFieldValue(['streamSettings', 'realitySettings', 'target']) as string | undefined) ?? '').trim();
|
|
if (!target) {
|
|
messageApi.warning(t('pages.inbounds.form.realityTargetRequired'));
|
|
return;
|
|
}
|
|
setScanning(true);
|
|
try {
|
|
const msg = await HttpUtil.post<RealityScanResult>(
|
|
'/panel/api/server/scanRealityTarget',
|
|
{ target },
|
|
{ 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);
|
|
if (r.feasible) {
|
|
messageApi.success(t('pages.inbounds.toasts.scanRealityTargetFeasible'));
|
|
} else {
|
|
messageApi.warning(r.reason || t('pages.inbounds.toasts.scanRealityTargetNotFeasible'));
|
|
}
|
|
} 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 = () => {
|
|
form.setFieldValue(
|
|
['streamSettings', 'realitySettings', 'shortIds'],
|
|
RandomUtil.randomShortIds().split(',').map((s) => s.trim()).filter(Boolean),
|
|
);
|
|
};
|
|
|
|
const getNewEchCert = async () => {
|
|
const sni = form.getFieldValue(['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 };
|
|
form.setFieldValue(['streamSettings', 'tlsSettings', 'echServerKeys'], obj.echServerKeys);
|
|
form.setFieldValue(['streamSettings', 'tlsSettings', 'settings', 'echConfigList'], obj.echConfigList);
|
|
}
|
|
} finally {
|
|
setSaving(false);
|
|
}
|
|
};
|
|
|
|
const clearEchCert = () => {
|
|
form.setFieldValue(['streamSettings', 'tlsSettings', 'echServerKeys'], '');
|
|
form.setFieldValue(['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 = (form.getFieldValue(['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 = (form.getFieldValue(
|
|
['streamSettings', 'tlsSettings', 'settings', 'pinnedPeerCertSha256'],
|
|
) as string[] | undefined) ?? [];
|
|
const merged = Array.from(new Set([...current, ...hashes]));
|
|
form.setFieldValue(['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 = ((form.getFieldValue(['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 = form.getFieldValue('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 = (form.getFieldValue(
|
|
['streamSettings', 'tlsSettings', 'settings', 'pinnedPeerCertSha256'],
|
|
) as string[] | undefined) ?? [];
|
|
const merged = Array.from(new Set([...current, ...hashes]));
|
|
form.setFieldValue(['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;
|
|
}
|
|
form.setFieldValue(
|
|
['streamSettings', 'tlsSettings', 'certificates', certName, 'certificateFile'],
|
|
obj.webCertFile ?? '',
|
|
);
|
|
form.setFieldValue(
|
|
['streamSettings', 'tlsSettings', 'certificates', certName, 'keyFile'],
|
|
obj.webKeyFile ?? '',
|
|
);
|
|
} finally {
|
|
setSaving(false);
|
|
}
|
|
};
|
|
|
|
const clearCertFiles = (certName: number) => {
|
|
form.setFieldValue(
|
|
['streamSettings', 'tlsSettings', 'certificates', certName, 'certificateFile'],
|
|
'',
|
|
);
|
|
form.setFieldValue(
|
|
['streamSettings', 'tlsSettings', 'certificates', certName, 'keyFile'],
|
|
'',
|
|
);
|
|
};
|
|
|
|
const onSecurityChange = async (next: string) => {
|
|
setScanResult(null);
|
|
const current = (form.getFieldValue('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;
|
|
}
|
|
form.setFieldValue('streamSettings', cleaned);
|
|
if (next === 'reality') {
|
|
try {
|
|
const msg = await HttpUtil.get('/panel/api/server/getNewX25519Cert');
|
|
if (msg?.success) {
|
|
const obj = msg.obj as { privateKey: string; publicKey: string };
|
|
form.setFieldValue(['streamSettings', 'realitySettings', 'privateKey'], obj.privateKey);
|
|
form.setFieldValue(['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,
|
|
getNewEchCert,
|
|
clearEchCert,
|
|
pinFromCert,
|
|
pinFromRemote,
|
|
setCertFromPanel,
|
|
clearCertFiles,
|
|
onSecurityChange,
|
|
};
|
|
}
|