probe_http.go 24 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789
  1. package outbound
  2. import (
  3. "context"
  4. "crypto/tls"
  5. "encoding/json"
  6. "errors"
  7. "fmt"
  8. "io"
  9. "io/fs"
  10. "net"
  11. "net/http"
  12. "net/http/httptrace"
  13. "net/url"
  14. "os"
  15. "strconv"
  16. "strings"
  17. "sync"
  18. "time"
  19. "github.com/mhsanaei/3x-ui/v3/internal/amneziawgnet"
  20. "github.com/mhsanaei/3x-ui/v3/internal/config"
  21. "github.com/mhsanaei/3x-ui/v3/internal/util/json_util"
  22. "github.com/mhsanaei/3x-ui/v3/internal/xray"
  23. )
  24. // HTTP-mode probing works by spinning up ONE temporary xray instance per
  25. // batch: every outbound under test gets its own loopback SOCKS inbound plus
  26. // an inboundTag→outboundTag routing rule, and the panel then issues a real,
  27. // individually-timed HTTP request through each inbound. Measuring the request
  28. // client-side (instead of polling xray's observatory) returns the moment the
  29. // response lands, yields the actual HTTP status, and allows an httptrace
  30. // timing breakdown — while the shared process keeps "Test All" at one xray
  31. // spawn per batch instead of one per outbound. The reported delay comes from
  32. // a second request on the kept-alive connection, so it reflects the tunnel's
  33. // real per-request round-trip rather than the stacked SOCKS/proxy/TLS
  34. // handshakes of connection establishment. Mode "real" instead reports the
  35. // cold request's full elapsed time and skips the warm request.
  36. const (
  37. // httpProbeTimeout bounds each probe request end-to-end (a probe makes
  38. // two: a cold one for the breakdown, a warm one for the delay).
  39. httpProbeTimeout = 10 * time.Second
  40. // probeDrainLimit caps how much response body a probe reads back to keep
  41. // the connection reusable for the warm request.
  42. probeDrainLimit = 256 << 10
  43. // httpProbeConcurrency caps parallel probe requests within a batch —
  44. // enough to keep a batch fast, low enough not to spike CPU with TLS
  45. // handshakes on small VPSes.
  46. httpProbeConcurrency = 16
  47. // batchPortsReadyTimeout bounds the wait for the temp instance to open
  48. // its test inbounds.
  49. batchPortsReadyTimeout = 10 * time.Second
  50. // maxBatchItems caps one batch request; the frontend chunks below this.
  51. maxBatchItems = 50
  52. // tcpBatchConcurrency caps parallel TCP-mode items in a batch (each item
  53. // already dials its endpoints concurrently).
  54. tcpBatchConcurrency = 8
  55. // egressTraceTimeout keeps diagnostic trace metadata from extending a
  56. // successful HTTP probe by the full reachability timeout.
  57. egressTraceTimeout = 3 * time.Second
  58. defaultTestURL = "https://www.google.com/generate_204"
  59. egressTraceHost = "cloudflare.com"
  60. egressTracePath = "/cdn-cgi/trace"
  61. )
  62. // httpTestSemaphore serialises HTTP-mode batches (each spawns a temp xray
  63. // instance, which is too expensive to run in parallel). TCP-mode probes are
  64. // dial-only and don't need the semaphore.
  65. var httpTestSemaphore sync.Mutex
  66. // batchProcess is the slice of xray.Process the batch engine needs; a seam
  67. // so unit tests can stub the process without an xray binary.
  68. type batchProcess interface {
  69. Start() error
  70. Stop() error
  71. IsRunning() bool
  72. GetResult() string
  73. }
  74. var newBatchProcess = func(cfg *xray.Config, configPath string) batchProcess {
  75. return xray.NewTestProcess(cfg, configPath)
  76. }
  77. var egressTraceProbe = probeEgressTrace
  78. // httpBatchItem is one outbound inside an HTTP-mode batch. result is the
  79. // pre-allocated entry in the caller's result slice, filled in place.
  80. type httpBatchItem struct {
  81. index int
  82. tag string
  83. outbound map[string]any
  84. result *TestOutboundResult
  85. }
  86. func probeModeLabel(mode string) string {
  87. switch mode {
  88. case "tcp", "real":
  89. return mode
  90. default:
  91. return "http"
  92. }
  93. }
  94. // TestOutbound probes a single outbound; legacy single-test API kept for the
  95. // /testOutbound endpoint. Dispatch matches TestOutbounds: mode "tcp" dials
  96. // the outbound's endpoints directly, anything else routes a real HTTP request
  97. // through a temp xray instance (UDP-transport outbounds are always forced to
  98. // the HTTP probe — a raw dial can't measure them).
  99. func (s *OutboundService) TestOutbound(outboundJSON string, testURL string, allOutboundsJSON string, mode string) (*TestOutboundResult, error) {
  100. var ob map[string]any
  101. if err := json.Unmarshal([]byte(outboundJSON), &ob); err != nil {
  102. return &TestOutboundResult{Mode: probeModeLabel(mode), Success: false, Error: fmt.Sprintf("Invalid outbound JSON: %v", err)}, nil
  103. }
  104. results := s.testOutboundsParsed([]map[string]any{ob}, testURL, allOutboundsJSON, mode)
  105. return results[0], nil
  106. }
  107. // TestOutbounds probes a JSON array of outbounds and returns one result per
  108. // input, in input order, each carrying the outbound's tag. allOutboundsJSON
  109. // supplies the config context (sockopt.dialerProxy chains); testURL falls
  110. // back to the default probe URL when empty.
  111. func (s *OutboundService) TestOutbounds(outboundsJSON string, testURL string, allOutboundsJSON string, mode string) ([]*TestOutboundResult, error) {
  112. var raw []json.RawMessage
  113. if err := json.Unmarshal([]byte(outboundsJSON), &raw); err != nil {
  114. return nil, fmt.Errorf("invalid outbounds JSON: %w", err)
  115. }
  116. if len(raw) > maxBatchItems {
  117. return nil, fmt.Errorf("too many outbounds in one request (max %d)", maxBatchItems)
  118. }
  119. items := make([]map[string]any, len(raw))
  120. for i, r := range raw {
  121. var ob map[string]any
  122. if err := json.Unmarshal(r, &ob); err == nil {
  123. items[i] = ob
  124. }
  125. }
  126. return s.testOutboundsParsed(items, testURL, allOutboundsJSON, mode), nil
  127. }
  128. // testOutboundsParsed splits items into the TCP lane (direct dials, bounded
  129. // worker pool) and the HTTP lane (one shared temp xray instance), runs both,
  130. // and returns results aligned with items. A nil item marks unparseable input.
  131. func (s *OutboundService) testOutboundsParsed(items []map[string]any, testURL string, allOutboundsJSON string, mode string) []*TestOutboundResult {
  132. results := make([]*TestOutboundResult, len(items))
  133. modeLabel := probeModeLabel(mode)
  134. probeLabel := modeLabel
  135. if probeLabel == "tcp" {
  136. probeLabel = "http"
  137. }
  138. realDelay := mode == "real"
  139. type tcpEntry struct {
  140. idx int
  141. ob map[string]any
  142. }
  143. var tcpLane []tcpEntry
  144. var httpItems []*httpBatchItem
  145. seenTags := make(map[string]bool)
  146. for i, ob := range items {
  147. if ob == nil {
  148. results[i] = &TestOutboundResult{Mode: modeLabel, Success: false, Error: "Invalid outbound JSON"}
  149. continue
  150. }
  151. // A bare TCP dial only proves reachability for TCP-based proxies.
  152. // UDP protocols (wireguard, hysteria, kcp/quic transports) ignore
  153. // unauthenticated packets, so a raw dial can't tell "reachable" from
  154. // "dead" — route them through the real xray probe.
  155. if mode == "tcp" && !outboundTransportIsUDP(ob) {
  156. tcpLane = append(tcpLane, tcpEntry{idx: i, ob: ob})
  157. continue
  158. }
  159. tag, _ := ob["tag"].(string)
  160. r := &TestOutboundResult{Tag: tag, Mode: probeLabel}
  161. results[i] = r
  162. protocol, _ := ob["protocol"].(string)
  163. // The core lowercases the id before it resolves the handler.
  164. protocol = strings.ToLower(protocol)
  165. switch {
  166. case tag == "":
  167. r.Error = "Outbound has no tag"
  168. case protocol == "blackhole" || tag == "blocked":
  169. r.Error = "Blocked/blackhole outbound cannot be tested"
  170. case protocol == "loopback":
  171. r.Error = "Loopback outbound cannot be tested"
  172. case protocol == "freedom" || protocol == "dns":
  173. // Direct/DNS outbounds aren't proxies — an HTTP probe through them
  174. // would only measure the host's own reachability, not a tunnel.
  175. r.Error = "Direct/DNS outbound cannot be tested"
  176. case seenTags[tag]:
  177. r.Error = fmt.Sprintf("Duplicate outbound tag in batch: %s", tag)
  178. default:
  179. seenTags[tag] = true
  180. httpItems = append(httpItems, &httpBatchItem{index: i, tag: tag, outbound: ob, result: r})
  181. }
  182. }
  183. if len(tcpLane) > 0 {
  184. var wg sync.WaitGroup
  185. sem := make(chan struct{}, tcpBatchConcurrency)
  186. for _, e := range tcpLane {
  187. wg.Add(1)
  188. go func(e tcpEntry) {
  189. defer wg.Done()
  190. sem <- struct{}{}
  191. defer func() { <-sem }()
  192. obJSON, err := json.Marshal(e.ob)
  193. if err != nil {
  194. tag, _ := e.ob["tag"].(string)
  195. results[e.idx] = &TestOutboundResult{Tag: tag, Mode: "tcp", Success: false, Error: fmt.Sprintf("Invalid outbound JSON: %v", err)}
  196. return
  197. }
  198. r, _ := s.testOutboundTCP(string(obJSON))
  199. results[e.idx] = r
  200. }(e)
  201. }
  202. wg.Wait()
  203. }
  204. if len(httpItems) == 0 {
  205. return results
  206. }
  207. failAll := func(msg string) {
  208. for _, it := range httpItems {
  209. it.result.Success = false
  210. it.result.Error = msg
  211. }
  212. }
  213. var allOutbounds []any
  214. if allOutboundsJSON != "" {
  215. if err := json.Unmarshal([]byte(allOutboundsJSON), &allOutbounds); err != nil {
  216. failAll(fmt.Sprintf("Invalid allOutbounds JSON: %v", err))
  217. return results
  218. }
  219. }
  220. if testURL == "" {
  221. testURL = defaultTestURL
  222. }
  223. if !httpTestSemaphore.TryLock() {
  224. failAll("Another outbound test is already running, please wait")
  225. return results
  226. }
  227. defer httpTestSemaphore.Unlock()
  228. retryPerItem, err := runHTTPProbeBatch(httpItems, allOutbounds, testURL, realDelay)
  229. if err == nil {
  230. return results
  231. }
  232. if !retryPerItem || len(httpItems) == 1 {
  233. failAll(err.Error())
  234. return results
  235. }
  236. // The shared process never came up — one structurally-bad outbound can
  237. // poison the whole batch config. Retry each item in its own isolated
  238. // instance so the broken outbound reports xray's real error and the
  239. // rest still get tested. Serial: the poisoned case fails fast (~1s).
  240. for _, it := range httpItems {
  241. if _, ferr := runHTTPProbeBatch([]*httpBatchItem{it}, allOutbounds, testURL, realDelay); ferr != nil {
  242. it.result.Success = false
  243. it.result.Error = ferr.Error()
  244. }
  245. }
  246. return results
  247. }
  248. // runHTTPProbeBatch makes one shared-process attempt for the given items,
  249. // writing per-request outcomes into the items' results. It returns a non-nil
  250. // error only when the process never became usable; retryPerItem reports
  251. // whether splitting the batch into per-item instances could help (true for
  252. // start failures / early exits that a poisoned config would explain, false
  253. // for environmental failures like a missing binary or no free ports).
  254. func runHTTPProbeBatch(items []*httpBatchItem, allOutbounds []any, testURL string, realDelay bool) (retryPerItem bool, err error) {
  255. ports, release, err := reserveLoopbackPorts(len(items))
  256. if err != nil {
  257. return false, fmt.Errorf("Failed to reserve test ports: %w", err)
  258. }
  259. defer release()
  260. cfg := buildBatchTestConfig(items, allOutbounds, ports)
  261. configPath, err := createTestConfigPath()
  262. if err != nil {
  263. return false, fmt.Errorf("Failed to create test config path: %w", err)
  264. }
  265. defer os.Remove(configPath)
  266. proc := newBatchProcess(cfg, configPath)
  267. defer func() {
  268. if proc.IsRunning() {
  269. _ = proc.Stop()
  270. }
  271. }()
  272. // Free the reserved ports just before xray binds them; the window is
  273. // milliseconds, and a lost race makes xray exit fast, which surfaces
  274. // below and triggers the per-item retry with fresh ports.
  275. release()
  276. if err := proc.Start(); err != nil {
  277. if errors.Is(err, fs.ErrNotExist) {
  278. // Binary missing — per-item retries would all fail the same way.
  279. return false, fmt.Errorf("Failed to start test xray instance: %w", err)
  280. }
  281. return true, fmt.Errorf("Failed to start test xray instance: %w", err)
  282. }
  283. if err := waitForPortsReady(proc, ports, batchPortsReadyTimeout); err != nil {
  284. return err.exited, err
  285. }
  286. sem := make(chan struct{}, httpProbeConcurrency)
  287. var wg sync.WaitGroup
  288. for i := range items {
  289. wg.Add(1)
  290. go func(it *httpBatchItem, port int) {
  291. defer wg.Done()
  292. sem <- struct{}{}
  293. defer func() { <-sem }()
  294. probeThroughSocks(port, testURL, httpProbeTimeout, realDelay, it.result)
  295. }(items[i], ports[i])
  296. }
  297. wg.Wait()
  298. if !proc.IsRunning() {
  299. detail := proc.GetResult()
  300. for _, it := range items {
  301. if !it.result.Success {
  302. it.result.Error = "Xray process exited: " + detail
  303. }
  304. }
  305. }
  306. return false, nil
  307. }
  308. // portsReadyError distinguishes "process died" (a poisoned config — worth a
  309. // per-item retry) from "ports never opened while alive" (environmental).
  310. type portsReadyError struct {
  311. msg string
  312. exited bool
  313. }
  314. func (e *portsReadyError) Error() string { return e.msg }
  315. // waitForPortsReady polls until every test inbound accepts connections,
  316. // aborting as soon as the process exits.
  317. func waitForPortsReady(proc batchProcess, ports []int, timeout time.Duration) *portsReadyError {
  318. deadline := time.Now().Add(timeout)
  319. for _, port := range ports {
  320. for {
  321. if !proc.IsRunning() {
  322. return &portsReadyError{msg: "Xray process exited: " + proc.GetResult(), exited: true}
  323. }
  324. conn, err := (&net.Dialer{Timeout: 100 * time.Millisecond}).DialContext(context.Background(), "tcp", fmt.Sprintf("127.0.0.1:%d", port))
  325. if err == nil {
  326. conn.Close()
  327. break
  328. }
  329. if time.Now().After(deadline) {
  330. return &portsReadyError{msg: fmt.Sprintf("Xray failed to open test inbounds: port %d not ready after %v", port, timeout)}
  331. }
  332. time.Sleep(50 * time.Millisecond)
  333. }
  334. }
  335. return nil
  336. }
  337. // buildBatchTestConfig assembles the temp instance config: one loopback SOCKS
  338. // inbound per tested outbound, a routing rule binding each inbound to its
  339. // outbound tag, and the full outbound context so dialerProxy chains resolve.
  340. func buildBatchTestConfig(items []*httpBatchItem, allOutbounds []any, ports []int) *xray.Config {
  341. // allOutbounds is the template's outbound list; subscription outbounds
  342. // are injected at runtime and aren't part of it, so append any tested
  343. // outbound whose tag is missing. When a tested outbound's tag collides
  344. // with a template outbound, the template version wins — same semantics
  345. // as the pre-batch tester.
  346. outbounds := make([]any, 0, len(allOutbounds)+len(items))
  347. outbounds = append(outbounds, allOutbounds...)
  348. for _, it := range items {
  349. if !outboundsContainTag(outbounds, it.tag) {
  350. outbounds = append(outbounds, it.outbound)
  351. }
  352. }
  353. // Bridge amneziawg entries like GetXrayConfig does -- one raw entry fails
  354. // the whole temp config; drop unbridgeable ones, not unrelated items.
  355. bridged := make([]any, 0, len(outbounds))
  356. for _, ob := range outbounds {
  357. m, ok := ob.(map[string]any)
  358. if !ok {
  359. bridged = append(bridged, ob)
  360. continue
  361. }
  362. if p, _ := m["protocol"].(string); !strings.EqualFold(p, "amneziawg") {
  363. bridged = append(bridged, ob)
  364. continue
  365. }
  366. raw, err := json.Marshal(m)
  367. if err != nil {
  368. continue
  369. }
  370. repl, ok := amneziawgnet.BuildSocksBridge(raw)
  371. if !ok {
  372. continue
  373. }
  374. var replacement any
  375. if json.Unmarshal(repl, &replacement) == nil {
  376. bridged = append(bridged, replacement)
  377. }
  378. }
  379. outbounds = bridged
  380. for _, ob := range outbounds {
  381. outbound, ok := ob.(map[string]any)
  382. if !ok {
  383. continue
  384. }
  385. // The temp instance must not touch kernel WireGuard devices.
  386. if protocol, ok := outbound["protocol"].(string); ok && strings.EqualFold(protocol, "wireguard") {
  387. if settings, ok := outbound["settings"].(map[string]any); ok {
  388. settings["noKernelTun"] = true
  389. } else {
  390. outbound["settings"] = map[string]any{"noKernelTun": true}
  391. }
  392. }
  393. }
  394. outboundsJSON, _ := json.Marshal(outbounds)
  395. inbounds := make([]xray.InboundConfig, len(items))
  396. rules := make([]any, len(items))
  397. for i, it := range items {
  398. inTag := fmt.Sprintf("test-in-%d", i)
  399. inbounds[i] = xray.InboundConfig{
  400. Listen: json_util.RawMessage(`"127.0.0.1"`),
  401. Port: ports[i],
  402. Protocol: "socks",
  403. Settings: json_util.RawMessage(`{"auth":"noauth","udp":false}`),
  404. Tag: inTag,
  405. }
  406. rules[i] = map[string]any{
  407. "type": "field",
  408. "inboundTag": []string{inTag},
  409. "outboundTag": it.tag,
  410. }
  411. }
  412. routingJSON, _ := json.Marshal(map[string]any{
  413. "domainStrategy": "AsIs",
  414. "rules": rules,
  415. })
  416. logJSON, _ := json.Marshal(map[string]any{
  417. "loglevel": "warning",
  418. "access": "none",
  419. "error": "",
  420. "dnsLog": false,
  421. })
  422. return &xray.Config{
  423. LogConfig: json_util.RawMessage(logJSON),
  424. InboundConfigs: inbounds,
  425. OutboundConfigs: json_util.RawMessage(outboundsJSON),
  426. RouterConfig: json_util.RawMessage(routingJSON),
  427. Policy: json_util.RawMessage(`{}`),
  428. Stats: json_util.RawMessage(`{}`),
  429. }
  430. }
  431. // outboundsContainTag reports whether any outbound in the slice has the given tag.
  432. func outboundsContainTag(outbounds []any, tag string) bool {
  433. for _, ob := range outbounds {
  434. if m, ok := ob.(map[string]any); ok {
  435. if t, _ := m["tag"].(string); t == tag {
  436. return true
  437. }
  438. }
  439. }
  440. return false
  441. }
  442. // probeThroughSocks probes the local SOCKS inbound at the given port and
  443. // fills result. A first, cold GET proves reachability and carries the
  444. // httptrace breakdown: any HTTP response — including 4xx/5xx and unfollowed
  445. // redirects — counts as reachable; only transport-level failures (refused,
  446. // reset, timeout, proxy errors) are failures. Delay is then re-measured on a
  447. // warm request over the kept-alive connection — the real round-trip through
  448. // the established tunnel — falling back to the cold total if the warm request
  449. // fails. The test URL's hostname is resolved by xray (Go's SOCKS5 client
  450. // sends the domain to the proxy), so DNS goes through the outbound too.
  451. func probeThroughSocks(port int, testURL string, timeout time.Duration, realDelay bool, result *TestOutboundResult) {
  452. proxyURL := &url.URL{Scheme: "socks5", Host: net.JoinHostPort("127.0.0.1", strconv.Itoa(port))}
  453. tr := &http.Transport{
  454. Proxy: http.ProxyURL(proxyURL),
  455. MaxIdleConns: 1,
  456. MaxIdleConnsPerHost: 1,
  457. IdleConnTimeout: timeout,
  458. }
  459. defer tr.CloseIdleConnections()
  460. client := &http.Client{
  461. Transport: tr,
  462. Timeout: timeout,
  463. // A redirect would re-dial through the proxy and skew the timing;
  464. // the 3xx itself already proves the outbound works.
  465. CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse },
  466. }
  467. // Timing breakdown. ConnectStart/Done wrap the TCP dial to the local
  468. // inbound (the SOCKS handshake isn't traced, and xray ACKs CONNECT
  469. // before dialing upstream — so the real outbound establishment lands in
  470. // the TLS phase for https URLs, or inside TTFB for plain http).
  471. var (
  472. connStart, tlsStart time.Time
  473. connDur, tlsDur, ttfbDur time.Duration
  474. connDone, tlsDone, gotFirstRB bool
  475. )
  476. start := time.Now()
  477. trace := &httptrace.ClientTrace{
  478. ConnectStart: func(network, addr string) {
  479. if connStart.IsZero() {
  480. connStart = time.Now()
  481. }
  482. },
  483. ConnectDone: func(network, addr string, err error) {
  484. if err == nil && !connDone && !connStart.IsZero() {
  485. connDone = true
  486. connDur = time.Since(connStart)
  487. }
  488. },
  489. TLSHandshakeStart: func() {
  490. if tlsStart.IsZero() {
  491. tlsStart = time.Now()
  492. }
  493. },
  494. TLSHandshakeDone: func(_ tls.ConnectionState, err error) {
  495. if err == nil && !tlsDone && !tlsStart.IsZero() {
  496. tlsDone = true
  497. tlsDur = time.Since(tlsStart)
  498. }
  499. },
  500. GotFirstResponseByte: func() {
  501. if !gotFirstRB {
  502. gotFirstRB = true
  503. ttfbDur = time.Since(start)
  504. }
  505. },
  506. }
  507. req, err := http.NewRequestWithContext(httptrace.WithClientTrace(context.Background(), trace), http.MethodGet, testURL, nil)
  508. if err != nil {
  509. result.Error = err.Error()
  510. return
  511. }
  512. resp, err := client.Do(req)
  513. coldDelay := time.Since(start).Milliseconds()
  514. if err != nil {
  515. result.Error = err.Error()
  516. return
  517. }
  518. drainAndClose(resp)
  519. result.Success = true
  520. result.HTTPStatus = resp.StatusCode
  521. if connDone {
  522. result.ConnectMs = max(connDur.Milliseconds(), 1)
  523. }
  524. if tlsDone {
  525. result.TLSMs = max(tlsDur.Milliseconds(), 1)
  526. }
  527. if gotFirstRB {
  528. result.TTFBMs = max(ttfbDur.Milliseconds(), 1)
  529. }
  530. delay := coldDelay
  531. if !realDelay {
  532. if warmDelay, ok := timedWarmGet(client, testURL); ok {
  533. delay = warmDelay
  534. }
  535. }
  536. result.Delay = max(delay, 1)
  537. if !realDelay {
  538. result.Egress = egressTraceProbe(proxyURL)
  539. }
  540. }
  541. // probeEgressTrace fetches Cloudflare's plain-text trace endpoint through the
  542. // same SOCKS route used by the HTTP probe. It asks one IPv4 and one IPv6
  543. // Cloudflare address directly, when available, while keeping the TLS SNI as
  544. // cloudflare.com. Failures are intentionally ignored by the caller: egress
  545. // metadata is diagnostic, not reachability.
  546. func probeEgressTrace(proxyURL *url.URL) *TestEgressResult {
  547. ipv4, ipv6 := cloudflareTraceTargets()
  548. if ipv4 == nil && ipv6 == nil {
  549. return nil
  550. }
  551. tr := &http.Transport{
  552. Proxy: http.ProxyURL(proxyURL),
  553. TLSClientConfig: &tls.Config{
  554. ServerName: egressTraceHost,
  555. },
  556. }
  557. defer tr.CloseIdleConnections()
  558. client := &http.Client{
  559. Transport: tr,
  560. Timeout: egressTraceTimeout,
  561. }
  562. egress := &TestEgressResult{}
  563. targets := make([]net.IP, 0, 2)
  564. if ipv4 != nil {
  565. targets = append(targets, ipv4)
  566. }
  567. if ipv6 != nil {
  568. targets = append(targets, ipv6)
  569. }
  570. results := make(chan map[string]string, len(targets))
  571. for _, target := range targets {
  572. go func(ip net.IP) {
  573. results <- fetchCloudflareTrace(client, ip)
  574. }(target)
  575. }
  576. for range targets {
  577. applyEgressTrace(egress, <-results)
  578. }
  579. if egress.IPv4 == "" && egress.IPv6 == "" && egress.Country == "" && egress.Warp == "" {
  580. return nil
  581. }
  582. return egress
  583. }
  584. func cloudflareTraceTargets() (net.IP, net.IP) {
  585. ctx, cancel := context.WithTimeout(context.Background(), egressTraceTimeout)
  586. defer cancel()
  587. addrs, err := net.DefaultResolver.LookupIPAddr(ctx, egressTraceHost)
  588. if err != nil {
  589. return nil, nil
  590. }
  591. var ipv4, ipv6 net.IP
  592. for _, addr := range addrs {
  593. ip := addr.IP
  594. if ipv4 == nil {
  595. if v4 := ip.To4(); v4 != nil {
  596. ipv4 = v4
  597. continue
  598. }
  599. }
  600. if ipv6 == nil && ip.To4() == nil && ip.To16() != nil {
  601. ipv6 = ip
  602. }
  603. if ipv4 != nil && ipv6 != nil {
  604. break
  605. }
  606. }
  607. return ipv4, ipv6
  608. }
  609. func fetchCloudflareTrace(client *http.Client, ip net.IP) map[string]string {
  610. if ip == nil {
  611. return nil
  612. }
  613. traceURL := (&url.URL{
  614. Scheme: "https",
  615. Host: net.JoinHostPort(ip.String(), "443"),
  616. Path: egressTracePath,
  617. }).String()
  618. req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, traceURL, nil)
  619. if err != nil {
  620. return nil
  621. }
  622. req.Host = egressTraceHost
  623. resp, err := client.Do(req)
  624. if err != nil {
  625. return nil
  626. }
  627. defer resp.Body.Close()
  628. if resp.StatusCode < 200 || resp.StatusCode >= 300 {
  629. return nil
  630. }
  631. body, err := io.ReadAll(io.LimitReader(resp.Body, 16<<10))
  632. if err != nil {
  633. return nil
  634. }
  635. return parseCloudflareTrace(string(body))
  636. }
  637. func applyEgressTrace(egress *TestEgressResult, values map[string]string) {
  638. if len(values) == 0 {
  639. return
  640. }
  641. if ip := net.ParseIP(values["ip"]); ip != nil {
  642. if ip.To4() != nil {
  643. if egress.IPv4 == "" {
  644. egress.IPv4 = values["ip"]
  645. }
  646. } else if egress.IPv6 == "" {
  647. egress.IPv6 = values["ip"]
  648. }
  649. }
  650. if egress.Country == "" {
  651. egress.Country = values["loc"]
  652. }
  653. if values["warp"] == "on" || egress.Warp == "" {
  654. egress.Warp = values["warp"]
  655. }
  656. }
  657. func parseCloudflareTrace(body string) map[string]string {
  658. values := make(map[string]string)
  659. for line := range strings.SplitSeq(body, "\n") {
  660. line = strings.TrimSpace(line)
  661. if line == "" {
  662. continue
  663. }
  664. key, value, ok := strings.Cut(line, "=")
  665. if !ok {
  666. continue
  667. }
  668. values[strings.TrimSpace(key)] = strings.TrimSpace(value)
  669. }
  670. return values
  671. }
  672. // timedWarmGet re-issues the probe request over the transport's kept-alive
  673. // connection and returns its duration — the tunnel's per-request round-trip.
  674. func timedWarmGet(client *http.Client, testURL string) (int64, bool) {
  675. req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, testURL, nil)
  676. if err != nil {
  677. return 0, false
  678. }
  679. start := time.Now()
  680. resp, err := client.Do(req)
  681. delay := time.Since(start).Milliseconds()
  682. if err != nil {
  683. return 0, false
  684. }
  685. drainAndClose(resp)
  686. return delay, true
  687. }
  688. // drainAndClose consumes the body (bounded by probeDrainLimit) so the
  689. // connection returns to the keep-alive pool for the warm request.
  690. func drainAndClose(resp *http.Response) {
  691. _, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, probeDrainLimit))
  692. resp.Body.Close()
  693. }
  694. // reserveLoopbackPorts grabs n free loopback ports and keeps the listeners
  695. // open so nothing else claims them; release() frees them (idempotent — the
  696. // caller releases right before starting xray and again via defer).
  697. func reserveLoopbackPorts(n int) ([]int, func(), error) {
  698. listeners := make([]net.Listener, 0, n)
  699. release := func() {
  700. for _, l := range listeners {
  701. l.Close()
  702. }
  703. }
  704. ports := make([]int, 0, n)
  705. for range n {
  706. l, err := (&net.ListenConfig{}).Listen(context.Background(), "tcp", "127.0.0.1:0")
  707. if err != nil {
  708. release()
  709. return nil, nil, err
  710. }
  711. listeners = append(listeners, l)
  712. ports = append(ports, l.Addr().(*net.TCPAddr).Port)
  713. }
  714. return ports, release, nil
  715. }
  716. // createTestConfigPath returns a unique path for a temporary xray config file in the bin folder.
  717. // The temp file is created and closed so the path is reserved; Start() will overwrite it.
  718. func createTestConfigPath() (string, error) {
  719. tmpFile, err := os.CreateTemp(config.GetBinFolderPath(), "xray_test_*.json")
  720. if err != nil {
  721. return "", err
  722. }
  723. path := tmpFile.Name()
  724. if err := tmpFile.Close(); err != nil {
  725. os.Remove(path)
  726. return "", err
  727. }
  728. return path, nil
  729. }