useXraySetting.ts 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497
  1. import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
  2. import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
  3. import { z } from 'zod';
  4. import { HttpUtil, Msg } from '@/utils';
  5. import { parseMsg } from '@/utils/zodValidate';
  6. import { keys } from '@/api/queryKeys';
  7. import {
  8. OutboundTrafficListSchema,
  9. OutboundTestResultListSchema,
  10. XrayConfigPayloadSchema,
  11. XraySettingsValueSchema,
  12. type OutboundTestResult,
  13. type OutboundTrafficRow,
  14. } from '@/schemas/xray';
  15. const DIRTY_POLL_MS = 1000;
  16. const DEFAULT_TEST_URL = 'https://www.google.com/generate_204';
  17. // One HTTP-mode batch request tests this many outbounds through a single
  18. // shared temp xray instance; chunking keeps responses bounded (~30s worst
  19. // case — each probe is a cold plus a warm request) and lands Test All
  20. // results progressively.
  21. const HTTP_BATCH_CHUNK = 16;
  22. export function isUdpOutbound(outbound: unknown): boolean {
  23. const o = outbound as { protocol?: string; streamSettings?: { network?: string } } | null | undefined;
  24. const p = o?.protocol;
  25. const n = o?.streamSettings?.network;
  26. return p === 'wireguard' || p === 'hysteria' || n === 'hysteria' || n === 'kcp' || n === 'quic';
  27. }
  28. export type OutboundTestMode = 'tcp' | 'http' | 'real';
  29. export type { OutboundTrafficRow, OutboundTestResult };
  30. export type XraySettingsValue = z.infer<typeof XraySettingsValueSchema>;
  31. export interface OutboundTestState {
  32. testing?: boolean;
  33. result?: OutboundTestResult | null;
  34. mode?: string;
  35. }
  36. export type SetTemplate = (
  37. next: XraySettingsValue | null | ((prev: XraySettingsValue | null) => XraySettingsValue | null),
  38. ) => void;
  39. export interface UseXraySettingResult {
  40. fetched: boolean;
  41. spinning: boolean;
  42. saveDisabled: boolean;
  43. fetchError: string;
  44. xraySetting: string;
  45. setXraySetting: (next: string) => void;
  46. templateSettings: XraySettingsValue | null;
  47. setTemplateSettings: SetTemplate;
  48. outboundTestUrl: string;
  49. setOutboundTestUrl: (v: string) => void;
  50. inboundTags: string[];
  51. clientReverseTags: string[];
  52. subscriptionOutbounds: unknown[];
  53. subscriptionOutboundTags: string[];
  54. outboundsTraffic: OutboundTrafficRow[];
  55. outboundTestStates: Record<number, OutboundTestState>;
  56. subscriptionTestStates: Record<string, OutboundTestState>;
  57. testingAll: boolean;
  58. fetchAll: () => Promise<void>;
  59. fetchOutboundsTraffic: () => Promise<void>;
  60. resetOutboundsTraffic: (tag: string) => Promise<void>;
  61. testOutbound: (
  62. index: number,
  63. outbound: unknown,
  64. mode?: string,
  65. ) => Promise<OutboundTestResult | null>;
  66. testSubscriptionOutbound: (
  67. tag: string,
  68. outbound: unknown,
  69. mode?: string,
  70. ) => Promise<OutboundTestResult | null>;
  71. testAllOutbounds: (mode?: string) => Promise<void>;
  72. saveAll: () => Promise<void>;
  73. resetToDefault: () => Promise<void>;
  74. }
  75. type XrayConfigPayload = z.infer<typeof XrayConfigPayloadSchema>;
  76. export async function fetchXrayConfig(): Promise<XrayConfigPayload> {
  77. const msg = await HttpUtil.post('/panel/api/xray/', undefined, { silent: true });
  78. if (!msg?.success) throw new Error(msg?.msg || 'Failed to load xray config');
  79. if (typeof msg.obj !== 'string') throw new Error('Malformed xray config response: expected string');
  80. let parsed: unknown;
  81. try {
  82. parsed = JSON.parse(msg.obj);
  83. } catch (e) {
  84. const err = e as Error;
  85. throw new Error(`Malformed xray config response: ${err.message}`, { cause: e });
  86. }
  87. const result = XrayConfigPayloadSchema.safeParse(parsed);
  88. if (!result.success) {
  89. console.warn('[zod] xray/ config payload failed validation', result.error.issues);
  90. return parsed as XrayConfigPayload;
  91. }
  92. return result.data;
  93. }
  94. async function fetchOutboundsTraffic(): Promise<OutboundTrafficRow[]> {
  95. const msg = await HttpUtil.get('/panel/api/xray/getOutboundsTraffic', undefined, { silent: true });
  96. if (!msg?.success) throw new Error(msg?.msg || 'Failed to fetch outbounds traffic');
  97. const validated = parseMsg(msg, OutboundTrafficListSchema, 'xray/getOutboundsTraffic');
  98. return Array.isArray(validated.obj) ? validated.obj : [];
  99. }
  100. export function useXraySetting(): UseXraySettingResult {
  101. const queryClient = useQueryClient();
  102. const configQuery = useQuery({
  103. queryKey: keys.xray.config(),
  104. queryFn: fetchXrayConfig,
  105. staleTime: Infinity,
  106. });
  107. const trafficQuery = useQuery({
  108. queryKey: keys.xray.outboundsTraffic(),
  109. queryFn: fetchOutboundsTraffic,
  110. staleTime: Infinity,
  111. });
  112. const [saveDisabled, setSaveDisabled] = useState(true);
  113. const [xraySetting, setXraySettingState] = useState('');
  114. const [templateSettings, setTemplateSettingsState] = useState<XraySettingsValue | null>(null);
  115. const [outboundTestUrl, setOutboundTestUrlState] = useState(DEFAULT_TEST_URL);
  116. const [inboundTags, setInboundTags] = useState<string[]>([]);
  117. const [clientReverseTags, setClientReverseTags] = useState<string[]>([]);
  118. const [subscriptionOutbounds, setSubscriptionOutbounds] = useState<unknown[]>([]);
  119. const [subscriptionOutboundTags, setSubscriptionOutboundTags] = useState<string[]>([]);
  120. const [outboundTestStates, setOutboundTestStates] = useState<Record<number, OutboundTestState>>({});
  121. // Subscription outbounds aren't in templateSettings.outbounds, so their test
  122. // results are keyed by tag rather than by index.
  123. const [subscriptionTestStates, setSubscriptionTestStates] = useState<Record<string, OutboundTestState>>({});
  124. const [testingAll, setTestingAll] = useState(false);
  125. const oldXraySettingRef = useRef('');
  126. const oldOutboundTestUrlRef = useRef('');
  127. const syncingRef = useRef(false);
  128. const xraySettingRef = useRef('');
  129. const outboundTestUrlRef = useRef(outboundTestUrl);
  130. const templateSettingsRef = useRef<XraySettingsValue | null>(null);
  131. const subscriptionOutboundsRef = useRef<unknown[]>([]);
  132. xraySettingRef.current = xraySetting;
  133. outboundTestUrlRef.current = outboundTestUrl;
  134. templateSettingsRef.current = templateSettings;
  135. subscriptionOutboundsRef.current = subscriptionOutbounds;
  136. // Seed local editor state from the config query. Runs on first fetch and
  137. // every time the query refetches (e.g. after a successful save).
  138. useEffect(() => {
  139. if (!configQuery.data) return;
  140. const obj = configQuery.data;
  141. const pretty = JSON.stringify(obj.xraySetting, null, 2);
  142. syncingRef.current = true;
  143. setXraySettingState(pretty);
  144. setTemplateSettingsState(obj.xraySetting);
  145. oldXraySettingRef.current = pretty;
  146. syncingRef.current = false;
  147. setInboundTags(obj.inboundTags || []);
  148. setClientReverseTags(obj.clientReverseTags || []);
  149. setSubscriptionOutbounds(obj.subscriptionOutbounds || []);
  150. setSubscriptionOutboundTags(obj.subscriptionOutboundTags || []);
  151. const nextUrl = obj.outboundTestUrl || DEFAULT_TEST_URL;
  152. setOutboundTestUrlState(nextUrl);
  153. oldOutboundTestUrlRef.current = nextUrl;
  154. setSaveDisabled(true);
  155. }, [configQuery.data]);
  156. const fetched = configQuery.data !== undefined || configQuery.isError;
  157. const fetchError = configQuery.error ? (configQuery.error as Error).message : '';
  158. const setXraySetting = useCallback((next: string) => {
  159. setXraySettingState(next);
  160. if (syncingRef.current) return;
  161. try {
  162. const parsed = JSON.parse(next);
  163. syncingRef.current = true;
  164. setTemplateSettingsState(parsed);
  165. syncingRef.current = false;
  166. } catch {
  167. /* ignore — wait for user to finish */
  168. }
  169. }, []);
  170. const setTemplateSettings: SetTemplate = useCallback((nextOrFn) => {
  171. setTemplateSettingsState((prev) => {
  172. const next = typeof nextOrFn === 'function' ? nextOrFn(prev) : nextOrFn;
  173. if (next == null) return next;
  174. if (!syncingRef.current) {
  175. try {
  176. syncingRef.current = true;
  177. setXraySettingState(JSON.stringify(next, null, 2));
  178. } finally {
  179. syncingRef.current = false;
  180. }
  181. }
  182. return next;
  183. });
  184. }, []);
  185. const setOutboundTestUrl = useCallback((v: string) => {
  186. setOutboundTestUrlState(v);
  187. }, []);
  188. const fetchAll = useCallback(async () => {
  189. await queryClient.invalidateQueries({ queryKey: keys.xray.config() });
  190. }, [queryClient]);
  191. const fetchOutboundsTrafficCb = useCallback(async () => {
  192. await queryClient.invalidateQueries({ queryKey: keys.xray.outboundsTraffic() });
  193. }, [queryClient]);
  194. const saveMut = useMutation({
  195. mutationFn: async () => {
  196. const sentXraySetting = xraySettingRef.current;
  197. const sentTestUrl = outboundTestUrlRef.current || DEFAULT_TEST_URL;
  198. const msg = await HttpUtil.post('/panel/api/xray/update', {
  199. xraySetting: sentXraySetting,
  200. outboundTestUrl: sentTestUrl,
  201. });
  202. return { msg, sentXraySetting, sentTestUrl };
  203. },
  204. onSuccess: ({ msg, sentXraySetting, sentTestUrl }) => {
  205. if (!msg?.success) return;
  206. oldXraySettingRef.current = sentXraySetting;
  207. oldOutboundTestUrlRef.current = sentTestUrl;
  208. setSaveDisabled(true);
  209. queryClient.invalidateQueries({ queryKey: keys.xray.config() });
  210. },
  211. });
  212. const resetTrafficMut = useMutation({
  213. mutationFn: (tag: string) =>
  214. HttpUtil.post('/panel/api/xray/resetOutboundsTraffic', { tag }),
  215. onSuccess: (msg) => {
  216. if (msg?.success) queryClient.invalidateQueries({ queryKey: keys.xray.outboundsTraffic() });
  217. },
  218. });
  219. const resetDefaultMut = useMutation({
  220. mutationFn: async (): Promise<Msg<XraySettingsValue>> => {
  221. const raw = await HttpUtil.get('/panel/api/setting/getDefaultJsonConfig');
  222. return parseMsg(raw, XraySettingsValueSchema, 'setting/getDefaultJsonConfig');
  223. },
  224. onSuccess: (msg) => {
  225. if (msg?.success && msg.obj) {
  226. const cloned = JSON.parse(JSON.stringify(msg.obj));
  227. setTemplateSettings(cloned);
  228. }
  229. },
  230. });
  231. const saveAll = useCallback(async () => { await saveMut.mutateAsync(); }, [saveMut]);
  232. const resetOutboundsTraffic = useCallback(async (tag: string) => { await resetTrafficMut.mutateAsync(tag); }, [resetTrafficMut]);
  233. const resetToDefault = useCallback(async () => { await resetDefaultMut.mutateAsync(); }, [resetDefaultMut]);
  234. const spinning = saveMut.isPending || resetDefaultMut.isPending;
  235. // Shared POST + parse for a batch of outbound tests. The backend probes the
  236. // whole batch through one shared temp xray instance and returns results in
  237. // request order; this aligns them by index and shapes failures so every
  238. // input gets an OutboundTestResult.
  239. const postOutboundTestBatch = useCallback(
  240. async (outbounds: unknown[], effMode: string): Promise<OutboundTestResult[]> => {
  241. const failAll = (error: string): OutboundTestResult[] =>
  242. outbounds.map(() => ({ success: false, error, mode: effMode }));
  243. try {
  244. const raw = await HttpUtil.post('/panel/api/xray/testOutbounds', {
  245. outbounds: JSON.stringify(outbounds),
  246. allOutbounds: JSON.stringify(templateSettingsRef.current?.outbounds || []),
  247. mode: effMode,
  248. });
  249. const msg = parseMsg(raw, OutboundTestResultListSchema, 'xray/testOutbounds');
  250. if (!msg?.success || !Array.isArray(msg.obj)) return failAll(msg?.msg || 'Unknown error');
  251. const list = msg.obj;
  252. return outbounds.map((_ob, i) => list[i] ?? { success: false, error: 'Missing result', mode: effMode });
  253. } catch (e) {
  254. return failAll(String(e));
  255. }
  256. },
  257. [],
  258. );
  259. const testOutbound = useCallback(
  260. async (index: number, outbound: unknown, mode = 'tcp'): Promise<OutboundTestResult | null> => {
  261. if (!outbound) return null;
  262. const effMode = mode === 'tcp' && isUdpOutbound(outbound) ? 'http' : mode;
  263. setOutboundTestStates((prev) => ({
  264. ...prev,
  265. [index]: { testing: true, result: null, mode: effMode },
  266. }));
  267. const [result] = await postOutboundTestBatch([outbound], effMode);
  268. setOutboundTestStates((prev) => ({ ...prev, [index]: { testing: false, result } }));
  269. return result.success ? result : null;
  270. },
  271. [postOutboundTestBatch],
  272. );
  273. // Test a subscription outbound (not present in templateSettings.outbounds);
  274. // results are keyed by tag in subscriptionTestStates.
  275. const testSubscriptionOutbound = useCallback(
  276. async (tag: string, outbound: unknown, mode = 'tcp'): Promise<OutboundTestResult | null> => {
  277. if (!outbound || !tag) return null;
  278. const effMode = mode === 'tcp' && isUdpOutbound(outbound) ? 'http' : mode;
  279. setSubscriptionTestStates((prev) => ({
  280. ...prev,
  281. [tag]: { testing: true, result: null, mode: effMode },
  282. }));
  283. const [result] = await postOutboundTestBatch([outbound], effMode);
  284. setSubscriptionTestStates((prev) => ({ ...prev, [tag]: { testing: false, result } }));
  285. return result.success ? result : null;
  286. },
  287. [postOutboundTestBatch],
  288. );
  289. const testAllOutbounds = useCallback(async (mode = 'tcp') => {
  290. // Template outbounds key their results by index (outboundTestStates);
  291. // subscription outbounds aren't in the template, so they key by tag
  292. // (subscriptionTestStates). Both go through the same probe endpoint.
  293. const templateList = templateSettingsRef.current?.outbounds || [];
  294. const subList = (subscriptionOutboundsRef.current || []) as Array<{ tag?: string; protocol?: string }>;
  295. if ((templateList.length === 0 && subList.length === 0) || testingAll) return;
  296. setTestingAll(true);
  297. try {
  298. type TcpEntry =
  299. | { kind: 'tpl'; index: number; outbound: unknown }
  300. | { kind: 'sub'; tag: string; outbound: unknown };
  301. const tcpQueue: TcpEntry[] = [];
  302. // HTTP batches stay homogeneous (all template or all subscription) so a
  303. // tag shared between a template and a subscription outbound can't collide
  304. // inside one batch, and each batch's results route to one state map.
  305. const probeMode = mode === 'real' ? 'real' : 'http';
  306. const httpTplQueue: { index: number; outbound: unknown }[] = [];
  307. const httpSubQueue: { tag: string; outbound: unknown }[] = [];
  308. const enqueue = (ob: { tag?: string; protocol?: string }, kind: 'tpl' | 'sub', index: number, tag: string) => {
  309. const proto = ob?.protocol;
  310. if (proto === 'blackhole' || proto === 'loopback' || ob?.tag === 'blocked') return;
  311. // freedom ("direct") and dns aren't proxies — skip them in every mode.
  312. if (proto === 'freedom' || proto === 'dns') return;
  313. if (kind === 'sub' && !tag) return;
  314. const toHttp = mode !== 'tcp' || isUdpOutbound(ob);
  315. if (kind === 'tpl') {
  316. if (toHttp) httpTplQueue.push({ index, outbound: ob });
  317. else tcpQueue.push({ kind: 'tpl', index, outbound: ob });
  318. } else if (toHttp) {
  319. httpSubQueue.push({ tag, outbound: ob });
  320. } else {
  321. tcpQueue.push({ kind: 'sub', tag, outbound: ob });
  322. }
  323. };
  324. templateList.forEach((ob, i) => enqueue(ob, 'tpl', i, ''));
  325. subList.forEach((ob) => enqueue(ob, 'sub', -1, typeof ob?.tag === 'string' ? ob.tag : ''));
  326. // TCP probes are dial-only and cheap server-side; per-item requests
  327. // keep results landing one by one, each routed to its own state map.
  328. const runTcpLane = async () => {
  329. const queue = [...tcpQueue];
  330. const worker = async () => {
  331. while (queue.length > 0) {
  332. const item = queue.shift();
  333. if (!item) break;
  334. if (item.kind === 'sub') await testSubscriptionOutbound(item.tag, item.outbound, mode);
  335. else await testOutbound(item.index, item.outbound, mode);
  336. }
  337. };
  338. await Promise.all(Array.from({ length: Math.min(8, queue.length) }, () => worker()));
  339. };
  340. // HTTP probes go out as chunked batches — one temp xray spawn per
  341. // chunk instead of one per outbound, with results landing per chunk.
  342. const runTplHttpLane = async () => {
  343. for (let at = 0; at < httpTplQueue.length; at += HTTP_BATCH_CHUNK) {
  344. const chunk = httpTplQueue.slice(at, at + HTTP_BATCH_CHUNK);
  345. setOutboundTestStates((prev) => {
  346. const next = { ...prev };
  347. for (const item of chunk) next[item.index] = { testing: true, result: null, mode: probeMode };
  348. return next;
  349. });
  350. const results = await postOutboundTestBatch(chunk.map((c) => c.outbound), probeMode);
  351. setOutboundTestStates((prev) => {
  352. const next = { ...prev };
  353. chunk.forEach((item, i) => {
  354. next[item.index] = { testing: false, result: results[i] };
  355. });
  356. return next;
  357. });
  358. }
  359. };
  360. const runSubHttpLane = async () => {
  361. for (let at = 0; at < httpSubQueue.length; at += HTTP_BATCH_CHUNK) {
  362. const chunk = httpSubQueue.slice(at, at + HTTP_BATCH_CHUNK);
  363. setSubscriptionTestStates((prev) => {
  364. const next = { ...prev };
  365. for (const item of chunk) next[item.tag] = { testing: true, result: null, mode: probeMode };
  366. return next;
  367. });
  368. const results = await postOutboundTestBatch(chunk.map((c) => c.outbound), probeMode);
  369. setSubscriptionTestStates((prev) => {
  370. const next = { ...prev };
  371. chunk.forEach((item, i) => {
  372. next[item.tag] = { testing: false, result: results[i] };
  373. });
  374. return next;
  375. });
  376. }
  377. };
  378. // HTTP batches must not overlap: the backend serialises them with a
  379. // non-blocking lock and rejects a second concurrent batch ("Another
  380. // outbound test is already running"). Run the template and subscription
  381. // HTTP lanes one after the other; TCP probes don't take that lock, so
  382. // they still run alongside.
  383. const runHttpLane = async () => {
  384. await runTplHttpLane();
  385. await runSubHttpLane();
  386. };
  387. await Promise.all([runTcpLane(), runHttpLane()]);
  388. } finally {
  389. setTestingAll(false);
  390. }
  391. }, [testingAll, testOutbound, testSubscriptionOutbound, postOutboundTestBatch]);
  392. useEffect(() => {
  393. const timer = window.setInterval(() => {
  394. const dirtyXray = oldXraySettingRef.current !== xraySettingRef.current;
  395. const dirtyUrl = oldOutboundTestUrlRef.current !== outboundTestUrlRef.current;
  396. setSaveDisabled(!(dirtyXray || dirtyUrl));
  397. }, DIRTY_POLL_MS);
  398. return () => window.clearInterval(timer);
  399. }, []);
  400. const outboundsTraffic = useMemo(() => trafficQuery.data ?? [], [trafficQuery.data]);
  401. return useMemo(
  402. () => ({
  403. fetched,
  404. spinning,
  405. saveDisabled,
  406. fetchError,
  407. xraySetting,
  408. setXraySetting,
  409. templateSettings,
  410. setTemplateSettings,
  411. outboundTestUrl,
  412. setOutboundTestUrl,
  413. inboundTags,
  414. clientReverseTags,
  415. subscriptionOutbounds,
  416. subscriptionOutboundTags,
  417. outboundsTraffic,
  418. outboundTestStates,
  419. subscriptionTestStates,
  420. testingAll,
  421. fetchAll,
  422. fetchOutboundsTraffic: fetchOutboundsTrafficCb,
  423. resetOutboundsTraffic,
  424. testOutbound,
  425. testSubscriptionOutbound,
  426. testAllOutbounds,
  427. saveAll,
  428. resetToDefault,
  429. }),
  430. [
  431. fetched,
  432. spinning,
  433. saveDisabled,
  434. fetchError,
  435. xraySetting,
  436. setXraySetting,
  437. templateSettings,
  438. setTemplateSettings,
  439. outboundTestUrl,
  440. setOutboundTestUrl,
  441. inboundTags,
  442. clientReverseTags,
  443. subscriptionOutbounds,
  444. subscriptionOutboundTags,
  445. outboundsTraffic,
  446. outboundTestStates,
  447. subscriptionTestStates,
  448. testingAll,
  449. fetchAll,
  450. fetchOutboundsTrafficCb,
  451. resetOutboundsTraffic,
  452. testOutbound,
  453. testSubscriptionOutbound,
  454. testAllOutbounds,
  455. saveAll,
  456. resetToDefault,
  457. ],
  458. );
  459. }