probe_http_test.go 17 KB

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