From 1c0fdb4527819ca8c07b7dd1397003d0a8ee9cdf Mon Sep 17 00:00:00 2001 From: MHSanaei Date: Sat, 13 Jun 2026 11:48:02 +0200 Subject: [PATCH] fix(outbounds): test subscriptions in Test All, skip direct/dns Test All only iterated the editable template outbounds, so subscription outbounds (the read-only "from subscriptions" table) were never probed in bulk. They are now queued too, keyed by tag in subscriptionTestStates so their rows light up live; the template and subscription HTTP lanes run serially to respect the backend's single-batch lock (TCP runs alongside). Also stop testing freedom ("direct") and dns outbounds: they aren't proxies, so an HTTP probe through them only measures the host's own reachability, not a tunnel. They are now untestable in every mode -- the per-row button is disabled and Test All skips them -- with a matching backend guard so a direct API caller can't HTTP-test them either. --- frontend/src/hooks/useXraySetting.ts | 85 +++++++++++++++---- .../pages/xray/outbounds/OutboundCardList.tsx | 2 +- .../xray/outbounds/SubscriptionOutbounds.tsx | 2 +- .../xray/outbounds/outbounds-tab-helpers.ts | 7 +- .../xray/outbounds/useOutboundColumns.tsx | 2 +- internal/web/service/outbound/probe_http.go | 4 + 6 files changed, 79 insertions(+), 23 deletions(-) diff --git a/frontend/src/hooks/useXraySetting.ts b/frontend/src/hooks/useXraySetting.ts index 9542b86cb..271513011 100644 --- a/frontend/src/hooks/useXraySetting.ts +++ b/frontend/src/hooks/useXraySetting.ts @@ -142,10 +142,12 @@ export function useXraySetting(): UseXraySettingResult { const xraySettingRef = useRef(''); const outboundTestUrlRef = useRef(outboundTestUrl); const templateSettingsRef = useRef(null); + const subscriptionOutboundsRef = useRef([]); xraySettingRef.current = xraySetting; outboundTestUrlRef.current = outboundTestUrl; templateSettingsRef.current = templateSettings; + subscriptionOutboundsRef.current = subscriptionOutbounds; // Seed local editor state from the config query. Runs on first fetch and // every time the query refetches (e.g. after a successful save). @@ -316,41 +318,61 @@ export function useXraySetting(): UseXraySettingResult { ); const testAllOutbounds = useCallback(async (mode = 'tcp') => { - const list = templateSettingsRef.current?.outbounds || []; - if (list.length === 0 || testingAll) return; + // Template outbounds key their results by index (outboundTestStates); + // subscription outbounds aren't in the template, so they key by tag + // (subscriptionTestStates). Both go through the same probe endpoint. + const templateList = templateSettingsRef.current?.outbounds || []; + const subList = (subscriptionOutboundsRef.current || []) as Array<{ tag?: string; protocol?: string }>; + if ((templateList.length === 0 && subList.length === 0) || testingAll) return; setTestingAll(true); try { - const tcpQueue: { index: number; outbound: unknown }[] = []; - const httpQueue: { index: number; outbound: unknown }[] = []; - list.forEach((ob, i) => { - const tag = ob?.tag; + type TcpEntry = + | { kind: 'tpl'; index: number; outbound: unknown } + | { kind: 'sub'; tag: string; outbound: unknown }; + const tcpQueue: TcpEntry[] = []; + // HTTP batches stay homogeneous (all template or all subscription) so a + // tag shared between a template and a subscription outbound can't collide + // inside one batch, and each batch's results route to one state map. + const httpTplQueue: { index: number; outbound: unknown }[] = []; + const httpSubQueue: { tag: string; outbound: unknown }[] = []; + const enqueue = (ob: { tag?: string; protocol?: string }, kind: 'tpl' | 'sub', index: number, tag: string) => { const proto = ob?.protocol; - if (proto === 'blackhole' || proto === 'loopback' || tag === 'blocked') return; - if (mode === 'tcp' && (proto === 'freedom' || proto === 'dns')) return; - if (mode === 'http' || isUdpOutbound(ob)) { - httpQueue.push({ index: i, outbound: ob }); + if (proto === 'blackhole' || proto === 'loopback' || ob?.tag === 'blocked') return; + // freedom ("direct") and dns aren't proxies — skip them in every mode. + if (proto === 'freedom' || proto === 'dns') return; + if (kind === 'sub' && !tag) return; + const toHttp = mode === 'http' || isUdpOutbound(ob); + if (kind === 'tpl') { + if (toHttp) httpTplQueue.push({ index, outbound: ob }); + else tcpQueue.push({ kind: 'tpl', index, outbound: ob }); + } else if (toHttp) { + httpSubQueue.push({ tag, outbound: ob }); } else { - tcpQueue.push({ index: i, outbound: ob }); + tcpQueue.push({ kind: 'sub', tag, outbound: ob }); } - }); + }; + templateList.forEach((ob, i) => enqueue(ob, 'tpl', i, '')); + subList.forEach((ob) => enqueue(ob, 'sub', -1, typeof ob?.tag === 'string' ? ob.tag : '')); + // TCP probes are dial-only and cheap server-side; per-item requests - // keep results landing one by one. + // keep results landing one by one, each routed to its own state map. const runTcpLane = async () => { const queue = [...tcpQueue]; const worker = async () => { while (queue.length > 0) { const item = queue.shift(); if (!item) break; - await testOutbound(item.index, item.outbound, mode); + if (item.kind === 'sub') await testSubscriptionOutbound(item.tag, item.outbound, mode); + else await testOutbound(item.index, item.outbound, mode); } }; await Promise.all(Array.from({ length: Math.min(8, queue.length) }, () => worker())); }; // HTTP probes go out as chunked batches — one temp xray spawn per // chunk instead of one per outbound, with results landing per chunk. - const runHttpLane = async () => { - for (let at = 0; at < httpQueue.length; at += HTTP_BATCH_CHUNK) { - const chunk = httpQueue.slice(at, at + HTTP_BATCH_CHUNK); + const runTplHttpLane = async () => { + for (let at = 0; at < httpTplQueue.length; at += HTTP_BATCH_CHUNK) { + const chunk = httpTplQueue.slice(at, at + HTTP_BATCH_CHUNK); setOutboundTestStates((prev) => { const next = { ...prev }; for (const item of chunk) next[item.index] = { testing: true, result: null, mode: 'http' }; @@ -366,11 +388,38 @@ export function useXraySetting(): UseXraySettingResult { }); } }; + const runSubHttpLane = async () => { + for (let at = 0; at < httpSubQueue.length; at += HTTP_BATCH_CHUNK) { + const chunk = httpSubQueue.slice(at, at + HTTP_BATCH_CHUNK); + setSubscriptionTestStates((prev) => { + const next = { ...prev }; + for (const item of chunk) next[item.tag] = { testing: true, result: null, mode: 'http' }; + return next; + }); + const results = await postOutboundTestBatch(chunk.map((c) => c.outbound), 'http'); + setSubscriptionTestStates((prev) => { + const next = { ...prev }; + chunk.forEach((item, i) => { + next[item.tag] = { testing: false, result: results[i] }; + }); + return next; + }); + } + }; + // HTTP batches must not overlap: the backend serialises them with a + // non-blocking lock and rejects a second concurrent batch ("Another + // outbound test is already running"). Run the template and subscription + // HTTP lanes one after the other; TCP probes don't take that lock, so + // they still run alongside. + const runHttpLane = async () => { + await runTplHttpLane(); + await runSubHttpLane(); + }; await Promise.all([runTcpLane(), runHttpLane()]); } finally { setTestingAll(false); } - }, [testingAll, testOutbound, postOutboundTestBatch]); + }, [testingAll, testOutbound, testSubscriptionOutbound, postOutboundTestBatch]); useEffect(() => { const timer = window.setInterval(() => { diff --git a/frontend/src/pages/xray/outbounds/OutboundCardList.tsx b/frontend/src/pages/xray/outbounds/OutboundCardList.tsx index 0fa65ac3e..4f7bd47ff 100644 --- a/frontend/src/pages/xray/outbounds/OutboundCardList.tsx +++ b/frontend/src/pages/xray/outbounds/OutboundCardList.tsx @@ -110,7 +110,7 @@ export default function OutboundCardList({ shape="circle" size="small" loading={isTesting(outboundTestStates, index)} - disabled={isUntestable(record, testMode) || isTesting(outboundTestStates, index)} + disabled={isUntestable(record) || isTesting(outboundTestStates, index)} icon={} onClick={() => onTest(index, testMode)} /> diff --git a/frontend/src/pages/xray/outbounds/SubscriptionOutbounds.tsx b/frontend/src/pages/xray/outbounds/SubscriptionOutbounds.tsx index 0ef0ba5fe..22bda3202 100644 --- a/frontend/src/pages/xray/outbounds/SubscriptionOutbounds.tsx +++ b/frontend/src/pages/xray/outbounds/SubscriptionOutbounds.tsx @@ -110,7 +110,7 @@ export default function SubscriptionOutbounds({ shape="circle" size={isMobile ? 'small' : undefined} loading={isTesting(subscriptionTestStates, key)} - disabled={!record.tag || isUntestable(record, testMode) || isTesting(subscriptionTestStates, key)} + disabled={!record.tag || isUntestable(record) || isTesting(subscriptionTestStates, key)} icon={} onClick={() => onTestSubscription(record as unknown as Record, testMode)} /> diff --git a/frontend/src/pages/xray/outbounds/outbounds-tab-helpers.ts b/frontend/src/pages/xray/outbounds/outbounds-tab-helpers.ts index b09f93dd3..80e00023f 100644 --- a/frontend/src/pages/xray/outbounds/outbounds-tab-helpers.ts +++ b/frontend/src/pages/xray/outbounds/outbounds-tab-helpers.ts @@ -31,10 +31,13 @@ export function outboundAddresses(o: OutboundRow): string[] { } } -export function isUntestable(o: OutboundRow, mode: string): boolean { +export function isUntestable(o: OutboundRow): boolean { if (!o) return true; if (o.protocol === Protocols.Blackhole || o.protocol === Protocols.Loopback || o.tag === 'blocked') return true; - if (mode === 'tcp' && (o.protocol === Protocols.Freedom || o.protocol === Protocols.DNS)) return true; + // freedom ("direct") and dns aren't proxies — a TCP dial has no endpoint and + // an HTTP probe would only measure the host's own direct reachability, so + // they're untestable in every mode. + if (o.protocol === Protocols.Freedom || o.protocol === Protocols.DNS) return true; return false; } diff --git a/frontend/src/pages/xray/outbounds/useOutboundColumns.tsx b/frontend/src/pages/xray/outbounds/useOutboundColumns.tsx index 702ff49b5..37ecd3f25 100644 --- a/frontend/src/pages/xray/outbounds/useOutboundColumns.tsx +++ b/frontend/src/pages/xray/outbounds/useOutboundColumns.tsx @@ -172,7 +172,7 @@ export function useOutboundColumns({ type="primary" shape="circle" loading={isTesting(outboundTestStates, index)} - disabled={isUntestable(record, testMode) || isTesting(outboundTestStates, index)} + disabled={isUntestable(record) || isTesting(outboundTestStates, index)} icon={} onClick={() => onTest(index, testMode)} /> diff --git a/internal/web/service/outbound/probe_http.go b/internal/web/service/outbound/probe_http.go index 4bb16a29e..072528ae5 100644 --- a/internal/web/service/outbound/probe_http.go +++ b/internal/web/service/outbound/probe_http.go @@ -160,6 +160,10 @@ func (s *OutboundService) testOutboundsParsed(items []map[string]any, testURL st r.Error = "Blocked/blackhole outbound cannot be tested" case protocol == "loopback": r.Error = "Loopback outbound cannot be tested" + case protocol == "freedom" || protocol == "dns": + // Direct/DNS outbounds aren't proxies — an HTTP probe through them + // would only measure the host's own reachability, not a tunnel. + r.Error = "Direct/DNS outbound cannot be tested" case seenTags[tag]: r.Error = fmt.Sprintf("Duplicate outbound tag in batch: %s", tag) default: