info_test.go 22 KB

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