mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-08-29 14:37:13 +00:00
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.
This commit is contained in:
@@ -142,10 +142,12 @@ export function useXraySetting(): UseXraySettingResult {
|
|||||||
const xraySettingRef = useRef('');
|
const xraySettingRef = useRef('');
|
||||||
const outboundTestUrlRef = useRef(outboundTestUrl);
|
const outboundTestUrlRef = useRef(outboundTestUrl);
|
||||||
const templateSettingsRef = useRef<XraySettingsValue | null>(null);
|
const templateSettingsRef = useRef<XraySettingsValue | null>(null);
|
||||||
|
const subscriptionOutboundsRef = useRef<unknown[]>([]);
|
||||||
|
|
||||||
xraySettingRef.current = xraySetting;
|
xraySettingRef.current = xraySetting;
|
||||||
outboundTestUrlRef.current = outboundTestUrl;
|
outboundTestUrlRef.current = outboundTestUrl;
|
||||||
templateSettingsRef.current = templateSettings;
|
templateSettingsRef.current = templateSettings;
|
||||||
|
subscriptionOutboundsRef.current = subscriptionOutbounds;
|
||||||
|
|
||||||
// Seed local editor state from the config query. Runs on first fetch and
|
// Seed local editor state from the config query. Runs on first fetch and
|
||||||
// every time the query refetches (e.g. after a successful save).
|
// 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 testAllOutbounds = useCallback(async (mode = 'tcp') => {
|
||||||
const list = templateSettingsRef.current?.outbounds || [];
|
// Template outbounds key their results by index (outboundTestStates);
|
||||||
if (list.length === 0 || testingAll) return;
|
// 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);
|
setTestingAll(true);
|
||||||
try {
|
try {
|
||||||
const tcpQueue: { index: number; outbound: unknown }[] = [];
|
type TcpEntry =
|
||||||
const httpQueue: { index: number; outbound: unknown }[] = [];
|
| { kind: 'tpl'; index: number; outbound: unknown }
|
||||||
list.forEach((ob, i) => {
|
| { kind: 'sub'; tag: string; outbound: unknown };
|
||||||
const tag = ob?.tag;
|
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;
|
const proto = ob?.protocol;
|
||||||
if (proto === 'blackhole' || proto === 'loopback' || tag === 'blocked') return;
|
if (proto === 'blackhole' || proto === 'loopback' || ob?.tag === 'blocked') return;
|
||||||
if (mode === 'tcp' && (proto === 'freedom' || proto === 'dns')) return;
|
// freedom ("direct") and dns aren't proxies — skip them in every mode.
|
||||||
if (mode === 'http' || isUdpOutbound(ob)) {
|
if (proto === 'freedom' || proto === 'dns') return;
|
||||||
httpQueue.push({ index: i, outbound: ob });
|
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 {
|
} 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
|
// 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 runTcpLane = async () => {
|
||||||
const queue = [...tcpQueue];
|
const queue = [...tcpQueue];
|
||||||
const worker = async () => {
|
const worker = async () => {
|
||||||
while (queue.length > 0) {
|
while (queue.length > 0) {
|
||||||
const item = queue.shift();
|
const item = queue.shift();
|
||||||
if (!item) break;
|
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()));
|
await Promise.all(Array.from({ length: Math.min(8, queue.length) }, () => worker()));
|
||||||
};
|
};
|
||||||
// HTTP probes go out as chunked batches — one temp xray spawn per
|
// HTTP probes go out as chunked batches — one temp xray spawn per
|
||||||
// chunk instead of one per outbound, with results landing per chunk.
|
// chunk instead of one per outbound, with results landing per chunk.
|
||||||
const runHttpLane = async () => {
|
const runTplHttpLane = async () => {
|
||||||
for (let at = 0; at < httpQueue.length; at += HTTP_BATCH_CHUNK) {
|
for (let at = 0; at < httpTplQueue.length; at += HTTP_BATCH_CHUNK) {
|
||||||
const chunk = httpQueue.slice(at, at + HTTP_BATCH_CHUNK);
|
const chunk = httpTplQueue.slice(at, at + HTTP_BATCH_CHUNK);
|
||||||
setOutboundTestStates((prev) => {
|
setOutboundTestStates((prev) => {
|
||||||
const next = { ...prev };
|
const next = { ...prev };
|
||||||
for (const item of chunk) next[item.index] = { testing: true, result: null, mode: 'http' };
|
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()]);
|
await Promise.all([runTcpLane(), runHttpLane()]);
|
||||||
} finally {
|
} finally {
|
||||||
setTestingAll(false);
|
setTestingAll(false);
|
||||||
}
|
}
|
||||||
}, [testingAll, testOutbound, postOutboundTestBatch]);
|
}, [testingAll, testOutbound, testSubscriptionOutbound, postOutboundTestBatch]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const timer = window.setInterval(() => {
|
const timer = window.setInterval(() => {
|
||||||
|
|||||||
@@ -110,7 +110,7 @@ export default function OutboundCardList({
|
|||||||
shape="circle"
|
shape="circle"
|
||||||
size="small"
|
size="small"
|
||||||
loading={isTesting(outboundTestStates, index)}
|
loading={isTesting(outboundTestStates, index)}
|
||||||
disabled={isUntestable(record, testMode) || isTesting(outboundTestStates, index)}
|
disabled={isUntestable(record) || isTesting(outboundTestStates, index)}
|
||||||
icon={<ThunderboltOutlined />}
|
icon={<ThunderboltOutlined />}
|
||||||
onClick={() => onTest(index, testMode)}
|
onClick={() => onTest(index, testMode)}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -110,7 +110,7 @@ export default function SubscriptionOutbounds({
|
|||||||
shape="circle"
|
shape="circle"
|
||||||
size={isMobile ? 'small' : undefined}
|
size={isMobile ? 'small' : undefined}
|
||||||
loading={isTesting(subscriptionTestStates, key)}
|
loading={isTesting(subscriptionTestStates, key)}
|
||||||
disabled={!record.tag || isUntestable(record, testMode) || isTesting(subscriptionTestStates, key)}
|
disabled={!record.tag || isUntestable(record) || isTesting(subscriptionTestStates, key)}
|
||||||
icon={<ThunderboltOutlined />}
|
icon={<ThunderboltOutlined />}
|
||||||
onClick={() => onTestSubscription(record as unknown as Record<string, unknown>, testMode)}
|
onClick={() => onTestSubscription(record as unknown as Record<string, unknown>, testMode)}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -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) return true;
|
||||||
if (o.protocol === Protocols.Blackhole || o.protocol === Protocols.Loopback || o.tag === 'blocked') 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;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -172,7 +172,7 @@ export function useOutboundColumns({
|
|||||||
type="primary"
|
type="primary"
|
||||||
shape="circle"
|
shape="circle"
|
||||||
loading={isTesting(outboundTestStates, index)}
|
loading={isTesting(outboundTestStates, index)}
|
||||||
disabled={isUntestable(record, testMode) || isTesting(outboundTestStates, index)}
|
disabled={isUntestable(record) || isTesting(outboundTestStates, index)}
|
||||||
icon={<ThunderboltOutlined />}
|
icon={<ThunderboltOutlined />}
|
||||||
onClick={() => onTest(index, testMode)}
|
onClick={() => onTest(index, testMode)}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -160,6 +160,10 @@ func (s *OutboundService) testOutboundsParsed(items []map[string]any, testURL st
|
|||||||
r.Error = "Blocked/blackhole outbound cannot be tested"
|
r.Error = "Blocked/blackhole outbound cannot be tested"
|
||||||
case protocol == "loopback":
|
case protocol == "loopback":
|
||||||
r.Error = "Loopback outbound cannot be tested"
|
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]:
|
case seenTags[tag]:
|
||||||
r.Error = fmt.Sprintf("Duplicate outbound tag in batch: %s", tag)
|
r.Error = fmt.Sprintf("Duplicate outbound tag in batch: %s", tag)
|
||||||
default:
|
default:
|
||||||
|
|||||||
Reference in New Issue
Block a user