probe_http_test.go 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489
  1. package outbound
  2. import (
  3. "encoding/json"
  4. "errors"
  5. "fmt"
  6. "io"
  7. "io/fs"
  8. "net"
  9. "net/http"
  10. "net/http/httptest"
  11. "strconv"
  12. "strings"
  13. "sync"
  14. "testing"
  15. "time"
  16. "github.com/mhsanaei/3x-ui/v3/internal/xray"
  17. )
  18. // stubProcess implements batchProcess without an xray binary. When serveSocks
  19. // is set, Start opens a minimal SOCKS5 server on every inbound port from the
  20. // config, so probes run against a real tunnel.
  21. type stubProcess struct {
  22. cfg *xray.Config
  23. startErr error
  24. result string
  25. serveSocks bool
  26. running bool
  27. listeners []net.Listener
  28. }
  29. func (p *stubProcess) Start() error {
  30. if p.startErr != nil {
  31. return p.startErr
  32. }
  33. for _, in := range p.cfg.InboundConfigs {
  34. l, err := net.Listen("tcp", fmt.Sprintf("127.0.0.1:%d", in.Port))
  35. if err != nil {
  36. return err
  37. }
  38. p.listeners = append(p.listeners, l)
  39. if p.serveSocks {
  40. go serveStubSocks(l)
  41. }
  42. }
  43. p.running = true
  44. return nil
  45. }
  46. func (p *stubProcess) Stop() error {
  47. for _, l := range p.listeners {
  48. l.Close()
  49. }
  50. p.running = false
  51. return nil
  52. }
  53. func (p *stubProcess) IsRunning() bool { return p.running }
  54. func (p *stubProcess) GetResult() string {
  55. if p.result != "" {
  56. return p.result
  57. }
  58. return "stub exited"
  59. }
  60. // serveStubSocks answers SOCKS5 no-auth CONNECTs and pipes to the requested
  61. // target — just enough protocol for net/http's socks5 client.
  62. func serveStubSocks(l net.Listener) {
  63. for {
  64. conn, err := l.Accept()
  65. if err != nil {
  66. return
  67. }
  68. go func(c net.Conn) {
  69. defer c.Close()
  70. hello := make([]byte, 2)
  71. if _, err := io.ReadFull(c, hello); err != nil {
  72. return
  73. }
  74. methods := make([]byte, hello[1])
  75. if _, err := io.ReadFull(c, methods); err != nil {
  76. return
  77. }
  78. c.Write([]byte{0x05, 0x00})
  79. hdr := make([]byte, 4)
  80. if _, err := io.ReadFull(c, hdr); err != nil {
  81. return
  82. }
  83. var host string
  84. switch hdr[3] {
  85. case 0x01:
  86. b := make([]byte, 4)
  87. io.ReadFull(c, b)
  88. host = net.IP(b).String()
  89. case 0x03:
  90. lb := make([]byte, 1)
  91. io.ReadFull(c, lb)
  92. b := make([]byte, lb[0])
  93. io.ReadFull(c, b)
  94. host = string(b)
  95. case 0x04:
  96. b := make([]byte, 16)
  97. io.ReadFull(c, b)
  98. host = net.IP(b).String()
  99. default:
  100. return
  101. }
  102. pb := make([]byte, 2)
  103. if _, err := io.ReadFull(c, pb); err != nil {
  104. return
  105. }
  106. port := int(pb[0])<<8 | int(pb[1])
  107. upstream, err := net.Dial("tcp", net.JoinHostPort(host, strconv.Itoa(port)))
  108. if err != nil {
  109. c.Write([]byte{0x05, 0x05, 0x00, 0x01, 0, 0, 0, 0, 0, 0})
  110. return
  111. }
  112. defer upstream.Close()
  113. c.Write([]byte{0x05, 0x00, 0x00, 0x01, 0, 0, 0, 0, 0, 0})
  114. go io.Copy(upstream, c)
  115. io.Copy(c, upstream)
  116. }(conn)
  117. }
  118. }
  119. func withStubProcess(t *testing.T, factory func(cfg *xray.Config, configPath string) batchProcess) {
  120. t.Helper()
  121. // createTestConfigPath writes into the bin folder, which doesn't exist
  122. // when running tests from the package directory.
  123. t.Setenv("XUI_BIN_FOLDER", t.TempDir())
  124. orig := newBatchProcess
  125. newBatchProcess = factory
  126. t.Cleanup(func() { newBatchProcess = orig })
  127. }
  128. func mustJSON(t *testing.T, v any) string {
  129. t.Helper()
  130. b, err := json.Marshal(v)
  131. if err != nil {
  132. t.Fatalf("marshal: %v", err)
  133. }
  134. return string(b)
  135. }
  136. func TestBuildBatchTestConfig(t *testing.T) {
  137. items := []*httpBatchItem{
  138. {tag: "wg-sub", outbound: map[string]any{"tag": "wg-sub", "protocol": "wireguard"}},
  139. {tag: "proxy-a", outbound: map[string]any{"tag": "proxy-a", "protocol": "vless"}},
  140. }
  141. allOutbounds := []any{
  142. map[string]any{"tag": "direct", "protocol": "freedom", "settings": map[string]any{}},
  143. map[string]any{"tag": "proxy-a", "protocol": "vless", "settings": map[string]any{"address": "a.example.com"}},
  144. }
  145. ports := []int{61001, 61002}
  146. cfg := buildBatchTestConfig(items, allOutbounds, ports)
  147. raw, err := json.Marshal(cfg)
  148. if err != nil {
  149. t.Fatalf("marshal config: %v", err)
  150. }
  151. var m map[string]any
  152. if err := json.Unmarshal(raw, &m); err != nil {
  153. t.Fatalf("unmarshal config: %v", err)
  154. }
  155. inbounds, _ := m["inbounds"].([]any)
  156. if len(inbounds) != 2 {
  157. t.Fatalf("expected 2 inbounds, got %d", len(inbounds))
  158. }
  159. for i, raw := range inbounds {
  160. in := raw.(map[string]any)
  161. if got := in["tag"]; got != fmt.Sprintf("test-in-%d", i) {
  162. t.Errorf("inbound %d tag = %v", i, got)
  163. }
  164. if got := int(in["port"].(float64)); got != ports[i] {
  165. t.Errorf("inbound %d port = %d, want %d", i, got, ports[i])
  166. }
  167. if got := in["protocol"]; got != "socks" {
  168. t.Errorf("inbound %d protocol = %v", i, got)
  169. }
  170. if got := in["listen"]; got != "127.0.0.1" {
  171. t.Errorf("inbound %d listen = %v", i, got)
  172. }
  173. settings := in["settings"].(map[string]any)
  174. if settings["auth"] != "noauth" || settings["udp"] != false {
  175. t.Errorf("inbound %d settings = %v", i, settings)
  176. }
  177. }
  178. routing := m["routing"].(map[string]any)
  179. rules, _ := routing["rules"].([]any)
  180. if len(rules) != 2 {
  181. t.Fatalf("expected 2 routing rules, got %d", len(rules))
  182. }
  183. wantTags := []string{"wg-sub", "proxy-a"}
  184. for i, raw := range rules {
  185. rule := raw.(map[string]any)
  186. inTags := rule["inboundTag"].([]any)
  187. if len(inTags) != 1 || inTags[0] != fmt.Sprintf("test-in-%d", i) {
  188. t.Errorf("rule %d inboundTag = %v", i, inTags)
  189. }
  190. if rule["outboundTag"] != wantTags[i] {
  191. t.Errorf("rule %d outboundTag = %v, want %s", i, rule["outboundTag"], wantTags[i])
  192. }
  193. }
  194. outbounds, _ := m["outbounds"].([]any)
  195. if len(outbounds) != 3 {
  196. t.Fatalf("expected 3 outbounds (wg-sub appended once, proxy-a deduped), got %d", len(outbounds))
  197. }
  198. var wg map[string]any
  199. for _, raw := range outbounds {
  200. ob := raw.(map[string]any)
  201. if ob["tag"] == "wg-sub" {
  202. wg = ob
  203. }
  204. }
  205. if wg == nil {
  206. t.Fatal("wg-sub not appended to outbounds")
  207. }
  208. if settings, _ := wg["settings"].(map[string]any); settings == nil || settings["noKernelTun"] != true {
  209. t.Errorf("wireguard settings missing noKernelTun: %v", wg["settings"])
  210. }
  211. if m["burstObservatory"] != nil {
  212. t.Errorf("burstObservatory should not be set, got %v", m["burstObservatory"])
  213. }
  214. if m["metrics"] != nil {
  215. t.Errorf("metrics should not be set, got %v", m["metrics"])
  216. }
  217. }
  218. func TestTestOutboundsPrevalidationAndOrdering(t *testing.T) {
  219. calls := 0
  220. withStubProcess(t, func(cfg *xray.Config, configPath string) batchProcess {
  221. calls++
  222. return &stubProcess{cfg: cfg, startErr: errors.New("boom")}
  223. })
  224. batch := mustJSON(t, []any{
  225. map[string]any{"protocol": "vless"}, // no tag
  226. map[string]any{"tag": "bh", "protocol": "blackhole"}, // blackhole
  227. map[string]any{"tag": "loop", "protocol": "loopback"}, // loopback
  228. map[string]any{"tag": "a", "protocol": "socks"}, // valid
  229. map[string]any{"tag": "a", "protocol": "vless"}, // duplicate
  230. })
  231. results, err := (&OutboundService{}).TestOutbounds(batch, "http://example.invalid/gen", "", "http")
  232. if err != nil {
  233. t.Fatalf("TestOutbounds: %v", err)
  234. }
  235. if len(results) != 5 {
  236. t.Fatalf("expected 5 results, got %d", len(results))
  237. }
  238. wantErrs := []string{
  239. "Outbound has no tag",
  240. "Blocked/blackhole outbound cannot be tested",
  241. "Loopback outbound cannot be tested",
  242. "Failed to start test xray instance: boom",
  243. "Duplicate outbound tag in batch: a",
  244. }
  245. for i, want := range wantErrs {
  246. if results[i].Success {
  247. t.Errorf("result %d unexpectedly succeeded", i)
  248. }
  249. if results[i].Error != want {
  250. t.Errorf("result %d error = %q, want %q", i, results[i].Error, want)
  251. }
  252. }
  253. if results[3].Tag != "a" || results[4].Tag != "a" || results[1].Tag != "bh" {
  254. t.Errorf("tags not propagated: %+v", results)
  255. }
  256. // Single valid item → no per-item fallback round.
  257. if calls != 1 {
  258. t.Errorf("process spawned %d times, want 1", calls)
  259. }
  260. }
  261. func TestTestOutboundsFallbackOnStartFailure(t *testing.T) {
  262. calls := 0
  263. withStubProcess(t, func(cfg *xray.Config, configPath string) batchProcess {
  264. calls++
  265. return &stubProcess{cfg: cfg, startErr: errors.New("boom")}
  266. })
  267. batch := mustJSON(t, []any{
  268. map[string]any{"tag": "a", "protocol": "socks"},
  269. map[string]any{"tag": "b", "protocol": "vless"},
  270. })
  271. results, err := (&OutboundService{}).TestOutbounds(batch, "http://example.invalid/gen", "", "http")
  272. if err != nil {
  273. t.Fatalf("TestOutbounds: %v", err)
  274. }
  275. for i, r := range results {
  276. if r.Success || r.Error != "Failed to start test xray instance: boom" {
  277. t.Errorf("result %d = %+v, want start failure", i, r)
  278. }
  279. }
  280. // 1 shared attempt + 2 isolated fallback attempts.
  281. if calls != 3 {
  282. t.Errorf("process spawned %d times, want 3", calls)
  283. }
  284. }
  285. func TestTestOutboundsNoFallbackWhenBinaryMissing(t *testing.T) {
  286. calls := 0
  287. withStubProcess(t, func(cfg *xray.Config, configPath string) batchProcess {
  288. calls++
  289. return &stubProcess{cfg: cfg, startErr: &fs.PathError{Op: "exec", Path: "xray", Err: fs.ErrNotExist}}
  290. })
  291. batch := mustJSON(t, []any{
  292. map[string]any{"tag": "a", "protocol": "socks"},
  293. map[string]any{"tag": "b", "protocol": "vless"},
  294. })
  295. results, err := (&OutboundService{}).TestOutbounds(batch, "http://example.invalid/gen", "", "http")
  296. if err != nil {
  297. t.Fatalf("TestOutbounds: %v", err)
  298. }
  299. for i, r := range results {
  300. if r.Success || !strings.HasPrefix(r.Error, "Failed to start test xray instance:") {
  301. t.Errorf("result %d = %+v, want start failure", i, r)
  302. }
  303. }
  304. if calls != 1 {
  305. t.Errorf("process spawned %d times, want 1 (no fallback for missing binary)", calls)
  306. }
  307. }
  308. func TestTestOutboundsSemaphoreBusy(t *testing.T) {
  309. withStubProcess(t, func(cfg *xray.Config, configPath string) batchProcess {
  310. t.Fatal("process must not be spawned while semaphore is held")
  311. return nil
  312. })
  313. httpTestSemaphore.Lock()
  314. defer httpTestSemaphore.Unlock()
  315. batch := mustJSON(t, []any{map[string]any{"tag": "a", "protocol": "socks"}})
  316. results, err := (&OutboundService{}).TestOutbounds(batch, "", "", "http")
  317. if err != nil {
  318. t.Fatalf("TestOutbounds: %v", err)
  319. }
  320. if results[0].Success || results[0].Error != "Another outbound test is already running, please wait" {
  321. t.Errorf("result = %+v, want busy error", results[0])
  322. }
  323. }
  324. func TestTestOutboundsInputValidation(t *testing.T) {
  325. s := &OutboundService{}
  326. if _, err := s.TestOutbounds("not json", "", "", "tcp"); err == nil {
  327. t.Error("expected error for invalid JSON")
  328. }
  329. big := make([]any, maxBatchItems+1)
  330. for i := range big {
  331. big[i] = map[string]any{"tag": fmt.Sprintf("t%d", i), "protocol": "socks"}
  332. }
  333. if _, err := s.TestOutbounds(mustJSON(t, big), "", "", "tcp"); err == nil {
  334. t.Error("expected error for oversized batch")
  335. }
  336. results, err := s.TestOutbounds("[]", "", "", "tcp")
  337. if err != nil || len(results) != 0 {
  338. t.Errorf("empty batch: results=%v err=%v", results, err)
  339. }
  340. }
  341. func TestTestOutboundsTCPLane(t *testing.T) {
  342. l, err := net.Listen("tcp", "127.0.0.1:0")
  343. if err != nil {
  344. t.Fatalf("listen: %v", err)
  345. }
  346. defer l.Close()
  347. go func() {
  348. for {
  349. conn, err := l.Accept()
  350. if err != nil {
  351. return
  352. }
  353. conn.Close()
  354. }
  355. }()
  356. port := l.Addr().(*net.TCPAddr).Port
  357. batch := mustJSON(t, []any{map[string]any{
  358. "tag": "t1",
  359. "protocol": "socks",
  360. "settings": map[string]any{"servers": []any{map[string]any{"address": "127.0.0.1", "port": port}}},
  361. }})
  362. results, err := (&OutboundService{}).TestOutbounds(batch, "", "", "tcp")
  363. if err != nil {
  364. t.Fatalf("TestOutbounds: %v", err)
  365. }
  366. r := results[0]
  367. if !r.Success || r.Mode != "tcp" || r.Tag != "t1" || len(r.Endpoints) != 1 {
  368. t.Errorf("unexpected tcp result: %+v", r)
  369. }
  370. }
  371. func TestTestOutboundsHTTPBatchThroughStubSocks(t *testing.T) {
  372. var mu sync.Mutex
  373. requestsPerConn := make(map[string]int)
  374. srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
  375. mu.Lock()
  376. requestsPerConn[r.RemoteAddr]++
  377. mu.Unlock()
  378. w.WriteHeader(http.StatusNoContent)
  379. }))
  380. defer srv.Close()
  381. var proc *stubProcess
  382. calls := 0
  383. withStubProcess(t, func(cfg *xray.Config, configPath string) batchProcess {
  384. calls++
  385. proc = &stubProcess{cfg: cfg, serveSocks: true}
  386. return proc
  387. })
  388. batch := mustJSON(t, []any{
  389. map[string]any{"tag": "a", "protocol": "vless"},
  390. map[string]any{"tag": "b", "protocol": "trojan"},
  391. })
  392. results, err := (&OutboundService{}).TestOutbounds(batch, srv.URL, "", "http")
  393. if err != nil {
  394. t.Fatalf("TestOutbounds: %v", err)
  395. }
  396. if calls != 1 {
  397. t.Fatalf("process spawned %d times, want 1", calls)
  398. }
  399. for i, r := range results {
  400. if !r.Success {
  401. t.Fatalf("result %d failed: %+v", i, r)
  402. }
  403. if r.HTTPStatus != http.StatusNoContent {
  404. t.Errorf("result %d status = %d, want 204", i, r.HTTPStatus)
  405. }
  406. if r.Delay < 1 || r.ConnectMs < 1 || r.TTFBMs < 1 {
  407. t.Errorf("result %d timing not populated: %+v", i, r)
  408. }
  409. if r.TLSMs != 0 {
  410. t.Errorf("result %d TLSMs = %d, want 0 for plain http", i, r.TLSMs)
  411. }
  412. if r.Mode != "http" {
  413. t.Errorf("result %d mode = %q", i, r.Mode)
  414. }
  415. }
  416. if proc.IsRunning() {
  417. t.Error("temp process not stopped after batch")
  418. }
  419. mu.Lock()
  420. defer mu.Unlock()
  421. totalRequests := 0
  422. for addr, n := range requestsPerConn {
  423. totalRequests += n
  424. if n != 2 {
  425. t.Errorf("connection %s served %d requests, want 2 (warm delay request must reuse the cold request's connection)", addr, n)
  426. }
  427. }
  428. if totalRequests != 4 {
  429. t.Errorf("test URL served %d requests, want 4 (cold + warm per probe)", totalRequests)
  430. }
  431. }
  432. func TestProbeThroughSocksTransportFailure(t *testing.T) {
  433. // A listener that accepts and immediately closes — SOCKS handshake dies.
  434. l, err := net.Listen("tcp", "127.0.0.1:0")
  435. if err != nil {
  436. t.Fatalf("listen: %v", err)
  437. }
  438. defer l.Close()
  439. go func() {
  440. for {
  441. conn, err := l.Accept()
  442. if err != nil {
  443. return
  444. }
  445. conn.Close()
  446. }
  447. }()
  448. var result TestOutboundResult
  449. probeThroughSocks(l.Addr().(*net.TCPAddr).Port, "http://127.0.0.1:9/", 2*time.Second, &result)
  450. if result.Success || result.Error == "" {
  451. t.Errorf("expected transport failure, got %+v", result)
  452. }
  453. }