|
|
@@ -14,6 +14,7 @@ import (
|
|
|
"net/url"
|
|
|
"os"
|
|
|
"strconv"
|
|
|
+ "strings"
|
|
|
"sync"
|
|
|
"time"
|
|
|
|
|
|
@@ -54,8 +55,13 @@ const (
|
|
|
// tcpBatchConcurrency caps parallel TCP-mode items in a batch (each item
|
|
|
// already dials its endpoints concurrently).
|
|
|
tcpBatchConcurrency = 8
|
|
|
+ // egressTraceTimeout keeps diagnostic trace metadata from extending a
|
|
|
+ // successful HTTP probe by the full reachability timeout.
|
|
|
+ egressTraceTimeout = 3 * time.Second
|
|
|
|
|
|
- defaultTestURL = "https://www.google.com/generate_204"
|
|
|
+ defaultTestURL = "https://www.google.com/generate_204"
|
|
|
+ egressTraceHost = "cloudflare.com"
|
|
|
+ egressTracePath = "/cdn-cgi/trace"
|
|
|
)
|
|
|
|
|
|
// httpTestSemaphore serialises HTTP-mode batches (each spawns a temp xray
|
|
|
@@ -76,6 +82,8 @@ var newBatchProcess = func(cfg *xray.Config, configPath string) batchProcess {
|
|
|
return xray.NewTestProcess(cfg, configPath)
|
|
|
}
|
|
|
|
|
|
+var egressTraceProbe = probeEgressTrace
|
|
|
+
|
|
|
// httpBatchItem is one outbound inside an HTTP-mode batch. result is the
|
|
|
// pre-allocated entry in the caller's result slice, filled in place.
|
|
|
type httpBatchItem struct {
|
|
|
@@ -542,6 +550,150 @@ func probeThroughSocks(port int, testURL string, timeout time.Duration, realDela
|
|
|
}
|
|
|
}
|
|
|
result.Delay = max(delay, 1)
|
|
|
+ if !realDelay {
|
|
|
+ result.Egress = egressTraceProbe(proxyURL)
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+// probeEgressTrace fetches Cloudflare's plain-text trace endpoint through the
|
|
|
+// same SOCKS route used by the HTTP probe. It asks one IPv4 and one IPv6
|
|
|
+// Cloudflare address directly, when available, while keeping the TLS SNI as
|
|
|
+// cloudflare.com. Failures are intentionally ignored by the caller: egress
|
|
|
+// metadata is diagnostic, not reachability.
|
|
|
+func probeEgressTrace(proxyURL *url.URL) *TestEgressResult {
|
|
|
+ ipv4, ipv6 := cloudflareTraceTargets()
|
|
|
+ if ipv4 == nil && ipv6 == nil {
|
|
|
+ return nil
|
|
|
+ }
|
|
|
+
|
|
|
+ tr := &http.Transport{
|
|
|
+ Proxy: http.ProxyURL(proxyURL),
|
|
|
+ TLSClientConfig: &tls.Config{
|
|
|
+ ServerName: egressTraceHost,
|
|
|
+ },
|
|
|
+ }
|
|
|
+ defer tr.CloseIdleConnections()
|
|
|
+
|
|
|
+ client := &http.Client{
|
|
|
+ Transport: tr,
|
|
|
+ Timeout: egressTraceTimeout,
|
|
|
+ }
|
|
|
+
|
|
|
+ egress := &TestEgressResult{}
|
|
|
+ targets := make([]net.IP, 0, 2)
|
|
|
+ if ipv4 != nil {
|
|
|
+ targets = append(targets, ipv4)
|
|
|
+ }
|
|
|
+ if ipv6 != nil {
|
|
|
+ targets = append(targets, ipv6)
|
|
|
+ }
|
|
|
+ results := make(chan map[string]string, len(targets))
|
|
|
+ for _, target := range targets {
|
|
|
+ go func(ip net.IP) {
|
|
|
+ results <- fetchCloudflareTrace(client, ip)
|
|
|
+ }(target)
|
|
|
+ }
|
|
|
+ for range targets {
|
|
|
+ applyEgressTrace(egress, <-results)
|
|
|
+ }
|
|
|
+ if egress.IPv4 == "" && egress.IPv6 == "" && egress.Country == "" && egress.Warp == "" {
|
|
|
+ return nil
|
|
|
+ }
|
|
|
+
|
|
|
+ return egress
|
|
|
+}
|
|
|
+
|
|
|
+func cloudflareTraceTargets() (net.IP, net.IP) {
|
|
|
+ ctx, cancel := context.WithTimeout(context.Background(), egressTraceTimeout)
|
|
|
+ defer cancel()
|
|
|
+
|
|
|
+ addrs, err := net.DefaultResolver.LookupIPAddr(ctx, egressTraceHost)
|
|
|
+ if err != nil {
|
|
|
+ return nil, nil
|
|
|
+ }
|
|
|
+ var ipv4, ipv6 net.IP
|
|
|
+ for _, addr := range addrs {
|
|
|
+ ip := addr.IP
|
|
|
+ if ipv4 == nil {
|
|
|
+ if v4 := ip.To4(); v4 != nil {
|
|
|
+ ipv4 = v4
|
|
|
+ continue
|
|
|
+ }
|
|
|
+ }
|
|
|
+ if ipv6 == nil && ip.To4() == nil && ip.To16() != nil {
|
|
|
+ ipv6 = ip
|
|
|
+ }
|
|
|
+ if ipv4 != nil && ipv6 != nil {
|
|
|
+ break
|
|
|
+ }
|
|
|
+ }
|
|
|
+ return ipv4, ipv6
|
|
|
+}
|
|
|
+
|
|
|
+func fetchCloudflareTrace(client *http.Client, ip net.IP) map[string]string {
|
|
|
+ if ip == nil {
|
|
|
+ return nil
|
|
|
+ }
|
|
|
+ traceURL := (&url.URL{
|
|
|
+ Scheme: "https",
|
|
|
+ Host: net.JoinHostPort(ip.String(), "443"),
|
|
|
+ Path: egressTracePath,
|
|
|
+ }).String()
|
|
|
+ req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, traceURL, nil)
|
|
|
+ if err != nil {
|
|
|
+ return nil
|
|
|
+ }
|
|
|
+ req.Host = egressTraceHost
|
|
|
+ resp, err := client.Do(req)
|
|
|
+ if err != nil {
|
|
|
+ return nil
|
|
|
+ }
|
|
|
+ defer resp.Body.Close()
|
|
|
+ if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
|
|
+ return nil
|
|
|
+ }
|
|
|
+ body, err := io.ReadAll(io.LimitReader(resp.Body, 16<<10))
|
|
|
+ if err != nil {
|
|
|
+ return nil
|
|
|
+ }
|
|
|
+ return parseCloudflareTrace(string(body))
|
|
|
+}
|
|
|
+
|
|
|
+func applyEgressTrace(egress *TestEgressResult, values map[string]string) {
|
|
|
+ if len(values) == 0 {
|
|
|
+ return
|
|
|
+ }
|
|
|
+ if ip := net.ParseIP(values["ip"]); ip != nil {
|
|
|
+ if ip.To4() != nil {
|
|
|
+ if egress.IPv4 == "" {
|
|
|
+ egress.IPv4 = values["ip"]
|
|
|
+ }
|
|
|
+ } else if egress.IPv6 == "" {
|
|
|
+ egress.IPv6 = values["ip"]
|
|
|
+ }
|
|
|
+ }
|
|
|
+ if egress.Country == "" {
|
|
|
+ egress.Country = values["loc"]
|
|
|
+ }
|
|
|
+ if values["warp"] == "on" || egress.Warp == "" {
|
|
|
+ egress.Warp = values["warp"]
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+func parseCloudflareTrace(body string) map[string]string {
|
|
|
+ values := make(map[string]string)
|
|
|
+ for line := range strings.SplitSeq(body, "\n") {
|
|
|
+ line = strings.TrimSpace(line)
|
|
|
+ if line == "" {
|
|
|
+ continue
|
|
|
+ }
|
|
|
+ key, value, ok := strings.Cut(line, "=")
|
|
|
+ if !ok {
|
|
|
+ continue
|
|
|
+ }
|
|
|
+ values[strings.TrimSpace(key)] = strings.TrimSpace(value)
|
|
|
+ }
|
|
|
+ return values
|
|
|
}
|
|
|
|
|
|
// timedWarmGet re-issues the probe request over the transport's kept-alive
|