mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-07-24 13:36:24 +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:
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user