probe_http_test.go 13 KB

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