useSecurityActions.ts 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393
  1. import type { Dispatch, SetStateAction } from 'react';
  2. import { useTranslation } from 'react-i18next';
  3. import type { UseFormReturn } from 'react-hook-form';
  4. import type { MessageInstance } from 'antd/es/message/interface';
  5. import type { HookAPI as ModalHookAPI } from 'antd/es/modal/useModal';
  6. import { HttpUtil, RandomUtil } from '@/utils';
  7. import { createTlsSettingsWithDefaultCert } from '@/lib/xray/inbound-tls-defaults';
  8. import { RealityStreamSettingsSchema } from '@/schemas/protocols/security/reality';
  9. import type { InboundFormValues } from '@/schemas/forms/inbound-form';
  10. import type { RealityScanResult } from '@/generated/types';
  11. interface UseSecurityActionsArgs {
  12. methods: UseFormReturn<InboundFormValues>;
  13. setSaving: Dispatch<SetStateAction<boolean>>;
  14. messageApi: MessageInstance;
  15. modal: ModalHookAPI;
  16. /*
  17. * Node the inbound is deployed to (null = central panel). "Set Cert from
  18. * Panel" must read the node's own cert paths for a node-assigned inbound —
  19. * the central panel's paths don't exist on the node. See issue #4854.
  20. */
  21. nodeId: number | null;
  22. setScanResult: Dispatch<SetStateAction<RealityScanResult | null>>;
  23. setScanning: Dispatch<SetStateAction<boolean>>;
  24. }
  25. /*
  26. * Server-side TLS / Reality key + certificate generation handlers for the
  27. * inbound modal's security tab. Each talks to a /panel server endpoint and
  28. * writes the result back into the form. Lifted out of InboundFormModal so
  29. * the modal body stays focused on orchestration.
  30. */
  31. export function useSecurityActions({
  32. methods,
  33. setSaving,
  34. messageApi,
  35. modal,
  36. nodeId,
  37. setScanResult,
  38. setScanning,
  39. }: UseSecurityActionsArgs) {
  40. const { t } = useTranslation();
  41. const setValue = methods.setValue as unknown as (name: string, value: unknown) => void;
  42. const getValues = methods.getValues as unknown as (name?: string) => unknown;
  43. const genRealityKeypair = async () => {
  44. setSaving(true);
  45. try {
  46. const msg = await HttpUtil.get('/panel/api/server/getNewX25519Cert');
  47. if (msg?.success) {
  48. const obj = msg.obj as { privateKey: string; publicKey: string };
  49. setValue('streamSettings.realitySettings.privateKey', obj.privateKey);
  50. setValue('streamSettings.realitySettings.settings.publicKey', obj.publicKey);
  51. }
  52. } finally {
  53. setSaving(false);
  54. }
  55. };
  56. const clearRealityKeypair = () => {
  57. setValue('streamSettings.realitySettings.privateKey', '');
  58. setValue('streamSettings.realitySettings.settings.publicKey', '');
  59. };
  60. const genMldsa65 = async () => {
  61. setSaving(true);
  62. try {
  63. const msg = await HttpUtil.get('/panel/api/server/getNewmldsa65');
  64. if (msg?.success) {
  65. const obj = msg.obj as { seed: string; verify: string };
  66. setValue('streamSettings.realitySettings.mldsa65Seed', obj.seed);
  67. setValue('streamSettings.realitySettings.settings.mldsa65Verify', obj.verify);
  68. }
  69. } finally {
  70. setSaving(false);
  71. }
  72. };
  73. const clearMldsa65 = () => {
  74. setValue('streamSettings.realitySettings.mldsa65Seed', '');
  75. setValue('streamSettings.realitySettings.settings.mldsa65Verify', '');
  76. };
  77. /*
  78. * replaceServerNames is for picking a target wholesale: keeping the previous
  79. * target's SNI would leave a REALITY config that cannot work.
  80. */
  81. const applyRealityScanResult = (r: RealityScanResult, replaceServerNames = false) => {
  82. setScanResult(r);
  83. setValue('streamSettings.realitySettings.target', r.target);
  84. /*
  85. * Names off an untrusted chain are not usable as SNI; names off a trusted
  86. * one are, even when the SNI sent did not match them, which is how a stale
  87. * SNI recovers instead of failing every rescan.
  88. */
  89. if (replaceServerNames) {
  90. setValue('streamSettings.realitySettings.serverNames', r.serverNames ?? []);
  91. } else if ((r.certValid || r.certChainValid) && r.serverNames?.length) {
  92. setValue('streamSettings.realitySettings.serverNames', r.serverNames);
  93. }
  94. };
  95. const scanRealityTarget = async (allowPrivate = false) => {
  96. const target = (
  97. (getValues('streamSettings.realitySettings.target') as string | undefined) ?? ''
  98. ).trim();
  99. if (!target) {
  100. messageApi.warning(t('pages.inbounds.form.realityTargetRequired'));
  101. return;
  102. }
  103. const xver = Number(getValues('streamSettings.realitySettings.xver')) || 0;
  104. /*
  105. * Clients dial the target but send an SNI from serverNames, so the probe
  106. * must too — a fronting proxy answers a bare target name with its default
  107. * certificate, which then reads as an untrusted target.
  108. */
  109. const serverNames =
  110. (getValues('streamSettings.realitySettings.serverNames') as string[] | undefined) ?? [];
  111. const sni = (serverNames.find((n) => typeof n === 'string' && n.trim() !== '') ?? '').trim();
  112. setScanning(true);
  113. try {
  114. const msg = await HttpUtil.post<RealityScanResult>(
  115. '/panel/api/server/scanRealityTarget',
  116. { target, sni, xver, allowPrivate },
  117. { silent: true },
  118. );
  119. if (!msg?.success || !msg.obj) {
  120. setScanResult(null);
  121. messageApi.error(msg?.msg || t('pages.inbounds.toasts.scanRealityTargetError'));
  122. return;
  123. }
  124. const r = msg.obj;
  125. applyRealityScanResult(r);
  126. /*
  127. * The SSRF guard refuses a LAN/Docker target until the operator confirms
  128. * it; the retry carries the opt-in for this one probe.
  129. */
  130. if (r.privateTarget && !allowPrivate) {
  131. modal.confirm({
  132. title: t('pages.inbounds.form.scanPrivateConfirmTitle'),
  133. content: t('pages.inbounds.form.scanPrivateConfirmContent', {
  134. target: r.target || target,
  135. }),
  136. okText: t('confirm'),
  137. cancelText: t('cancel'),
  138. onOk: () => scanRealityTarget(true),
  139. });
  140. return;
  141. }
  142. if (!r.feasible) {
  143. messageApi.warning(r.reason || t('pages.inbounds.toasts.scanRealityTargetNotFeasible'));
  144. } else if (r.privateTarget) {
  145. messageApi.warning(t('pages.inbounds.toasts.scanRealityTargetPrivate'));
  146. } else {
  147. messageApi.success(t('pages.inbounds.toasts.scanRealityTargetFeasible'));
  148. }
  149. } finally {
  150. setScanning(false);
  151. }
  152. };
  153. const scanRealityCandidates = async (targets?: string): Promise<RealityScanResult[]> => {
  154. const msg = await HttpUtil.post<RealityScanResult[]>(
  155. '/panel/api/server/scanRealityTargets',
  156. targets ? { targets } : {},
  157. { silent: true },
  158. );
  159. if (!msg?.success || !Array.isArray(msg.obj)) {
  160. messageApi.error(msg?.msg || t('pages.inbounds.toasts.scanRealityTargetError'));
  161. return [];
  162. }
  163. return msg.obj;
  164. };
  165. const randomizeShortIds = () => {
  166. setValue(
  167. 'streamSettings.realitySettings.shortIds',
  168. RandomUtil.randomShortIds()
  169. .split(',')
  170. .map((s) => s.trim())
  171. .filter(Boolean),
  172. );
  173. };
  174. const randomizeSpiderX = () => {
  175. setValue('streamSettings.realitySettings.settings.spiderX', `/${RandomUtil.randomSeq(15)}`);
  176. };
  177. const getNewEchCert = async () => {
  178. const sni = getValues('streamSettings.tlsSettings.serverName');
  179. setSaving(true);
  180. try {
  181. const msg = await HttpUtil.post('/panel/api/server/getNewEchCert', { sni });
  182. if (msg?.success) {
  183. const obj = msg.obj as { echServerKeys: string; echConfigList: string };
  184. setValue('streamSettings.tlsSettings.echServerKeys', obj.echServerKeys);
  185. setValue('streamSettings.tlsSettings.settings.echConfigList', obj.echConfigList);
  186. }
  187. } finally {
  188. setSaving(false);
  189. }
  190. };
  191. const clearEchCert = () => {
  192. setValue('streamSettings.tlsSettings.echServerKeys', '');
  193. setValue('streamSettings.tlsSettings.settings.echConfigList', '');
  194. };
  195. /*
  196. * Fill the pinned-cert field from the inbound's own certificate: read the
  197. * first configured cert (file path or inline content) and ask the server for
  198. * its hex SHA-256, then merge the hash(es) into pinnedPeerCertSha256.
  199. */
  200. const pinFromCert = async () => {
  201. const certs = (getValues('streamSettings.tlsSettings.certificates') ?? []) as Array<{
  202. certificateFile?: string;
  203. certificate?: string[];
  204. }>;
  205. const first = certs[0];
  206. const certFile = first?.certificateFile?.trim() ?? '';
  207. const certContent = Array.isArray(first?.certificate)
  208. ? first.certificate.join('\n').trim()
  209. : '';
  210. if (!certFile && !certContent) {
  211. messageApi.warning(t('pages.inbounds.setDefaultCertEmpty'));
  212. return;
  213. }
  214. setSaving(true);
  215. try {
  216. const msg = await HttpUtil.post('/panel/api/server/getCertHash', { certFile, certContent });
  217. if (!msg?.success) {
  218. messageApi.warning(msg?.msg || t('pages.inbounds.setDefaultCertEmpty'));
  219. return;
  220. }
  221. const hashes = (msg.obj as string[] | undefined) ?? [];
  222. if (hashes.length === 0) return;
  223. const current =
  224. (getValues('streamSettings.tlsSettings.settings.pinnedPeerCertSha256') as
  225. | string[]
  226. | undefined) ?? [];
  227. const merged = Array.from(new Set([...current, ...hashes]));
  228. setValue('streamSettings.tlsSettings.settings.pinnedPeerCertSha256', merged);
  229. } finally {
  230. setSaving(false);
  231. }
  232. };
  233. /*
  234. * Fill the pinned-cert field by pinging the configured SNI: fetches the live
  235. * remote certificate hash via `xray tls ping`. Useful when the panel doesn't
  236. * hold the cert file (a CDN front / external endpoint).
  237. */
  238. const pinFromRemote = async (allowPrivate = false) => {
  239. const server = (
  240. (getValues('streamSettings.tlsSettings.serverName') as string | undefined) ?? ''
  241. ).trim();
  242. if (!server) {
  243. messageApi.warning(t('pages.inbounds.form.pinFromRemoteNoSni'));
  244. return;
  245. }
  246. /*
  247. * `xray tls ping` defaults to :443, but a self-hosted inbound rarely
  248. * listens there. Append the inbound's own port (unless the SNI already
  249. * carries one) so the ping reaches the actual TLS endpoint.
  250. */
  251. const port = getValues('port') as number | undefined;
  252. const target = /:\d+$/.test(server) || !port ? server : `${server}:${port}`;
  253. setSaving(true);
  254. try {
  255. const msg = await HttpUtil.post(
  256. '/panel/api/server/getRemoteCertHash',
  257. { server: target, allowPrivate },
  258. { silent: true },
  259. );
  260. // The SSRF guard refuses a LAN/loopback endpoint until the operator confirms it.
  261. const blocked = (msg?.obj as { privateTarget?: boolean } | null | undefined)?.privateTarget;
  262. if (!msg?.success && blocked && !allowPrivate) {
  263. modal.confirm({
  264. title: t('pages.inbounds.form.scanPrivateConfirmTitle'),
  265. content: t('pages.inbounds.form.scanPrivateConfirmContent', { target }),
  266. okText: t('confirm'),
  267. cancelText: t('cancel'),
  268. onOk: () => pinFromRemote(true),
  269. });
  270. return;
  271. }
  272. if (!msg?.success) {
  273. messageApi.warning(msg?.msg || t('pages.inbounds.form.pinFromRemoteFailed'));
  274. return;
  275. }
  276. const hashes = (msg.obj as string[] | undefined) ?? [];
  277. if (hashes.length === 0) return;
  278. const current =
  279. (getValues('streamSettings.tlsSettings.settings.pinnedPeerCertSha256') as
  280. | string[]
  281. | undefined) ?? [];
  282. const merged = Array.from(new Set([...current, ...hashes]));
  283. setValue('streamSettings.tlsSettings.settings.pinnedPeerCertSha256', merged);
  284. } finally {
  285. setSaving(false);
  286. }
  287. };
  288. const setCertFromPanel = async (certName: number) => {
  289. setSaving(true);
  290. try {
  291. /*
  292. * Node-assigned inbounds run on the node, so their cert files must be the
  293. * node's own paths (fetched through the central panel), not this panel's.
  294. */
  295. const msg =
  296. typeof nodeId === 'number'
  297. ? await HttpUtil.get(`/panel/api/nodes/webCert/${nodeId}`, undefined, { silent: true })
  298. : await HttpUtil.post('/panel/api/setting/all', undefined, { silent: true });
  299. if (!msg?.success) {
  300. messageApi.warning(msg?.msg || t('pages.inbounds.setDefaultCertEmpty'));
  301. return;
  302. }
  303. const obj = msg.obj as { webCertFile?: string; webKeyFile?: string };
  304. if (!obj?.webCertFile && !obj?.webKeyFile) {
  305. messageApi.warning(t('pages.inbounds.setDefaultCertEmpty'));
  306. return;
  307. }
  308. setValue(
  309. `streamSettings.tlsSettings.certificates.${certName}.certificateFile`,
  310. obj.webCertFile ?? '',
  311. );
  312. setValue(`streamSettings.tlsSettings.certificates.${certName}.keyFile`, obj.webKeyFile ?? '');
  313. } finally {
  314. setSaving(false);
  315. }
  316. };
  317. const clearCertFiles = (certName: number) => {
  318. setValue(`streamSettings.tlsSettings.certificates.${certName}.certificateFile`, '');
  319. setValue(`streamSettings.tlsSettings.certificates.${certName}.keyFile`, '');
  320. };
  321. const onSecurityChange = async (next: string) => {
  322. setScanResult(null);
  323. const current = (getValues('streamSettings') as Record<string, unknown>) ?? {};
  324. const cleaned: Record<string, unknown> = { ...current, security: next };
  325. delete cleaned.tlsSettings;
  326. delete cleaned.realitySettings;
  327. if (next === 'tls') {
  328. cleaned.tlsSettings = createTlsSettingsWithDefaultCert();
  329. }
  330. if (next === 'reality') {
  331. const reality = RealityStreamSettingsSchema.parse({}) as Record<string, unknown>;
  332. reality.target = '';
  333. reality.serverNames = [];
  334. reality.shortIds = RandomUtil.randomShortIds()
  335. .split(',')
  336. .map((s) => s.trim())
  337. .filter(Boolean);
  338. cleaned.realitySettings = reality;
  339. }
  340. setValue('streamSettings', cleaned);
  341. if (next === 'reality') {
  342. randomizeSpiderX();
  343. try {
  344. const msg = await HttpUtil.get('/panel/api/server/getNewX25519Cert');
  345. if (msg?.success) {
  346. const obj = msg.obj as { privateKey: string; publicKey: string };
  347. setValue('streamSettings.realitySettings.privateKey', obj.privateKey);
  348. setValue('streamSettings.realitySettings.settings.publicKey', obj.publicKey);
  349. }
  350. } catch {
  351. /* best-effort: leave keypair fields empty if server call fails */
  352. }
  353. }
  354. };
  355. return {
  356. genRealityKeypair,
  357. clearRealityKeypair,
  358. genMldsa65,
  359. clearMldsa65,
  360. scanRealityTarget,
  361. scanRealityCandidates,
  362. applyRealityScanResult,
  363. randomizeShortIds,
  364. randomizeSpiderX,
  365. getNewEchCert,
  366. clearEchCert,
  367. pinFromCert,
  368. pinFromRemote,
  369. setCertFromPanel,
  370. clearCertFiles,
  371. onSecurityChange,
  372. };
  373. }