From 31d7ed5103caecd5d7f928555077d2db54592d70 Mon Sep 17 00:00:00 2001 From: MHSanaei Date: Wed, 27 May 2026 04:53:13 +0200 Subject: [PATCH] refactor(outbound): probe via xray burstObservatory instead of SOCKS round-trip Replace the HTTP-mode outbound test that spun up a SOCKS inbound and ran an httptrace'd request from the Go client with a probe-only xray config: burstObservatory probes the target outbound directly and the result is read from xray's /debug/vars metrics endpoint. The probe lives inside xray, so the measured delay and failure reasons reflect what xray itself sees over the real proxy chain. Drops the DNS/Connect/TLS/TTFB breakdown (and statusCode) since the observatory snapshot only exposes total delay; the frontend popover is updated accordingly. --- frontend/src/pages/xray/OutboundsTab.tsx | 9 +- frontend/src/schemas/xray.ts | 5 - web/service/outbound.go | 263 ++++++++++------------- 3 files changed, 121 insertions(+), 156 deletions(-) diff --git a/frontend/src/pages/xray/OutboundsTab.tsx b/frontend/src/pages/xray/OutboundsTab.tsx index 84cef86d..af535acb 100644 --- a/frontend/src/pages/xray/OutboundsTab.tsx +++ b/frontend/src/pages/xray/OutboundsTab.tsx @@ -101,10 +101,10 @@ function showSecurity(security?: string): boolean { return security === 'tls' || security === 'reality'; } -function hasBreakdown(r: { endpoints?: unknown[]; ttfbMs?: number; tlsMs?: number; connectMs?: number; dnsMs?: number; statusCode?: number; error?: string } | null | undefined): boolean { +function hasBreakdown(r: { endpoints?: unknown[]; error?: string } | null | undefined): boolean { if (!r) return false; if (r.endpoints?.length) return true; - return !!(r.ttfbMs || r.tlsMs || r.connectMs || r.dnsMs || r.statusCode || r.error); + return !!r.error; } export default function OutboundsTab({ @@ -335,11 +335,6 @@ export default function OutboundsTab({ {hasBreakdown(r) && ( <> - {r.ttfbMs ?
TTFB: {r.ttfbMs} ms
: null} - {r.tlsMs ?
TLS: {r.tlsMs} ms
: null} - {r.connectMs ?
Connect: {r.connectMs} ms
: null} - {r.dnsMs ?
DNS: {r.dnsMs} ms
: null} - {r.statusCode ?
HTTP {r.statusCode}
: null} {(r.endpoints || []).map((ep) => (
diff --git a/frontend/src/schemas/xray.ts b/frontend/src/schemas/xray.ts index 2a87e09c..ad19e5dd 100644 --- a/frontend/src/schemas/xray.ts +++ b/frontend/src/schemas/xray.ts @@ -54,11 +54,6 @@ export const OutboundTestResultSchema = z.object({ delay: z.number().optional(), error: z.string().optional(), mode: z.string().optional(), - ttfbMs: z.number().optional(), - tlsMs: z.number().optional(), - connectMs: z.number().optional(), - dnsMs: z.number().optional(), - statusCode: z.number().optional(), endpoints: z .array( z.object({ diff --git a/web/service/outbound.go b/web/service/outbound.go index 7e5a0b2e..84bfe21f 100644 --- a/web/service/outbound.go +++ b/web/service/outbound.go @@ -1,15 +1,10 @@ package service import ( - "context" - "crypto/tls" "encoding/json" "fmt" - "io" "net" "net/http" - "net/http/httptrace" - "net/url" "os" "strconv" "sync" @@ -123,21 +118,14 @@ func (s *OutboundService) ResetOutboundTraffic(tag string) error { } // 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). +// Delay is in milliseconds. Endpoints is only populated for TCP-mode +// probes; HTTP mode reports the round-trip delay measured by xray's +// burstObservatory probe. type TestOutboundResult struct { - Success bool `json:"success"` - 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"` + Success bool `json:"success"` + Delay int64 `json:"delay"` + Error string `json:"error,omitempty"` + Mode string `json:"mode,omitempty"` Endpoints []TestEndpointResult `json:"endpoints,omitempty"` } @@ -370,6 +358,13 @@ func numAsInt(v any) int { 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" @@ -406,12 +401,12 @@ func (s *OutboundService) testOutboundHTTP(outboundJSON string, testURL string, allOutbounds = []any{testOutbound} } - testPort, err := findAvailablePort() + 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, testPort) + testConfig := s.createTestConfig(outboundTag, allOutbounds, metricsPort, testURL) testConfigPath, err := createTestConfigPath() if err != nil { @@ -430,12 +425,12 @@ func (s *OutboundService) testOutboundHTTP(outboundJSON string, testURL string, return &TestOutboundResult{Mode: "http", Success: false, Error: fmt.Sprintf("Failed to start test xray instance: %v", err)}, nil } - if err := waitForPort(testPort, 3*time.Second); 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 listening: %v", err)}, nil + return &TestOutboundResult{Mode: "http", Success: false, Error: fmt.Sprintf("Xray failed to start metrics listener: %v", err)}, nil } if !testProcess.IsRunning() { @@ -443,22 +438,15 @@ func (s *OutboundService) testOutboundHTTP(outboundJSON string, testURL string, return &TestOutboundResult{Mode: "http", Success: false, Error: fmt.Sprintf("Xray process exited: %s", result)}, nil } - return s.testConnection(testPort, testURL) + return pollObservatoryResult(testProcess, metricsPort, outboundTag, 12*time.Second), nil } -// createTestConfig creates a test config by copying all outbounds unchanged and adding -// only the test inbound (SOCKS) and a route rule that sends traffic to the given outbound tag. -func (s *OutboundService) createTestConfig(outboundTag string, allOutbounds []any, testPort int) *xray.Config { - // Test inbound (SOCKS proxy) - only addition to inbounds - testInbound := xray.InboundConfig{ - Tag: "test-inbound", - Listen: json_util.RawMessage(`"127.0.0.1"`), - Port: testPort, - Protocol: "socks", - Settings: json_util.RawMessage(`{"auth":"noauth","udp":true}`), - } - - // Outbounds: copy all, but set noKernelTun=true for WireGuard outbounds +// createTestConfig builds a probe-only xray config: the original outbounds +// are kept as-is so dialerProxy chains still resolve, a burstObservatory +// is wired to probe the target tag, and a metrics listener exposes the +// observatory snapshot via /debug/vars. No inbound or routing rules are +// needed — burstObservatory issues the probe traffic itself. +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) @@ -467,35 +455,37 @@ func (s *OutboundService) createTestConfig(outboundTag string, allOutbounds []an continue } if protocol, ok := outbound["protocol"].(string); ok && protocol == "wireguard" { - // Set noKernelTun to true for WireGuard outbounds if settings, ok := outbound["settings"].(map[string]any); ok { settings["noKernelTun"] = true } else { - // Create settings if it doesn't exist - outbound["settings"] = map[string]any{ - "noKernelTun": true, - } + outbound["settings"] = map[string]any{"noKernelTun": true} } } processedOutbounds[i] = outbound } outboundsJSON, _ := json.Marshal(processedOutbounds) - // Create routing rule to route all traffic through test outbound - routingRules := []map[string]any{ - { - "type": "field", - "outboundTag": outboundTag, - "network": "tcp,udp", - }, - } - routingJSON, _ := json.Marshal(map[string]any{ "domainStrategy": "AsIs", - "rules": routingRules, + "rules": []any{}, + }) + + burstObservatoryJSON, _ := json.Marshal(map[string]any{ + "subjectSelector": []string{outboundTag}, + "pingConfig": map[string]any{ + "destination": probeURL, + "interval": "1s", + "connectivity": "", + "timeout": "5s", + "samplingCount": 1, + }, + }) + + metricsJSON, _ := json.Marshal(map[string]any{ + "tag": "test-metrics", + "listen": fmt.Sprintf("127.0.0.1:%d", metricsPort), }) - // Disable logging for test process to avoid creating orphaned log files logConfig := map[string]any{ "loglevel": "warning", "access": "none", @@ -504,107 +494,92 @@ func (s *OutboundService) createTestConfig(outboundTag string, allOutbounds []an } logJSON, _ := json.Marshal(logConfig) - // Create minimal config cfg := &xray.Config{ - LogConfig: json_util.RawMessage(logJSON), - InboundConfigs: []xray.InboundConfig{ - testInbound, - }, - OutboundConfigs: json_util.RawMessage(string(outboundsJSON)), - RouterConfig: json_util.RawMessage(string(routingJSON)), - Policy: json_util.RawMessage(`{}`), - Stats: json_util.RawMessage(`{}`), + 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 } -// 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 &TestOutboundResult{Mode: "http", Success: false, Error: fmt.Sprintf("Invalid proxy URL: %v", err)}, nil +// 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"` +} + +// pollObservatoryResult repeatedly reads /debug/vars and returns as soon +// as the target outbound reports alive=true. burstObservatory updates the +// snapshot after each ping (interval=1s, timeout=5s), so a healthy +// outbound usually surfaces within ~2s and the timeout caps the wait for +// truly dead ones. +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) } - 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, - }, + msg := "Probe timed out — outbound did not become reachable" + if sawEntry && lastEntry.LastTryTime > 0 { + msg = fmt.Sprintf("All probes failed (last attempt %ds ago)", 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 } } - - warmup := mkClient() - warmupResp, err := warmup.Get(testURL) - if err != nil { - 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() - - 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 &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() - - 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 + return observatoryEntry{}, false } // waitForPort polls until the given TCP port is accepting connections or the timeout expires.