mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-07-06 12:54:20 +00:00
feat(outbound): add real-delay connection test mode
The HTTP probe reports the warm per-request round-trip, which reads lower than the delay figure client apps show for the same server. Add a third "real" test mode that reuses the temp-instance HTTP probe but reports the cold request's full elapsed time - tunnel establishment included - and skips the warm request. UDP-transport outbounds forced out of the TCP lane still report "http"; in real mode they report "real". The mode joins the TCP/HTTP toggle on the outbounds tab, with the label translated in all 13 locales.
This commit is contained in:
@@ -29,6 +29,8 @@ export function isUdpOutbound(outbound: unknown): boolean {
|
||||
return p === 'wireguard' || p === 'hysteria' || n === 'hysteria' || n === 'kcp' || n === 'quic';
|
||||
}
|
||||
|
||||
export type OutboundTestMode = 'tcp' | 'http' | 'real';
|
||||
|
||||
export type { OutboundTrafficRow, OutboundTestResult };
|
||||
|
||||
export type XraySettingsValue = z.infer<typeof XraySettingsValueSchema>;
|
||||
@@ -289,7 +291,7 @@ export function useXraySetting(): UseXraySettingResult {
|
||||
const testOutbound = useCallback(
|
||||
async (index: number, outbound: unknown, mode = 'tcp'): Promise<OutboundTestResult | null> => {
|
||||
if (!outbound) return null;
|
||||
const effMode = isUdpOutbound(outbound) ? 'http' : mode;
|
||||
const effMode = mode === 'tcp' && isUdpOutbound(outbound) ? 'http' : mode;
|
||||
setOutboundTestStates((prev) => ({
|
||||
...prev,
|
||||
[index]: { testing: true, result: null, mode: effMode },
|
||||
@@ -306,7 +308,7 @@ export function useXraySetting(): UseXraySettingResult {
|
||||
const testSubscriptionOutbound = useCallback(
|
||||
async (tag: string, outbound: unknown, mode = 'tcp'): Promise<OutboundTestResult | null> => {
|
||||
if (!outbound || !tag) return null;
|
||||
const effMode = isUdpOutbound(outbound) ? 'http' : mode;
|
||||
const effMode = mode === 'tcp' && isUdpOutbound(outbound) ? 'http' : mode;
|
||||
setSubscriptionTestStates((prev) => ({
|
||||
...prev,
|
||||
[tag]: { testing: true, result: null, mode: effMode },
|
||||
@@ -334,6 +336,7 @@ export function useXraySetting(): UseXraySettingResult {
|
||||
// HTTP batches stay homogeneous (all template or all subscription) so a
|
||||
// tag shared between a template and a subscription outbound can't collide
|
||||
// inside one batch, and each batch's results route to one state map.
|
||||
const probeMode = mode === 'real' ? 'real' : 'http';
|
||||
const httpTplQueue: { index: number; outbound: unknown }[] = [];
|
||||
const httpSubQueue: { tag: string; outbound: unknown }[] = [];
|
||||
const enqueue = (ob: { tag?: string; protocol?: string }, kind: 'tpl' | 'sub', index: number, tag: string) => {
|
||||
@@ -342,7 +345,7 @@ export function useXraySetting(): UseXraySettingResult {
|
||||
// freedom ("direct") and dns aren't proxies — skip them in every mode.
|
||||
if (proto === 'freedom' || proto === 'dns') return;
|
||||
if (kind === 'sub' && !tag) return;
|
||||
const toHttp = mode === 'http' || isUdpOutbound(ob);
|
||||
const toHttp = mode !== 'tcp' || isUdpOutbound(ob);
|
||||
if (kind === 'tpl') {
|
||||
if (toHttp) httpTplQueue.push({ index, outbound: ob });
|
||||
else tcpQueue.push({ kind: 'tpl', index, outbound: ob });
|
||||
@@ -376,10 +379,10 @@ export function useXraySetting(): UseXraySettingResult {
|
||||
const chunk = httpTplQueue.slice(at, at + HTTP_BATCH_CHUNK);
|
||||
setOutboundTestStates((prev) => {
|
||||
const next = { ...prev };
|
||||
for (const item of chunk) next[item.index] = { testing: true, result: null, mode: 'http' };
|
||||
for (const item of chunk) next[item.index] = { testing: true, result: null, mode: probeMode };
|
||||
return next;
|
||||
});
|
||||
const results = await postOutboundTestBatch(chunk.map((c) => c.outbound), 'http');
|
||||
const results = await postOutboundTestBatch(chunk.map((c) => c.outbound), probeMode);
|
||||
setOutboundTestStates((prev) => {
|
||||
const next = { ...prev };
|
||||
chunk.forEach((item, i) => {
|
||||
@@ -394,10 +397,10 @@ export function useXraySetting(): UseXraySettingResult {
|
||||
const chunk = httpSubQueue.slice(at, at + HTTP_BATCH_CHUNK);
|
||||
setSubscriptionTestStates((prev) => {
|
||||
const next = { ...prev };
|
||||
for (const item of chunk) next[item.tag] = { testing: true, result: null, mode: 'http' };
|
||||
for (const item of chunk) next[item.tag] = { testing: true, result: null, mode: probeMode };
|
||||
return next;
|
||||
});
|
||||
const results = await postOutboundTestBatch(chunk.map((c) => c.outbound), 'http');
|
||||
const results = await postOutboundTestBatch(chunk.map((c) => c.outbound), probeMode);
|
||||
setSubscriptionTestStates((prev) => {
|
||||
const next = { ...prev };
|
||||
chunk.forEach((item, i) => {
|
||||
|
||||
@@ -1305,7 +1305,7 @@ export const sections: readonly Section[] = [
|
||||
params: [
|
||||
{ name: 'outbound', in: 'body (form)', type: 'string', desc: 'JSON-encoded single outbound 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 a fast dial-only probe (parallel-safe). Default/empty uses a full HTTP probe through a temp xray instance.' },
|
||||
{ name: 'mode', in: 'body (form)', type: 'string', desc: '"tcp" for a fast dial-only probe (parallel-safe), "real" for a real-delay probe whose delay is the full request time including tunnel establishment. Default/empty uses a full HTTP probe reporting the warm per-request round-trip. Both HTTP variants run through a temp xray instance.' },
|
||||
],
|
||||
body: 'outbound={"protocol":"freedom","settings":{}}&mode=tcp',
|
||||
},
|
||||
@@ -1316,7 +1316,7 @@ export const sections: readonly Section[] = [
|
||||
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.' },
|
||||
{ name: 'mode', in: 'body (form)', type: 'string', desc: '"tcp" for fast dial-only probes (UDP-transport outbounds are still probed over HTTP), "real" for real-delay probes whose delay is the full request time including tunnel establishment. Default/empty routes an HTTP request through each outbound and reports the warm per-request round-trip.' },
|
||||
],
|
||||
body: 'outbounds=[{"tag":"direct","protocol":"freedom","settings":{}}]&mode=http',
|
||||
},
|
||||
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
|
||||
import { SizeFormatter } from '@/utils';
|
||||
import { OutboundProtocols as Protocols } from '@/schemas/primitives';
|
||||
import type { OutboundTestState, OutboundTrafficRow } from '@/hooks/useXraySetting';
|
||||
import type { OutboundTestMode, OutboundTestState, OutboundTrafficRow } from '@/hooks/useXraySetting';
|
||||
|
||||
import type { OutboundRow } from './outbounds-tab-types';
|
||||
import TestResultPopover from './TestResultPopover';
|
||||
@@ -28,7 +28,7 @@ import {
|
||||
|
||||
interface OutboundCardListProps {
|
||||
rows: OutboundRow[];
|
||||
testMode: 'tcp' | 'http';
|
||||
testMode: OutboundTestMode;
|
||||
outboundsTraffic: OutboundTrafficRow[];
|
||||
outboundTestStates: Record<number, OutboundTestState>;
|
||||
setFirst: (idx: number) => void;
|
||||
|
||||
@@ -45,7 +45,7 @@ import OutboundFormModal from './OutboundFormModal';
|
||||
import { propagateOutboundTagRename } from '../basics/helpers';
|
||||
import { planOutboundDeletion, applyOutboundDeletion } from '../reference-cleanup';
|
||||
import DeletionImpactList from '../DeletionImpactList';
|
||||
import type { XraySettingsValue, SetTemplate, OutboundTestState, OutboundTrafficRow } from '@/hooks/useXraySetting';
|
||||
import type { XraySettingsValue, SetTemplate, OutboundTestMode, OutboundTestState, OutboundTrafficRow } from '@/hooks/useXraySetting';
|
||||
import './OutboundsTab.css';
|
||||
|
||||
import type { OutboundRow } from './outbounds-tab-types';
|
||||
@@ -110,7 +110,7 @@ export default function OutboundsTab({
|
||||
const { t } = useTranslation();
|
||||
const [modal, modalContextHolder] = Modal.useModal();
|
||||
const [messageApi, messageContextHolder] = message.useMessage();
|
||||
const [testMode, setTestMode] = useState<'tcp' | 'http'>('tcp');
|
||||
const [testMode, setTestMode] = useState<OutboundTestMode>('tcp');
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [editingOutbound, setEditingOutbound] = useState<Record<string, unknown> | null>(null);
|
||||
const [editingIndex, setEditingIndex] = useState<number | null>(null);
|
||||
@@ -486,6 +486,7 @@ export default function OutboundsTab({
|
||||
<Radio.Group value={testMode} onChange={(e) => setTestMode(e.target.value)} buttonStyle="solid" size="small">
|
||||
<Radio.Button value="tcp">TCP</Radio.Button>
|
||||
<Radio.Button value="http">HTTP</Radio.Button>
|
||||
<Radio.Button value="real">{t('pages.xray.outbound.modeRealDelay')}</Radio.Button>
|
||||
</Radio.Group>
|
||||
</Tooltip>
|
||||
<Button type="primary" loading={testingAll} icon={<PlayCircleOutlined />} onClick={() => onTestAll(testMode)}>
|
||||
|
||||
@@ -6,16 +6,17 @@ import type { ColumnsType } from 'antd/es/table';
|
||||
|
||||
import { SizeFormatter } from '@/utils';
|
||||
import { OutboundProtocols as Protocols } from '@/schemas/primitives';
|
||||
import { isUdpOutbound } from '@/hooks/useXraySetting';
|
||||
import type { OutboundTestState, OutboundTrafficRow } from '@/hooks/useXraySetting';
|
||||
import type { OutboundTestMode, OutboundTestState, OutboundTrafficRow } from '@/hooks/useXraySetting';
|
||||
|
||||
import type { OutboundRow } from './outbounds-tab-types';
|
||||
import TestResultPopover from './TestResultPopover';
|
||||
import {
|
||||
effectiveTestMode,
|
||||
isTesting,
|
||||
isUntestable,
|
||||
outboundAddresses,
|
||||
showSecurity,
|
||||
testModeLabel,
|
||||
testResult,
|
||||
trafficFor,
|
||||
} from './outbounds-tab-helpers';
|
||||
@@ -24,7 +25,7 @@ interface SubscriptionOutboundsProps {
|
||||
subscriptionOutbounds: unknown[];
|
||||
outboundsTraffic: OutboundTrafficRow[];
|
||||
subscriptionTestStates: Record<string, OutboundTestState>;
|
||||
testMode: 'tcp' | 'http';
|
||||
testMode: OutboundTestMode;
|
||||
isMobile: boolean;
|
||||
onTestSubscription: (outbound: Record<string, unknown>, mode: string) => void;
|
||||
}
|
||||
@@ -104,7 +105,7 @@ export default function SubscriptionOutbounds({
|
||||
const testButton = (record: OutboundRow) => {
|
||||
const key = record.tag || '';
|
||||
return (
|
||||
<Tooltip title={`${t('check')} (${(isUdpOutbound(record) ? 'http' : testMode).toUpperCase()})`}>
|
||||
<Tooltip title={`${t('check')} (${testModeLabel(effectiveTestMode(record, testMode), t)})`}>
|
||||
<Button
|
||||
aria-label={t('check')}
|
||||
type="primary"
|
||||
|
||||
@@ -5,6 +5,8 @@ import { CheckCircleFilled, CloseCircleFilled } from '@ant-design/icons';
|
||||
|
||||
import type { OutboundTestResult } from '@/hooks/useXraySetting';
|
||||
|
||||
import { testModeLabel } from './outbounds-tab-helpers';
|
||||
|
||||
interface TestResultPopoverProps {
|
||||
result: OutboundTestResult;
|
||||
// Custom trigger element; defaults to the ok/fail latency pill.
|
||||
@@ -39,7 +41,7 @@ export default function TestResultPopover({ result: r, children }: TestResultPop
|
||||
<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>}
|
||||
{r.mode && <span className="mode-badge">{testModeLabel(String(r.mode), t)}</span>}
|
||||
</div>
|
||||
{(r.endpoints || []).map((ep) => (
|
||||
<div key={ep.address} className="endpoint-row">
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import type { TFunction } from 'i18next';
|
||||
|
||||
import { OutboundProtocols as Protocols } from '@/schemas/primitives';
|
||||
import type { OutboundTestState, OutboundTrafficRow } from '@/hooks/useXraySetting';
|
||||
import { isUdpOutbound } from '@/hooks/useXraySetting';
|
||||
import type { OutboundTestMode, OutboundTestState, OutboundTrafficRow } from '@/hooks/useXraySetting';
|
||||
|
||||
import type { OutboundRow } from './outbounds-tab-types';
|
||||
|
||||
@@ -45,6 +48,14 @@ export function showSecurity(security?: string): boolean {
|
||||
return security === 'tls' || security === 'reality';
|
||||
}
|
||||
|
||||
export function effectiveTestMode(o: unknown, mode: OutboundTestMode): OutboundTestMode {
|
||||
return mode === 'tcp' && isUdpOutbound(o) ? 'http' : mode;
|
||||
}
|
||||
|
||||
export function testModeLabel(mode: string, t: TFunction): string {
|
||||
return mode === 'real' ? t('pages.xray.outbound.modeRealDelay') : mode.toUpperCase();
|
||||
}
|
||||
|
||||
export function trafficFor(outboundsTraffic: OutboundTrafficRow[], o: OutboundRow): { up: number; down: number } {
|
||||
const tr = outboundsTraffic.find((x) => x.tag === o.tag);
|
||||
return { up: tr?.up || 0, down: tr?.down || 0 };
|
||||
|
||||
@@ -16,22 +16,23 @@ import type { ColumnsType } from 'antd/es/table';
|
||||
|
||||
import { SizeFormatter } from '@/utils';
|
||||
import { OutboundProtocols as Protocols } from '@/schemas/primitives';
|
||||
import { isUdpOutbound } from '@/hooks/useXraySetting';
|
||||
import type { OutboundTestState, OutboundTrafficRow } from '@/hooks/useXraySetting';
|
||||
import type { OutboundTestMode, OutboundTestState, OutboundTrafficRow } from '@/hooks/useXraySetting';
|
||||
|
||||
import type { OutboundRow } from './outbounds-tab-types';
|
||||
import TestResultPopover from './TestResultPopover';
|
||||
import {
|
||||
effectiveTestMode,
|
||||
isTesting,
|
||||
isUntestable,
|
||||
outboundAddresses,
|
||||
showSecurity,
|
||||
testModeLabel,
|
||||
testResult,
|
||||
trafficFor,
|
||||
} from './outbounds-tab-helpers';
|
||||
|
||||
interface OutboundColumnsParams {
|
||||
testMode: 'tcp' | 'http';
|
||||
testMode: OutboundTestMode;
|
||||
rows: OutboundRow[];
|
||||
outboundsTraffic: OutboundTrafficRow[];
|
||||
outboundTestStates: Record<number, OutboundTestState>;
|
||||
@@ -167,7 +168,7 @@ export function useOutboundColumns({
|
||||
align: 'center',
|
||||
width: 80,
|
||||
render: (_v, record, index) => (
|
||||
<Tooltip title={`${t('check')} (${(isUdpOutbound(record) ? 'http' : testMode).toUpperCase()})`}>
|
||||
<Tooltip title={`${t('check')} (${testModeLabel(effectiveTestMode(record, testMode), t)})`}>
|
||||
<Button
|
||||
type="primary"
|
||||
shape="circle"
|
||||
|
||||
@@ -258,8 +258,8 @@ func (a *XraySettingController) resetOutboundsTraffic(c *gin.Context) {
|
||||
|
||||
// testOutbound tests an outbound configuration and returns the delay/response time.
|
||||
// Optional form "allOutbounds": JSON array of all outbounds; used to resolve sockopt.dialerProxy dependencies.
|
||||
// Optional form "mode": "tcp" for a fast dial-only probe (parallel-safe),
|
||||
// anything else (default) for a full HTTP probe through a temp xray instance.
|
||||
// Optional form "mode": "tcp" for a fast dial-only probe, "real" for the cold
|
||||
// full-request delay, anything else (default) for a full HTTP probe through a temp xray instance.
|
||||
func (a *XraySettingController) testOutbound(c *gin.Context) {
|
||||
outboundJSON := c.PostForm("outbound")
|
||||
allOutboundsJSON := c.PostForm("allOutbounds")
|
||||
@@ -291,8 +291,8 @@ func (a *XraySettingController) testOutbound(c *gin.Context) {
|
||||
// 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.
|
||||
// Optional form "mode": "tcp" for fast dial-only probes, "real" for the cold
|
||||
// full-request delay, 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")
|
||||
|
||||
@@ -32,7 +32,8 @@ import (
|
||||
// spawn per batch instead of one per outbound. The reported delay comes from
|
||||
// a second request on the kept-alive connection, so it reflects the tunnel's
|
||||
// real per-request round-trip rather than the stacked SOCKS/proxy/TLS
|
||||
// handshakes of connection establishment.
|
||||
// handshakes of connection establishment. Mode "real" instead reports the
|
||||
// cold request's full elapsed time and skips the warm request.
|
||||
|
||||
const (
|
||||
// httpProbeTimeout bounds each probe request end-to-end (a probe makes
|
||||
@@ -84,6 +85,15 @@ type httpBatchItem struct {
|
||||
result *TestOutboundResult
|
||||
}
|
||||
|
||||
func probeModeLabel(mode string) string {
|
||||
switch mode {
|
||||
case "tcp", "real":
|
||||
return mode
|
||||
default:
|
||||
return "http"
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
@@ -92,11 +102,7 @@ type httpBatchItem struct {
|
||||
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
|
||||
return &TestOutboundResult{Mode: probeModeLabel(mode), 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
|
||||
@@ -130,10 +136,12 @@ func (s *OutboundService) TestOutbounds(outboundsJSON string, testURL string, al
|
||||
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"
|
||||
modeLabel := probeModeLabel(mode)
|
||||
probeLabel := modeLabel
|
||||
if probeLabel == "tcp" {
|
||||
probeLabel = "http"
|
||||
}
|
||||
realDelay := mode == "real"
|
||||
|
||||
type tcpEntry struct {
|
||||
idx int
|
||||
@@ -158,7 +166,7 @@ func (s *OutboundService) testOutboundsParsed(items []map[string]any, testURL st
|
||||
}
|
||||
|
||||
tag, _ := ob["tag"].(string)
|
||||
r := &TestOutboundResult{Tag: tag, Mode: "http"}
|
||||
r := &TestOutboundResult{Tag: tag, Mode: probeLabel}
|
||||
results[i] = r
|
||||
protocol, _ := ob["protocol"].(string)
|
||||
switch {
|
||||
@@ -231,7 +239,7 @@ func (s *OutboundService) testOutboundsParsed(items []map[string]any, testURL st
|
||||
}
|
||||
defer httpTestSemaphore.Unlock()
|
||||
|
||||
retryPerItem, err := runHTTPProbeBatch(httpItems, allOutbounds, testURL)
|
||||
retryPerItem, err := runHTTPProbeBatch(httpItems, allOutbounds, testURL, realDelay)
|
||||
if err == nil {
|
||||
return results
|
||||
}
|
||||
@@ -244,7 +252,7 @@ func (s *OutboundService) testOutboundsParsed(items []map[string]any, testURL st
|
||||
// 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 {
|
||||
if _, ferr := runHTTPProbeBatch([]*httpBatchItem{it}, allOutbounds, testURL, realDelay); ferr != nil {
|
||||
it.result.Success = false
|
||||
it.result.Error = ferr.Error()
|
||||
}
|
||||
@@ -258,7 +266,7 @@ func (s *OutboundService) testOutboundsParsed(items []map[string]any, testURL st
|
||||
// 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) {
|
||||
func runHTTPProbeBatch(items []*httpBatchItem, allOutbounds []any, testURL string, realDelay bool) (retryPerItem bool, err error) {
|
||||
ports, release, err := reserveLoopbackPorts(len(items))
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("Failed to reserve test ports: %w", err)
|
||||
@@ -304,7 +312,7 @@ func runHTTPProbeBatch(items []*httpBatchItem, allOutbounds []any, testURL strin
|
||||
defer wg.Done()
|
||||
sem <- struct{}{}
|
||||
defer func() { <-sem }()
|
||||
probeThroughSocks(port, testURL, httpProbeTimeout, it.result)
|
||||
probeThroughSocks(port, testURL, httpProbeTimeout, realDelay, it.result)
|
||||
}(items[i], ports[i])
|
||||
}
|
||||
wg.Wait()
|
||||
@@ -444,7 +452,7 @@ func outboundsContainTag(outbounds []any, tag string) bool {
|
||||
// the established tunnel — falling back to the cold total if the warm request
|
||||
// fails. 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) {
|
||||
func probeThroughSocks(port int, testURL string, timeout time.Duration, realDelay bool, result *TestOutboundResult) {
|
||||
proxyURL := &url.URL{Scheme: "socks5", Host: net.JoinHostPort("127.0.0.1", strconv.Itoa(port))}
|
||||
tr := &http.Transport{
|
||||
Proxy: http.ProxyURL(proxyURL),
|
||||
@@ -528,8 +536,10 @@ func probeThroughSocks(port int, testURL string, timeout time.Duration, result *
|
||||
}
|
||||
|
||||
delay := coldDelay
|
||||
if warmDelay, ok := timedWarmGet(client, testURL); ok {
|
||||
delay = warmDelay
|
||||
if !realDelay {
|
||||
if warmDelay, ok := timedWarmGet(client, testURL); ok {
|
||||
delay = warmDelay
|
||||
}
|
||||
}
|
||||
result.Delay = max(delay, 1)
|
||||
}
|
||||
|
||||
@@ -464,6 +464,94 @@ func TestTestOutboundsHTTPBatchThroughStubSocks(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestTestOutboundsRealDelayBatchThroughStubSocks(t *testing.T) {
|
||||
var mu sync.Mutex
|
||||
requestsPerConn := make(map[string]int)
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
mu.Lock()
|
||||
requestsPerConn[r.RemoteAddr]++
|
||||
mu.Unlock()
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
withStubProcess(t, func(cfg *xray.Config, configPath string) batchProcess {
|
||||
return &stubProcess{cfg: cfg, serveSocks: true}
|
||||
})
|
||||
|
||||
batch := mustJSON(t, []any{
|
||||
map[string]any{"tag": "a", "protocol": "vless"},
|
||||
map[string]any{"tag": "wg", "protocol": "wireguard"},
|
||||
})
|
||||
results, err := (&OutboundService{}).TestOutbounds(batch, srv.URL, "", "real")
|
||||
if err != nil {
|
||||
t.Fatalf("TestOutbounds: %v", err)
|
||||
}
|
||||
for i, r := range results {
|
||||
if !r.Success {
|
||||
t.Fatalf("result %d failed: %+v", i, r)
|
||||
}
|
||||
if r.Mode != "real" {
|
||||
t.Errorf("result %d mode = %q, want %q", i, r.Mode, "real")
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
totalRequests := 0
|
||||
for addr, n := range requestsPerConn {
|
||||
totalRequests += n
|
||||
if n != 1 {
|
||||
t.Errorf("connection %s served %d requests, want 1 (real mode must skip the warm request)", addr, n)
|
||||
}
|
||||
}
|
||||
if totalRequests != 2 {
|
||||
t.Errorf("test URL served %d requests, want 2 (one cold request per probe)", totalRequests)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTestOutboundsTCPModeForcesUDPToHTTPProbe(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
withStubProcess(t, func(cfg *xray.Config, configPath string) batchProcess {
|
||||
return &stubProcess{cfg: cfg, serveSocks: true}
|
||||
})
|
||||
|
||||
batch := mustJSON(t, []any{map[string]any{"tag": "wg", "protocol": "wireguard"}})
|
||||
results, err := (&OutboundService{}).TestOutbounds(batch, srv.URL, "", "tcp")
|
||||
if err != nil {
|
||||
t.Fatalf("TestOutbounds: %v", err)
|
||||
}
|
||||
r := results[0]
|
||||
if !r.Success || r.Mode != "http" {
|
||||
t.Errorf("UDP outbound in tcp mode = %+v, want success with mode %q", r, "http")
|
||||
}
|
||||
}
|
||||
|
||||
func TestProbeModeLabel(t *testing.T) {
|
||||
cases := []struct{ mode, want string }{
|
||||
{"tcp", "tcp"},
|
||||
{"real", "real"},
|
||||
{"http", "http"},
|
||||
{"", "http"},
|
||||
{"bogus", "http"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := probeModeLabel(c.mode); got != c.want {
|
||||
t.Errorf("probeModeLabel(%q) = %q, want %q", c.mode, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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")
|
||||
@@ -482,7 +570,7 @@ func TestProbeThroughSocksTransportFailure(t *testing.T) {
|
||||
}()
|
||||
|
||||
var result TestOutboundResult
|
||||
probeThroughSocks(l.Addr().(*net.TCPAddr).Port, "http://127.0.0.1:9/", 2*time.Second, &result)
|
||||
probeThroughSocks(l.Addr().(*net.TCPAddr).Port, "http://127.0.0.1:9/", 2*time.Second, false, &result)
|
||||
if result.Success || result.Error == "" {
|
||||
t.Errorf("expected transport failure, got %+v", result)
|
||||
}
|
||||
|
||||
@@ -1627,7 +1627,8 @@
|
||||
"testSuccess": "الاختبار ناجح",
|
||||
"testFailed": "فشل الاختبار",
|
||||
"testError": "فشل اختبار المخرج",
|
||||
"testModeTooltip": "TCP: فحص dial سريع. HTTP: طلب كامل عبر xray.",
|
||||
"modeRealDelay": "التأخير الفعلي",
|
||||
"testModeTooltip": "TCP: فحص dial سريع. HTTP: طلب كامل عبر xray. التأخير الفعلي: الوقت الكامل شاملاً إنشاء الاتصال.",
|
||||
"testAll": "اختبار الكل",
|
||||
"httpStatus": "حالة HTTP",
|
||||
"breakdownConnect": "اتصال البروكسي",
|
||||
|
||||
@@ -1743,7 +1743,8 @@
|
||||
"testSuccess": "Test successful",
|
||||
"testFailed": "Test failed",
|
||||
"testError": "Failed to test outbound",
|
||||
"testModeTooltip": "TCP: fast dial-only probe. HTTP: full request through xray.",
|
||||
"modeRealDelay": "Real delay",
|
||||
"testModeTooltip": "TCP: fast dial-only probe. HTTP: full request through xray. Real delay: total time including connection setup.",
|
||||
"testAll": "Test all",
|
||||
"httpStatus": "HTTP status",
|
||||
"breakdownConnect": "Proxy connect",
|
||||
|
||||
@@ -1627,7 +1627,8 @@
|
||||
"testSuccess": "Prueba exitosa",
|
||||
"testFailed": "Prueba fallida",
|
||||
"testError": "Error al probar la salida",
|
||||
"testModeTooltip": "TCP: sonda rápida solo de dial. HTTP: petición completa a través de xray.",
|
||||
"modeRealDelay": "Retardo real",
|
||||
"testModeTooltip": "TCP: sonda rápida solo de dial. HTTP: petición completa a través de xray. Retardo real: tiempo total incluyendo el establecimiento de la conexión.",
|
||||
"testAll": "Probar todo",
|
||||
"httpStatus": "Estado HTTP",
|
||||
"breakdownConnect": "Conexión al proxy",
|
||||
|
||||
@@ -1627,7 +1627,8 @@
|
||||
"testSuccess": "تست موفقیتآمیز",
|
||||
"testFailed": "تست ناموفق",
|
||||
"testError": "خطا در تست خروجی",
|
||||
"testModeTooltip": "TCP: فقط dial سریع. HTTP: درخواست کامل از طریق xray.",
|
||||
"modeRealDelay": "تأخیر واقعی",
|
||||
"testModeTooltip": "TCP: فقط dial سریع. HTTP: درخواست کامل از طریق xray. تأخیر واقعی: کل زمان همراه با برقراری اتصال.",
|
||||
"testAll": "تست همه",
|
||||
"httpStatus": "وضعیت HTTP",
|
||||
"breakdownConnect": "اتصال پروکسی",
|
||||
|
||||
@@ -1627,7 +1627,8 @@
|
||||
"testSuccess": "Tes berhasil",
|
||||
"testFailed": "Tes gagal",
|
||||
"testError": "Gagal menguji outbound",
|
||||
"testModeTooltip": "TCP: probe dial-only cepat. HTTP: permintaan penuh via xray.",
|
||||
"modeRealDelay": "Delay nyata",
|
||||
"testModeTooltip": "TCP: probe dial-only cepat. HTTP: permintaan penuh via xray. Delay nyata: total waktu termasuk pembentukan koneksi.",
|
||||
"testAll": "Tes semua",
|
||||
"httpStatus": "Status HTTP",
|
||||
"breakdownConnect": "Koneksi proxy",
|
||||
|
||||
@@ -1627,7 +1627,8 @@
|
||||
"testSuccess": "テスト成功",
|
||||
"testFailed": "テスト失敗",
|
||||
"testError": "アウトバウンドのテストに失敗しました",
|
||||
"testModeTooltip": "TCP: 高速 dial-only プローブ。HTTP: xray を経由した完全リクエスト。",
|
||||
"modeRealDelay": "実際の遅延",
|
||||
"testModeTooltip": "TCP: 高速 dial-only プローブ。HTTP: xray を経由した完全リクエスト。実際の遅延: 接続確立を含む合計時間。",
|
||||
"testAll": "すべてテスト",
|
||||
"httpStatus": "HTTPステータス",
|
||||
"breakdownConnect": "プロキシ接続",
|
||||
|
||||
@@ -1627,7 +1627,8 @@
|
||||
"testSuccess": "Teste bem-sucedido",
|
||||
"testFailed": "Teste falhou",
|
||||
"testError": "Falha ao testar saída",
|
||||
"testModeTooltip": "TCP: sondagem rápida apenas de dial. HTTP: requisição completa pelo xray.",
|
||||
"modeRealDelay": "Latência real",
|
||||
"testModeTooltip": "TCP: sondagem rápida apenas de dial. HTTP: requisição completa pelo xray. Latência real: tempo total incluindo o estabelecimento da conexão.",
|
||||
"testAll": "Testar todos",
|
||||
"httpStatus": "Status HTTP",
|
||||
"breakdownConnect": "Conexão do proxy",
|
||||
|
||||
@@ -1627,7 +1627,8 @@
|
||||
"testSuccess": "Тест успешен",
|
||||
"testFailed": "Тест не пройден",
|
||||
"testError": "Не удалось протестировать исходящее подключение",
|
||||
"testModeTooltip": "TCP: быстрый dial-only probe. HTTP: полный запрос через xray.",
|
||||
"modeRealDelay": "Реальная задержка",
|
||||
"testModeTooltip": "TCP: быстрый dial-only probe. HTTP: полный запрос через xray. Реальная задержка: полное время с установлением соединения.",
|
||||
"testAll": "Тестировать все",
|
||||
"httpStatus": "HTTP-статус",
|
||||
"breakdownConnect": "Подключение к прокси",
|
||||
|
||||
@@ -1627,7 +1627,8 @@
|
||||
"testSuccess": "Test başarılı",
|
||||
"testFailed": "Test başarısız",
|
||||
"testError": "Giden bağlantı test edilemedi",
|
||||
"testModeTooltip": "TCP: hızlı sadece arama (dial-only) testi. HTTP: Xray üzerinden tam istek.",
|
||||
"modeRealDelay": "Gerçek gecikme",
|
||||
"testModeTooltip": "TCP: hızlı sadece arama (dial-only) testi. HTTP: Xray üzerinden tam istek. Gerçek gecikme: bağlantı kurulumu dahil toplam süre.",
|
||||
"testAll": "Tümünü Test Et",
|
||||
"httpStatus": "HTTP durumu",
|
||||
"breakdownConnect": "Proxy bağlantısı",
|
||||
|
||||
@@ -1627,7 +1627,8 @@
|
||||
"testSuccess": "Тест успішний",
|
||||
"testFailed": "Тест не пройдено",
|
||||
"testError": "Не вдалося протестувати вихідне з'єднання",
|
||||
"testModeTooltip": "TCP: швидкий dial-only probe. HTTP: повний запит через xray.",
|
||||
"modeRealDelay": "Реальна затримка",
|
||||
"testModeTooltip": "TCP: швидкий dial-only probe. HTTP: повний запит через xray. Реальна затримка: повний час із встановленням з'єднання.",
|
||||
"testAll": "Тестувати всі",
|
||||
"httpStatus": "HTTP-статус",
|
||||
"breakdownConnect": "Підключення до проксі",
|
||||
|
||||
@@ -1627,7 +1627,8 @@
|
||||
"testSuccess": "Kiểm tra thành công",
|
||||
"testFailed": "Kiểm tra thất bại",
|
||||
"testError": "Không thể kiểm tra đầu ra",
|
||||
"testModeTooltip": "TCP: probe dial nhanh. HTTP: yêu cầu đầy đủ qua xray.",
|
||||
"modeRealDelay": "Độ trễ thực",
|
||||
"testModeTooltip": "TCP: probe dial nhanh. HTTP: yêu cầu đầy đủ qua xray. Độ trễ thực: tổng thời gian gồm cả thiết lập kết nối.",
|
||||
"testAll": "Kiểm tra tất cả",
|
||||
"httpStatus": "Trạng thái HTTP",
|
||||
"breakdownConnect": "Kết nối proxy",
|
||||
|
||||
@@ -1627,7 +1627,8 @@
|
||||
"testSuccess": "测试成功",
|
||||
"testFailed": "测试失败",
|
||||
"testError": "测试出站失败",
|
||||
"testModeTooltip": "TCP: 快速 dial-only 探测。HTTP: 通过 xray 的完整请求。",
|
||||
"modeRealDelay": "真实延迟",
|
||||
"testModeTooltip": "TCP: 快速 dial-only 探测。HTTP: 通过 xray 的完整请求。真实延迟: 含建立连接的总耗时。",
|
||||
"testAll": "全部测试",
|
||||
"httpStatus": "HTTP 状态",
|
||||
"breakdownConnect": "代理连接",
|
||||
|
||||
@@ -1627,7 +1627,8 @@
|
||||
"testSuccess": "測試成功",
|
||||
"testFailed": "測試失敗",
|
||||
"testError": "測試出站失敗",
|
||||
"testModeTooltip": "TCP: 快速 dial-only 探測。HTTP: 透過 xray 的完整請求。",
|
||||
"modeRealDelay": "真實延遲",
|
||||
"testModeTooltip": "TCP: 快速 dial-only 探測。HTTP: 透過 xray 的完整請求。真實延遲: 含建立連線的總耗時。",
|
||||
"testAll": "全部測試",
|
||||
"httpStatus": "HTTP 狀態",
|
||||
"breakdownConnect": "代理連線",
|
||||
|
||||
Reference in New Issue
Block a user