1
0

probe_http.go 20 KB

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