mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-09-22 17:56:36 +08:00
feat(outbound): batched connection tester with direct timed HTTP probes
Replace the per-outbound burstObservatory polling (one temp xray spawn + up to 15s of /debug/vars polling per outbound, serialised) with one shared temp xray instance per batch: every tested outbound gets its own loopback SOCKS inbound plus an inboundTag->outboundTag routing rule, and the panel times a real HTTP request through each one in parallel. The probe returns as soon as the response lands and records the HTTP status plus an httptrace breakdown (proxy connect / TLS via outbound / first byte) shown in the result popover. New POST /panel/api/xray/testOutbounds endpoint (array in, results in input order, max 50); the legacy /testOutbound endpoint now delegates to the same engine. Test All chunks HTTP probes 16 per request, and a batch whose shared process never comes up (one structurally-broken outbound poisons the config) retries each item in an isolated instance so the broken outbound reports xray's real error while the rest still test.
This commit is contained in:
@@ -7524,6 +7524,46 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"/panel/api/xray/testOutbounds": {
|
||||||
|
"post": {
|
||||||
|
"tags": [
|
||||||
|
"Xray Settings"
|
||||||
|
],
|
||||||
|
"summary": "Test a batch of outbounds (max 50) through one shared temp xray instance. Returns an array of results in input order, each with the outbound tag, delay, HTTP status and a connect/TLS/TTFB timing breakdown.",
|
||||||
|
"operationId": "post_panel_api_xray_testOutbounds",
|
||||||
|
"requestBody": {
|
||||||
|
"required": true,
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"type": "object"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "Successful response",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"success": {
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
"msg": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"obj": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"/panel/api/xray/balancerStatus": {
|
"/panel/api/xray/balancerStatus": {
|
||||||
"post": {
|
"post": {
|
||||||
"tags": [
|
"tags": [
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import { parseMsg } from '@/utils/zodValidate';
|
|||||||
import { keys } from '@/api/queryKeys';
|
import { keys } from '@/api/queryKeys';
|
||||||
import {
|
import {
|
||||||
OutboundTrafficListSchema,
|
OutboundTrafficListSchema,
|
||||||
OutboundTestResultSchema,
|
OutboundTestResultListSchema,
|
||||||
XrayConfigPayloadSchema,
|
XrayConfigPayloadSchema,
|
||||||
XraySettingsValueSchema,
|
XraySettingsValueSchema,
|
||||||
type OutboundTestResult,
|
type OutboundTestResult,
|
||||||
@@ -16,6 +16,10 @@ import {
|
|||||||
|
|
||||||
const DIRTY_POLL_MS = 1000;
|
const DIRTY_POLL_MS = 1000;
|
||||||
const DEFAULT_TEST_URL = 'https://www.google.com/generate_204';
|
const DEFAULT_TEST_URL = 'https://www.google.com/generate_204';
|
||||||
|
// One HTTP-mode batch request tests this many outbounds through a single
|
||||||
|
// shared temp xray instance; chunking keeps responses bounded (~15s worst
|
||||||
|
// case) and lands Test All results progressively.
|
||||||
|
const HTTP_BATCH_CHUNK = 16;
|
||||||
|
|
||||||
export function isUdpOutbound(outbound: unknown): boolean {
|
export function isUdpOutbound(outbound: unknown): boolean {
|
||||||
const o = outbound as { protocol?: string; streamSettings?: { network?: string } } | null | undefined;
|
const o = outbound as { protocol?: string; streamSettings?: { network?: string } } | null | undefined;
|
||||||
@@ -254,21 +258,26 @@ export function useXraySetting(): UseXraySettingResult {
|
|||||||
|
|
||||||
const spinning = saveMut.isPending || resetDefaultMut.isPending;
|
const spinning = saveMut.isPending || resetDefaultMut.isPending;
|
||||||
|
|
||||||
// Shared POST + parse for a single outbound test. Returns an OutboundTestResult
|
// Shared POST + parse for a batch of outbound tests. The backend probes the
|
||||||
// (success or a failure-shaped result); callers store it under their own key.
|
// whole batch through one shared temp xray instance and returns results in
|
||||||
const postOutboundTest = useCallback(
|
// request order; this aligns them by index and shapes failures so every
|
||||||
async (outbound: unknown, effMode: string): Promise<OutboundTestResult> => {
|
// input gets an OutboundTestResult.
|
||||||
|
const postOutboundTestBatch = useCallback(
|
||||||
|
async (outbounds: unknown[], effMode: string): Promise<OutboundTestResult[]> => {
|
||||||
|
const failAll = (error: string): OutboundTestResult[] =>
|
||||||
|
outbounds.map(() => ({ success: false, error, mode: effMode }));
|
||||||
try {
|
try {
|
||||||
const raw = await HttpUtil.post('/panel/api/xray/testOutbound', {
|
const raw = await HttpUtil.post('/panel/api/xray/testOutbounds', {
|
||||||
outbound: JSON.stringify(outbound),
|
outbounds: JSON.stringify(outbounds),
|
||||||
allOutbounds: JSON.stringify(templateSettingsRef.current?.outbounds || []),
|
allOutbounds: JSON.stringify(templateSettingsRef.current?.outbounds || []),
|
||||||
mode: effMode,
|
mode: effMode,
|
||||||
});
|
});
|
||||||
const msg = parseMsg(raw, OutboundTestResultSchema, 'xray/testOutbound');
|
const msg = parseMsg(raw, OutboundTestResultListSchema, 'xray/testOutbounds');
|
||||||
if (msg?.success && msg.obj) return msg.obj;
|
if (!msg?.success || !Array.isArray(msg.obj)) return failAll(msg?.msg || 'Unknown error');
|
||||||
return { success: false, error: msg?.msg || 'Unknown error', mode: effMode };
|
const list = msg.obj;
|
||||||
|
return outbounds.map((_ob, i) => list[i] ?? { success: false, error: 'Missing result', mode: effMode });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return { success: false, error: String(e), mode: effMode };
|
return failAll(String(e));
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[],
|
[],
|
||||||
@@ -282,11 +291,11 @@ export function useXraySetting(): UseXraySettingResult {
|
|||||||
...prev,
|
...prev,
|
||||||
[index]: { testing: true, result: null, mode: effMode },
|
[index]: { testing: true, result: null, mode: effMode },
|
||||||
}));
|
}));
|
||||||
const result = await postOutboundTest(outbound, effMode);
|
const [result] = await postOutboundTestBatch([outbound], effMode);
|
||||||
setOutboundTestStates((prev) => ({ ...prev, [index]: { testing: false, result } }));
|
setOutboundTestStates((prev) => ({ ...prev, [index]: { testing: false, result } }));
|
||||||
return result.success ? result : null;
|
return result.success ? result : null;
|
||||||
},
|
},
|
||||||
[postOutboundTest],
|
[postOutboundTestBatch],
|
||||||
);
|
);
|
||||||
|
|
||||||
// Test a subscription outbound (not present in templateSettings.outbounds);
|
// Test a subscription outbound (not present in templateSettings.outbounds);
|
||||||
@@ -299,11 +308,11 @@ export function useXraySetting(): UseXraySettingResult {
|
|||||||
...prev,
|
...prev,
|
||||||
[tag]: { testing: true, result: null, mode: effMode },
|
[tag]: { testing: true, result: null, mode: effMode },
|
||||||
}));
|
}));
|
||||||
const result = await postOutboundTest(outbound, effMode);
|
const [result] = await postOutboundTestBatch([outbound], effMode);
|
||||||
setSubscriptionTestStates((prev) => ({ ...prev, [tag]: { testing: false, result } }));
|
setSubscriptionTestStates((prev) => ({ ...prev, [tag]: { testing: false, result } }));
|
||||||
return result.success ? result : null;
|
return result.success ? result : null;
|
||||||
},
|
},
|
||||||
[postOutboundTest],
|
[postOutboundTestBatch],
|
||||||
);
|
);
|
||||||
|
|
||||||
const testAllOutbounds = useCallback(async (mode = 'tcp') => {
|
const testAllOutbounds = useCallback(async (mode = 'tcp') => {
|
||||||
@@ -324,7 +333,10 @@ export function useXraySetting(): UseXraySettingResult {
|
|||||||
tcpQueue.push({ index: i, outbound: ob });
|
tcpQueue.push({ index: i, outbound: ob });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
const runLane = async (queue: { index: number; outbound: unknown }[], concurrency: number) => {
|
// TCP probes are dial-only and cheap server-side; per-item requests
|
||||||
|
// keep results landing one by one.
|
||||||
|
const runTcpLane = async () => {
|
||||||
|
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();
|
||||||
@@ -332,14 +344,33 @@ export function useXraySetting(): UseXraySettingResult {
|
|||||||
await testOutbound(item.index, item.outbound, mode);
|
await testOutbound(item.index, item.outbound, mode);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
const workers = Array.from({ length: Math.min(concurrency, queue.length) }, () => worker());
|
await Promise.all(Array.from({ length: Math.min(8, queue.length) }, () => worker()));
|
||||||
await Promise.all(workers);
|
|
||||||
};
|
};
|
||||||
await Promise.all([runLane(tcpQueue, 8), runLane(httpQueue, 1)]);
|
// 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);
|
||||||
|
setOutboundTestStates((prev) => {
|
||||||
|
const next = { ...prev };
|
||||||
|
for (const item of chunk) next[item.index] = { testing: true, result: null, mode: 'http' };
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
const results = await postOutboundTestBatch(chunk.map((c) => c.outbound), 'http');
|
||||||
|
setOutboundTestStates((prev) => {
|
||||||
|
const next = { ...prev };
|
||||||
|
chunk.forEach((item, i) => {
|
||||||
|
next[item.index] = { testing: false, result: results[i] };
|
||||||
|
});
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
await Promise.all([runTcpLane(), runHttpLane()]);
|
||||||
} finally {
|
} finally {
|
||||||
setTestingAll(false);
|
setTestingAll(false);
|
||||||
}
|
}
|
||||||
}, [testingAll, testOutbound]);
|
}, [testingAll, testOutbound, postOutboundTestBatch]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const timer = window.setInterval(() => {
|
const timer = window.setInterval(() => {
|
||||||
|
|||||||
@@ -1063,6 +1063,17 @@ export const sections: readonly Section[] = [
|
|||||||
],
|
],
|
||||||
body: 'outbound={"protocol":"freedom","settings":{}}&mode=tcp',
|
body: 'outbound={"protocol":"freedom","settings":{}}&mode=tcp',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
path: '/panel/api/xray/testOutbounds',
|
||||||
|
summary: 'Test a batch of outbounds (max 50) through one shared temp xray instance. Returns an array of results in input order, each with the outbound tag, delay, HTTP status and a connect/TLS/TTFB timing breakdown.',
|
||||||
|
params: [
|
||||||
|
{ name: 'outbounds', in: 'body (form)', type: 'string', desc: 'JSON array of outbound configs to test (required).' },
|
||||||
|
{ name: 'allOutbounds', in: 'body (form)', type: 'string', desc: 'JSON array of all outbounds — used to resolve dialerProxy chains.' },
|
||||||
|
{ name: 'mode', in: 'body (form)', type: 'string', desc: '"tcp" for fast dial-only probes (UDP-transport outbounds are still probed over HTTP). Default/empty routes a real HTTP request through each outbound.' },
|
||||||
|
],
|
||||||
|
body: 'outbounds=[{"tag":"direct","protocol":"freedom","settings":{}}]&mode=http',
|
||||||
|
},
|
||||||
{
|
{
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
path: '/panel/api/xray/balancerStatus',
|
path: '/panel/api/xray/balancerStatus',
|
||||||
|
|||||||
@@ -7,8 +7,6 @@ import {
|
|||||||
DeleteOutlined,
|
DeleteOutlined,
|
||||||
VerticalAlignTopOutlined,
|
VerticalAlignTopOutlined,
|
||||||
ThunderboltOutlined,
|
ThunderboltOutlined,
|
||||||
CheckCircleFilled,
|
|
||||||
CloseCircleFilled,
|
|
||||||
LoadingOutlined,
|
LoadingOutlined,
|
||||||
} from '@ant-design/icons';
|
} from '@ant-design/icons';
|
||||||
|
|
||||||
@@ -17,6 +15,7 @@ import { OutboundProtocols as Protocols } from '@/schemas/primitives';
|
|||||||
import type { OutboundTestState, OutboundTrafficRow } from '@/hooks/useXraySetting';
|
import type { OutboundTestState, OutboundTrafficRow } from '@/hooks/useXraySetting';
|
||||||
|
|
||||||
import type { OutboundRow } from './outbounds-tab-types';
|
import type { OutboundRow } from './outbounds-tab-types';
|
||||||
|
import TestResultPopover from './TestResultPopover';
|
||||||
import {
|
import {
|
||||||
isTesting,
|
isTesting,
|
||||||
isUntestable,
|
isUntestable,
|
||||||
@@ -102,10 +101,7 @@ export default function OutboundCardList({
|
|||||||
<span className="traffic-down">↓ {SizeFormatter.sizeFormat(trafficFor(outboundsTraffic, record).down)}</span>
|
<span className="traffic-down">↓ {SizeFormatter.sizeFormat(trafficFor(outboundsTraffic, record).down)}</span>
|
||||||
<span className="card-test">
|
<span className="card-test">
|
||||||
{testResult(outboundTestStates, index) ? (
|
{testResult(outboundTestStates, index) ? (
|
||||||
<span className={testResult(outboundTestStates, index)!.success ? 'pill-ok' : 'pill-fail'}>
|
<TestResultPopover result={testResult(outboundTestStates, index)!} />
|
||||||
{testResult(outboundTestStates, index)!.success ? <CheckCircleFilled /> : <CloseCircleFilled />}
|
|
||||||
{testResult(outboundTestStates, index)!.success ? <span>{testResult(outboundTestStates, index)!.delay} ms</span> : <span>failed</span>}
|
|
||||||
</span>
|
|
||||||
) : isTesting(outboundTestStates, index) ? (
|
) : isTesting(outboundTestStates, index) ? (
|
||||||
<LoadingOutlined />
|
<LoadingOutlined />
|
||||||
) : null}
|
) : null}
|
||||||
|
|||||||
@@ -210,6 +210,25 @@
|
|||||||
color: #e04141;
|
color: #e04141;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.outbound-test-popover .breakdown-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
font-size: 11px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.outbound-test-popover .breakdown-row .bd-label {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
opacity: 0.85;
|
||||||
|
}
|
||||||
|
|
||||||
|
.outbound-test-popover .breakdown-row .bd-value {
|
||||||
|
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
||||||
|
opacity: 0.75;
|
||||||
|
}
|
||||||
|
|
||||||
.subscription-outbounds-head {
|
.subscription-outbounds-head {
|
||||||
margin-bottom: 8px;
|
margin-bottom: 8px;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,7 @@
|
|||||||
import { useMemo } from 'react';
|
import { useMemo } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { Button, Popover, Table, Tag, Tooltip } from 'antd';
|
import { Button, Table, Tag, Tooltip } from 'antd';
|
||||||
import {
|
import { ThunderboltOutlined, LoadingOutlined } from '@ant-design/icons';
|
||||||
ThunderboltOutlined,
|
|
||||||
CheckCircleFilled,
|
|
||||||
CloseCircleFilled,
|
|
||||||
LoadingOutlined,
|
|
||||||
} from '@ant-design/icons';
|
|
||||||
import type { ColumnsType } from 'antd/es/table';
|
import type { ColumnsType } from 'antd/es/table';
|
||||||
|
|
||||||
import { SizeFormatter } from '@/utils';
|
import { SizeFormatter } from '@/utils';
|
||||||
@@ -15,8 +10,8 @@ import { isUdpOutbound } from '@/hooks/useXraySetting';
|
|||||||
import type { OutboundTestState, OutboundTrafficRow } from '@/hooks/useXraySetting';
|
import type { OutboundTestState, OutboundTrafficRow } from '@/hooks/useXraySetting';
|
||||||
|
|
||||||
import type { OutboundRow } from './outbounds-tab-types';
|
import type { OutboundRow } from './outbounds-tab-types';
|
||||||
|
import TestResultPopover from './TestResultPopover';
|
||||||
import {
|
import {
|
||||||
hasBreakdown,
|
|
||||||
isTesting,
|
isTesting,
|
||||||
isUntestable,
|
isUntestable,
|
||||||
outboundAddresses,
|
outboundAddresses,
|
||||||
@@ -103,36 +98,7 @@ export default function SubscriptionOutbounds({
|
|||||||
const key = record.tag || '';
|
const key = record.tag || '';
|
||||||
const r = testResult(subscriptionTestStates, key);
|
const r = testResult(subscriptionTestStates, key);
|
||||||
if (!r) return isTesting(subscriptionTestStates, key) ? <LoadingOutlined /> : <span className="empty">—</span>;
|
if (!r) return isTesting(subscriptionTestStates, key) ? <LoadingOutlined /> : <span className="empty">—</span>;
|
||||||
return (
|
return <TestResultPopover result={r} />;
|
||||||
<Popover
|
|
||||||
placement="topLeft"
|
|
||||||
rootClassName="outbound-test-popover"
|
|
||||||
content={
|
|
||||||
<div className="timing-breakdown">
|
|
||||||
<div className={`td-head ${r.success ? 'ok' : 'fail'}`}>
|
|
||||||
{r.success ? <span>{r.delay} ms</span> : <span>{r.error || 'failed'}</span>}
|
|
||||||
{r.mode && <span className="mode-badge">{String(r.mode).toUpperCase()}</span>}
|
|
||||||
</div>
|
|
||||||
{hasBreakdown(r) && (
|
|
||||||
<>
|
|
||||||
{(r.endpoints || []).map((ep) => (
|
|
||||||
<div key={ep.address} className="endpoint-row">
|
|
||||||
<span className={ep.success ? 'dot-ok' : 'dot-fail'}>●</span>
|
|
||||||
<span className="ep-addr">{ep.address}</span>
|
|
||||||
<span className="ep-meta">{ep.success ? `${ep.delay} ms` : ep.error || 'failed'}</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<span className={r.success ? 'pill-ok' : 'pill-fail'}>
|
|
||||||
{r.success ? <CheckCircleFilled /> : <CloseCircleFilled />}
|
|
||||||
{r.success ? <span>{r.delay} ms</span> : <span>failed</span>}
|
|
||||||
</span>
|
|
||||||
</Popover>
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const testButton = (record: OutboundRow) => {
|
const testButton = (record: OutboundRow) => {
|
||||||
|
|||||||
@@ -0,0 +1,68 @@
|
|||||||
|
import type { ReactNode } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { Popover } from 'antd';
|
||||||
|
import { CheckCircleFilled, CloseCircleFilled } from '@ant-design/icons';
|
||||||
|
|
||||||
|
import type { OutboundTestResult } from '@/hooks/useXraySetting';
|
||||||
|
|
||||||
|
interface TestResultPopoverProps {
|
||||||
|
result: OutboundTestResult;
|
||||||
|
// Custom trigger element; defaults to the ok/fail latency pill.
|
||||||
|
children?: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Latency pill + detail popover for an outbound test result: per-endpoint
|
||||||
|
// dial outcomes for TCP probes, HTTP status and the timing breakdown for
|
||||||
|
// HTTP probes.
|
||||||
|
export default function TestResultPopover({ result: r, children }: TestResultPopoverProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
const breakdown: Array<{ key: string; label: string; value: string }> = [];
|
||||||
|
if (typeof r.httpStatus === 'number') {
|
||||||
|
breakdown.push({ key: 'status', label: t('pages.xray.outbound.httpStatus'), value: String(r.httpStatus) });
|
||||||
|
}
|
||||||
|
if (typeof r.connectMs === 'number') {
|
||||||
|
breakdown.push({ key: 'connect', label: t('pages.xray.outbound.breakdownConnect'), value: `${r.connectMs} ms` });
|
||||||
|
}
|
||||||
|
if (typeof r.tlsMs === 'number') {
|
||||||
|
breakdown.push({ key: 'tls', label: t('pages.xray.outbound.breakdownTls'), value: `${r.tlsMs} ms` });
|
||||||
|
}
|
||||||
|
if (typeof r.ttfbMs === 'number') {
|
||||||
|
breakdown.push({ key: 'ttfb', label: t('pages.xray.outbound.breakdownTtfb'), value: `${r.ttfbMs} ms` });
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Popover
|
||||||
|
placement="topLeft"
|
||||||
|
rootClassName="outbound-test-popover"
|
||||||
|
content={
|
||||||
|
<div className="timing-breakdown">
|
||||||
|
<div className={`td-head ${r.success ? 'ok' : 'fail'}`}>
|
||||||
|
{r.success ? <span>{r.delay} ms</span> : <span>{r.error || 'failed'}</span>}
|
||||||
|
{r.mode && <span className="mode-badge">{String(r.mode).toUpperCase()}</span>}
|
||||||
|
</div>
|
||||||
|
{(r.endpoints || []).map((ep) => (
|
||||||
|
<div key={ep.address} className="endpoint-row">
|
||||||
|
<span className={ep.success ? 'dot-ok' : 'dot-fail'}>●</span>
|
||||||
|
<span className="ep-addr">{ep.address}</span>
|
||||||
|
<span className="ep-meta">{ep.success ? `${ep.delay} ms` : ep.error || 'failed'}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{breakdown.map((row) => (
|
||||||
|
<div key={row.key} className="breakdown-row">
|
||||||
|
<span className="bd-label">{row.label}</span>
|
||||||
|
<span className="bd-value">{row.value}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{children ?? (
|
||||||
|
<span className={r.success ? 'pill-ok' : 'pill-fail'}>
|
||||||
|
{r.success ? <CheckCircleFilled /> : <CloseCircleFilled />}
|
||||||
|
{r.success ? <span>{r.delay} ms</span> : <span>failed</span>}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</Popover>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -42,12 +42,6 @@ export function showSecurity(security?: string): boolean {
|
|||||||
return security === 'tls' || security === 'reality';
|
return security === 'tls' || security === 'reality';
|
||||||
}
|
}
|
||||||
|
|
||||||
export function hasBreakdown(r: { endpoints?: unknown[]; error?: string } | null | undefined): boolean {
|
|
||||||
if (!r) return false;
|
|
||||||
if (r.endpoints?.length) return true;
|
|
||||||
return !!r.error;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function trafficFor(outboundsTraffic: OutboundTrafficRow[], o: OutboundRow): { up: number; down: number } {
|
export function trafficFor(outboundsTraffic: OutboundTrafficRow[], o: OutboundRow): { up: number; down: number } {
|
||||||
const tr = outboundsTraffic.find((x) => x.tag === o.tag);
|
const tr = outboundsTraffic.find((x) => x.tag === o.tag);
|
||||||
return { up: tr?.up || 0, down: tr?.down || 0 };
|
return { up: tr?.up || 0, down: tr?.down || 0 };
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useMemo } from 'react';
|
import { useMemo } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { Button, Dropdown, Popover, Tag, Tooltip } from 'antd';
|
import { Button, Dropdown, Tag, Tooltip } from 'antd';
|
||||||
import {
|
import {
|
||||||
RetweetOutlined,
|
RetweetOutlined,
|
||||||
MoreOutlined,
|
MoreOutlined,
|
||||||
@@ -8,8 +8,6 @@ import {
|
|||||||
DeleteOutlined,
|
DeleteOutlined,
|
||||||
VerticalAlignTopOutlined,
|
VerticalAlignTopOutlined,
|
||||||
ThunderboltOutlined,
|
ThunderboltOutlined,
|
||||||
CheckCircleFilled,
|
|
||||||
CloseCircleFilled,
|
|
||||||
LoadingOutlined,
|
LoadingOutlined,
|
||||||
ArrowUpOutlined,
|
ArrowUpOutlined,
|
||||||
ArrowDownOutlined,
|
ArrowDownOutlined,
|
||||||
@@ -22,8 +20,8 @@ import { isUdpOutbound } from '@/hooks/useXraySetting';
|
|||||||
import type { OutboundTestState, OutboundTrafficRow } from '@/hooks/useXraySetting';
|
import type { OutboundTestState, OutboundTrafficRow } from '@/hooks/useXraySetting';
|
||||||
|
|
||||||
import type { OutboundRow } from './outbounds-tab-types';
|
import type { OutboundRow } from './outbounds-tab-types';
|
||||||
|
import TestResultPopover from './TestResultPopover';
|
||||||
import {
|
import {
|
||||||
hasBreakdown,
|
|
||||||
isTesting,
|
isTesting,
|
||||||
isUntestable,
|
isUntestable,
|
||||||
outboundAddresses,
|
outboundAddresses,
|
||||||
@@ -160,36 +158,7 @@ export function useOutboundColumns({
|
|||||||
render: (_v, _record, index) => {
|
render: (_v, _record, index) => {
|
||||||
const r = testResult(outboundTestStates, index);
|
const r = testResult(outboundTestStates, index);
|
||||||
if (!r) return isTesting(outboundTestStates, index) ? <LoadingOutlined /> : <span className="empty">—</span>;
|
if (!r) return isTesting(outboundTestStates, index) ? <LoadingOutlined /> : <span className="empty">—</span>;
|
||||||
return (
|
return <TestResultPopover result={r} />;
|
||||||
<Popover
|
|
||||||
placement="topLeft"
|
|
||||||
rootClassName="outbound-test-popover"
|
|
||||||
content={
|
|
||||||
<div className="timing-breakdown">
|
|
||||||
<div className={`td-head ${r.success ? 'ok' : 'fail'}`}>
|
|
||||||
{r.success ? <span>{r.delay} ms</span> : <span>{r.error || 'failed'}</span>}
|
|
||||||
{r.mode && <span className="mode-badge">{String(r.mode).toUpperCase()}</span>}
|
|
||||||
</div>
|
|
||||||
{hasBreakdown(r) && (
|
|
||||||
<>
|
|
||||||
{(r.endpoints || []).map((ep) => (
|
|
||||||
<div key={ep.address} className="endpoint-row">
|
|
||||||
<span className={ep.success ? 'dot-ok' : 'dot-fail'}>●</span>
|
|
||||||
<span className="ep-addr">{ep.address}</span>
|
|
||||||
<span className="ep-meta">{ep.success ? `${ep.delay} ms` : ep.error || 'failed'}</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<span className={r.success ? 'pill-ok' : 'pill-fail'}>
|
|
||||||
{r.success ? <CheckCircleFilled /> : <CloseCircleFilled />}
|
|
||||||
{r.success ? <span>{r.delay} ms</span> : <span>failed</span>}
|
|
||||||
</span>
|
|
||||||
</Popover>
|
|
||||||
);
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -56,10 +56,18 @@ export const OutboundTrafficRowSchema = z.object({
|
|||||||
export const OutboundTrafficListSchema = z.array(OutboundTrafficRowSchema);
|
export const OutboundTrafficListSchema = z.array(OutboundTrafficRowSchema);
|
||||||
|
|
||||||
export const OutboundTestResultSchema = z.object({
|
export const OutboundTestResultSchema = z.object({
|
||||||
|
tag: z.string().optional(),
|
||||||
success: z.boolean(),
|
success: z.boolean(),
|
||||||
delay: z.number().optional(),
|
delay: z.number().optional(),
|
||||||
error: z.string().optional(),
|
error: z.string().optional(),
|
||||||
mode: z.string().optional(),
|
mode: z.string().optional(),
|
||||||
|
// HTTP-mode extras: status answered by the test URL plus the httptrace
|
||||||
|
// timing breakdown (dial to local inbound / target TLS via the outbound /
|
||||||
|
// time to first byte).
|
||||||
|
httpStatus: z.number().optional(),
|
||||||
|
connectMs: z.number().optional(),
|
||||||
|
tlsMs: z.number().optional(),
|
||||||
|
ttfbMs: z.number().optional(),
|
||||||
endpoints: z
|
endpoints: z
|
||||||
.array(
|
.array(
|
||||||
z.object({
|
z.object({
|
||||||
@@ -72,6 +80,9 @@ export const OutboundTestResultSchema = z.object({
|
|||||||
.optional(),
|
.optional(),
|
||||||
}).loose();
|
}).loose();
|
||||||
|
|
||||||
|
// Batch results from /xray/testOutbounds, aligned with the request order.
|
||||||
|
export const OutboundTestResultListSchema = z.array(OutboundTestResultSchema);
|
||||||
|
|
||||||
export const RuleFormSchema = z.object({
|
export const RuleFormSchema = z.object({
|
||||||
domain: z.string(),
|
domain: z.string(),
|
||||||
ip: z.string(),
|
ip: z.string(),
|
||||||
|
|||||||
@@ -48,6 +48,7 @@ func (a *XraySettingController) initRouter(g *gin.RouterGroup) {
|
|||||||
g.POST("/update", a.updateSetting)
|
g.POST("/update", a.updateSetting)
|
||||||
g.POST("/resetOutboundsTraffic", a.resetOutboundsTraffic)
|
g.POST("/resetOutboundsTraffic", a.resetOutboundsTraffic)
|
||||||
g.POST("/testOutbound", a.testOutbound)
|
g.POST("/testOutbound", a.testOutbound)
|
||||||
|
g.POST("/testOutbounds", a.testOutbounds)
|
||||||
g.POST("/balancerStatus", a.balancerStatus)
|
g.POST("/balancerStatus", a.balancerStatus)
|
||||||
g.POST("/balancerOverride", a.balancerOverride)
|
g.POST("/balancerOverride", a.balancerOverride)
|
||||||
g.POST("/routeTest", a.routeTest)
|
g.POST("/routeTest", a.routeTest)
|
||||||
@@ -286,6 +287,39 @@ func (a *XraySettingController) testOutbound(c *gin.Context) {
|
|||||||
jsonObj(c, result, nil)
|
jsonObj(c, result, nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// testOutbounds tests a batch of outbound configurations through one shared
|
||||||
|
// temp xray instance and returns an array of results in input order.
|
||||||
|
// Form "outbounds": JSON array of outbound configs (required).
|
||||||
|
// Optional form "allOutbounds": JSON array of all outbounds; used to resolve sockopt.dialerProxy dependencies.
|
||||||
|
// Optional form "mode": "tcp" for fast dial-only probes, anything else
|
||||||
|
// (default) for real HTTP requests routed through each outbound.
|
||||||
|
func (a *XraySettingController) testOutbounds(c *gin.Context) {
|
||||||
|
outboundsJSON := c.PostForm("outbounds")
|
||||||
|
allOutboundsJSON := c.PostForm("allOutbounds")
|
||||||
|
mode := c.PostForm("mode")
|
||||||
|
|
||||||
|
if outboundsJSON == "" {
|
||||||
|
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), common.NewError("outbounds parameter is required"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load the test URL from server settings to prevent SSRF via user-controlled URLs
|
||||||
|
testURL, _ := a.SettingService.GetXrayOutboundTestUrl()
|
||||||
|
testURL, err := service.SanitizePublicHTTPURL(testURL, false)
|
||||||
|
if err != nil {
|
||||||
|
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
results, err := a.OutboundService.TestOutbounds(outboundsJSON, testURL, allOutboundsJSON, mode)
|
||||||
|
if err != nil {
|
||||||
|
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
jsonObj(c, results, nil)
|
||||||
|
}
|
||||||
|
|
||||||
// balancerStatus reports the live state (override + strategy picks) of the
|
// balancerStatus reports the live state (override + strategy picks) of the
|
||||||
// balancer tags given as a comma-separated "tags" form field.
|
// balancer tags given as a comma-separated "tags" form field.
|
||||||
func (a *XraySettingController) balancerStatus(c *gin.Context) {
|
func (a *XraySettingController) balancerStatus(c *gin.Context) {
|
||||||
|
|||||||
@@ -4,17 +4,13 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net"
|
"net"
|
||||||
"net/http"
|
|
||||||
"os"
|
|
||||||
"strconv"
|
"strconv"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/mhsanaei/3x-ui/v3/internal/config"
|
|
||||||
"github.com/mhsanaei/3x-ui/v3/internal/database"
|
"github.com/mhsanaei/3x-ui/v3/internal/database"
|
||||||
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
||||||
"github.com/mhsanaei/3x-ui/v3/internal/logger"
|
"github.com/mhsanaei/3x-ui/v3/internal/logger"
|
||||||
"github.com/mhsanaei/3x-ui/v3/internal/util/json_util"
|
|
||||||
"github.com/mhsanaei/3x-ui/v3/internal/xray"
|
"github.com/mhsanaei/3x-ui/v3/internal/xray"
|
||||||
|
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
@@ -24,11 +20,6 @@ import (
|
|||||||
// It handles outbound traffic monitoring and statistics.
|
// It handles outbound traffic monitoring and statistics.
|
||||||
type OutboundService struct{}
|
type OutboundService struct{}
|
||||||
|
|
||||||
// httpTestSemaphore serialises HTTP-mode probes (each one spawns a temp xray
|
|
||||||
// instance, which is too expensive to run in parallel). TCP-mode probes are
|
|
||||||
// dial-only and don't need the semaphore.
|
|
||||||
var httpTestSemaphore sync.Mutex
|
|
||||||
|
|
||||||
func (s *OutboundService) AddTraffic(traffics []*xray.Traffic, clientTraffics []*xray.ClientTraffic) (error, bool) {
|
func (s *OutboundService) AddTraffic(traffics []*xray.Traffic, clientTraffics []*xray.ClientTraffic) (error, bool) {
|
||||||
var err error
|
var err error
|
||||||
db := database.GetDB()
|
db := database.GetDB()
|
||||||
@@ -119,14 +110,25 @@ func (s *OutboundService) ResetOutboundTraffic(tag string) error {
|
|||||||
|
|
||||||
// TestOutboundResult represents the result of testing an outbound.
|
// TestOutboundResult represents the result of testing an outbound.
|
||||||
// Delay is in milliseconds. Endpoints is only populated for TCP-mode
|
// Delay is in milliseconds. Endpoints is only populated for TCP-mode
|
||||||
// probes; HTTP mode reports the round-trip delay measured by xray's
|
// probes; HTTP mode reports the time of a real HTTP request routed
|
||||||
// burstObservatory probe.
|
// through the outbound, with an optional timing breakdown.
|
||||||
type TestOutboundResult struct {
|
type TestOutboundResult struct {
|
||||||
|
Tag string `json:"tag,omitempty"`
|
||||||
Success bool `json:"success"`
|
Success bool `json:"success"`
|
||||||
Delay int64 `json:"delay"`
|
Delay int64 `json:"delay"`
|
||||||
Error string `json:"error,omitempty"`
|
Error string `json:"error,omitempty"`
|
||||||
Mode string `json:"mode,omitempty"`
|
Mode string `json:"mode,omitempty"`
|
||||||
|
|
||||||
|
// HTTP-mode extras. Any HTTP response counts as reachable; HTTPStatus
|
||||||
|
// records what the test URL answered. ConnectMs is the dial to the local
|
||||||
|
// test inbound; TLSMs covers outbound-chain establishment + target TLS
|
||||||
|
// (https URLs only, since xray ACKs the SOCKS CONNECT before dialing
|
||||||
|
// upstream); TTFBMs is request start → first response byte.
|
||||||
|
HTTPStatus int `json:"httpStatus,omitempty"`
|
||||||
|
ConnectMs int64 `json:"connectMs,omitempty"`
|
||||||
|
TLSMs int64 `json:"tlsMs,omitempty"`
|
||||||
|
TTFBMs int64 `json:"ttfbMs,omitempty"`
|
||||||
|
|
||||||
Endpoints []TestEndpointResult `json:"endpoints,omitempty"`
|
Endpoints []TestEndpointResult `json:"endpoints,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -139,31 +141,6 @@ type TestEndpointResult struct {
|
|||||||
Error string `json:"error,omitempty"`
|
Error string `json:"error,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestOutbound dispatches to the chosen probe mode:
|
|
||||||
// - mode="tcp": dial the outbound's host:port directly. No xray spin-up,
|
|
||||||
// parallel-safe, ~100ms per endpoint. Doesn't validate the proxy
|
|
||||||
// protocol — only that the remote is reachable on TCP.
|
|
||||||
// - mode="" or "http": spin a temp xray instance, route a real HTTP
|
|
||||||
// request through it, return delay + a DNS/Connect/TLS/TTFB breakdown.
|
|
||||||
// Authoritative but expensive and serialised by httpTestSemaphore.
|
|
||||||
//
|
|
||||||
// allOutboundsJSON is only consulted in HTTP mode (it backs
|
|
||||||
// sockopt.dialerProxy chains during test).
|
|
||||||
func (s *OutboundService) TestOutbound(outboundJSON string, testURL string, allOutboundsJSON string, mode string) (*TestOutboundResult, error) {
|
|
||||||
if mode == "tcp" {
|
|
||||||
// A bare TCP dial only proves reachability for TCP-based proxies.
|
|
||||||
// UDP protocols (wireguard, hysteria, kcp/quic transports) ignore
|
|
||||||
// unauthenticated packets, so a raw dial can't tell "reachable" from
|
|
||||||
// "dead" — route them through the authoritative xray handshake probe.
|
|
||||||
var ob map[string]any
|
|
||||||
if json.Unmarshal([]byte(outboundJSON), &ob) == nil && outboundTransportIsUDP(ob) {
|
|
||||||
return s.testOutboundHTTP(outboundJSON, testURL, allOutboundsJSON)
|
|
||||||
}
|
|
||||||
return s.testOutboundTCP(outboundJSON)
|
|
||||||
}
|
|
||||||
return s.testOutboundHTTP(outboundJSON, testURL, allOutboundsJSON)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *OutboundService) testOutboundTCP(outboundJSON string) (*TestOutboundResult, error) {
|
func (s *OutboundService) testOutboundTCP(outboundJSON string) (*TestOutboundResult, error) {
|
||||||
var ob map[string]any
|
var ob map[string]any
|
||||||
if err := json.Unmarshal([]byte(outboundJSON), &ob); err != nil {
|
if err := json.Unmarshal([]byte(outboundJSON), &ob); err != nil {
|
||||||
@@ -172,12 +149,12 @@ func (s *OutboundService) testOutboundTCP(outboundJSON string) (*TestOutboundRes
|
|||||||
tag, _ := ob["tag"].(string)
|
tag, _ := ob["tag"].(string)
|
||||||
protocol, _ := ob["protocol"].(string)
|
protocol, _ := ob["protocol"].(string)
|
||||||
if protocol == "blackhole" || protocol == "freedom" || tag == "blocked" {
|
if protocol == "blackhole" || protocol == "freedom" || tag == "blocked" {
|
||||||
return &TestOutboundResult{Mode: "tcp", Success: false, Error: "Outbound has no testable endpoint"}, nil
|
return &TestOutboundResult{Tag: tag, Mode: "tcp", Success: false, Error: "Outbound has no testable endpoint"}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
endpoints := extractOutboundEndpoints(ob)
|
endpoints := extractOutboundEndpoints(ob)
|
||||||
if len(endpoints) == 0 {
|
if len(endpoints) == 0 {
|
||||||
return &TestOutboundResult{Mode: "tcp", Success: false, Error: "No testable endpoint"}, nil
|
return &TestOutboundResult{Tag: tag, Mode: "tcp", Success: false, Error: "No testable endpoint"}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
results := make([]TestEndpointResult, len(endpoints))
|
results := make([]TestEndpointResult, len(endpoints))
|
||||||
@@ -203,7 +180,7 @@ func (s *OutboundService) testOutboundTCP(outboundJSON string) (*TestOutboundRes
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
out := &TestOutboundResult{Mode: "tcp", Endpoints: results}
|
out := &TestOutboundResult{Tag: tag, Mode: "tcp", Endpoints: results}
|
||||||
if bestDelay >= 0 {
|
if bestDelay >= 0 {
|
||||||
out.Success = true
|
out.Success = true
|
||||||
out.Delay = bestDelay
|
out.Delay = bestDelay
|
||||||
@@ -312,276 +289,3 @@ func numAsInt(v any) int {
|
|||||||
}
|
}
|
||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
|
|
||||||
// testOutboundHTTP spins up a temporary xray instance whose only job is
|
|
||||||
// to run a burstObservatory probe against the target outbound, then polls
|
|
||||||
// xray's metrics /debug/vars endpoint until that outbound is reported
|
|
||||||
// alive (success) or the deadline expires (failure). The probe lives
|
|
||||||
// inside xray, so the measured delay and any failure reason reflect what
|
|
||||||
// xray itself sees over the real proxy chain — no SOCKS round-trip on
|
|
||||||
// the client side.
|
|
||||||
func (s *OutboundService) testOutboundHTTP(outboundJSON string, testURL string, allOutboundsJSON string) (*TestOutboundResult, error) {
|
|
||||||
if testURL == "" {
|
|
||||||
testURL = "https://www.google.com/generate_204"
|
|
||||||
}
|
|
||||||
|
|
||||||
if !httpTestSemaphore.TryLock() {
|
|
||||||
return &TestOutboundResult{
|
|
||||||
Mode: "http",
|
|
||||||
Success: false,
|
|
||||||
Error: "Another outbound test is already running, please wait",
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
defer httpTestSemaphore.Unlock()
|
|
||||||
|
|
||||||
var testOutbound map[string]any
|
|
||||||
if err := json.Unmarshal([]byte(outboundJSON), &testOutbound); err != nil {
|
|
||||||
return &TestOutboundResult{Mode: "http", Success: false, Error: fmt.Sprintf("Invalid outbound JSON: %v", err)}, nil
|
|
||||||
}
|
|
||||||
outboundTag, _ := testOutbound["tag"].(string)
|
|
||||||
if outboundTag == "" {
|
|
||||||
return &TestOutboundResult{Mode: "http", Success: false, Error: "Outbound has no tag"}, nil
|
|
||||||
}
|
|
||||||
if protocol, _ := testOutbound["protocol"].(string); protocol == "blackhole" || outboundTag == "blocked" {
|
|
||||||
return &TestOutboundResult{Mode: "http", Success: false, Error: "Blocked/blackhole outbound cannot be tested"}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
var allOutbounds []any
|
|
||||||
if allOutboundsJSON != "" {
|
|
||||||
if err := json.Unmarshal([]byte(allOutboundsJSON), &allOutbounds); err != nil {
|
|
||||||
return &TestOutboundResult{Mode: "http", Success: false, Error: fmt.Sprintf("Invalid allOutbounds JSON: %v", err)}, nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// The outbound under test must be present in the config so burstObservatory
|
|
||||||
// has something with outboundTag to probe. allOutbounds is the template's
|
|
||||||
// outbounds (for dialerProxy chains); subscription outbounds are injected at
|
|
||||||
// runtime and aren't part of it, so without this the probe targets a tag that
|
|
||||||
// doesn't exist in the config and every test times out. Append (don't replace)
|
|
||||||
// so manual outbounds' dialerProxy chains keep resolving.
|
|
||||||
if !outboundsContainTag(allOutbounds, outboundTag) {
|
|
||||||
allOutbounds = append(allOutbounds, testOutbound)
|
|
||||||
}
|
|
||||||
|
|
||||||
metricsPort, err := findAvailablePort()
|
|
||||||
if err != nil {
|
|
||||||
return &TestOutboundResult{Mode: "http", Success: false, Error: fmt.Sprintf("Failed to find available port: %v", err)}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
testConfig := s.createTestConfig(outboundTag, allOutbounds, metricsPort, testURL)
|
|
||||||
|
|
||||||
testConfigPath, err := createTestConfigPath()
|
|
||||||
if err != nil {
|
|
||||||
return &TestOutboundResult{Mode: "http", Success: false, Error: fmt.Sprintf("Failed to create test config path: %v", err)}, nil
|
|
||||||
}
|
|
||||||
defer os.Remove(testConfigPath)
|
|
||||||
|
|
||||||
testProcess := xray.NewTestProcess(testConfig, testConfigPath)
|
|
||||||
defer func() {
|
|
||||||
if testProcess.IsRunning() {
|
|
||||||
testProcess.Stop()
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
|
|
||||||
if err := testProcess.Start(); err != nil {
|
|
||||||
return &TestOutboundResult{Mode: "http", Success: false, Error: fmt.Sprintf("Failed to start test xray instance: %v", err)}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := waitForPort(metricsPort, 5*time.Second); err != nil {
|
|
||||||
if !testProcess.IsRunning() {
|
|
||||||
result := testProcess.GetResult()
|
|
||||||
return &TestOutboundResult{Mode: "http", Success: false, Error: fmt.Sprintf("Xray process exited: %s", result)}, nil
|
|
||||||
}
|
|
||||||
return &TestOutboundResult{Mode: "http", Success: false, Error: fmt.Sprintf("Xray failed to start metrics listener: %v", err)}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
if !testProcess.IsRunning() {
|
|
||||||
result := testProcess.GetResult()
|
|
||||||
return &TestOutboundResult{Mode: "http", Success: false, Error: fmt.Sprintf("Xray process exited: %s", result)}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
return pollObservatoryResult(testProcess, metricsPort, outboundTag, 15*time.Second), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// outboundsContainTag reports whether any outbound in the slice has the given tag.
|
|
||||||
func outboundsContainTag(outbounds []any, tag string) bool {
|
|
||||||
for _, ob := range outbounds {
|
|
||||||
if m, ok := ob.(map[string]any); ok {
|
|
||||||
if t, _ := m["tag"].(string); t == tag {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *OutboundService) createTestConfig(outboundTag string, allOutbounds []any, metricsPort int, probeURL string) *xray.Config {
|
|
||||||
processedOutbounds := make([]any, len(allOutbounds))
|
|
||||||
for i, ob := range allOutbounds {
|
|
||||||
outbound, ok := ob.(map[string]any)
|
|
||||||
if !ok {
|
|
||||||
processedOutbounds[i] = ob
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if protocol, ok := outbound["protocol"].(string); ok && protocol == "wireguard" {
|
|
||||||
if settings, ok := outbound["settings"].(map[string]any); ok {
|
|
||||||
settings["noKernelTun"] = true
|
|
||||||
} else {
|
|
||||||
outbound["settings"] = map[string]any{"noKernelTun": true}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
processedOutbounds[i] = outbound
|
|
||||||
}
|
|
||||||
outboundsJSON, _ := json.Marshal(processedOutbounds)
|
|
||||||
|
|
||||||
routingJSON, _ := json.Marshal(map[string]any{
|
|
||||||
"domainStrategy": "AsIs",
|
|
||||||
"rules": []any{},
|
|
||||||
})
|
|
||||||
|
|
||||||
burstObservatoryJSON, _ := json.Marshal(map[string]any{
|
|
||||||
"subjectSelector": []string{outboundTag},
|
|
||||||
"pingConfig": map[string]any{
|
|
||||||
"destination": probeURL,
|
|
||||||
"interval": "1s",
|
|
||||||
"connectivity": "",
|
|
||||||
"timeout": "10s",
|
|
||||||
"samplingCount": 1,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
metricsJSON, _ := json.Marshal(map[string]any{
|
|
||||||
"tag": "test-metrics",
|
|
||||||
"listen": fmt.Sprintf("127.0.0.1:%d", metricsPort),
|
|
||||||
})
|
|
||||||
|
|
||||||
logConfig := map[string]any{
|
|
||||||
"loglevel": "warning",
|
|
||||||
"access": "none",
|
|
||||||
"error": "",
|
|
||||||
"dnsLog": false,
|
|
||||||
}
|
|
||||||
logJSON, _ := json.Marshal(logConfig)
|
|
||||||
|
|
||||||
cfg := &xray.Config{
|
|
||||||
LogConfig: json_util.RawMessage(logJSON),
|
|
||||||
InboundConfigs: []xray.InboundConfig{},
|
|
||||||
OutboundConfigs: json_util.RawMessage(string(outboundsJSON)),
|
|
||||||
RouterConfig: json_util.RawMessage(string(routingJSON)),
|
|
||||||
Policy: json_util.RawMessage(`{}`),
|
|
||||||
Stats: json_util.RawMessage(`{}`),
|
|
||||||
BurstObservatory: json_util.RawMessage(string(burstObservatoryJSON)),
|
|
||||||
Metrics: json_util.RawMessage(string(metricsJSON)),
|
|
||||||
}
|
|
||||||
|
|
||||||
return cfg
|
|
||||||
}
|
|
||||||
|
|
||||||
// observatoryEntry mirrors the per-outbound shape published by xray's
|
|
||||||
// observatory under /debug/vars.
|
|
||||||
type observatoryEntry struct {
|
|
||||||
Alive bool `json:"alive"`
|
|
||||||
Delay int64 `json:"delay"`
|
|
||||||
LastSeenTime int64 `json:"last_seen_time"`
|
|
||||||
LastTryTime int64 `json:"last_try_time"`
|
|
||||||
OutboundTag string `json:"outbound_tag"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func pollObservatoryResult(testProcess *xray.Process, metricsPort int, tag string, timeout time.Duration) *TestOutboundResult {
|
|
||||||
url := fmt.Sprintf("http://127.0.0.1:%d/debug/vars", metricsPort)
|
|
||||||
client := &http.Client{Timeout: 2 * time.Second}
|
|
||||||
deadline := time.Now().Add(timeout)
|
|
||||||
var lastEntry observatoryEntry
|
|
||||||
var sawEntry bool
|
|
||||||
for time.Now().Before(deadline) {
|
|
||||||
if !testProcess.IsRunning() {
|
|
||||||
result := testProcess.GetResult()
|
|
||||||
return &TestOutboundResult{Mode: "http", Success: false, Error: fmt.Sprintf("Xray process exited: %s", result)}
|
|
||||||
}
|
|
||||||
entry, ok := fetchObservatoryEntry(client, url, tag)
|
|
||||||
if ok {
|
|
||||||
if entry.Alive {
|
|
||||||
delay := entry.Delay
|
|
||||||
if delay <= 0 {
|
|
||||||
delay = 1
|
|
||||||
}
|
|
||||||
return &TestOutboundResult{Mode: "http", Success: true, Delay: delay}
|
|
||||||
}
|
|
||||||
lastEntry = entry
|
|
||||||
sawEntry = true
|
|
||||||
}
|
|
||||||
time.Sleep(400 * time.Millisecond)
|
|
||||||
}
|
|
||||||
|
|
||||||
msg := "Probe timed out — outbound did not become reachable (see Xray log for details)"
|
|
||||||
if sawEntry && lastEntry.LastTryTime > 0 {
|
|
||||||
msg = fmt.Sprintf("All probes failed (last attempt %ds ago; see Xray log for details)", time.Now().Unix()-lastEntry.LastTryTime)
|
|
||||||
}
|
|
||||||
return &TestOutboundResult{Mode: "http", Success: false, Error: msg}
|
|
||||||
}
|
|
||||||
|
|
||||||
func fetchObservatoryEntry(client *http.Client, url, tag string) (observatoryEntry, bool) {
|
|
||||||
resp, err := client.Get(url)
|
|
||||||
if err != nil {
|
|
||||||
return observatoryEntry{}, false
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
if resp.StatusCode != http.StatusOK {
|
|
||||||
return observatoryEntry{}, false
|
|
||||||
}
|
|
||||||
var payload struct {
|
|
||||||
Observatory map[string]observatoryEntry `json:"observatory"`
|
|
||||||
}
|
|
||||||
if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil {
|
|
||||||
return observatoryEntry{}, false
|
|
||||||
}
|
|
||||||
if entry, ok := payload.Observatory[tag]; ok {
|
|
||||||
return entry, true
|
|
||||||
}
|
|
||||||
for _, entry := range payload.Observatory {
|
|
||||||
if entry.OutboundTag == tag {
|
|
||||||
return entry, true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return observatoryEntry{}, false
|
|
||||||
}
|
|
||||||
|
|
||||||
// waitForPort polls until the given TCP port is accepting connections or the timeout expires.
|
|
||||||
func waitForPort(port int, timeout time.Duration) error {
|
|
||||||
deadline := time.Now().Add(timeout)
|
|
||||||
for time.Now().Before(deadline) {
|
|
||||||
conn, err := net.DialTimeout("tcp", fmt.Sprintf("127.0.0.1:%d", port), 100*time.Millisecond)
|
|
||||||
if err == nil {
|
|
||||||
conn.Close()
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
time.Sleep(50 * time.Millisecond)
|
|
||||||
}
|
|
||||||
return fmt.Errorf("port %d not ready after %v", port, timeout)
|
|
||||||
}
|
|
||||||
|
|
||||||
// findAvailablePort finds an available port for testing
|
|
||||||
func findAvailablePort() (int, error) {
|
|
||||||
listener, err := net.Listen("tcp", ":0")
|
|
||||||
if err != nil {
|
|
||||||
return 0, err
|
|
||||||
}
|
|
||||||
defer listener.Close()
|
|
||||||
|
|
||||||
addr := listener.Addr().(*net.TCPAddr)
|
|
||||||
return addr.Port, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// createTestConfigPath returns a unique path for a temporary xray config file in the bin folder.
|
|
||||||
// The temp file is created and closed so the path is reserved; Start() will overwrite it.
|
|
||||||
func createTestConfigPath() (string, error) {
|
|
||||||
tmpFile, err := os.CreateTemp(config.GetBinFolderPath(), "xray_test_*.json")
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
path := tmpFile.Name()
|
|
||||||
if err := tmpFile.Close(); err != nil {
|
|
||||||
os.Remove(path)
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
return path, nil
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -0,0 +1,552 @@
|
|||||||
|
package outbound
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/tls"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io/fs"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptrace"
|
||||||
|
"net/url"
|
||||||
|
"os"
|
||||||
|
"strconv"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/mhsanaei/3x-ui/v3/internal/config"
|
||||||
|
"github.com/mhsanaei/3x-ui/v3/internal/util/json_util"
|
||||||
|
"github.com/mhsanaei/3x-ui/v3/internal/xray"
|
||||||
|
)
|
||||||
|
|
||||||
|
// HTTP-mode probing works by spinning up ONE temporary xray instance per
|
||||||
|
// batch: every outbound under test gets its own loopback SOCKS inbound plus
|
||||||
|
// an inboundTag→outboundTag routing rule, and the panel then issues a real,
|
||||||
|
// individually-timed HTTP request through each inbound. Measuring the request
|
||||||
|
// client-side (instead of polling xray's observatory) returns the moment the
|
||||||
|
// response lands, yields the actual HTTP status, and allows an httptrace
|
||||||
|
// timing breakdown — while the shared process keeps "Test All" at one xray
|
||||||
|
// spawn per batch instead of one per outbound.
|
||||||
|
|
||||||
|
const (
|
||||||
|
// httpProbeTimeout bounds one probe request end-to-end.
|
||||||
|
httpProbeTimeout = 10 * time.Second
|
||||||
|
// httpProbeConcurrency caps parallel probe requests within a batch —
|
||||||
|
// enough to keep a batch fast, low enough not to spike CPU with TLS
|
||||||
|
// handshakes on small VPSes.
|
||||||
|
httpProbeConcurrency = 16
|
||||||
|
// batchPortsReadyTimeout bounds the wait for the temp instance to open
|
||||||
|
// its test inbounds.
|
||||||
|
batchPortsReadyTimeout = 10 * time.Second
|
||||||
|
// maxBatchItems caps one batch request; the frontend chunks below this.
|
||||||
|
maxBatchItems = 50
|
||||||
|
// tcpBatchConcurrency caps parallel TCP-mode items in a batch (each item
|
||||||
|
// already dials its endpoints concurrently).
|
||||||
|
tcpBatchConcurrency = 8
|
||||||
|
|
||||||
|
defaultTestURL = "https://www.google.com/generate_204"
|
||||||
|
)
|
||||||
|
|
||||||
|
// httpTestSemaphore serialises HTTP-mode batches (each spawns a temp xray
|
||||||
|
// instance, which is too expensive to run in parallel). TCP-mode probes are
|
||||||
|
// dial-only and don't need the semaphore.
|
||||||
|
var httpTestSemaphore sync.Mutex
|
||||||
|
|
||||||
|
// batchProcess is the slice of xray.Process the batch engine needs; a seam
|
||||||
|
// so unit tests can stub the process without an xray binary.
|
||||||
|
type batchProcess interface {
|
||||||
|
Start() error
|
||||||
|
Stop() error
|
||||||
|
IsRunning() bool
|
||||||
|
GetResult() string
|
||||||
|
}
|
||||||
|
|
||||||
|
var newBatchProcess = func(cfg *xray.Config, configPath string) batchProcess {
|
||||||
|
return xray.NewTestProcess(cfg, configPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
// httpBatchItem is one outbound inside an HTTP-mode batch. result is the
|
||||||
|
// pre-allocated entry in the caller's result slice, filled in place.
|
||||||
|
type httpBatchItem struct {
|
||||||
|
index int
|
||||||
|
tag string
|
||||||
|
outbound map[string]any
|
||||||
|
result *TestOutboundResult
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestOutbound probes a single outbound; legacy single-test API kept for the
|
||||||
|
// /testOutbound endpoint. Dispatch matches TestOutbounds: mode "tcp" dials
|
||||||
|
// the outbound's endpoints directly, anything else routes a real HTTP request
|
||||||
|
// through a temp xray instance (UDP-transport outbounds are always forced to
|
||||||
|
// the HTTP probe — a raw dial can't measure them).
|
||||||
|
func (s *OutboundService) TestOutbound(outboundJSON string, testURL string, allOutboundsJSON string, mode string) (*TestOutboundResult, error) {
|
||||||
|
var ob map[string]any
|
||||||
|
if err := json.Unmarshal([]byte(outboundJSON), &ob); err != nil {
|
||||||
|
m := "http"
|
||||||
|
if mode == "tcp" {
|
||||||
|
m = "tcp"
|
||||||
|
}
|
||||||
|
return &TestOutboundResult{Mode: m, Success: false, Error: fmt.Sprintf("Invalid outbound JSON: %v", err)}, nil
|
||||||
|
}
|
||||||
|
results := s.testOutboundsParsed([]map[string]any{ob}, testURL, allOutboundsJSON, mode)
|
||||||
|
return results[0], nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestOutbounds probes a JSON array of outbounds and returns one result per
|
||||||
|
// input, in input order, each carrying the outbound's tag. allOutboundsJSON
|
||||||
|
// supplies the config context (sockopt.dialerProxy chains); testURL falls
|
||||||
|
// back to the default probe URL when empty.
|
||||||
|
func (s *OutboundService) TestOutbounds(outboundsJSON string, testURL string, allOutboundsJSON string, mode string) ([]*TestOutboundResult, error) {
|
||||||
|
var raw []json.RawMessage
|
||||||
|
if err := json.Unmarshal([]byte(outboundsJSON), &raw); err != nil {
|
||||||
|
return nil, fmt.Errorf("invalid outbounds JSON: %v", err)
|
||||||
|
}
|
||||||
|
if len(raw) > maxBatchItems {
|
||||||
|
return nil, fmt.Errorf("too many outbounds in one request (max %d)", maxBatchItems)
|
||||||
|
}
|
||||||
|
items := make([]map[string]any, len(raw))
|
||||||
|
for i, r := range raw {
|
||||||
|
var ob map[string]any
|
||||||
|
if err := json.Unmarshal(r, &ob); err == nil {
|
||||||
|
items[i] = ob
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return s.testOutboundsParsed(items, testURL, allOutboundsJSON, mode), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// testOutboundsParsed splits items into the TCP lane (direct dials, bounded
|
||||||
|
// worker pool) and the HTTP lane (one shared temp xray instance), runs both,
|
||||||
|
// and returns results aligned with items. A nil item marks unparseable input.
|
||||||
|
func (s *OutboundService) testOutboundsParsed(items []map[string]any, testURL string, allOutboundsJSON string, mode string) []*TestOutboundResult {
|
||||||
|
results := make([]*TestOutboundResult, len(items))
|
||||||
|
|
||||||
|
modeLabel := "http"
|
||||||
|
if mode == "tcp" {
|
||||||
|
modeLabel = "tcp"
|
||||||
|
}
|
||||||
|
|
||||||
|
type tcpEntry struct {
|
||||||
|
idx int
|
||||||
|
ob map[string]any
|
||||||
|
}
|
||||||
|
var tcpLane []tcpEntry
|
||||||
|
var httpItems []*httpBatchItem
|
||||||
|
seenTags := make(map[string]bool)
|
||||||
|
|
||||||
|
for i, ob := range items {
|
||||||
|
if ob == nil {
|
||||||
|
results[i] = &TestOutboundResult{Mode: modeLabel, Success: false, Error: "Invalid outbound JSON"}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// A bare TCP dial only proves reachability for TCP-based proxies.
|
||||||
|
// UDP protocols (wireguard, hysteria, kcp/quic transports) ignore
|
||||||
|
// unauthenticated packets, so a raw dial can't tell "reachable" from
|
||||||
|
// "dead" — route them through the real xray probe.
|
||||||
|
if mode == "tcp" && !outboundTransportIsUDP(ob) {
|
||||||
|
tcpLane = append(tcpLane, tcpEntry{idx: i, ob: ob})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
tag, _ := ob["tag"].(string)
|
||||||
|
r := &TestOutboundResult{Tag: tag, Mode: "http"}
|
||||||
|
results[i] = r
|
||||||
|
protocol, _ := ob["protocol"].(string)
|
||||||
|
switch {
|
||||||
|
case tag == "":
|
||||||
|
r.Error = "Outbound has no tag"
|
||||||
|
case protocol == "blackhole" || tag == "blocked":
|
||||||
|
r.Error = "Blocked/blackhole outbound cannot be tested"
|
||||||
|
case protocol == "loopback":
|
||||||
|
r.Error = "Loopback outbound cannot be tested"
|
||||||
|
case seenTags[tag]:
|
||||||
|
r.Error = fmt.Sprintf("Duplicate outbound tag in batch: %s", tag)
|
||||||
|
default:
|
||||||
|
seenTags[tag] = true
|
||||||
|
httpItems = append(httpItems, &httpBatchItem{index: i, tag: tag, outbound: ob, result: r})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(tcpLane) > 0 {
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
sem := make(chan struct{}, tcpBatchConcurrency)
|
||||||
|
for _, e := range tcpLane {
|
||||||
|
wg.Add(1)
|
||||||
|
go func(e tcpEntry) {
|
||||||
|
defer wg.Done()
|
||||||
|
sem <- struct{}{}
|
||||||
|
defer func() { <-sem }()
|
||||||
|
obJSON, err := json.Marshal(e.ob)
|
||||||
|
if err != nil {
|
||||||
|
tag, _ := e.ob["tag"].(string)
|
||||||
|
results[e.idx] = &TestOutboundResult{Tag: tag, Mode: "tcp", Success: false, Error: fmt.Sprintf("Invalid outbound JSON: %v", err)}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
r, _ := s.testOutboundTCP(string(obJSON))
|
||||||
|
results[e.idx] = r
|
||||||
|
}(e)
|
||||||
|
}
|
||||||
|
wg.Wait()
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(httpItems) == 0 {
|
||||||
|
return results
|
||||||
|
}
|
||||||
|
|
||||||
|
failAll := func(msg string) {
|
||||||
|
for _, it := range httpItems {
|
||||||
|
it.result.Success = false
|
||||||
|
it.result.Error = msg
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var allOutbounds []any
|
||||||
|
if allOutboundsJSON != "" {
|
||||||
|
if err := json.Unmarshal([]byte(allOutboundsJSON), &allOutbounds); err != nil {
|
||||||
|
failAll(fmt.Sprintf("Invalid allOutbounds JSON: %v", err))
|
||||||
|
return results
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if testURL == "" {
|
||||||
|
testURL = defaultTestURL
|
||||||
|
}
|
||||||
|
|
||||||
|
if !httpTestSemaphore.TryLock() {
|
||||||
|
failAll("Another outbound test is already running, please wait")
|
||||||
|
return results
|
||||||
|
}
|
||||||
|
defer httpTestSemaphore.Unlock()
|
||||||
|
|
||||||
|
retryPerItem, err := runHTTPProbeBatch(httpItems, allOutbounds, testURL)
|
||||||
|
if err == nil {
|
||||||
|
return results
|
||||||
|
}
|
||||||
|
if !retryPerItem || len(httpItems) == 1 {
|
||||||
|
failAll(err.Error())
|
||||||
|
return results
|
||||||
|
}
|
||||||
|
// The shared process never came up — one structurally-bad outbound can
|
||||||
|
// poison the whole batch config. Retry each item in its own isolated
|
||||||
|
// instance so the broken outbound reports xray's real error and the
|
||||||
|
// rest still get tested. Serial: the poisoned case fails fast (~1s).
|
||||||
|
for _, it := range httpItems {
|
||||||
|
if _, ferr := runHTTPProbeBatch([]*httpBatchItem{it}, allOutbounds, testURL); ferr != nil {
|
||||||
|
it.result.Success = false
|
||||||
|
it.result.Error = ferr.Error()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return results
|
||||||
|
}
|
||||||
|
|
||||||
|
// runHTTPProbeBatch makes one shared-process attempt for the given items,
|
||||||
|
// writing per-request outcomes into the items' results. It returns a non-nil
|
||||||
|
// error only when the process never became usable; retryPerItem reports
|
||||||
|
// whether splitting the batch into per-item instances could help (true for
|
||||||
|
// start failures / early exits that a poisoned config would explain, false
|
||||||
|
// for environmental failures like a missing binary or no free ports).
|
||||||
|
func runHTTPProbeBatch(items []*httpBatchItem, allOutbounds []any, testURL string) (retryPerItem bool, err error) {
|
||||||
|
ports, release, err := reserveLoopbackPorts(len(items))
|
||||||
|
if err != nil {
|
||||||
|
return false, fmt.Errorf("Failed to reserve test ports: %v", err)
|
||||||
|
}
|
||||||
|
defer release()
|
||||||
|
|
||||||
|
cfg := buildBatchTestConfig(items, allOutbounds, ports)
|
||||||
|
|
||||||
|
configPath, err := createTestConfigPath()
|
||||||
|
if err != nil {
|
||||||
|
return false, fmt.Errorf("Failed to create test config path: %v", err)
|
||||||
|
}
|
||||||
|
defer os.Remove(configPath)
|
||||||
|
|
||||||
|
proc := newBatchProcess(cfg, configPath)
|
||||||
|
defer func() {
|
||||||
|
if proc.IsRunning() {
|
||||||
|
proc.Stop()
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
// Free the reserved ports just before xray binds them; the window is
|
||||||
|
// milliseconds, and a lost race makes xray exit fast, which surfaces
|
||||||
|
// below and triggers the per-item retry with fresh ports.
|
||||||
|
release()
|
||||||
|
if err := proc.Start(); err != nil {
|
||||||
|
if errors.Is(err, fs.ErrNotExist) {
|
||||||
|
// Binary missing — per-item retries would all fail the same way.
|
||||||
|
return false, fmt.Errorf("Failed to start test xray instance: %v", err)
|
||||||
|
}
|
||||||
|
return true, fmt.Errorf("Failed to start test xray instance: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := waitForPortsReady(proc, ports, batchPortsReadyTimeout); err != nil {
|
||||||
|
return err.exited, err
|
||||||
|
}
|
||||||
|
|
||||||
|
sem := make(chan struct{}, httpProbeConcurrency)
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
for i := range items {
|
||||||
|
wg.Add(1)
|
||||||
|
go func(it *httpBatchItem, port int) {
|
||||||
|
defer wg.Done()
|
||||||
|
sem <- struct{}{}
|
||||||
|
defer func() { <-sem }()
|
||||||
|
probeThroughSocks(port, testURL, httpProbeTimeout, it.result)
|
||||||
|
}(items[i], ports[i])
|
||||||
|
}
|
||||||
|
wg.Wait()
|
||||||
|
|
||||||
|
if !proc.IsRunning() {
|
||||||
|
detail := proc.GetResult()
|
||||||
|
for _, it := range items {
|
||||||
|
if !it.result.Success {
|
||||||
|
it.result.Error = "Xray process exited: " + detail
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// portsReadyError distinguishes "process died" (a poisoned config — worth a
|
||||||
|
// per-item retry) from "ports never opened while alive" (environmental).
|
||||||
|
type portsReadyError struct {
|
||||||
|
msg string
|
||||||
|
exited bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *portsReadyError) Error() string { return e.msg }
|
||||||
|
|
||||||
|
// waitForPortsReady polls until every test inbound accepts connections,
|
||||||
|
// aborting as soon as the process exits.
|
||||||
|
func waitForPortsReady(proc batchProcess, ports []int, timeout time.Duration) *portsReadyError {
|
||||||
|
deadline := time.Now().Add(timeout)
|
||||||
|
for _, port := range ports {
|
||||||
|
for {
|
||||||
|
if !proc.IsRunning() {
|
||||||
|
return &portsReadyError{msg: "Xray process exited: " + proc.GetResult(), exited: true}
|
||||||
|
}
|
||||||
|
conn, err := net.DialTimeout("tcp", fmt.Sprintf("127.0.0.1:%d", port), 100*time.Millisecond)
|
||||||
|
if err == nil {
|
||||||
|
conn.Close()
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if time.Now().After(deadline) {
|
||||||
|
return &portsReadyError{msg: fmt.Sprintf("Xray failed to open test inbounds: port %d not ready after %v", port, timeout)}
|
||||||
|
}
|
||||||
|
time.Sleep(50 * time.Millisecond)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildBatchTestConfig assembles the temp instance config: one loopback SOCKS
|
||||||
|
// inbound per tested outbound, a routing rule binding each inbound to its
|
||||||
|
// outbound tag, and the full outbound context so dialerProxy chains resolve.
|
||||||
|
func buildBatchTestConfig(items []*httpBatchItem, allOutbounds []any, ports []int) *xray.Config {
|
||||||
|
// allOutbounds is the template's outbound list; subscription outbounds
|
||||||
|
// are injected at runtime and aren't part of it, so append any tested
|
||||||
|
// outbound whose tag is missing. When a tested outbound's tag collides
|
||||||
|
// with a template outbound, the template version wins — same semantics
|
||||||
|
// as the pre-batch tester.
|
||||||
|
outbounds := make([]any, 0, len(allOutbounds)+len(items))
|
||||||
|
outbounds = append(outbounds, allOutbounds...)
|
||||||
|
for _, it := range items {
|
||||||
|
if !outboundsContainTag(outbounds, it.tag) {
|
||||||
|
outbounds = append(outbounds, it.outbound)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, ob := range outbounds {
|
||||||
|
outbound, ok := ob.(map[string]any)
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// The temp instance must not touch kernel WireGuard devices.
|
||||||
|
if protocol, ok := outbound["protocol"].(string); ok && protocol == "wireguard" {
|
||||||
|
if settings, ok := outbound["settings"].(map[string]any); ok {
|
||||||
|
settings["noKernelTun"] = true
|
||||||
|
} else {
|
||||||
|
outbound["settings"] = map[string]any{"noKernelTun": true}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
outboundsJSON, _ := json.Marshal(outbounds)
|
||||||
|
|
||||||
|
inbounds := make([]xray.InboundConfig, len(items))
|
||||||
|
rules := make([]any, len(items))
|
||||||
|
for i, it := range items {
|
||||||
|
inTag := fmt.Sprintf("test-in-%d", i)
|
||||||
|
inbounds[i] = xray.InboundConfig{
|
||||||
|
Listen: json_util.RawMessage(`"127.0.0.1"`),
|
||||||
|
Port: ports[i],
|
||||||
|
Protocol: "socks",
|
||||||
|
Settings: json_util.RawMessage(`{"auth":"noauth","udp":false}`),
|
||||||
|
Tag: inTag,
|
||||||
|
}
|
||||||
|
rules[i] = map[string]any{
|
||||||
|
"type": "field",
|
||||||
|
"inboundTag": []string{inTag},
|
||||||
|
"outboundTag": it.tag,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
routingJSON, _ := json.Marshal(map[string]any{
|
||||||
|
"domainStrategy": "AsIs",
|
||||||
|
"rules": rules,
|
||||||
|
})
|
||||||
|
|
||||||
|
logJSON, _ := json.Marshal(map[string]any{
|
||||||
|
"loglevel": "warning",
|
||||||
|
"access": "none",
|
||||||
|
"error": "",
|
||||||
|
"dnsLog": false,
|
||||||
|
})
|
||||||
|
|
||||||
|
return &xray.Config{
|
||||||
|
LogConfig: json_util.RawMessage(logJSON),
|
||||||
|
InboundConfigs: inbounds,
|
||||||
|
OutboundConfigs: json_util.RawMessage(outboundsJSON),
|
||||||
|
RouterConfig: json_util.RawMessage(routingJSON),
|
||||||
|
Policy: json_util.RawMessage(`{}`),
|
||||||
|
Stats: json_util.RawMessage(`{}`),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// outboundsContainTag reports whether any outbound in the slice has the given tag.
|
||||||
|
func outboundsContainTag(outbounds []any, tag string) bool {
|
||||||
|
for _, ob := range outbounds {
|
||||||
|
if m, ok := ob.(map[string]any); ok {
|
||||||
|
if t, _ := m["tag"].(string); t == tag {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// probeThroughSocks issues one timed GET through the local SOCKS inbound at
|
||||||
|
// the given port and fills result. Any HTTP response — including 4xx/5xx and
|
||||||
|
// unfollowed redirects — counts as reachable; only transport-level failures
|
||||||
|
// (refused, reset, timeout, proxy errors) are failures. Delay is request
|
||||||
|
// start → response headers; the test URL's hostname is resolved by xray
|
||||||
|
// (Go's SOCKS5 client sends the domain to the proxy), so DNS goes through
|
||||||
|
// the outbound too.
|
||||||
|
func probeThroughSocks(port int, testURL string, timeout time.Duration, result *TestOutboundResult) {
|
||||||
|
proxyURL := &url.URL{Scheme: "socks5", Host: net.JoinHostPort("127.0.0.1", strconv.Itoa(port))}
|
||||||
|
tr := &http.Transport{
|
||||||
|
Proxy: http.ProxyURL(proxyURL),
|
||||||
|
DisableKeepAlives: true,
|
||||||
|
}
|
||||||
|
defer tr.CloseIdleConnections()
|
||||||
|
client := &http.Client{
|
||||||
|
Transport: tr,
|
||||||
|
Timeout: timeout,
|
||||||
|
// A redirect would re-dial through the proxy and skew the timing;
|
||||||
|
// the 3xx itself already proves the outbound works.
|
||||||
|
CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse },
|
||||||
|
}
|
||||||
|
|
||||||
|
// Timing breakdown. ConnectStart/Done wrap the TCP dial to the local
|
||||||
|
// inbound (the SOCKS handshake isn't traced, and xray ACKs CONNECT
|
||||||
|
// before dialing upstream — so the real outbound establishment lands in
|
||||||
|
// the TLS phase for https URLs, or inside TTFB for plain http).
|
||||||
|
var (
|
||||||
|
connStart, tlsStart time.Time
|
||||||
|
connDur, tlsDur, ttfbDur time.Duration
|
||||||
|
connDone, tlsDone, gotFirstRB bool
|
||||||
|
)
|
||||||
|
start := time.Now()
|
||||||
|
trace := &httptrace.ClientTrace{
|
||||||
|
ConnectStart: func(network, addr string) {
|
||||||
|
if connStart.IsZero() {
|
||||||
|
connStart = time.Now()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
ConnectDone: func(network, addr string, err error) {
|
||||||
|
if err == nil && !connDone && !connStart.IsZero() {
|
||||||
|
connDone = true
|
||||||
|
connDur = time.Since(connStart)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
TLSHandshakeStart: func() {
|
||||||
|
if tlsStart.IsZero() {
|
||||||
|
tlsStart = time.Now()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
TLSHandshakeDone: func(_ tls.ConnectionState, err error) {
|
||||||
|
if err == nil && !tlsDone && !tlsStart.IsZero() {
|
||||||
|
tlsDone = true
|
||||||
|
tlsDur = time.Since(tlsStart)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
GotFirstResponseByte: func() {
|
||||||
|
if !gotFirstRB {
|
||||||
|
gotFirstRB = true
|
||||||
|
ttfbDur = time.Since(start)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
req, err := http.NewRequestWithContext(httptrace.WithClientTrace(context.Background(), trace), http.MethodGet, testURL, nil)
|
||||||
|
if err != nil {
|
||||||
|
result.Error = err.Error()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
delay := time.Since(start).Milliseconds()
|
||||||
|
if err != nil {
|
||||||
|
result.Error = err.Error()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
resp.Body.Close()
|
||||||
|
|
||||||
|
result.Success = true
|
||||||
|
result.Delay = max(delay, 1)
|
||||||
|
result.HTTPStatus = resp.StatusCode
|
||||||
|
if connDone {
|
||||||
|
result.ConnectMs = max(connDur.Milliseconds(), 1)
|
||||||
|
}
|
||||||
|
if tlsDone {
|
||||||
|
result.TLSMs = max(tlsDur.Milliseconds(), 1)
|
||||||
|
}
|
||||||
|
if gotFirstRB {
|
||||||
|
result.TTFBMs = max(ttfbDur.Milliseconds(), 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// reserveLoopbackPorts grabs n free loopback ports and keeps the listeners
|
||||||
|
// open so nothing else claims them; release() frees them (idempotent — the
|
||||||
|
// caller releases right before starting xray and again via defer).
|
||||||
|
func reserveLoopbackPorts(n int) ([]int, func(), error) {
|
||||||
|
listeners := make([]net.Listener, 0, n)
|
||||||
|
release := func() {
|
||||||
|
for _, l := range listeners {
|
||||||
|
l.Close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ports := make([]int, 0, n)
|
||||||
|
for range n {
|
||||||
|
l, err := net.Listen("tcp", "127.0.0.1:0")
|
||||||
|
if err != nil {
|
||||||
|
release()
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
listeners = append(listeners, l)
|
||||||
|
ports = append(ports, l.Addr().(*net.TCPAddr).Port)
|
||||||
|
}
|
||||||
|
return ports, release, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// createTestConfigPath returns a unique path for a temporary xray config file in the bin folder.
|
||||||
|
// The temp file is created and closed so the path is reserved; Start() will overwrite it.
|
||||||
|
func createTestConfigPath() (string, error) {
|
||||||
|
tmpFile, err := os.CreateTemp(config.GetBinFolderPath(), "xray_test_*.json")
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
path := tmpFile.Name()
|
||||||
|
if err := tmpFile.Close(); err != nil {
|
||||||
|
os.Remove(path)
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return path, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,470 @@
|
|||||||
|
package outbound
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"io/fs"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/mhsanaei/3x-ui/v3/internal/xray"
|
||||||
|
)
|
||||||
|
|
||||||
|
// stubProcess implements batchProcess without an xray binary. When serveSocks
|
||||||
|
// is set, Start opens a minimal SOCKS5 server on every inbound port from the
|
||||||
|
// config, so probes run against a real tunnel.
|
||||||
|
type stubProcess struct {
|
||||||
|
cfg *xray.Config
|
||||||
|
startErr error
|
||||||
|
result string
|
||||||
|
serveSocks bool
|
||||||
|
|
||||||
|
running bool
|
||||||
|
listeners []net.Listener
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *stubProcess) Start() error {
|
||||||
|
if p.startErr != nil {
|
||||||
|
return p.startErr
|
||||||
|
}
|
||||||
|
for _, in := range p.cfg.InboundConfigs {
|
||||||
|
l, err := net.Listen("tcp", fmt.Sprintf("127.0.0.1:%d", in.Port))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
p.listeners = append(p.listeners, l)
|
||||||
|
if p.serveSocks {
|
||||||
|
go serveStubSocks(l)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
p.running = true
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *stubProcess) Stop() error {
|
||||||
|
for _, l := range p.listeners {
|
||||||
|
l.Close()
|
||||||
|
}
|
||||||
|
p.running = false
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *stubProcess) IsRunning() bool { return p.running }
|
||||||
|
func (p *stubProcess) GetResult() string {
|
||||||
|
if p.result != "" {
|
||||||
|
return p.result
|
||||||
|
}
|
||||||
|
return "stub exited"
|
||||||
|
}
|
||||||
|
|
||||||
|
// serveStubSocks answers SOCKS5 no-auth CONNECTs and pipes to the requested
|
||||||
|
// target — just enough protocol for net/http's socks5 client.
|
||||||
|
func serveStubSocks(l net.Listener) {
|
||||||
|
for {
|
||||||
|
conn, err := l.Accept()
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
go func(c net.Conn) {
|
||||||
|
defer c.Close()
|
||||||
|
hello := make([]byte, 2)
|
||||||
|
if _, err := io.ReadFull(c, hello); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
methods := make([]byte, hello[1])
|
||||||
|
if _, err := io.ReadFull(c, methods); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.Write([]byte{0x05, 0x00})
|
||||||
|
hdr := make([]byte, 4)
|
||||||
|
if _, err := io.ReadFull(c, hdr); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var host string
|
||||||
|
switch hdr[3] {
|
||||||
|
case 0x01:
|
||||||
|
b := make([]byte, 4)
|
||||||
|
io.ReadFull(c, b)
|
||||||
|
host = net.IP(b).String()
|
||||||
|
case 0x03:
|
||||||
|
lb := make([]byte, 1)
|
||||||
|
io.ReadFull(c, lb)
|
||||||
|
b := make([]byte, lb[0])
|
||||||
|
io.ReadFull(c, b)
|
||||||
|
host = string(b)
|
||||||
|
case 0x04:
|
||||||
|
b := make([]byte, 16)
|
||||||
|
io.ReadFull(c, b)
|
||||||
|
host = net.IP(b).String()
|
||||||
|
default:
|
||||||
|
return
|
||||||
|
}
|
||||||
|
pb := make([]byte, 2)
|
||||||
|
if _, err := io.ReadFull(c, pb); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
port := int(pb[0])<<8 | int(pb[1])
|
||||||
|
upstream, err := net.Dial("tcp", net.JoinHostPort(host, strconv.Itoa(port)))
|
||||||
|
if err != nil {
|
||||||
|
c.Write([]byte{0x05, 0x05, 0x00, 0x01, 0, 0, 0, 0, 0, 0})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer upstream.Close()
|
||||||
|
c.Write([]byte{0x05, 0x00, 0x00, 0x01, 0, 0, 0, 0, 0, 0})
|
||||||
|
go io.Copy(upstream, c)
|
||||||
|
io.Copy(c, upstream)
|
||||||
|
}(conn)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func withStubProcess(t *testing.T, factory func(cfg *xray.Config, configPath string) batchProcess) {
|
||||||
|
t.Helper()
|
||||||
|
// createTestConfigPath writes into the bin folder, which doesn't exist
|
||||||
|
// when running tests from the package directory.
|
||||||
|
t.Setenv("XUI_BIN_FOLDER", t.TempDir())
|
||||||
|
orig := newBatchProcess
|
||||||
|
newBatchProcess = factory
|
||||||
|
t.Cleanup(func() { newBatchProcess = orig })
|
||||||
|
}
|
||||||
|
|
||||||
|
func mustJSON(t *testing.T, v any) string {
|
||||||
|
t.Helper()
|
||||||
|
b, err := json.Marshal(v)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("marshal: %v", err)
|
||||||
|
}
|
||||||
|
return string(b)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildBatchTestConfig(t *testing.T) {
|
||||||
|
items := []*httpBatchItem{
|
||||||
|
{tag: "wg-sub", outbound: map[string]any{"tag": "wg-sub", "protocol": "wireguard"}},
|
||||||
|
{tag: "proxy-a", outbound: map[string]any{"tag": "proxy-a", "protocol": "vless"}},
|
||||||
|
}
|
||||||
|
allOutbounds := []any{
|
||||||
|
map[string]any{"tag": "direct", "protocol": "freedom", "settings": map[string]any{}},
|
||||||
|
map[string]any{"tag": "proxy-a", "protocol": "vless", "settings": map[string]any{"address": "a.example.com"}},
|
||||||
|
}
|
||||||
|
ports := []int{61001, 61002}
|
||||||
|
|
||||||
|
cfg := buildBatchTestConfig(items, allOutbounds, ports)
|
||||||
|
raw, err := json.Marshal(cfg)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("marshal config: %v", err)
|
||||||
|
}
|
||||||
|
var m map[string]any
|
||||||
|
if err := json.Unmarshal(raw, &m); err != nil {
|
||||||
|
t.Fatalf("unmarshal config: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
inbounds, _ := m["inbounds"].([]any)
|
||||||
|
if len(inbounds) != 2 {
|
||||||
|
t.Fatalf("expected 2 inbounds, got %d", len(inbounds))
|
||||||
|
}
|
||||||
|
for i, raw := range inbounds {
|
||||||
|
in := raw.(map[string]any)
|
||||||
|
if got := in["tag"]; got != fmt.Sprintf("test-in-%d", i) {
|
||||||
|
t.Errorf("inbound %d tag = %v", i, got)
|
||||||
|
}
|
||||||
|
if got := int(in["port"].(float64)); got != ports[i] {
|
||||||
|
t.Errorf("inbound %d port = %d, want %d", i, got, ports[i])
|
||||||
|
}
|
||||||
|
if got := in["protocol"]; got != "socks" {
|
||||||
|
t.Errorf("inbound %d protocol = %v", i, got)
|
||||||
|
}
|
||||||
|
if got := in["listen"]; got != "127.0.0.1" {
|
||||||
|
t.Errorf("inbound %d listen = %v", i, got)
|
||||||
|
}
|
||||||
|
settings := in["settings"].(map[string]any)
|
||||||
|
if settings["auth"] != "noauth" || settings["udp"] != false {
|
||||||
|
t.Errorf("inbound %d settings = %v", i, settings)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
routing := m["routing"].(map[string]any)
|
||||||
|
rules, _ := routing["rules"].([]any)
|
||||||
|
if len(rules) != 2 {
|
||||||
|
t.Fatalf("expected 2 routing rules, got %d", len(rules))
|
||||||
|
}
|
||||||
|
wantTags := []string{"wg-sub", "proxy-a"}
|
||||||
|
for i, raw := range rules {
|
||||||
|
rule := raw.(map[string]any)
|
||||||
|
inTags := rule["inboundTag"].([]any)
|
||||||
|
if len(inTags) != 1 || inTags[0] != fmt.Sprintf("test-in-%d", i) {
|
||||||
|
t.Errorf("rule %d inboundTag = %v", i, inTags)
|
||||||
|
}
|
||||||
|
if rule["outboundTag"] != wantTags[i] {
|
||||||
|
t.Errorf("rule %d outboundTag = %v, want %s", i, rule["outboundTag"], wantTags[i])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
outbounds, _ := m["outbounds"].([]any)
|
||||||
|
if len(outbounds) != 3 {
|
||||||
|
t.Fatalf("expected 3 outbounds (wg-sub appended once, proxy-a deduped), got %d", len(outbounds))
|
||||||
|
}
|
||||||
|
var wg map[string]any
|
||||||
|
for _, raw := range outbounds {
|
||||||
|
ob := raw.(map[string]any)
|
||||||
|
if ob["tag"] == "wg-sub" {
|
||||||
|
wg = ob
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if wg == nil {
|
||||||
|
t.Fatal("wg-sub not appended to outbounds")
|
||||||
|
}
|
||||||
|
if settings, _ := wg["settings"].(map[string]any); settings == nil || settings["noKernelTun"] != true {
|
||||||
|
t.Errorf("wireguard settings missing noKernelTun: %v", wg["settings"])
|
||||||
|
}
|
||||||
|
|
||||||
|
if m["burstObservatory"] != nil {
|
||||||
|
t.Errorf("burstObservatory should not be set, got %v", m["burstObservatory"])
|
||||||
|
}
|
||||||
|
if m["metrics"] != nil {
|
||||||
|
t.Errorf("metrics should not be set, got %v", m["metrics"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTestOutboundsPrevalidationAndOrdering(t *testing.T) {
|
||||||
|
calls := 0
|
||||||
|
withStubProcess(t, func(cfg *xray.Config, configPath string) batchProcess {
|
||||||
|
calls++
|
||||||
|
return &stubProcess{cfg: cfg, startErr: errors.New("boom")}
|
||||||
|
})
|
||||||
|
|
||||||
|
batch := mustJSON(t, []any{
|
||||||
|
map[string]any{"protocol": "vless"}, // no tag
|
||||||
|
map[string]any{"tag": "bh", "protocol": "blackhole"}, // blackhole
|
||||||
|
map[string]any{"tag": "loop", "protocol": "loopback"}, // loopback
|
||||||
|
map[string]any{"tag": "a", "protocol": "socks"}, // valid
|
||||||
|
map[string]any{"tag": "a", "protocol": "vless"}, // duplicate
|
||||||
|
})
|
||||||
|
results, err := (&OutboundService{}).TestOutbounds(batch, "http://example.invalid/gen", "", "http")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("TestOutbounds: %v", err)
|
||||||
|
}
|
||||||
|
if len(results) != 5 {
|
||||||
|
t.Fatalf("expected 5 results, got %d", len(results))
|
||||||
|
}
|
||||||
|
wantErrs := []string{
|
||||||
|
"Outbound has no tag",
|
||||||
|
"Blocked/blackhole outbound cannot be tested",
|
||||||
|
"Loopback outbound cannot be tested",
|
||||||
|
"Failed to start test xray instance: boom",
|
||||||
|
"Duplicate outbound tag in batch: a",
|
||||||
|
}
|
||||||
|
for i, want := range wantErrs {
|
||||||
|
if results[i].Success {
|
||||||
|
t.Errorf("result %d unexpectedly succeeded", i)
|
||||||
|
}
|
||||||
|
if results[i].Error != want {
|
||||||
|
t.Errorf("result %d error = %q, want %q", i, results[i].Error, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if results[3].Tag != "a" || results[4].Tag != "a" || results[1].Tag != "bh" {
|
||||||
|
t.Errorf("tags not propagated: %+v", results)
|
||||||
|
}
|
||||||
|
// Single valid item → no per-item fallback round.
|
||||||
|
if calls != 1 {
|
||||||
|
t.Errorf("process spawned %d times, want 1", calls)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTestOutboundsFallbackOnStartFailure(t *testing.T) {
|
||||||
|
calls := 0
|
||||||
|
withStubProcess(t, func(cfg *xray.Config, configPath string) batchProcess {
|
||||||
|
calls++
|
||||||
|
return &stubProcess{cfg: cfg, startErr: errors.New("boom")}
|
||||||
|
})
|
||||||
|
|
||||||
|
batch := mustJSON(t, []any{
|
||||||
|
map[string]any{"tag": "a", "protocol": "socks"},
|
||||||
|
map[string]any{"tag": "b", "protocol": "vless"},
|
||||||
|
})
|
||||||
|
results, err := (&OutboundService{}).TestOutbounds(batch, "http://example.invalid/gen", "", "http")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("TestOutbounds: %v", err)
|
||||||
|
}
|
||||||
|
for i, r := range results {
|
||||||
|
if r.Success || r.Error != "Failed to start test xray instance: boom" {
|
||||||
|
t.Errorf("result %d = %+v, want start failure", i, r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 1 shared attempt + 2 isolated fallback attempts.
|
||||||
|
if calls != 3 {
|
||||||
|
t.Errorf("process spawned %d times, want 3", calls)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTestOutboundsNoFallbackWhenBinaryMissing(t *testing.T) {
|
||||||
|
calls := 0
|
||||||
|
withStubProcess(t, func(cfg *xray.Config, configPath string) batchProcess {
|
||||||
|
calls++
|
||||||
|
return &stubProcess{cfg: cfg, startErr: &fs.PathError{Op: "exec", Path: "xray", Err: fs.ErrNotExist}}
|
||||||
|
})
|
||||||
|
|
||||||
|
batch := mustJSON(t, []any{
|
||||||
|
map[string]any{"tag": "a", "protocol": "socks"},
|
||||||
|
map[string]any{"tag": "b", "protocol": "vless"},
|
||||||
|
})
|
||||||
|
results, err := (&OutboundService{}).TestOutbounds(batch, "http://example.invalid/gen", "", "http")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("TestOutbounds: %v", err)
|
||||||
|
}
|
||||||
|
for i, r := range results {
|
||||||
|
if r.Success || !strings.HasPrefix(r.Error, "Failed to start test xray instance:") {
|
||||||
|
t.Errorf("result %d = %+v, want start failure", i, r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if calls != 1 {
|
||||||
|
t.Errorf("process spawned %d times, want 1 (no fallback for missing binary)", calls)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTestOutboundsSemaphoreBusy(t *testing.T) {
|
||||||
|
withStubProcess(t, func(cfg *xray.Config, configPath string) batchProcess {
|
||||||
|
t.Fatal("process must not be spawned while semaphore is held")
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
|
||||||
|
httpTestSemaphore.Lock()
|
||||||
|
defer httpTestSemaphore.Unlock()
|
||||||
|
|
||||||
|
batch := mustJSON(t, []any{map[string]any{"tag": "a", "protocol": "socks"}})
|
||||||
|
results, err := (&OutboundService{}).TestOutbounds(batch, "", "", "http")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("TestOutbounds: %v", err)
|
||||||
|
}
|
||||||
|
if results[0].Success || results[0].Error != "Another outbound test is already running, please wait" {
|
||||||
|
t.Errorf("result = %+v, want busy error", results[0])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTestOutboundsInputValidation(t *testing.T) {
|
||||||
|
s := &OutboundService{}
|
||||||
|
if _, err := s.TestOutbounds("not json", "", "", "tcp"); err == nil {
|
||||||
|
t.Error("expected error for invalid JSON")
|
||||||
|
}
|
||||||
|
|
||||||
|
big := make([]any, maxBatchItems+1)
|
||||||
|
for i := range big {
|
||||||
|
big[i] = map[string]any{"tag": fmt.Sprintf("t%d", i), "protocol": "socks"}
|
||||||
|
}
|
||||||
|
if _, err := s.TestOutbounds(mustJSON(t, big), "", "", "tcp"); err == nil {
|
||||||
|
t.Error("expected error for oversized batch")
|
||||||
|
}
|
||||||
|
|
||||||
|
results, err := s.TestOutbounds("[]", "", "", "tcp")
|
||||||
|
if err != nil || len(results) != 0 {
|
||||||
|
t.Errorf("empty batch: results=%v err=%v", results, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTestOutboundsTCPLane(t *testing.T) {
|
||||||
|
l, err := net.Listen("tcp", "127.0.0.1:0")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("listen: %v", err)
|
||||||
|
}
|
||||||
|
defer l.Close()
|
||||||
|
go func() {
|
||||||
|
for {
|
||||||
|
conn, err := l.Accept()
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
conn.Close()
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
port := l.Addr().(*net.TCPAddr).Port
|
||||||
|
|
||||||
|
batch := mustJSON(t, []any{map[string]any{
|
||||||
|
"tag": "t1",
|
||||||
|
"protocol": "socks",
|
||||||
|
"settings": map[string]any{"servers": []any{map[string]any{"address": "127.0.0.1", "port": port}}},
|
||||||
|
}})
|
||||||
|
results, err := (&OutboundService{}).TestOutbounds(batch, "", "", "tcp")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("TestOutbounds: %v", err)
|
||||||
|
}
|
||||||
|
r := results[0]
|
||||||
|
if !r.Success || r.Mode != "tcp" || r.Tag != "t1" || len(r.Endpoints) != 1 {
|
||||||
|
t.Errorf("unexpected tcp result: %+v", r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTestOutboundsHTTPBatchThroughStubSocks(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
var proc *stubProcess
|
||||||
|
calls := 0
|
||||||
|
withStubProcess(t, func(cfg *xray.Config, configPath string) batchProcess {
|
||||||
|
calls++
|
||||||
|
proc = &stubProcess{cfg: cfg, serveSocks: true}
|
||||||
|
return proc
|
||||||
|
})
|
||||||
|
|
||||||
|
batch := mustJSON(t, []any{
|
||||||
|
map[string]any{"tag": "a", "protocol": "vless"},
|
||||||
|
map[string]any{"tag": "b", "protocol": "trojan"},
|
||||||
|
})
|
||||||
|
results, err := (&OutboundService{}).TestOutbounds(batch, srv.URL, "", "http")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("TestOutbounds: %v", err)
|
||||||
|
}
|
||||||
|
if calls != 1 {
|
||||||
|
t.Fatalf("process spawned %d times, want 1", calls)
|
||||||
|
}
|
||||||
|
for i, r := range results {
|
||||||
|
if !r.Success {
|
||||||
|
t.Fatalf("result %d failed: %+v", i, r)
|
||||||
|
}
|
||||||
|
if r.HTTPStatus != http.StatusNoContent {
|
||||||
|
t.Errorf("result %d status = %d, want 204", i, r.HTTPStatus)
|
||||||
|
}
|
||||||
|
if r.Delay < 1 || r.ConnectMs < 1 || r.TTFBMs < 1 {
|
||||||
|
t.Errorf("result %d timing not populated: %+v", i, r)
|
||||||
|
}
|
||||||
|
if r.TLSMs != 0 {
|
||||||
|
t.Errorf("result %d TLSMs = %d, want 0 for plain http", i, r.TLSMs)
|
||||||
|
}
|
||||||
|
if r.Mode != "http" {
|
||||||
|
t.Errorf("result %d mode = %q", i, r.Mode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if proc.IsRunning() {
|
||||||
|
t.Error("temp process not stopped after batch")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProbeThroughSocksTransportFailure(t *testing.T) {
|
||||||
|
// A listener that accepts and immediately closes — SOCKS handshake dies.
|
||||||
|
l, err := net.Listen("tcp", "127.0.0.1:0")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("listen: %v", err)
|
||||||
|
}
|
||||||
|
defer l.Close()
|
||||||
|
go func() {
|
||||||
|
for {
|
||||||
|
conn, err := l.Accept()
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
conn.Close()
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
var result TestOutboundResult
|
||||||
|
probeThroughSocks(l.Addr().(*net.TCPAddr).Port, "http://127.0.0.1:9/", 2*time.Second, &result)
|
||||||
|
if result.Success || result.Error == "" {
|
||||||
|
t.Errorf("expected transport failure, got %+v", result)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1384,6 +1384,10 @@
|
|||||||
"testError": "فشل اختبار المخرج",
|
"testError": "فشل اختبار المخرج",
|
||||||
"testModeTooltip": "TCP: فحص dial سريع. HTTP: طلب كامل عبر xray.",
|
"testModeTooltip": "TCP: فحص dial سريع. HTTP: طلب كامل عبر xray.",
|
||||||
"testAll": "اختبار الكل",
|
"testAll": "اختبار الكل",
|
||||||
|
"httpStatus": "حالة HTTP",
|
||||||
|
"breakdownConnect": "اتصال البروكسي",
|
||||||
|
"breakdownTls": "TLS عبر الصادر",
|
||||||
|
"breakdownTtfb": "أول بايت",
|
||||||
"nordvpn": "NordVPN",
|
"nordvpn": "NordVPN",
|
||||||
"accessToken": "رمز الوصول",
|
"accessToken": "رمز الوصول",
|
||||||
"country": "الدولة",
|
"country": "الدولة",
|
||||||
|
|||||||
@@ -1387,6 +1387,10 @@
|
|||||||
"testError": "Failed to test outbound",
|
"testError": "Failed to test outbound",
|
||||||
"testModeTooltip": "TCP: fast dial-only probe. HTTP: full request through xray.",
|
"testModeTooltip": "TCP: fast dial-only probe. HTTP: full request through xray.",
|
||||||
"testAll": "Test all",
|
"testAll": "Test all",
|
||||||
|
"httpStatus": "HTTP status",
|
||||||
|
"breakdownConnect": "Proxy connect",
|
||||||
|
"breakdownTls": "TLS via outbound",
|
||||||
|
"breakdownTtfb": "First byte",
|
||||||
"nordvpn": "NordVPN",
|
"nordvpn": "NordVPN",
|
||||||
"accessToken": "Access Token",
|
"accessToken": "Access Token",
|
||||||
"country": "Country",
|
"country": "Country",
|
||||||
|
|||||||
@@ -1384,6 +1384,10 @@
|
|||||||
"testError": "Error al probar la salida",
|
"testError": "Error al probar la salida",
|
||||||
"testModeTooltip": "TCP: sonda rápida solo de dial. HTTP: petición completa a través de xray.",
|
"testModeTooltip": "TCP: sonda rápida solo de dial. HTTP: petición completa a través de xray.",
|
||||||
"testAll": "Probar todo",
|
"testAll": "Probar todo",
|
||||||
|
"httpStatus": "Estado HTTP",
|
||||||
|
"breakdownConnect": "Conexión al proxy",
|
||||||
|
"breakdownTls": "TLS vía salida",
|
||||||
|
"breakdownTtfb": "Primer byte",
|
||||||
"nordvpn": "NordVPN",
|
"nordvpn": "NordVPN",
|
||||||
"accessToken": "Token de acceso",
|
"accessToken": "Token de acceso",
|
||||||
"country": "País",
|
"country": "País",
|
||||||
|
|||||||
@@ -1384,6 +1384,10 @@
|
|||||||
"testError": "خطا در تست خروجی",
|
"testError": "خطا در تست خروجی",
|
||||||
"testModeTooltip": "TCP: فقط dial سریع. HTTP: درخواست کامل از طریق xray.",
|
"testModeTooltip": "TCP: فقط dial سریع. HTTP: درخواست کامل از طریق xray.",
|
||||||
"testAll": "تست همه",
|
"testAll": "تست همه",
|
||||||
|
"httpStatus": "وضعیت HTTP",
|
||||||
|
"breakdownConnect": "اتصال پروکسی",
|
||||||
|
"breakdownTls": "TLS از طریق خروجی",
|
||||||
|
"breakdownTtfb": "اولین بایت",
|
||||||
"nordvpn": "NordVPN",
|
"nordvpn": "NordVPN",
|
||||||
"accessToken": "توکن دسترسی",
|
"accessToken": "توکن دسترسی",
|
||||||
"country": "کشور",
|
"country": "کشور",
|
||||||
|
|||||||
@@ -1384,6 +1384,10 @@
|
|||||||
"testError": "Gagal menguji outbound",
|
"testError": "Gagal menguji outbound",
|
||||||
"testModeTooltip": "TCP: probe dial-only cepat. HTTP: permintaan penuh via xray.",
|
"testModeTooltip": "TCP: probe dial-only cepat. HTTP: permintaan penuh via xray.",
|
||||||
"testAll": "Tes semua",
|
"testAll": "Tes semua",
|
||||||
|
"httpStatus": "Status HTTP",
|
||||||
|
"breakdownConnect": "Koneksi proxy",
|
||||||
|
"breakdownTls": "TLS melalui outbound",
|
||||||
|
"breakdownTtfb": "Byte pertama",
|
||||||
"nordvpn": "NordVPN",
|
"nordvpn": "NordVPN",
|
||||||
"accessToken": "Token Akses",
|
"accessToken": "Token Akses",
|
||||||
"country": "Negara",
|
"country": "Negara",
|
||||||
|
|||||||
@@ -1384,6 +1384,10 @@
|
|||||||
"testError": "アウトバウンドのテストに失敗しました",
|
"testError": "アウトバウンドのテストに失敗しました",
|
||||||
"testModeTooltip": "TCP: 高速 dial-only プローブ。HTTP: xray を経由した完全リクエスト。",
|
"testModeTooltip": "TCP: 高速 dial-only プローブ。HTTP: xray を経由した完全リクエスト。",
|
||||||
"testAll": "すべてテスト",
|
"testAll": "すべてテスト",
|
||||||
|
"httpStatus": "HTTPステータス",
|
||||||
|
"breakdownConnect": "プロキシ接続",
|
||||||
|
"breakdownTls": "アウトバウンド経由のTLS",
|
||||||
|
"breakdownTtfb": "最初のバイト",
|
||||||
"nordvpn": "NordVPN",
|
"nordvpn": "NordVPN",
|
||||||
"accessToken": "アクセストークン",
|
"accessToken": "アクセストークン",
|
||||||
"country": "国",
|
"country": "国",
|
||||||
|
|||||||
@@ -1384,6 +1384,10 @@
|
|||||||
"testError": "Falha ao testar saída",
|
"testError": "Falha ao testar saída",
|
||||||
"testModeTooltip": "TCP: sondagem rápida apenas de dial. HTTP: requisição completa pelo xray.",
|
"testModeTooltip": "TCP: sondagem rápida apenas de dial. HTTP: requisição completa pelo xray.",
|
||||||
"testAll": "Testar todos",
|
"testAll": "Testar todos",
|
||||||
|
"httpStatus": "Status HTTP",
|
||||||
|
"breakdownConnect": "Conexão do proxy",
|
||||||
|
"breakdownTls": "TLS via saída",
|
||||||
|
"breakdownTtfb": "Primeiro byte",
|
||||||
"nordvpn": "NordVPN",
|
"nordvpn": "NordVPN",
|
||||||
"accessToken": "Token de Acesso",
|
"accessToken": "Token de Acesso",
|
||||||
"country": "País",
|
"country": "País",
|
||||||
|
|||||||
@@ -1384,6 +1384,10 @@
|
|||||||
"testError": "Не удалось протестировать исходящее подключение",
|
"testError": "Не удалось протестировать исходящее подключение",
|
||||||
"testModeTooltip": "TCP: быстрый dial-only probe. HTTP: полный запрос через xray.",
|
"testModeTooltip": "TCP: быстрый dial-only probe. HTTP: полный запрос через xray.",
|
||||||
"testAll": "Тестировать все",
|
"testAll": "Тестировать все",
|
||||||
|
"httpStatus": "HTTP-статус",
|
||||||
|
"breakdownConnect": "Подключение к прокси",
|
||||||
|
"breakdownTls": "TLS через исходящий",
|
||||||
|
"breakdownTtfb": "Первый байт",
|
||||||
"nordvpn": "NordVPN",
|
"nordvpn": "NordVPN",
|
||||||
"accessToken": "Токен доступа",
|
"accessToken": "Токен доступа",
|
||||||
"country": "Страна",
|
"country": "Страна",
|
||||||
|
|||||||
@@ -1385,6 +1385,10 @@
|
|||||||
"testError": "Giden bağlantı test edilemedi",
|
"testError": "Giden bağlantı test edilemedi",
|
||||||
"testModeTooltip": "TCP: hızlı sadece arama (dial-only) testi. HTTP: Xray üzerinden tam istek.",
|
"testModeTooltip": "TCP: hızlı sadece arama (dial-only) testi. HTTP: Xray üzerinden tam istek.",
|
||||||
"testAll": "Tümünü Test Et",
|
"testAll": "Tümünü Test Et",
|
||||||
|
"httpStatus": "HTTP durumu",
|
||||||
|
"breakdownConnect": "Proxy bağlantısı",
|
||||||
|
"breakdownTls": "Giden üzerinden TLS",
|
||||||
|
"breakdownTtfb": "İlk bayt",
|
||||||
"nordvpn": "NordVPN",
|
"nordvpn": "NordVPN",
|
||||||
"accessToken": "Erişim Jetonu",
|
"accessToken": "Erişim Jetonu",
|
||||||
"country": "Ülke",
|
"country": "Ülke",
|
||||||
|
|||||||
@@ -1384,6 +1384,10 @@
|
|||||||
"testError": "Не вдалося протестувати вихідне з'єднання",
|
"testError": "Не вдалося протестувати вихідне з'єднання",
|
||||||
"testModeTooltip": "TCP: швидкий dial-only probe. HTTP: повний запит через xray.",
|
"testModeTooltip": "TCP: швидкий dial-only probe. HTTP: повний запит через xray.",
|
||||||
"testAll": "Тестувати всі",
|
"testAll": "Тестувати всі",
|
||||||
|
"httpStatus": "HTTP-статус",
|
||||||
|
"breakdownConnect": "Підключення до проксі",
|
||||||
|
"breakdownTls": "TLS через вихідний",
|
||||||
|
"breakdownTtfb": "Перший байт",
|
||||||
"nordvpn": "NordVPN",
|
"nordvpn": "NordVPN",
|
||||||
"accessToken": "Токен доступу",
|
"accessToken": "Токен доступу",
|
||||||
"country": "Країна",
|
"country": "Країна",
|
||||||
|
|||||||
@@ -1384,6 +1384,10 @@
|
|||||||
"testError": "Không thể kiểm tra đầu ra",
|
"testError": "Không thể kiểm tra đầu ra",
|
||||||
"testModeTooltip": "TCP: probe dial nhanh. HTTP: yêu cầu đầy đủ qua xray.",
|
"testModeTooltip": "TCP: probe dial nhanh. HTTP: yêu cầu đầy đủ qua xray.",
|
||||||
"testAll": "Kiểm tra tất cả",
|
"testAll": "Kiểm tra tất cả",
|
||||||
|
"httpStatus": "Trạng thái HTTP",
|
||||||
|
"breakdownConnect": "Kết nối proxy",
|
||||||
|
"breakdownTls": "TLS qua outbound",
|
||||||
|
"breakdownTtfb": "Byte đầu tiên",
|
||||||
"nordvpn": "NordVPN",
|
"nordvpn": "NordVPN",
|
||||||
"accessToken": "Mã truy cập",
|
"accessToken": "Mã truy cập",
|
||||||
"country": "Quốc gia",
|
"country": "Quốc gia",
|
||||||
|
|||||||
@@ -1384,6 +1384,10 @@
|
|||||||
"testError": "测试出站失败",
|
"testError": "测试出站失败",
|
||||||
"testModeTooltip": "TCP: 快速 dial-only 探测。HTTP: 通过 xray 的完整请求。",
|
"testModeTooltip": "TCP: 快速 dial-only 探测。HTTP: 通过 xray 的完整请求。",
|
||||||
"testAll": "全部测试",
|
"testAll": "全部测试",
|
||||||
|
"httpStatus": "HTTP 状态",
|
||||||
|
"breakdownConnect": "代理连接",
|
||||||
|
"breakdownTls": "经由出站的 TLS",
|
||||||
|
"breakdownTtfb": "首字节",
|
||||||
"nordvpn": "NordVPN",
|
"nordvpn": "NordVPN",
|
||||||
"accessToken": "访问令牌",
|
"accessToken": "访问令牌",
|
||||||
"country": "国家",
|
"country": "国家",
|
||||||
|
|||||||
@@ -1384,6 +1384,10 @@
|
|||||||
"testError": "測試出站失敗",
|
"testError": "測試出站失敗",
|
||||||
"testModeTooltip": "TCP: 快速 dial-only 探測。HTTP: 透過 xray 的完整請求。",
|
"testModeTooltip": "TCP: 快速 dial-only 探測。HTTP: 透過 xray 的完整請求。",
|
||||||
"testAll": "全部測試",
|
"testAll": "全部測試",
|
||||||
|
"httpStatus": "HTTP 狀態",
|
||||||
|
"breakdownConnect": "代理連線",
|
||||||
|
"breakdownTls": "經由出站的 TLS",
|
||||||
|
"breakdownTtfb": "首位元組",
|
||||||
"nordvpn": "NordVPN",
|
"nordvpn": "NordVPN",
|
||||||
"accessToken": "訪問令牌",
|
"accessToken": "訪問令牌",
|
||||||
"country": "國家",
|
"country": "國家",
|
||||||
|
|||||||
Reference in New Issue
Block a user