diff options
| author | surbiks <43953720+surbiks@users.noreply.github.com> | 2026-02-09 23:43:17 +0300 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2026-02-09 23:43:17 +0300 |
| commit | 4779939424eb047d30161631fd89a9876104084c (patch) | |
| tree | 89e2682aa528281879b3afb535f2b34124a17335 /web/service/outbound.go | |
| parent | 4a455aa5322e0803005da2d5d65b85a19dfc42e5 (diff) | |
Add url speed test for outbound (#3767)
* add outbound testing functionality with configurable test URL
* use no kernel tun for conflict errors
Diffstat (limited to 'web/service/outbound.go')
| -rw-r--r-- | web/service/outbound.go | 322 |
1 files changed, 322 insertions, 0 deletions
diff --git a/web/service/outbound.go b/web/service/outbound.go index 530d12eb..c55999b3 100644 --- a/web/service/outbound.go +++ b/web/service/outbound.go @@ -1,9 +1,23 @@ package service import ( + "crypto/tls" + "encoding/json" + "fmt" + "io" + "net" + "net/http" + "net/url" + "os" + "sync" + "time" + + "github.com/mhsanaei/3x-ui/v2/config" "github.com/mhsanaei/3x-ui/v2/database" "github.com/mhsanaei/3x-ui/v2/database/model" "github.com/mhsanaei/3x-ui/v2/logger" + "github.com/mhsanaei/3x-ui/v2/util/common" + "github.com/mhsanaei/3x-ui/v2/util/json_util" "github.com/mhsanaei/3x-ui/v2/xray" "gorm.io/gorm" @@ -13,6 +27,9 @@ import ( // It handles outbound traffic monitoring and statistics. type OutboundService struct{} +// testSemaphore limits concurrent outbound tests to prevent resource exhaustion. +var testSemaphore sync.Mutex + func (s *OutboundService) AddTraffic(traffics []*xray.Traffic, clientTraffics []*xray.ClientTraffic) (error, bool) { var err error db := database.GetDB() @@ -100,3 +117,308 @@ func (s *OutboundService) ResetOutboundTraffic(tag string) error { return nil } + +// TestOutboundResult represents the result of testing an outbound +type TestOutboundResult struct { + Success bool `json:"success"` + Delay int64 `json:"delay"` // Delay in milliseconds + Error string `json:"error,omitempty"` + StatusCode int `json:"statusCode,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) { + if testURL == "" { + testURL = "https://www.google.com/generate_204" + } + + // Limit to one concurrent test at a time + if !testSemaphore.TryLock() { + return &TestOutboundResult{ + Success: false, + Error: "Another outbound test is already running, please wait", + }, nil + } + defer testSemaphore.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 + } + outboundTag, _ := testOutbound["tag"].(string) + if outboundTag == "" { + return &TestOutboundResult{ + 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 + } + + // 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 + } + } + 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 + } + + // 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 + } + defer os.Remove(testConfigPath) // ensure temp file is removed even if process is not stopped + + // Create temporary xray process with its own config file + testProcess := xray.NewTestProcess(testConfig, testConfigPath) + defer func() { + if testProcess.IsRunning() { + testProcess.Stop() + } + }() + + // 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 + } + + // 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{ + 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 + } + + // 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 +} + +// 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 + 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" { + // 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, + } + } + } + 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, + }) + + // Disable logging for test process to avoid creating orphaned log files + logConfig := map[string]any{ + "loglevel": "warning", + "access": "none", + "error": "none", + "dnsLog": false, + } + 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(`{}`), + } + + 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) + if err != nil { + return 0, 0, common.NewErrorf("Invalid proxy URL: %v", err) + } + + // 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, + TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, + MaxIdleConns: 1, + IdleConnTimeout: 10 * 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) + if err != nil { + return 0, 0, common.NewErrorf("Request failed: %v", err) + } + io.Copy(io.Discard, warmupResp.Body) + warmupResp.Body.Close() + + // Measure the actual request on the warm connection + startTime := time.Now() + resp, err := client.Get(testURL) + delay := time.Since(startTime).Milliseconds() + + if err != nil { + return 0, 0, common.NewErrorf("Request failed: %v", err) + } + io.Copy(io.Discard, resp.Body) + resp.Body.Close() + + return delay, resp.StatusCode, nil +} + +// 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 +} |
