info_test.go 18 KB

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