From 8834e5fbbe549d6b087c6e80109db88014d11664 Mon Sep 17 00:00:00 2001 From: MHSanaei Date: Mon, 11 May 2026 04:17:23 +0200 Subject: [PATCH] feat(xray/outbounds): TCP probe mode + Test All + timing breakdown MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - service.TestOutbound now dispatches on `mode`: - "tcp": parallel net.DialTimeout to every server/peer endpoint (vmess/vless/trojan/ss/socks/http/wireguard). No xray spin-up, no semaphore — safe to run concurrently across outbounds. - "http" (default): existing temp-xray + SOCKS path, now with an httptrace.ClientTrace breakdown (DNS / Connect / TLS / TTFB) alongside the total delay and status code. - testSemaphore renamed to httpTestSemaphore — only HTTP probes serialise, TCP runs free. - TestOutboundResult carries the per-mode extras: timing fields for HTTP, per-endpoint dial list for TCP, plus a `mode` echo. - Controller reads `mode` from the form and passes it through. - useXraySetting: testOutbound accepts mode (default "tcp"); new testAllOutbounds(mode) runs a worker pool (concurrency 8 for TCP, 1 for HTTP) and skips blackhole / loopback / blocked outbounds — also skips freedom / dns under TCP since they have no endpoint. - OutboundsTab: TCP/HTTP radio toggle and a Test All button land in the toolbar; the per-row ⚡ now uses the selected mode. Results surface in a popover with the full timing breakdown plus the endpoint list for TCP probes. Latency header replaces the duplicate "check" column title. Practical effect: testing ten outbounds in TCP mode drops from ~50–100s (serial HTTP) to ~1–2s (parallel dial × 8). HTTP mode stays as the authoritative probe and now shows where the latency actually lives. --- frontend/src/pages/xray/OutboundsTab.vue | 202 +++++++++--- frontend/src/pages/xray/XrayPage.vue | 17 +- frontend/src/pages/xray/useXraySetting.js | 79 +++-- web/controller/xray_setting.go | 5 +- web/service/outbound.go | 372 +++++++++++++++------- 5 files changed, 484 insertions(+), 191 deletions(-) diff --git a/frontend/src/pages/xray/OutboundsTab.vue b/frontend/src/pages/xray/OutboundsTab.vue index 13e9d04f..11f3b898 100644 --- a/frontend/src/pages/xray/OutboundsTab.vue +++ b/frontend/src/pages/xray/OutboundsTab.vue @@ -16,6 +16,7 @@ import { LoadingOutlined, ArrowUpOutlined, ArrowDownOutlined, + PlayCircleOutlined, } from '@ant-design/icons-vue'; import { Modal } from 'ant-design-vue'; @@ -25,16 +26,11 @@ import OutboundFormModal from './OutboundFormModal.vue'; const { t } = useI18n(); -// Outbounds tab — list + actions over templateSettings.outbounds. -// Mirrors the legacy outbound table layout (identity / address / -// traffic / test result / test button) plus the row action menu -// (set first / edit / reset traffic / delete). Mobile collapses to -// a card list. - const props = defineProps({ templateSettings: { type: Object, default: null }, outboundsTraffic: { type: Array, default: () => [] }, outboundTestStates: { type: Object, default: () => ({}) }, + testingAll: { type: Boolean, default: false }, inboundTags: { type: Array, default: () => [] }, isMobile: { type: Boolean, default: false }, }); @@ -48,7 +44,9 @@ const inboundTagOptions = computed(() => { return [...out]; }); -const emit = defineEmits(['reset-traffic', 'test', 'show-warp', 'show-nord', 'delete']); +const emit = defineEmits(['reset-traffic', 'test', 'test-all', 'show-warp', 'show-nord', 'delete']); + +const testMode = ref('tcp'); // === Modal state ==================================================== const modalOpen = ref(false); @@ -141,10 +139,13 @@ function outboundAddresses(o) { return serverObj ? serverObj.map((s) => `${s.address}:${s.port}`) : []; } -function isUntestable(o) { - return o.protocol === Protocols.Blackhole +function isUntestable(o, mode = testMode.value) { + if (!o) return true; + if (o.protocol === Protocols.Blackhole || o.protocol === Protocols.Loopback - || o.tag === 'blocked'; + || o.tag === 'blocked') return true; + if (mode === 'tcp' && (o.protocol === Protocols.Freedom || o.protocol === Protocols.DNS)) return true; + return false; } function isTesting(idx) { return !!props.outboundTestStates?.[idx]?.testing; @@ -156,6 +157,12 @@ function showSecurity(security) { return security === 'tls' || security === 'reality'; } +function hasBreakdown(r) { + if (!r) return false; + if (r.endpoints?.length) return true; + return !!(r.ttfbMs || r.tlsMs || r.connectMs || r.dnsMs || r.statusCode || r.error); +} + // === Columns ======================================================== // Computed so titles re-render after a locale swap. const columns = computed(() => [ @@ -163,7 +170,7 @@ const columns = computed(() => [ { title: 'Tag', key: 'identity', align: 'left', width: 220 }, { title: t('pages.inbounds.address'), key: 'address', align: 'left', width: 230 }, { title: t('pages.inbounds.traffic'), key: 'traffic', align: 'left', width: 200 }, - { title: t('check'), key: 'testResult', align: 'left', width: 140 }, + { title: t('pages.xray.latency') !== 'pages.xray.latency' ? t('pages.xray.latency') : 'Latency', key: 'testResult', align: 'left', width: 140 }, { title: t('check'), key: 'test', align: 'center', width: 80 }, ]); @@ -177,8 +184,8 @@ const rows = computed(() => { - - + + diff --git a/frontend/src/pages/xray/useXraySetting.js b/frontend/src/pages/xray/useXraySetting.js index fd74b103..61b8c7f6 100644 --- a/frontend/src/pages/xray/useXraySetting.js +++ b/frontend/src/pages/xray/useXraySetting.js @@ -1,50 +1,24 @@ -// Drives the xray page's fetch / dirty / save lifecycle. The Go side -// returns the live xraySetting (the full JSON config), the inboundTags -// list, and a few sidecar values (clientReverseTags, outboundTestUrl) -// the structured tabs need. We keep the JSON as a string here — pretty- -// printed for the textarea; tabs that want a parsed view can JSON.parse -// it themselves. import { onMounted, onUnmounted, ref, watch } from 'vue'; import { HttpUtil, PromiseUtil } from '@/utils'; const DIRTY_POLL_MS = 1000; -// Hoists the parsed `templateSettings` alongside the JSON string so -// structured tabs (Basics/Routing/Outbounds/etc.) can mutate fields -// directly while the Advanced (JSON) tab edits the same data as text. -// We keep both in sync with two cooperating watches: -// • mutating templateSettings re-stringifies into xraySetting; -// • editing the JSON text re-parses into templateSettings (only on -// valid JSON — invalid edits leave templateSettings untouched -// so the structured tabs don't blow up while the user types). let syncing = false; export function useXraySetting() { const fetched = ref(false); const spinning = ref(false); const saveDisabled = ref(true); - // Holds a user-facing message when fetchAll fails; lets the page - // render an error UI instead of an endless spinner. const fetchError = ref(''); - const xraySetting = ref(''); const oldXraySetting = ref(''); - - // Parsed mirror — null until first successful fetch / parse. const templateSettings = ref(null); - const outboundTestUrl = ref('https://www.google.com/generate_204'); const oldOutboundTestUrl = ref(''); - const inboundTags = ref([]); const clientReverseTags = ref([]); const restartResult = ref(''); - - // Outbounds tab data — traffic stats + per-row test state. Test - // states are keyed by outbound index (sparse object), each entry - // is `{ testing, result }` where result is the wire response from - // /panel/xray/testOutbound or null while the test is in flight. const outboundsTraffic = ref([]); const outboundTestStates = ref({}); @@ -53,7 +27,6 @@ export function useXraySetting() { const msg = await HttpUtil.post('/panel/xray/'); if (!msg?.success) { fetchError.value = msg?.msg || 'Failed to load xray config'; - // Mark as fetched so the spinner clears and the error UI renders. fetched.value = true; return; } @@ -79,8 +52,7 @@ export function useXraySetting() { saveDisabled.value = true; } - // Structured tabs mutate templateSettings deeply. Re-stringify on - // change so the Advanced JSON view + the dirty-poll see the edits. + watch( templateSettings, (next) => { @@ -95,8 +67,6 @@ export function useXraySetting() { { deep: true }, ); - // Advanced JSON edits — only refresh templateSettings when the text - // parses, so structured tabs stay readable mid-edit. watch(xraySetting, (next) => { if (syncing) return; try { @@ -133,21 +103,19 @@ export function useXraySetting() { if (msg?.success) await fetchOutboundsTraffic(); } - // Merges a WebSocket `outbounds` event into outboundsTraffic in place. - // The xray traffic job pushes the full snapshot every ~10s so the user - // doesn't have to click the (now-removed) refresh button. function applyOutboundsEvent(payload) { if (Array.isArray(payload)) outboundsTraffic.value = payload; } - async function testOutbound(index, outbound) { + async function testOutbound(index, outbound, mode = 'tcp') { if (!outbound) return null; if (!outboundTestStates.value[index]) outboundTestStates.value[index] = {}; - outboundTestStates.value[index] = { testing: true, result: null }; + outboundTestStates.value[index] = { testing: true, result: null, mode }; try { const msg = await HttpUtil.post('/panel/xray/testOutbound', { outbound: JSON.stringify(outbound), allOutbounds: JSON.stringify(templateSettings.value?.outbounds || []), + mode, }); if (msg?.success) { outboundTestStates.value[index] = { testing: false, result: msg.obj }; @@ -155,24 +123,53 @@ export function useXraySetting() { } outboundTestStates.value[index] = { testing: false, - result: { success: false, error: msg?.msg || 'Unknown error' }, + result: { success: false, error: msg?.msg || 'Unknown error', mode }, }; } catch (e) { outboundTestStates.value[index] = { testing: false, - result: { success: false, error: String(e) }, + result: { success: false, error: String(e), mode }, }; } return null; } + const testingAll = ref(false); + async function testAllOutbounds(mode = 'tcp') { + const list = templateSettings.value?.outbounds || []; + if (list.length === 0 || testingAll.value) return; + testingAll.value = true; + try { + const concurrency = mode === 'tcp' ? 8 : 1; + const queue = list + .map((ob, i) => ({ index: i, outbound: ob })) + .filter(({ outbound }) => { + const tag = outbound?.tag; + const proto = outbound?.protocol; + if (proto === 'blackhole' || proto === 'loopback' || tag === 'blocked') return false; + if (mode === 'tcp' && (proto === 'freedom' || proto === 'dns')) return false; + return true; + }); + async function worker() { + while (queue.length > 0) { + const item = queue.shift(); + if (!item) break; + await testOutbound(item.index, item.outbound, mode); + } + } + const workers = Array.from({ length: Math.min(concurrency, queue.length) }, () => worker()); + await Promise.all(workers); + } finally { + testingAll.value = false; + } + } + async function resetToDefault() { spinning.value = true; try { const msg = await HttpUtil.get('/panel/setting/getDefaultJsonConfig'); if (msg?.success) { - // Mutate templateSettings — the watch above re-stringifies into - // xraySetting so the Advanced JSON tab and dirty-poll see it. + templateSettings.value = JSON.parse(JSON.stringify(msg.obj)); } } finally { @@ -234,11 +231,13 @@ export function useXraySetting() { restartResult, outboundsTraffic, outboundTestStates, + testingAll, fetchAll, fetchOutboundsTraffic, resetOutboundsTraffic, applyOutboundsEvent, testOutbound, + testAllOutbounds, saveAll, resetToDefault, restartXray, diff --git a/web/controller/xray_setting.go b/web/controller/xray_setting.go index db3cfdda..11242038 100644 --- a/web/controller/xray_setting.go +++ b/web/controller/xray_setting.go @@ -199,9 +199,12 @@ 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. func (a *XraySettingController) testOutbound(c *gin.Context) { outboundJSON := c.PostForm("outbound") allOutboundsJSON := c.PostForm("allOutbounds") + mode := c.PostForm("mode") if outboundJSON == "" { jsonMsg(c, I18nWeb(c, "somethingWentWrong"), common.NewError("outbound parameter is required")) @@ -211,7 +214,7 @@ func (a *XraySettingController) testOutbound(c *gin.Context) { // Load the test URL from server settings to prevent SSRF via user-controlled URLs testURL, _ := a.SettingService.GetXrayOutboundTestUrl() - result, err := a.OutboundService.TestOutbound(outboundJSON, testURL, allOutboundsJSON) + result, err := a.OutboundService.TestOutbound(outboundJSON, testURL, allOutboundsJSON, mode) if err != nil { jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err) return diff --git a/web/service/outbound.go b/web/service/outbound.go index 86449f06..4cef5247 100644 --- a/web/service/outbound.go +++ b/web/service/outbound.go @@ -1,13 +1,17 @@ package service import ( + "context" + "crypto/tls" "encoding/json" "fmt" "io" "net" "net/http" + "net/http/httptrace" "net/url" "os" + "strconv" "sync" "time" @@ -15,7 +19,6 @@ import ( "github.com/mhsanaei/3x-ui/v3/database" "github.com/mhsanaei/3x-ui/v3/database/model" "github.com/mhsanaei/3x-ui/v3/logger" - "github.com/mhsanaei/3x-ui/v3/util/common" "github.com/mhsanaei/3x-ui/v3/util/json_util" "github.com/mhsanaei/3x-ui/v3/xray" @@ -26,8 +29,10 @@ import ( // It handles outbound traffic monitoring and statistics. type OutboundService struct{} -// testSemaphore limits concurrent outbound tests to prevent resource exhaustion. -var testSemaphore sync.Mutex +// 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) { var err error @@ -117,90 +122,230 @@ func (s *OutboundService) ResetOutboundTraffic(tag string) error { return nil } -// TestOutboundResult represents the result of testing an outbound +// TestOutboundResult represents the result of testing an outbound. +// Delay/timing fields are in milliseconds. Endpoints is only populated for +// TCP-mode probes; the HTTP-mode timing breakdown lives in DNSMs/ConnectMs/ +// TLSMs/TTFBMs (any of these can be 0 if the underlying step was skipped — +// e.g. a non-TLS target leaves TLSMs at 0). type TestOutboundResult struct { Success bool `json:"success"` - Delay int64 `json:"delay"` // Delay in milliseconds + Delay int64 `json:"delay"` Error string `json:"error,omitempty"` StatusCode int `json:"statusCode,omitempty"` + Mode string `json:"mode,omitempty"` + + DNSMs int64 `json:"dnsMs,omitempty"` + ConnectMs int64 `json:"connectMs,omitempty"` + TLSMs int64 `json:"tlsMs,omitempty"` + TTFBMs int64 `json:"ttfbMs,omitempty"` + + Endpoints []TestEndpointResult `json:"endpoints,omitempty"` } -// TestOutbound tests an outbound by creating a temporary xray instance and measuring response time. -// allOutboundsJSON must be a JSON array of all outbounds; they are copied into the test config unchanged. -// Only the test inbound and a route rule (to the tested outbound tag) are added. -func (s *OutboundService) TestOutbound(outboundJSON string, testURL string, allOutboundsJSON string) (*TestOutboundResult, error) { +// TestEndpointResult is one entry in a TCP-mode probe — the per-endpoint +// dial outcome for outbounds that expose multiple servers/peers. +type TestEndpointResult struct { + Address string `json:"address"` + Success bool `json:"success"` + Delay int64 `json:"delay"` + 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" { + return s.testOutboundTCP(outboundJSON) + } + return s.testOutboundHTTP(outboundJSON, testURL, allOutboundsJSON) +} + +func (s *OutboundService) testOutboundTCP(outboundJSON string) (*TestOutboundResult, error) { + var ob map[string]any + if err := json.Unmarshal([]byte(outboundJSON), &ob); err != nil { + return &TestOutboundResult{Mode: "tcp", Success: false, Error: fmt.Sprintf("Invalid outbound JSON: %v", err)}, nil + } + tag, _ := ob["tag"].(string) + protocol, _ := ob["protocol"].(string) + if protocol == "blackhole" || protocol == "freedom" || tag == "blocked" { + return &TestOutboundResult{Mode: "tcp", Success: false, Error: "Outbound has no testable endpoint"}, nil + } + + endpoints := extractOutboundEndpoints(ob) + if len(endpoints) == 0 { + return &TestOutboundResult{Mode: "tcp", Success: false, Error: "No testable endpoint"}, nil + } + + results := make([]TestEndpointResult, len(endpoints)) + var wg sync.WaitGroup + for i := range endpoints { + wg.Add(1) + go func(i int) { + defer wg.Done() + results[i] = probeTCPEndpoint(endpoints[i], 5*time.Second) + }(i) + } + wg.Wait() + + var bestDelay int64 = -1 + var firstErr string + for _, r := range results { + if r.Success { + if bestDelay < 0 || r.Delay < bestDelay { + bestDelay = r.Delay + } + } else if firstErr == "" { + firstErr = r.Error + } + } + + out := &TestOutboundResult{Mode: "tcp", Endpoints: results} + if bestDelay >= 0 { + out.Success = true + out.Delay = bestDelay + } else { + out.Error = firstErr + if out.Error == "" { + out.Error = "All endpoints unreachable" + } + } + return out, nil +} + +func probeTCPEndpoint(endpoint string, timeout time.Duration) TestEndpointResult { + r := TestEndpointResult{Address: endpoint} + start := time.Now() + conn, err := net.DialTimeout("tcp", endpoint, timeout) + r.Delay = time.Since(start).Milliseconds() + if err != nil { + r.Error = err.Error() + return r + } + conn.Close() + r.Success = true + return r +} + +func extractOutboundEndpoints(ob map[string]any) []string { + protocol, _ := ob["protocol"].(string) + settings, _ := ob["settings"].(map[string]any) + if settings == nil { + return nil + } + var out []string + addServer := func(addr any, port any) { + host, _ := addr.(string) + p := numAsInt(port) + if host != "" && p > 0 { + out = append(out, fmt.Sprintf("%s:%d", host, p)) + } + } + switch protocol { + case "vmess": + if vnext, ok := settings["vnext"].([]any); ok { + for _, v := range vnext { + if vm, ok := v.(map[string]any); ok { + addServer(vm["address"], vm["port"]) + } + } + } + case "vless": + addServer(settings["address"], settings["port"]) + case "trojan", "shadowsocks", "http", "socks": + if servers, ok := settings["servers"].([]any); ok { + for _, sv := range servers { + if sm, ok := sv.(map[string]any); ok { + addServer(sm["address"], sm["port"]) + } + } + } + case "wireguard": + if peers, ok := settings["peers"].([]any); ok { + for _, p := range peers { + if pm, ok := p.(map[string]any); ok { + if ep, _ := pm["endpoint"].(string); ep != "" { + out = append(out, ep) + } + } + } + } + } + return out +} + +func numAsInt(v any) int { + switch n := v.(type) { + case float64: + return int(n) + case int: + return n + case int64: + return int(n) + case string: + if i, err := strconv.Atoi(n); err == nil { + return i + } + } + return 0 +} + +func (s *OutboundService) testOutboundHTTP(outboundJSON string, testURL string, allOutboundsJSON string) (*TestOutboundResult, error) { if testURL == "" { testURL = "https://www.google.com/generate_204" } - // Limit to one concurrent test at a time - if !testSemaphore.TryLock() { + if !httpTestSemaphore.TryLock() { return &TestOutboundResult{ + Mode: "http", Success: false, Error: "Another outbound test is already running, please wait", }, nil } - defer testSemaphore.Unlock() + defer httpTestSemaphore.Unlock() - // Parse the outbound being tested to get its tag var testOutbound map[string]any if err := json.Unmarshal([]byte(outboundJSON), &testOutbound); err != nil { - return &TestOutboundResult{ - Success: false, - Error: fmt.Sprintf("Invalid outbound JSON: %v", 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{ - Success: false, - Error: "Outbound has no tag", - }, nil + return &TestOutboundResult{Mode: "http", Success: false, Error: "Outbound has no tag"}, nil } if protocol, _ := testOutbound["protocol"].(string); protocol == "blackhole" || outboundTag == "blocked" { - return &TestOutboundResult{ - Success: false, - Error: "Blocked/blackhole outbound cannot be tested", - }, nil + return &TestOutboundResult{Mode: "http", Success: false, Error: "Blocked/blackhole outbound cannot be tested"}, nil } - // Use all outbounds when provided; otherwise fall back to single outbound var allOutbounds []any if allOutboundsJSON != "" { if err := json.Unmarshal([]byte(allOutboundsJSON), &allOutbounds); err != nil { - return &TestOutboundResult{ - Success: false, - Error: fmt.Sprintf("Invalid allOutbounds JSON: %v", err), - }, nil + return &TestOutboundResult{Mode: "http", Success: false, Error: fmt.Sprintf("Invalid allOutbounds JSON: %v", err)}, nil } } if len(allOutbounds) == 0 { allOutbounds = []any{testOutbound} } - // Find an available port for test inbound testPort, err := findAvailablePort() if err != nil { - return &TestOutboundResult{ - Success: false, - Error: fmt.Sprintf("Failed to find available port: %v", err), - }, nil + return &TestOutboundResult{Mode: "http", Success: false, Error: fmt.Sprintf("Failed to find available port: %v", err)}, nil } - // Copy all outbounds as-is, add only test inbound and route rule testConfig := s.createTestConfig(outboundTag, allOutbounds, testPort) - // Use a temporary config file so the main config.json is never overwritten testConfigPath, err := createTestConfigPath() if err != nil { - return &TestOutboundResult{ - Success: false, - Error: fmt.Sprintf("Failed to create test config path: %v", err), - }, nil + return &TestOutboundResult{Mode: "http", Success: false, Error: fmt.Sprintf("Failed to create test config path: %v", err)}, nil } - defer os.Remove(testConfigPath) // ensure temp file is removed even if process is not stopped + defer os.Remove(testConfigPath) - // Create temporary xray process with its own config file testProcess := xray.NewTestProcess(testConfig, testConfigPath) defer func() { if testProcess.IsRunning() { @@ -208,52 +353,24 @@ func (s *OutboundService) TestOutbound(outboundJSON string, testURL string, allO } }() - // Start the test process if err := testProcess.Start(); err != nil { - return &TestOutboundResult{ - Success: false, - Error: fmt.Sprintf("Failed to start test xray instance: %v", err), - }, nil + return &TestOutboundResult{Mode: "http", Success: false, Error: fmt.Sprintf("Failed to start test xray instance: %v", err)}, nil } - // Wait for xray to start listening on the test port if err := waitForPort(testPort, 3*time.Second); err != nil { if !testProcess.IsRunning() { result := testProcess.GetResult() - return &TestOutboundResult{ - Success: false, - Error: fmt.Sprintf("Xray process exited: %s", result), - }, nil + return &TestOutboundResult{Mode: "http", Success: false, Error: fmt.Sprintf("Xray process exited: %s", result)}, nil } - return &TestOutboundResult{ - Success: false, - Error: fmt.Sprintf("Xray failed to start listening: %v", err), - }, nil + return &TestOutboundResult{Mode: "http", Success: false, Error: fmt.Sprintf("Xray failed to start listening: %v", err)}, nil } - // Check if process is still running if !testProcess.IsRunning() { result := testProcess.GetResult() - return &TestOutboundResult{ - Success: false, - Error: fmt.Sprintf("Xray process exited: %s", result), - }, nil + return &TestOutboundResult{Mode: "http", Success: false, Error: fmt.Sprintf("Xray process exited: %s", result)}, nil } - // Test the connection through proxy - delay, statusCode, err := s.testConnection(testPort, testURL) - if err != nil { - return &TestOutboundResult{ - Success: false, - Error: err.Error(), - }, nil - } - - return &TestOutboundResult{ - Success: true, - Delay: delay, - StatusCode: statusCode, - }, nil + return s.testConnection(testPort, testURL) } // createTestConfig creates a test config by copying all outbounds unchanged and adding @@ -329,55 +446,92 @@ func (s *OutboundService) createTestConfig(outboundTag string, allOutbounds []an return cfg } -// testConnection tests the connection through the proxy and measures delay. -// It performs a warmup request first to establish the SOCKS connection and populate DNS caches, -// then measures the second request for a more accurate latency reading. -func (s *OutboundService) testConnection(proxyPort int, testURL string) (int64, int, error) { - // Create SOCKS5 proxy URL - proxyURL := fmt.Sprintf("socks5://127.0.0.1:%d", proxyPort) - - // Parse proxy URL - proxyURLParsed, err := url.Parse(proxyURL) +// testConnection runs the actual HTTP probe through the local SOCKS proxy. +// A warmup request seeds xray's DNS cache / handshake; then a fresh +// transport runs the measured request so httptrace sees a real cold +// connection and reports DNS/Connect/TLS/TTFB. Note that DNS and Connect +// reflect *client → SOCKS-on-loopback*, not the remote target — those +// happen inside xray and aren't visible to net/http. TLS and TTFB are +// the meaningful breakdown values for a SOCKS-proxied HTTPS probe. +func (s *OutboundService) testConnection(proxyPort int, testURL string) (*TestOutboundResult, error) { + proxyURLStr := fmt.Sprintf("socks5://127.0.0.1:%d", proxyPort) + proxyURLParsed, err := url.Parse(proxyURLStr) if err != nil { - return 0, 0, common.NewErrorf("Invalid proxy URL: %v", err) + return &TestOutboundResult{Mode: "http", Success: false, Error: fmt.Sprintf("Invalid proxy URL: %v", err)}, nil } - // Create HTTP client with proxy and keep-alive for connection reuse - client := &http.Client{ - Timeout: 10 * time.Second, - Transport: &http.Transport{ - Proxy: http.ProxyURL(proxyURLParsed), - DialContext: (&net.Dialer{ - Timeout: 5 * time.Second, - KeepAlive: 30 * time.Second, - }).DialContext, - MaxIdleConns: 1, - IdleConnTimeout: 10 * time.Second, - DisableCompression: true, - }, + mkClient := func() *http.Client { + return &http.Client{ + Timeout: 10 * time.Second, + Transport: &http.Transport{ + Proxy: http.ProxyURL(proxyURLParsed), + DialContext: (&net.Dialer{ + Timeout: 5 * time.Second, + KeepAlive: 30 * time.Second, + }).DialContext, + MaxIdleConns: 1, + IdleConnTimeout: 1 * time.Second, + DisableCompression: true, + }, + } } - // Warmup request: establishes SOCKS/TLS connection, DNS, and TCP to the target. - // This mirrors real-world usage where connections are reused. - warmupResp, err := client.Get(testURL) + warmup := mkClient() + warmupResp, err := warmup.Get(testURL) if err != nil { - return 0, 0, common.NewErrorf("Request failed: %v", err) + return &TestOutboundResult{Mode: "http", Success: false, Error: fmt.Sprintf("Request failed: %v", err)}, nil } io.Copy(io.Discard, warmupResp.Body) warmupResp.Body.Close() + warmup.CloseIdleConnections() - // Measure the actual request on the warm connection - startTime := time.Now() - resp, err := client.Get(testURL) - delay := time.Since(startTime).Milliseconds() + var dnsStart, dnsDone, connectStart, connectDone, tlsStart, tlsDone, firstByte time.Time + trace := &httptrace.ClientTrace{ + DNSStart: func(_ httptrace.DNSStartInfo) { dnsStart = time.Now() }, + DNSDone: func(_ httptrace.DNSDoneInfo) { dnsDone = time.Now() }, + ConnectStart: func(_, _ string) { connectStart = time.Now() }, + ConnectDone: func(_, _ string, _ error) { connectDone = time.Now() }, + TLSHandshakeStart: func() { tlsStart = time.Now() }, + TLSHandshakeDone: func(_ tls.ConnectionState, _ error) { tlsDone = time.Now() }, + GotFirstResponseByte: func() { firstByte = time.Now() }, + } + client := mkClient() + defer client.CloseIdleConnections() + ctx := httptrace.WithClientTrace(context.Background(), trace) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, testURL, nil) if err != nil { - return 0, 0, common.NewErrorf("Request failed: %v", err) + return &TestOutboundResult{Mode: "http", Success: false, Error: fmt.Sprintf("Request build failed: %v", err)}, nil + } + + startTime := time.Now() + resp, err := client.Do(req) + delay := time.Since(startTime).Milliseconds() + if err != nil { + return &TestOutboundResult{Mode: "http", Success: false, Error: fmt.Sprintf("Request failed: %v", err)}, nil } io.Copy(io.Discard, resp.Body) resp.Body.Close() - return delay, resp.StatusCode, nil + out := &TestOutboundResult{ + Mode: "http", + Success: true, + Delay: delay, + StatusCode: resp.StatusCode, + } + if !dnsStart.IsZero() && !dnsDone.IsZero() { + out.DNSMs = dnsDone.Sub(dnsStart).Milliseconds() + } + if !connectStart.IsZero() && !connectDone.IsZero() { + out.ConnectMs = connectDone.Sub(connectStart).Milliseconds() + } + if !tlsStart.IsZero() && !tlsDone.IsZero() { + out.TLSMs = tlsDone.Sub(tlsStart).Milliseconds() + } + if !firstByte.IsZero() { + out.TTFBMs = firstByte.Sub(startTime).Milliseconds() + } + return out, nil } // waitForPort polls until the given TCP port is accepting connections or the timeout expires.