outbound.go 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649
  1. package service
  2. import (
  3. "context"
  4. "crypto/tls"
  5. "encoding/json"
  6. "fmt"
  7. "io"
  8. "net"
  9. "net/http"
  10. "net/http/httptrace"
  11. "net/url"
  12. "os"
  13. "strconv"
  14. "sync"
  15. "time"
  16. "github.com/mhsanaei/3x-ui/v3/config"
  17. "github.com/mhsanaei/3x-ui/v3/database"
  18. "github.com/mhsanaei/3x-ui/v3/database/model"
  19. "github.com/mhsanaei/3x-ui/v3/logger"
  20. "github.com/mhsanaei/3x-ui/v3/util/json_util"
  21. "github.com/mhsanaei/3x-ui/v3/xray"
  22. "gorm.io/gorm"
  23. )
  24. // OutboundService provides business logic for managing Xray outbound configurations.
  25. // It handles outbound traffic monitoring and statistics.
  26. type OutboundService struct{}
  27. // httpTestSemaphore serialises HTTP-mode probes (each one spawns a temp xray
  28. // instance, which is too expensive to run in parallel). TCP-mode probes are
  29. // dial-only and don't need the semaphore.
  30. var httpTestSemaphore sync.Mutex
  31. func (s *OutboundService) AddTraffic(traffics []*xray.Traffic, clientTraffics []*xray.ClientTraffic) (error, bool) {
  32. var err error
  33. db := database.GetDB()
  34. tx := db.Begin()
  35. defer func() {
  36. if err != nil {
  37. tx.Rollback()
  38. } else {
  39. tx.Commit()
  40. }
  41. }()
  42. err = s.addOutboundTraffic(tx, traffics)
  43. if err != nil {
  44. return err, false
  45. }
  46. return nil, false
  47. }
  48. func (s *OutboundService) addOutboundTraffic(tx *gorm.DB, traffics []*xray.Traffic) error {
  49. if len(traffics) == 0 {
  50. return nil
  51. }
  52. var err error
  53. for _, traffic := range traffics {
  54. if traffic.IsOutbound {
  55. var outbound model.OutboundTraffics
  56. err = tx.Model(&model.OutboundTraffics{}).Where("tag = ?", traffic.Tag).
  57. FirstOrCreate(&outbound).Error
  58. if err != nil {
  59. return err
  60. }
  61. outbound.Tag = traffic.Tag
  62. outbound.Up = outbound.Up + traffic.Up
  63. outbound.Down = outbound.Down + traffic.Down
  64. outbound.Total = outbound.Up + outbound.Down
  65. err = tx.Save(&outbound).Error
  66. if err != nil {
  67. return err
  68. }
  69. }
  70. }
  71. return nil
  72. }
  73. func (s *OutboundService) GetOutboundsTraffic() ([]*model.OutboundTraffics, error) {
  74. db := database.GetDB()
  75. var traffics []*model.OutboundTraffics
  76. err := db.Model(model.OutboundTraffics{}).Find(&traffics).Error
  77. if err != nil {
  78. logger.Warning("Error retrieving OutboundTraffics: ", err)
  79. return nil, err
  80. }
  81. return traffics, nil
  82. }
  83. func (s *OutboundService) ResetOutboundTraffic(tag string) error {
  84. db := database.GetDB()
  85. whereText := "tag "
  86. if tag == "-alltags-" {
  87. whereText += " <> ?"
  88. } else {
  89. whereText += " = ?"
  90. }
  91. result := db.Model(model.OutboundTraffics{}).
  92. Where(whereText, tag).
  93. Updates(map[string]any{"up": 0, "down": 0, "total": 0})
  94. err := result.Error
  95. if err != nil {
  96. return err
  97. }
  98. return nil
  99. }
  100. // TestOutboundResult represents the result of testing an outbound.
  101. // Delay/timing fields are in milliseconds. Endpoints is only populated for
  102. // TCP-mode probes; the HTTP-mode timing breakdown lives in DNSMs/ConnectMs/
  103. // TLSMs/TTFBMs (any of these can be 0 if the underlying step was skipped —
  104. // e.g. a non-TLS target leaves TLSMs at 0).
  105. type TestOutboundResult struct {
  106. Success bool `json:"success"`
  107. Delay int64 `json:"delay"`
  108. Error string `json:"error,omitempty"`
  109. StatusCode int `json:"statusCode,omitempty"`
  110. Mode string `json:"mode,omitempty"`
  111. DNSMs int64 `json:"dnsMs,omitempty"`
  112. ConnectMs int64 `json:"connectMs,omitempty"`
  113. TLSMs int64 `json:"tlsMs,omitempty"`
  114. TTFBMs int64 `json:"ttfbMs,omitempty"`
  115. Endpoints []TestEndpointResult `json:"endpoints,omitempty"`
  116. }
  117. // TestEndpointResult is one entry in a TCP-mode probe — the per-endpoint
  118. // dial outcome for outbounds that expose multiple servers/peers.
  119. type TestEndpointResult struct {
  120. Address string `json:"address"`
  121. Success bool `json:"success"`
  122. Delay int64 `json:"delay"`
  123. Error string `json:"error,omitempty"`
  124. }
  125. // TestOutbound dispatches to the chosen probe mode:
  126. // - mode="tcp": dial the outbound's host:port directly. No xray spin-up,
  127. // parallel-safe, ~100ms per endpoint. Doesn't validate the proxy
  128. // protocol — only that the remote is reachable on TCP.
  129. // - mode="" or "http": spin a temp xray instance, route a real HTTP
  130. // request through it, return delay + a DNS/Connect/TLS/TTFB breakdown.
  131. // Authoritative but expensive and serialised by httpTestSemaphore.
  132. //
  133. // allOutboundsJSON is only consulted in HTTP mode (it backs
  134. // sockopt.dialerProxy chains during test).
  135. func (s *OutboundService) TestOutbound(outboundJSON string, testURL string, allOutboundsJSON string, mode string) (*TestOutboundResult, error) {
  136. if mode == "tcp" {
  137. return s.testOutboundTCP(outboundJSON)
  138. }
  139. return s.testOutboundHTTP(outboundJSON, testURL, allOutboundsJSON)
  140. }
  141. func (s *OutboundService) testOutboundTCP(outboundJSON string) (*TestOutboundResult, error) {
  142. var ob map[string]any
  143. if err := json.Unmarshal([]byte(outboundJSON), &ob); err != nil {
  144. return &TestOutboundResult{Mode: "tcp", Success: false, Error: fmt.Sprintf("Invalid outbound JSON: %v", err)}, nil
  145. }
  146. tag, _ := ob["tag"].(string)
  147. protocol, _ := ob["protocol"].(string)
  148. if protocol == "blackhole" || protocol == "freedom" || tag == "blocked" {
  149. return &TestOutboundResult{Mode: "tcp", Success: false, Error: "Outbound has no testable endpoint"}, nil
  150. }
  151. endpoints := extractOutboundEndpoints(ob)
  152. if len(endpoints) == 0 {
  153. return &TestOutboundResult{Mode: "tcp", Success: false, Error: "No testable endpoint"}, nil
  154. }
  155. results := make([]TestEndpointResult, len(endpoints))
  156. var wg sync.WaitGroup
  157. for i := range endpoints {
  158. wg.Add(1)
  159. go func(i int) {
  160. defer wg.Done()
  161. results[i] = probeEndpoint(endpoints[i], 5*time.Second)
  162. }(i)
  163. }
  164. wg.Wait()
  165. var bestDelay int64 = -1
  166. var firstErr string
  167. for _, r := range results {
  168. if r.Success {
  169. if bestDelay < 0 || r.Delay < bestDelay {
  170. bestDelay = r.Delay
  171. }
  172. } else if firstErr == "" {
  173. firstErr = r.Error
  174. }
  175. }
  176. mode := "tcp"
  177. if endpoints[0].Network == "udp" {
  178. mode = "udp"
  179. }
  180. out := &TestOutboundResult{Mode: mode, Endpoints: results}
  181. if bestDelay >= 0 {
  182. out.Success = true
  183. out.Delay = bestDelay
  184. } else {
  185. out.Error = firstErr
  186. if out.Error == "" {
  187. out.Error = "All endpoints unreachable"
  188. }
  189. }
  190. return out, nil
  191. }
  192. // outboundEndpoint is a host:port plus the transport its proxy actually
  193. // listens on. WireGuard (and WARP, which is WireGuard) is UDP-only, so a
  194. // TCP dial to its peer endpoint always times out — the probe must match
  195. // the transport of the outbound being tested.
  196. type outboundEndpoint struct {
  197. Address string
  198. Network string
  199. }
  200. func probeEndpoint(ep outboundEndpoint, timeout time.Duration) TestEndpointResult {
  201. if ep.Network == "udp" {
  202. return probeUDPEndpoint(ep.Address, timeout)
  203. }
  204. return probeTCPEndpoint(ep.Address, timeout)
  205. }
  206. func probeTCPEndpoint(endpoint string, timeout time.Duration) TestEndpointResult {
  207. r := TestEndpointResult{Address: endpoint}
  208. start := time.Now()
  209. conn, err := net.DialTimeout("tcp", endpoint, timeout)
  210. r.Delay = time.Since(start).Milliseconds()
  211. if err != nil {
  212. r.Error = err.Error()
  213. return r
  214. }
  215. conn.Close()
  216. r.Success = true
  217. return r
  218. }
  219. // probeUDPEndpoint sends a single byte and waits briefly for a reply or
  220. // an ICMP-driven error. WireGuard won't answer an unauthenticated byte,
  221. // so a read timeout is the normal "endpoint reachable" outcome; a
  222. // concrete error (e.g. ECONNREFUSED, "host unreachable") fails the probe.
  223. func probeUDPEndpoint(endpoint string, timeout time.Duration) TestEndpointResult {
  224. r := TestEndpointResult{Address: endpoint}
  225. start := time.Now()
  226. conn, err := net.DialTimeout("udp", endpoint, timeout)
  227. if err != nil {
  228. r.Delay = time.Since(start).Milliseconds()
  229. r.Error = err.Error()
  230. return r
  231. }
  232. defer conn.Close()
  233. if _, werr := conn.Write([]byte{0}); werr != nil {
  234. r.Delay = time.Since(start).Milliseconds()
  235. r.Error = werr.Error()
  236. return r
  237. }
  238. _ = conn.SetReadDeadline(time.Now().Add(timeout))
  239. buf := make([]byte, 64)
  240. _, rerr := conn.Read(buf)
  241. r.Delay = time.Since(start).Milliseconds()
  242. if rerr != nil {
  243. if nerr, ok := rerr.(net.Error); ok && nerr.Timeout() {
  244. r.Success = true
  245. return r
  246. }
  247. r.Error = rerr.Error()
  248. return r
  249. }
  250. r.Success = true
  251. return r
  252. }
  253. func extractOutboundEndpoints(ob map[string]any) []outboundEndpoint {
  254. protocol, _ := ob["protocol"].(string)
  255. settings, _ := ob["settings"].(map[string]any)
  256. if settings == nil {
  257. return nil
  258. }
  259. // Hysteria (and hysteria2 over trojan) is QUIC/UDP. Detect it via the
  260. // outer protocol or via streamSettings.network so trojan-with-hysteria2
  261. // transport gets probed over UDP too. kcp and quic are also UDP-based.
  262. network := "tcp"
  263. if protocol == "hysteria" || protocol == "wireguard" {
  264. network = "udp"
  265. }
  266. if stream, ok := ob["streamSettings"].(map[string]any); ok {
  267. if n, _ := stream["network"].(string); n == "hysteria" || n == "kcp" || n == "quic" {
  268. network = "udp"
  269. }
  270. }
  271. var out []outboundEndpoint
  272. addServer := func(addr any, port any) {
  273. host, _ := addr.(string)
  274. p := numAsInt(port)
  275. if host != "" && p > 0 {
  276. out = append(out, outboundEndpoint{Address: fmt.Sprintf("%s:%d", host, p), Network: network})
  277. }
  278. }
  279. switch protocol {
  280. case "vmess":
  281. if vnext, ok := settings["vnext"].([]any); ok {
  282. for _, v := range vnext {
  283. if vm, ok := v.(map[string]any); ok {
  284. addServer(vm["address"], vm["port"])
  285. }
  286. }
  287. }
  288. case "vless":
  289. addServer(settings["address"], settings["port"])
  290. case "hysteria":
  291. addServer(settings["address"], settings["port"])
  292. case "trojan", "shadowsocks", "http", "socks":
  293. if servers, ok := settings["servers"].([]any); ok {
  294. for _, sv := range servers {
  295. if sm, ok := sv.(map[string]any); ok {
  296. addServer(sm["address"], sm["port"])
  297. }
  298. }
  299. }
  300. case "wireguard":
  301. if peers, ok := settings["peers"].([]any); ok {
  302. for _, p := range peers {
  303. if pm, ok := p.(map[string]any); ok {
  304. if ep, _ := pm["endpoint"].(string); ep != "" {
  305. out = append(out, outboundEndpoint{Address: ep, Network: network})
  306. }
  307. }
  308. }
  309. }
  310. }
  311. return out
  312. }
  313. func numAsInt(v any) int {
  314. switch n := v.(type) {
  315. case float64:
  316. return int(n)
  317. case int:
  318. return n
  319. case int64:
  320. return int(n)
  321. case string:
  322. if i, err := strconv.Atoi(n); err == nil {
  323. return i
  324. }
  325. }
  326. return 0
  327. }
  328. func (s *OutboundService) testOutboundHTTP(outboundJSON string, testURL string, allOutboundsJSON string) (*TestOutboundResult, error) {
  329. if testURL == "" {
  330. testURL = "https://www.google.com/generate_204"
  331. }
  332. if !httpTestSemaphore.TryLock() {
  333. return &TestOutboundResult{
  334. Mode: "http",
  335. Success: false,
  336. Error: "Another outbound test is already running, please wait",
  337. }, nil
  338. }
  339. defer httpTestSemaphore.Unlock()
  340. var testOutbound map[string]any
  341. if err := json.Unmarshal([]byte(outboundJSON), &testOutbound); err != nil {
  342. return &TestOutboundResult{Mode: "http", Success: false, Error: fmt.Sprintf("Invalid outbound JSON: %v", err)}, nil
  343. }
  344. outboundTag, _ := testOutbound["tag"].(string)
  345. if outboundTag == "" {
  346. return &TestOutboundResult{Mode: "http", Success: false, Error: "Outbound has no tag"}, nil
  347. }
  348. if protocol, _ := testOutbound["protocol"].(string); protocol == "blackhole" || outboundTag == "blocked" {
  349. return &TestOutboundResult{Mode: "http", Success: false, Error: "Blocked/blackhole outbound cannot be tested"}, nil
  350. }
  351. var allOutbounds []any
  352. if allOutboundsJSON != "" {
  353. if err := json.Unmarshal([]byte(allOutboundsJSON), &allOutbounds); err != nil {
  354. return &TestOutboundResult{Mode: "http", Success: false, Error: fmt.Sprintf("Invalid allOutbounds JSON: %v", err)}, nil
  355. }
  356. }
  357. if len(allOutbounds) == 0 {
  358. allOutbounds = []any{testOutbound}
  359. }
  360. testPort, err := findAvailablePort()
  361. if err != nil {
  362. return &TestOutboundResult{Mode: "http", Success: false, Error: fmt.Sprintf("Failed to find available port: %v", err)}, nil
  363. }
  364. testConfig := s.createTestConfig(outboundTag, allOutbounds, testPort)
  365. testConfigPath, err := createTestConfigPath()
  366. if err != nil {
  367. return &TestOutboundResult{Mode: "http", Success: false, Error: fmt.Sprintf("Failed to create test config path: %v", err)}, nil
  368. }
  369. defer os.Remove(testConfigPath)
  370. testProcess := xray.NewTestProcess(testConfig, testConfigPath)
  371. defer func() {
  372. if testProcess.IsRunning() {
  373. testProcess.Stop()
  374. }
  375. }()
  376. if err := testProcess.Start(); err != nil {
  377. return &TestOutboundResult{Mode: "http", Success: false, Error: fmt.Sprintf("Failed to start test xray instance: %v", err)}, nil
  378. }
  379. if err := waitForPort(testPort, 3*time.Second); err != nil {
  380. if !testProcess.IsRunning() {
  381. result := testProcess.GetResult()
  382. return &TestOutboundResult{Mode: "http", Success: false, Error: fmt.Sprintf("Xray process exited: %s", result)}, nil
  383. }
  384. return &TestOutboundResult{Mode: "http", Success: false, Error: fmt.Sprintf("Xray failed to start listening: %v", err)}, nil
  385. }
  386. if !testProcess.IsRunning() {
  387. result := testProcess.GetResult()
  388. return &TestOutboundResult{Mode: "http", Success: false, Error: fmt.Sprintf("Xray process exited: %s", result)}, nil
  389. }
  390. return s.testConnection(testPort, testURL)
  391. }
  392. // createTestConfig creates a test config by copying all outbounds unchanged and adding
  393. // only the test inbound (SOCKS) and a route rule that sends traffic to the given outbound tag.
  394. func (s *OutboundService) createTestConfig(outboundTag string, allOutbounds []any, testPort int) *xray.Config {
  395. // Test inbound (SOCKS proxy) - only addition to inbounds
  396. testInbound := xray.InboundConfig{
  397. Tag: "test-inbound",
  398. Listen: json_util.RawMessage(`"127.0.0.1"`),
  399. Port: testPort,
  400. Protocol: "socks",
  401. Settings: json_util.RawMessage(`{"auth":"noauth","udp":true}`),
  402. }
  403. // Outbounds: copy all, but set noKernelTun=true for WireGuard outbounds
  404. processedOutbounds := make([]any, len(allOutbounds))
  405. for i, ob := range allOutbounds {
  406. outbound, ok := ob.(map[string]any)
  407. if !ok {
  408. processedOutbounds[i] = ob
  409. continue
  410. }
  411. if protocol, ok := outbound["protocol"].(string); ok && protocol == "wireguard" {
  412. // Set noKernelTun to true for WireGuard outbounds
  413. if settings, ok := outbound["settings"].(map[string]any); ok {
  414. settings["noKernelTun"] = true
  415. } else {
  416. // Create settings if it doesn't exist
  417. outbound["settings"] = map[string]any{
  418. "noKernelTun": true,
  419. }
  420. }
  421. }
  422. processedOutbounds[i] = outbound
  423. }
  424. outboundsJSON, _ := json.Marshal(processedOutbounds)
  425. // Create routing rule to route all traffic through test outbound
  426. routingRules := []map[string]any{
  427. {
  428. "type": "field",
  429. "outboundTag": outboundTag,
  430. "network": "tcp,udp",
  431. },
  432. }
  433. routingJSON, _ := json.Marshal(map[string]any{
  434. "domainStrategy": "AsIs",
  435. "rules": routingRules,
  436. })
  437. // Disable logging for test process to avoid creating orphaned log files
  438. logConfig := map[string]any{
  439. "loglevel": "warning",
  440. "access": "none",
  441. "error": "none",
  442. "dnsLog": false,
  443. }
  444. logJSON, _ := json.Marshal(logConfig)
  445. // Create minimal config
  446. cfg := &xray.Config{
  447. LogConfig: json_util.RawMessage(logJSON),
  448. InboundConfigs: []xray.InboundConfig{
  449. testInbound,
  450. },
  451. OutboundConfigs: json_util.RawMessage(string(outboundsJSON)),
  452. RouterConfig: json_util.RawMessage(string(routingJSON)),
  453. Policy: json_util.RawMessage(`{}`),
  454. Stats: json_util.RawMessage(`{}`),
  455. }
  456. return cfg
  457. }
  458. // testConnection runs the actual HTTP probe through the local SOCKS proxy.
  459. // A warmup request seeds xray's DNS cache / handshake; then a fresh
  460. // transport runs the measured request so httptrace sees a real cold
  461. // connection and reports DNS/Connect/TLS/TTFB. Note that DNS and Connect
  462. // reflect *client → SOCKS-on-loopback*, not the remote target — those
  463. // happen inside xray and aren't visible to net/http. TLS and TTFB are
  464. // the meaningful breakdown values for a SOCKS-proxied HTTPS probe.
  465. func (s *OutboundService) testConnection(proxyPort int, testURL string) (*TestOutboundResult, error) {
  466. proxyURLStr := fmt.Sprintf("socks5://127.0.0.1:%d", proxyPort)
  467. proxyURLParsed, err := url.Parse(proxyURLStr)
  468. if err != nil {
  469. return &TestOutboundResult{Mode: "http", Success: false, Error: fmt.Sprintf("Invalid proxy URL: %v", err)}, nil
  470. }
  471. mkClient := func() *http.Client {
  472. return &http.Client{
  473. Timeout: 10 * time.Second,
  474. Transport: &http.Transport{
  475. Proxy: http.ProxyURL(proxyURLParsed),
  476. DialContext: (&net.Dialer{
  477. Timeout: 5 * time.Second,
  478. KeepAlive: 30 * time.Second,
  479. }).DialContext,
  480. MaxIdleConns: 1,
  481. IdleConnTimeout: 1 * time.Second,
  482. DisableCompression: true,
  483. },
  484. }
  485. }
  486. warmup := mkClient()
  487. warmupResp, err := warmup.Get(testURL)
  488. if err != nil {
  489. return &TestOutboundResult{Mode: "http", Success: false, Error: fmt.Sprintf("Request failed: %v", err)}, nil
  490. }
  491. io.Copy(io.Discard, warmupResp.Body)
  492. warmupResp.Body.Close()
  493. warmup.CloseIdleConnections()
  494. var dnsStart, dnsDone, connectStart, connectDone, tlsStart, tlsDone, firstByte time.Time
  495. trace := &httptrace.ClientTrace{
  496. DNSStart: func(_ httptrace.DNSStartInfo) { dnsStart = time.Now() },
  497. DNSDone: func(_ httptrace.DNSDoneInfo) { dnsDone = time.Now() },
  498. ConnectStart: func(_, _ string) { connectStart = time.Now() },
  499. ConnectDone: func(_, _ string, _ error) { connectDone = time.Now() },
  500. TLSHandshakeStart: func() { tlsStart = time.Now() },
  501. TLSHandshakeDone: func(_ tls.ConnectionState, _ error) { tlsDone = time.Now() },
  502. GotFirstResponseByte: func() { firstByte = time.Now() },
  503. }
  504. client := mkClient()
  505. defer client.CloseIdleConnections()
  506. ctx := httptrace.WithClientTrace(context.Background(), trace)
  507. req, err := http.NewRequestWithContext(ctx, http.MethodGet, testURL, nil)
  508. if err != nil {
  509. return &TestOutboundResult{Mode: "http", Success: false, Error: fmt.Sprintf("Request build failed: %v", err)}, nil
  510. }
  511. startTime := time.Now()
  512. resp, err := client.Do(req)
  513. delay := time.Since(startTime).Milliseconds()
  514. if err != nil {
  515. return &TestOutboundResult{Mode: "http", Success: false, Error: fmt.Sprintf("Request failed: %v", err)}, nil
  516. }
  517. io.Copy(io.Discard, resp.Body)
  518. resp.Body.Close()
  519. out := &TestOutboundResult{
  520. Mode: "http",
  521. Success: true,
  522. Delay: delay,
  523. StatusCode: resp.StatusCode,
  524. }
  525. if !dnsStart.IsZero() && !dnsDone.IsZero() {
  526. out.DNSMs = dnsDone.Sub(dnsStart).Milliseconds()
  527. }
  528. if !connectStart.IsZero() && !connectDone.IsZero() {
  529. out.ConnectMs = connectDone.Sub(connectStart).Milliseconds()
  530. }
  531. if !tlsStart.IsZero() && !tlsDone.IsZero() {
  532. out.TLSMs = tlsDone.Sub(tlsStart).Milliseconds()
  533. }
  534. if !firstByte.IsZero() {
  535. out.TTFBMs = firstByte.Sub(startTime).Milliseconds()
  536. }
  537. return out, nil
  538. }
  539. // waitForPort polls until the given TCP port is accepting connections or the timeout expires.
  540. func waitForPort(port int, timeout time.Duration) error {
  541. deadline := time.Now().Add(timeout)
  542. for time.Now().Before(deadline) {
  543. conn, err := net.DialTimeout("tcp", fmt.Sprintf("127.0.0.1:%d", port), 100*time.Millisecond)
  544. if err == nil {
  545. conn.Close()
  546. return nil
  547. }
  548. time.Sleep(50 * time.Millisecond)
  549. }
  550. return fmt.Errorf("port %d not ready after %v", port, timeout)
  551. }
  552. // findAvailablePort finds an available port for testing
  553. func findAvailablePort() (int, error) {
  554. listener, err := net.Listen("tcp", ":0")
  555. if err != nil {
  556. return 0, err
  557. }
  558. defer listener.Close()
  559. addr := listener.Addr().(*net.TCPAddr)
  560. return addr.Port, nil
  561. }
  562. // createTestConfigPath returns a unique path for a temporary xray config file in the bin folder.
  563. // The temp file is created and closed so the path is reserved; Start() will overwrite it.
  564. func createTestConfigPath() (string, error) {
  565. tmpFile, err := os.CreateTemp(config.GetBinFolderPath(), "xray_test_*.json")
  566. if err != nil {
  567. return "", err
  568. }
  569. path := tmpFile.Name()
  570. if err := tmpFile.Close(); err != nil {
  571. os.Remove(path)
  572. return "", err
  573. }
  574. return path, nil
  575. }