info_test.go 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489
  1. //go:build !windows
  2. package localapi
  3. import (
  4. "bufio"
  5. "context"
  6. "encoding/json"
  7. "errors"
  8. "flag"
  9. "fmt"
  10. "io"
  11. "net"
  12. "net/http"
  13. "os"
  14. "path/filepath"
  15. "strings"
  16. "testing"
  17. "time"
  18. "github.com/slackhq/nebula/commands"
  19. "github.com/slackhq/nebula/config"
  20. "github.com/slackhq/nebula/test"
  21. "github.com/stretchr/testify/assert"
  22. "github.com/stretchr/testify/require"
  23. "golang.org/x/sys/unix"
  24. )
  25. type whoamiFlags struct {
  26. Name string
  27. }
  28. // infoRegistry holds one identity command, whoami, whose -name selects an outcome, and
  29. // one command the info API must withhold.
  30. func infoRegistry() *commands.Registry {
  31. reg := commands.New()
  32. reg.Register(&commands.Command{
  33. Name: "whoami",
  34. ShortDescription: "reports who a name belongs to",
  35. Identity: true,
  36. Flags: func() (*flag.FlagSet, any) {
  37. fl := flag.NewFlagSet("", flag.ContinueOnError)
  38. s := &whoamiFlags{}
  39. fl.StringVar(&s.Name, "name", "", "the name to look up")
  40. return fl, s
  41. },
  42. Run: func(fs any, a []string) (commands.Result, error) {
  43. switch name := fs.(*whoamiFlags).Name; name {
  44. case "":
  45. return commands.Value(map[string]any{"name": "me", "groups": []string{}}), nil
  46. case "nobody":
  47. return commands.NotFound("No active tunnel for vpn addr: %s", name), nil
  48. case "bad":
  49. return commands.Failed("The provided vpn addr could not be parsed: %s", name), nil
  50. case "boom":
  51. panic("this command is broken")
  52. case "err":
  53. return commands.Result{}, errors.New("an internal detail")
  54. default:
  55. return commands.Value(map[string]any{"name": name}), nil
  56. }
  57. },
  58. })
  59. reg.Register(echoCommand())
  60. return reg
  61. }
  62. func serveInfo(t *testing.T, reg *commands.Registry, mode os.FileMode) (*InfoServer, string) {
  63. t.Helper()
  64. path := testSocket(t)
  65. s := NewInfoServer(test.NewLogger(), reg)
  66. require.NoError(t, s.Rebind(context.Background(), path, Access{Mode: mode}))
  67. t.Cleanup(s.Stop)
  68. return s, path
  69. }
  70. // httpDo sends one request over the socket at path and returns the status, body and
  71. // headers. The host in the URL is a placeholder: the dialer ignores it.
  72. func TestInfoServesIdentityCommands(t *testing.T) {
  73. _, path := serveInfo(t, infoRegistry(), 0600)
  74. status, body, hdr := test.HTTPDo(t, path, http.MethodGet, "/v1/whoami?name=alice")
  75. assert.Equal(t, http.StatusOK, status)
  76. assert.JSONEq(t, `{"name":"alice"}`, body)
  77. assert.Equal(t, "application/json", hdr.Get("Content-Type"))
  78. status, body = test.HTTPGet(t, path, "/v1/whoami")
  79. assert.Equal(t, http.StatusOK, status)
  80. assert.JSONEq(t, `{"name":"me","groups":[]}`, body, "a parameter left out is the command's default")
  81. }
  82. // TestInfoWithholdsTheRest checks the filter at the wire: a command that exists but is
  83. // not an identity command is refused with the reason, and a name nothing is registered
  84. // under is unknown, so a caller can tell a typo from a closed door.
  85. func TestInfoWithholdsTheRest(t *testing.T) {
  86. _, path := serveInfo(t, infoRegistry(), 0600)
  87. status, body := test.HTTPGet(t, path, "/v1/echo?x=1")
  88. assert.Equal(t, http.StatusForbidden, status)
  89. assert.Contains(t, body, "echo is not available on this surface")
  90. assert.Contains(t, body, "identity lookups only")
  91. status, body = test.HTTPGet(t, path, "/v1/nope")
  92. assert.Equal(t, http.StatusNotFound, status)
  93. assert.Contains(t, body, `unknown command \"nope\"`)
  94. for _, target := range []string{"/", "/v2/whoami", "/v1/", "/v1/whoami/extra", "//v1/whoami", "/v1/x/../whoami", "/v1/../v1/whoami"} {
  95. status, body = test.HTTPGet(t, path, target)
  96. assert.Equal(t, http.StatusNotFound, status, target)
  97. assert.Contains(t, body, `"error"`, "%s: misses are JSON too", target)
  98. }
  99. }
  100. func TestInfoStatusCodes(t *testing.T) {
  101. _, path := serveInfo(t, infoRegistry(), 0600)
  102. for _, tc := range []struct {
  103. target string
  104. status int
  105. body string
  106. }{
  107. {"/v1/whoami?name=nobody", http.StatusNotFound, `{"error":"No active tunnel for vpn addr: nobody"}`},
  108. {"/v1/whoami?name=bad", http.StatusBadRequest, `{"error":"The provided vpn addr could not be parsed: bad"}`},
  109. {"/v1/whoami?nope=1", http.StatusBadRequest, `{"error":"whoami has no parameter \"nope\""}`},
  110. {"/v1/whoami?name=a&name=b", http.StatusBadRequest, `{"error":"parameter \"name\" was given more than once"}`},
  111. {"/v1/whoami?pretty=maybe", http.StatusBadRequest, `{"error":"pretty: expected a boolean, got \"maybe\""}`},
  112. // Run failed: the caller hears that it did and no more; the detail is for the log.
  113. {"/v1/whoami?name=err", http.StatusInternalServerError, `{"error":"command failed"}`},
  114. } {
  115. status, body := test.HTTPGet(t, path, tc.target)
  116. assert.Equal(t, tc.status, status, tc.target)
  117. assert.JSONEq(t, tc.body, body, tc.target)
  118. }
  119. status, body, hdr := test.HTTPDo(t, path, http.MethodPost, "/v1/whoami?name=alice")
  120. assert.Equal(t, http.StatusMethodNotAllowed, status)
  121. assert.Equal(t, http.MethodGet, hdr.Get("Allow"))
  122. assert.Contains(t, body, "read only")
  123. status, _, _ = test.HTTPDo(t, path, http.MethodDelete, "/v1")
  124. assert.Equal(t, http.StatusMethodNotAllowed, status)
  125. }
  126. // TestInfoPanicDoesNotKillTheDaemon: a broken command costs a request, not the overlay.
  127. // This surface may be open to anyone on the host, so that holds against them too.
  128. func TestInfoPanicDoesNotKillTheDaemon(t *testing.T) {
  129. _, path := serveInfo(t, infoRegistry(), 0600)
  130. status, body := test.HTTPGet(t, path, "/v1/whoami?name=boom")
  131. assert.Equal(t, http.StatusInternalServerError, status)
  132. assert.JSONEq(t, `{"error":"command failed"}`, body)
  133. status, body = test.HTTPGet(t, path, "/v1/whoami?name=alice")
  134. assert.Equal(t, http.StatusOK, status)
  135. assert.JSONEq(t, `{"name":"alice"}`, body)
  136. }
  137. func TestInfoDescribe(t *testing.T) {
  138. _, path := serveInfo(t, infoRegistry(), 0600)
  139. status, body := test.HTTPGet(t, path, "/v1")
  140. require.Equal(t, http.StatusOK, status)
  141. var schema []commands.CommandSchema
  142. require.NoError(t, json.Unmarshal([]byte(body), &schema))
  143. var names []commands.Name
  144. flags := map[commands.Name][]string{}
  145. for _, c := range schema {
  146. names = append(names, c.Name)
  147. for _, f := range c.Flags {
  148. flags[c.Name] = append(flags[c.Name], f.Name)
  149. }
  150. }
  151. assert.ElementsMatch(t, []commands.Name{"help", "whoami"}, names, "only what is served is described")
  152. assert.Equal(t, []string{"name", commands.FlagJSON, commands.FlagPretty}, flags["whoami"])
  153. }
  154. // TestInfoPresentationFlags: the query string takes -json and -pretty as a command line
  155. // would. The answer is JSON either way; pretty indents it.
  156. func TestInfoPresentationFlags(t *testing.T) {
  157. _, path := serveInfo(t, infoRegistry(), 0600)
  158. _, compact := test.HTTPGet(t, path, "/v1/whoami?name=alice&json=true")
  159. //nolint:testifylint // the exact bytes, newline included, are what is under test
  160. assert.Equal(t, "{\"name\":\"alice\"}\n", compact)
  161. _, pretty := test.HTTPGet(t, path, "/v1/whoami?name=alice&pretty")
  162. //nolint:testifylint // the layout is what is under test
  163. assert.Equal(t, "{\n \"name\": \"alice\"\n}\n", pretty)
  164. _, off := test.HTTPGet(t, path, "/v1/whoami?name=alice&pretty=false")
  165. assert.Equal(t, compact, off)
  166. }
  167. // TestInfoSocketMode: the mode is the operator's, pinned regardless of the umask, and a
  168. // changed mode on a reload is applied in place rather than by rebinding.
  169. func TestInfoSocketMode(t *testing.T) {
  170. old := unix.Umask(0077)
  171. defer unix.Umask(old)
  172. s, path := serveInfo(t, infoRegistry(), 0660)
  173. fi, err := os.Stat(path)
  174. require.NoError(t, err)
  175. assert.Equal(t, os.FileMode(0660), fi.Mode().Perm())
  176. require.NoError(t, s.Rebind(context.Background(), path, Access{Mode: 0640}))
  177. fi, err = os.Stat(path)
  178. require.NoError(t, err)
  179. assert.Equal(t, os.FileMode(0640), fi.Mode().Perm())
  180. assert.Equal(t, path, s.Path())
  181. status, _ := test.HTTPGet(t, path, "/v1/whoami")
  182. assert.Equal(t, http.StatusOK, status, "the same listener is still serving")
  183. }
  184. // TestInfoModeChangeRefusesASwappedSocket: a mode change on a reload is applied to the
  185. // socket in place, and only to it. Whoever can write the directory can replace the node
  186. // with a link to a file of their choosing; the daemon must not set the operator's mode
  187. // on that file. The change is refused, the file is untouched, and the endpoint is still
  188. // the one that was serving.
  189. func TestInfoModeChangeRefusesASwappedSocket(t *testing.T) {
  190. s, path := serveInfo(t, infoRegistry(), 0600)
  191. target := filepath.Join(filepath.Dir(path), "target")
  192. require.NoError(t, os.WriteFile(target, nil, 0600))
  193. require.NoError(t, os.Remove(path))
  194. require.NoError(t, os.Symlink(target, path))
  195. err := s.Rebind(context.Background(), path, Access{Mode: 0666})
  196. require.ErrorContains(t, err, "is not a socket")
  197. fi, err := os.Stat(target)
  198. require.NoError(t, err)
  199. assert.Equal(t, os.FileMode(0600), fi.Mode().Perm(), "the file behind the link keeps its mode")
  200. assert.Equal(t, path, s.Path())
  201. }
  202. func TestInfoRebindMovesAndStopRemoves(t *testing.T) {
  203. s, first := serveInfo(t, infoRegistry(), 0600)
  204. second := testSocket(t)
  205. require.NoError(t, s.Rebind(context.Background(), second, Access{Mode: 0600}))
  206. assert.Equal(t, second, s.Path())
  207. _, err := os.Stat(first)
  208. assert.True(t, os.IsNotExist(err), "the old socket is removed")
  209. status, body := test.HTTPGet(t, second, "/v1/whoami?name=alice")
  210. assert.Equal(t, http.StatusOK, status)
  211. assert.JSONEq(t, `{"name":"alice"}`, body)
  212. s.Stop()
  213. assert.Empty(t, s.Path())
  214. _, err = os.Stat(second)
  215. assert.True(t, os.IsNotExist(err), "Stop removes the socket")
  216. }
  217. func TestInfoRebindFailureKeepsTheOldSocket(t *testing.T) {
  218. s, first := serveInfo(t, infoRegistry(), 0600)
  219. err := s.Rebind(context.Background(), filepath.Join(privateBase(t), "missing", "n", "s.sock"), Access{Mode: 0600})
  220. require.Error(t, err)
  221. assert.Equal(t, first, s.Path())
  222. status, _ := test.HTTPGet(t, first, "/v1/whoami")
  223. assert.Equal(t, http.StatusOK, status)
  224. }
  225. func TestInfoContextCancellationStops(t *testing.T) {
  226. ctx, cancel := context.WithCancel(context.Background())
  227. path := testSocket(t)
  228. s := NewInfoServer(test.NewLogger(), infoRegistry())
  229. require.NoError(t, s.Rebind(ctx, path, Access{Mode: 0600}))
  230. t.Cleanup(s.Stop)
  231. cancel()
  232. require.Eventually(t, func() bool { return s.Path() == "" }, time.Second, 10*time.Millisecond)
  233. _, err := os.Stat(path)
  234. assert.True(t, os.IsNotExist(err))
  235. // And a reload that lands during shutdown must not bring it back.
  236. err = s.Rebind(ctx, path, Access{Mode: 0600})
  237. require.Error(t, err)
  238. assert.ErrorContains(t, err, "shutting down")
  239. }
  240. func TestInfoConfig(t *testing.T) {
  241. load := func(t *testing.T, yaml string) (infoConfig, error) {
  242. t.Helper()
  243. c := config.NewC(test.NewLogger())
  244. require.NoError(t, c.LoadString(yaml))
  245. return infoConfigFrom(c)
  246. }
  247. cfg, err := load(t, "local_api: {}\n")
  248. require.NoError(t, err)
  249. assert.False(t, cfg.Enabled)
  250. cfg, err = load(t, "local_api:\n info:\n socket: /run/nebula/info.sock\n")
  251. require.NoError(t, err)
  252. assert.False(t, cfg.Enabled, "a socket alone does not enable it")
  253. _, err = load(t, "local_api:\n info:\n enabled: true\n")
  254. require.Error(t, err)
  255. require.ErrorContains(t, err, "needs a socket path")
  256. cfg, err = load(t, "local_api:\n info:\n enabled: true\n socket: /run/nebula/info.sock\n")
  257. require.NoError(t, err)
  258. assert.Equal(t, infoConfig{Enabled: true, Path: "/run/nebula/info.sock", Access: Access{Mode: 0600}}, cfg)
  259. cfg, err = load(t, "local_api:\n info:\n enabled: true\n socket: /run/nebula/info.sock\n socket_mode: \"0660\"\n")
  260. require.NoError(t, err)
  261. assert.Equal(t, infoConfig{Enabled: true, Path: "/run/nebula/info.sock", Access: Access{Mode: 0660}}, cfg)
  262. _, err = load(t, "local_api:\n info:\n enabled: true\n socket: run/info.sock\n")
  263. require.Error(t, err, "a relative socket path is refused rather than resolved against anything")
  264. require.ErrorContains(t, err, "must be an absolute path")
  265. require.ErrorContains(t, err, InfoSocketKey)
  266. _, err = load(t, "local_api:\n info:\n enabled: true\n socket: /run/nebula/info.sock\n socket_mode: 0660\n")
  267. require.Error(t, err, "a bare number is whichever integer the yaml parser made of it")
  268. require.ErrorContains(t, err, "quoted octal string")
  269. for _, bad := range []string{`"abc"`, `"1777"`, `"08"`} {
  270. _, err = load(t, "local_api:\n info:\n enabled: true\n socket: /run/nebula/info.sock\n socket_mode: "+bad+"\n")
  271. require.Error(t, err, bad)
  272. require.ErrorContains(t, err, "not an octal file mode", bad)
  273. }
  274. _, err = load(t, fmt.Sprintf("local_api:\n info:\n enabled: true\n socket: /%s\n", strings.Repeat("x", sunPathMax)))
  275. require.Error(t, err)
  276. assert.ErrorContains(t, err, "too long")
  277. }
  278. // TestWireInfoFollowsConfig: off by default, up when enabled, down when disabled, and
  279. // never on the control socket's path.
  280. func TestWireInfoFollowsConfig(t *testing.T) {
  281. ctx, cancel := context.WithCancel(context.Background())
  282. t.Cleanup(cancel)
  283. ctlPath := testSocket(t)
  284. infoPath := testSocket(t)
  285. sectionWithMode := func(enabled bool, socket, mode string) string {
  286. return fmt.Sprintf("local_api:\n control:\n socket: %s\n info:\n enabled: %t\n socket: %s\n socket_mode: \"%s\"\n", ctlPath, enabled, socket, mode)
  287. }
  288. section := func(enabled bool, socket string) string {
  289. return sectionWithMode(enabled, socket, "0600")
  290. }
  291. c := config.NewC(test.NewLogger())
  292. require.NoError(t, c.LoadString(section(false, infoPath)))
  293. var logs lockedBuffer
  294. eps := Wire(ctx, test.NewLoggerWithOutput(&logs), c, infoRegistry())
  295. t.Cleanup(eps.Stop)
  296. assert.Equal(t, ctlPath, eps.Control.Path())
  297. assert.Empty(t, eps.Info.Path(), "off until enabled")
  298. require.NoError(t, c.ReloadConfigString(section(true, infoPath)).Err)
  299. assert.Equal(t, infoPath, eps.Info.Path())
  300. status, body := test.HTTPGet(t, infoPath, "/v1/whoami?name=alice")
  301. assert.Equal(t, http.StatusOK, status)
  302. assert.JSONEq(t, `{"name":"alice"}`, body)
  303. // A reload that changes nothing leaves it where it is.
  304. require.NoError(t, c.ReloadConfigString(section(true, infoPath)).Err)
  305. assert.Equal(t, infoPath, eps.Info.Path())
  306. // A reload that changes only the mode applies it in place.
  307. require.NoError(t, c.ReloadConfigString(sectionWithMode(true, infoPath, "0660")).Err)
  308. assert.Equal(t, infoPath, eps.Info.Path())
  309. fi, err := os.Stat(infoPath)
  310. require.NoError(t, err)
  311. assert.Equal(t, os.FileMode(0660), fi.Mode().Perm())
  312. // A reload that moves the socket moves it, and removes the old one.
  313. moved := testSocket(t)
  314. require.NoError(t, c.ReloadConfigString(section(true, moved)).Err)
  315. assert.Equal(t, moved, eps.Info.Path())
  316. _, err = os.Stat(infoPath)
  317. assert.True(t, os.IsNotExist(err))
  318. status, body = test.HTTPGet(t, moved, "/v1/whoami?name=bob")
  319. assert.Equal(t, http.StatusOK, status)
  320. assert.JSONEq(t, `{"name":"bob"}`, body)
  321. require.NoError(t, c.ReloadConfigString(section(true, infoPath)).Err)
  322. require.Equal(t, infoPath, eps.Info.Path())
  323. // The control socket's path is refused, and the running endpoint is kept, which is
  324. // what the log says.
  325. require.NoError(t, c.ReloadConfigString(section(true, ctlPath)).Err)
  326. assert.Equal(t, infoPath, eps.Info.Path())
  327. assert.Equal(t, ctlPath, eps.Control.Path())
  328. assert.Contains(t, logs.String(), "keeping the current endpoint")
  329. assert.NotContains(t, logs.String(), "Local info API is not available")
  330. require.NoError(t, c.ReloadConfigString(section(false, infoPath)).Err)
  331. assert.Empty(t, eps.Info.Path())
  332. _, err = os.Stat(infoPath)
  333. assert.True(t, os.IsNotExist(err))
  334. // One Stop takes both down.
  335. require.NoError(t, c.ReloadConfigString(section(true, infoPath)).Err)
  336. eps.Stop()
  337. assert.Empty(t, eps.Info.Path())
  338. assert.Empty(t, eps.Control.Path())
  339. }
  340. // TestWireInfoKeepsRunningOnABadReload: a typo in the section must not cut off the
  341. // programs relying on the endpoint; the operator is told and the endpoint stays.
  342. func TestWireInfoKeepsRunningOnABadReload(t *testing.T) {
  343. ctx, cancel := context.WithCancel(context.Background())
  344. t.Cleanup(cancel)
  345. infoPath := testSocket(t)
  346. good := fmt.Sprintf("local_api:\n info:\n enabled: true\n socket: %s\n", infoPath)
  347. c := config.NewC(test.NewLogger())
  348. require.NoError(t, c.LoadString(good))
  349. srv := wireInfo(ctx, test.NewLogger(), c, infoRegistry())
  350. t.Cleanup(srv.Stop)
  351. require.Equal(t, infoPath, srv.Path())
  352. require.NoError(t, c.ReloadConfigString(good+" socket_mode: 0660\n").Err)
  353. assert.Equal(t, infoPath, srv.Path())
  354. status, _ := test.HTTPGet(t, infoPath, "/v1/whoami")
  355. assert.Equal(t, http.StatusOK, status)
  356. }
  357. // rawInfoRequest opens the socket and writes req as it is, so a test can send what an
  358. // HTTP client would not: a body that never arrives. It returns the connection.
  359. func rawInfoRequest(t *testing.T, path, req string) net.Conn {
  360. t.Helper()
  361. conn, err := net.DialTimeout("unix", path, time.Second)
  362. require.NoError(t, err)
  363. t.Cleanup(func() { _ = conn.Close() })
  364. _, err = conn.Write([]byte(req))
  365. require.NoError(t, err)
  366. return conn
  367. }
  368. // TestInfoRefusesARequestBody: a request that announces a body is answered 400 at once,
  369. // before the body arrives, and the connection is closed once the read deadline has ended
  370. // the wait for it, so a client that never sends the body holds its slot for the deadline
  371. // and no longer.
  372. func TestInfoRefusesARequestBody(t *testing.T) {
  373. oldHeader, oldRead := infoReadHeaderTimeout, infoReadTimeout
  374. infoReadHeaderTimeout, infoReadTimeout = 200*time.Millisecond, 300*time.Millisecond
  375. t.Cleanup(func() { infoReadHeaderTimeout, infoReadTimeout = oldHeader, oldRead })
  376. _, path := serveInfo(t, infoRegistry(), 0600)
  377. start := time.Now()
  378. conn := rawInfoRequest(t, path, "GET /v1/whoami HTTP/1.1\r\nHost: nebula\r\nContent-Length: 10\r\n\r\n")
  379. require.NoError(t, conn.SetReadDeadline(time.Now().Add(2*time.Second)))
  380. res, err := http.ReadResponse(bufio.NewReader(conn), nil)
  381. require.NoError(t, err)
  382. body, _ := io.ReadAll(res.Body)
  383. res.Body.Close()
  384. assert.Equal(t, http.StatusBadRequest, res.StatusCode)
  385. assert.Contains(t, string(body), "takes no request body")
  386. assert.True(t, res.Close, "the connection is not kept for another request")
  387. assert.Less(t, time.Since(start), 200*time.Millisecond, "answered before the body, which never comes")
  388. _, err = conn.Read(make([]byte, 1))
  389. require.ErrorIs(t, err, io.EOF, "the server closed the connection")
  390. assert.Less(t, time.Since(start), time.Second, "and did so at the read deadline, not later")
  391. }
  392. // TestInfoDropsASlowClient: a client that sends its headers and then nothing loses its
  393. // connection at the read timeout rather than holding a slot for as long as it likes.
  394. func TestInfoDropsASlowClient(t *testing.T) {
  395. oldHeader, oldRead := infoReadHeaderTimeout, infoReadTimeout
  396. infoReadHeaderTimeout, infoReadTimeout = 200*time.Millisecond, 300*time.Millisecond
  397. t.Cleanup(func() { infoReadHeaderTimeout, infoReadTimeout = oldHeader, oldRead })
  398. _, path := serveInfo(t, infoRegistry(), 0600)
  399. // Half a request line, then silence: the header deadline ends it.
  400. conn := rawInfoRequest(t, path, "GET /v1/whoami HTTP/1.1\r\nHost: neb")
  401. require.NoError(t, conn.SetReadDeadline(time.Now().Add(2*time.Second)))
  402. start := time.Now()
  403. _, err := conn.Read(make([]byte, 1))
  404. require.Error(t, err, "the server closed the connection")
  405. assert.Less(t, time.Since(start), time.Second)
  406. // And the server still answers the next client.
  407. status, _ := test.HTTPGet(t, path, "/v1/whoami")
  408. assert.Equal(t, http.StatusOK, status)
  409. }