ctl_test.go 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294
  1. package ctl
  2. import (
  3. "encoding/json"
  4. "flag"
  5. "os"
  6. "path/filepath"
  7. "runtime"
  8. "strings"
  9. "testing"
  10. "github.com/slackhq/nebula/commands"
  11. "github.com/slackhq/nebula/localapi"
  12. "github.com/stretchr/testify/assert"
  13. "github.com/stretchr/testify/require"
  14. )
  15. // TestResolveSocketReportsAnUnreadableConfig: an operator who named a config must hear
  16. // that it could not be read, rather than have the command steered anywhere else.
  17. func TestResolveSocketReportsAnUnreadableConfig(t *testing.T) {
  18. missing := filepath.Join(t.TempDir(), "typo.yml")
  19. _, err := resolveSocket("", missing)
  20. require.Error(t, err)
  21. assert.Contains(t, err.Error(), missing)
  22. }
  23. // TestResolveSocketGuessedConfigNeedNotBeReadable: a config nobody named is a guess, and
  24. // one that cannot be read, a root daemon's config read by a member of the socket's
  25. // group, means the default path rather than an error.
  26. func TestResolveSocketGuessedConfigNeedNotBeReadable(t *testing.T) {
  27. missing := filepath.Join(t.TempDir(), "config.yml")
  28. p, err := socketFromConfigFile(missing, false)
  29. require.NoError(t, err)
  30. assert.Equal(t, localapi.DefaultSocketPath, p)
  31. _, err = socketFromConfigFile(missing, true)
  32. require.ErrorContains(t, err, missing)
  33. }
  34. // nowhereSocket is a socket name nothing listens on, spelled the way the platform
  35. // requires: a full pipe name on windows, an absolute path elsewhere.
  36. var nowhereSocket = map[bool]string{true: `\\.\pipe\nebula-nowhere`, false: "/nowhere/ctl.sock"}[runtime.GOOS == "windows"]
  37. // namedSocketConfig is a config that names nowhereSocket as the control socket. Single
  38. // quotes keep a pipe name's backslashes literal in YAML.
  39. var namedSocketConfig = "local_api:\n control:\n socket: '" + nowhereSocket + "'\n"
  40. // TestResolveSocketTrustsANamedConfig: a config the operator named yields the socket it
  41. // implies even when nothing is listening there, so a dial failure names that socket.
  42. func TestResolveSocketTrustsANamedConfig(t *testing.T) {
  43. cfg := filepath.Join(t.TempDir(), "config.yml")
  44. require.NoError(t, os.WriteFile(cfg, []byte(namedSocketConfig), 0600))
  45. p, err := resolveSocket("", cfg)
  46. require.NoError(t, err)
  47. assert.Equal(t, nowhereSocket, p)
  48. }
  49. // TestResolveSocketSaysTheSocketIsDisabled: a config that turns the control socket off
  50. // is answered with that, not with a dial to a path nothing serves. -socket still wins.
  51. func TestResolveSocketSaysTheSocketIsDisabled(t *testing.T) {
  52. cfg := filepath.Join(t.TempDir(), "config.yml")
  53. require.NoError(t, os.WriteFile(cfg, []byte("local_api:\n control:\n enabled: false\n"), 0600))
  54. _, err := resolveSocket("", cfg)
  55. require.ErrorContains(t, err, "the control socket is disabled in "+cfg+" by local_api.control.enabled")
  56. p, err := resolveSocket("/explicit.sock", cfg)
  57. require.NoError(t, err)
  58. assert.Equal(t, "/explicit.sock", p)
  59. }
  60. // TestResolveSocketUsesTheDefaultWithoutAnOverride: a config that does not name the
  61. // socket means the default path, the same one the daemon serves with that config, and
  62. // no config at all means the default too, since that is where a daemon started without
  63. // an override serves. Nothing is searched for.
  64. func TestResolveSocketUsesTheDefaultWithoutAnOverride(t *testing.T) {
  65. cfg := filepath.Join(t.TempDir(), "config.yml")
  66. require.NoError(t, os.WriteFile(cfg, []byte("static_host_map: {}\n"), 0600))
  67. p, err := resolveSocket("", cfg)
  68. require.NoError(t, err)
  69. assert.Equal(t, localapi.DefaultSocketPath, p)
  70. // The test binary has no config next to it.
  71. p, err = resolveSocket("", "")
  72. require.NoError(t, err)
  73. assert.Equal(t, localapi.DefaultSocketPath, p)
  74. }
  75. func TestResolveSocketFlagWins(t *testing.T) {
  76. p, err := resolveSocket("/explicit.sock", "/does/not/matter.yml")
  77. require.NoError(t, err)
  78. assert.Equal(t, "/explicit.sock", p)
  79. }
  80. // TestParseCommandReportsWhatFlagSaid covers the three ways a flag line goes wrong, which
  81. // are different mistakes: an unknown flag, a valid flag with a missing value, and a
  82. // valid flag with a value it cannot parse.
  83. func TestParseCommandReportsWhatFlagSaid(t *testing.T) {
  84. schema := []commands.CommandSchema{{
  85. Name: "change-remote",
  86. Flags: []commands.FlagSchema{
  87. {Name: "address"},
  88. {Name: "by-index", Bool: true},
  89. },
  90. }}
  91. cases := map[string]struct {
  92. argv []string
  93. want string
  94. }{
  95. "missing value": {[]string{"change-remote", "-address"}, "flag needs an argument: -address"},
  96. "bad bool": {[]string{"change-remote", "-by-index=maybe"}, "invalid boolean value \"maybe\" for -by-index"},
  97. "unknown flag": {[]string{"change-remote", "-nope"}, "flag provided but not defined: -nope"},
  98. }
  99. for name, tc := range cases {
  100. t.Run(name, func(t *testing.T) {
  101. _, _, err := parseCommand(schema, tc.argv)
  102. require.Error(t, err)
  103. require.ErrorContains(t, err, "nebula: change-remote: "+tc.want)
  104. require.ErrorContains(t, err, "see 'nebula ctl change-remote -h'")
  105. assert.NotContains(t, err.Error(), "does not take")
  106. })
  107. }
  108. }
  109. // TestParseCommandOffPresentationFlagIsNoPreference: the daemon treats -json=false as
  110. // no preference, so nebula ctl must too, or the same line renders differently on the
  111. // two surfaces. Only a true value asks for JSON, and -pretty implies it.
  112. func TestParseCommandOffPresentationFlagIsNoPreference(t *testing.T) {
  113. schema := []commands.CommandSchema{{
  114. Name: "device-info",
  115. Flags: []commands.FlagSchema{
  116. {Name: commands.FlagJSON, Bool: true, Default: "false"},
  117. {Name: commands.FlagPretty, Bool: true, Default: "false"},
  118. },
  119. }}
  120. for _, tc := range []struct {
  121. argv []string
  122. want commands.Format
  123. wire string
  124. }{
  125. {[]string{"device-info"}, commands.FormatUnset, "text"},
  126. {[]string{"device-info", "-json=false"}, commands.FormatUnset, "text"},
  127. {[]string{"device-info", "-json"}, commands.FormatJSON, "json"},
  128. {[]string{"device-info", "-pretty"}, commands.FormatPrettyJSON, "json"},
  129. } {
  130. t.Run(strings.Join(tc.argv[1:], " "), func(t *testing.T) {
  131. req, printedHelp, err := parseCommand(schema, tc.argv)
  132. require.NoError(t, err)
  133. assert.False(t, printedHelp)
  134. assert.Equal(t, tc.want, req.Format)
  135. assert.Equal(t, tc.wire, wireFormat(req.Format))
  136. assert.Nil(t, req.Params, "presentation flags are never sent as parameters")
  137. })
  138. }
  139. }
  140. // TestParseCommandReadsTheLineAsTheDaemonDoes: nebula ctl parses the line with the same
  141. // rules as the daemon, so an operand after "--" reaches the command on both and a
  142. // negative number before it is the flag it would be at an ssh prompt.
  143. func TestParseCommandReadsTheLineAsTheDaemonDoes(t *testing.T) {
  144. schema := []commands.CommandSchema{{Name: "set"}}
  145. req, printedHelp, err := parseCommand(schema, []string{"set", "--", "-1"})
  146. require.NoError(t, err)
  147. assert.False(t, printedHelp)
  148. assert.Equal(t, commands.Name("set"), req.name)
  149. assert.Nil(t, req.Params)
  150. assert.Equal(t, []string{"-1"}, req.Args)
  151. _, _, err = parseCommand(schema, []string{"set", "-1"})
  152. require.ErrorContains(t, err, "flag provided but not defined: -1")
  153. }
  154. // TestOutputRendersForTheFormatAsked: what the daemon answered is read by the format the
  155. // line asked for, never by sniffing the bytes. A text answer is the command's rendering,
  156. // printed as it came; a JSON answer whose value is a string stays quoted, since to the
  157. // script reading it that string is the JSON.
  158. func TestOutputRendersForTheFormatAsked(t *testing.T) {
  159. text := &localapi.Response{Text: []byte("Log level is: info\n")}
  160. assert.Equal(t, "Log level is: info\n", output(text, commands.FormatUnset))
  161. assert.Equal(t, "Log level is: info\n", output(text, commands.FormatText))
  162. str := &localapi.Response{Result: json.RawMessage(`"some string"`)}
  163. assert.Equal(t, `"some string"`, output(str, commands.FormatJSON))
  164. assert.Equal(t, `"some string"`, output(str, commands.FormatPrettyJSON))
  165. // Keys stay in the order the daemon wrote them and a uint64 past 2^53 is not rounded,
  166. // which decoding into `any` and encoding again would do.
  167. obj := &localapi.Response{Result: json.RawMessage(`{"z":18446744073709551615,"a":{"y":[1,2],"b":true}}`)}
  168. //nolint:testifylint // the layout is what is under test, which JSONEq would not see
  169. assert.Equal(t, `{"z":18446744073709551615,"a":{"y":[1,2],"b":true}}`, output(obj, commands.FormatJSON))
  170. //nolint:testifylint // as above
  171. assert.Equal(t, "{\n \"z\": 18446744073709551615,\n \"a\": {\n \"y\": [\n 1,\n 2\n ],\n \"b\": true\n }\n}", output(obj, commands.FormatPrettyJSON))
  172. bad := &localapi.Response{Result: json.RawMessage(`{"a":`)}
  173. assert.Equal(t, `{"a":`, output(bad, commands.FormatPrettyJSON), "what cannot be indented is printed as it came")
  174. none := &localapi.Response{}
  175. assert.Empty(t, output(none, commands.FormatJSON))
  176. assert.Empty(t, output(none, commands.FormatUnset))
  177. }
  178. // TestSplitLineLiftsOwnFlagsFromAnywhere: -config and -socket are the client's wherever
  179. // they appear, before the command or after it, in either spelling, so the two orders
  180. // mean the same line. Everything else after the command word stays in order for the
  181. // command's own parse, including flags and operands that happen to look like values.
  182. func TestSplitLineLiftsOwnFlagsFromAnywhere(t *testing.T) {
  183. newFS := func() *flag.FlagSet {
  184. fs := flag.NewFlagSet("nebula ctl", flag.ContinueOnError)
  185. fs.String(commands.FlagConfig, "", "")
  186. fs.String(commands.FlagSocket, "", "")
  187. return fs
  188. }
  189. for _, tc := range []struct {
  190. args []string
  191. wantOwn []string
  192. wantCmd []string
  193. }{
  194. {nil, nil, nil},
  195. {[]string{"identity"}, nil, []string{"identity"}},
  196. {[]string{"-config", "x", "identity"}, []string{"-config", "x"}, []string{"identity"}},
  197. {[]string{"identity", "-config", "x"}, []string{"-config", "x"}, []string{"identity"}},
  198. {[]string{"identity", "-config=x"}, []string{"-config=x"}, []string{"identity"}},
  199. {[]string{"identity", "--socket", "s"}, []string{"--socket", "s"}, []string{"identity"}},
  200. {[]string{"-socket", "s", "identity", "-config", "x"}, []string{"-socket", "s", "-config", "x"}, []string{"identity"}},
  201. // The command's flags and operands are left where they are, around the lifted ones.
  202. {[]string{"print-tunnel", "10.0.0.1", "-config", "x", "-json"}, []string{"-config", "x"}, []string{"print-tunnel", "10.0.0.1", "-json"}},
  203. {[]string{"change-remote", "-address", "1.2.3.4:5", "-socket", "s", "10.0.0.1"}, []string{"-socket", "s"}, []string{"change-remote", "-address", "1.2.3.4:5", "10.0.0.1"}},
  204. // -h before the command is the client's usage; after it, the command's help.
  205. {[]string{"-h"}, []string{"-h"}, nil},
  206. {[]string{"identity", "-h"}, nil, []string{"identity", "-h"}},
  207. // An unknown flag before the command is the client's to report, and does not
  208. // swallow the command word as its value.
  209. {[]string{"-nope", "identity"}, []string{"-nope"}, []string{"identity"}},
  210. // A -config with nothing after it is left for fs.Parse to report.
  211. {[]string{"identity", "-config"}, []string{"-config"}, []string{"identity"}},
  212. // "--" before the command is consumed, as fs.Parse consumed it; after the command
  213. // it belongs to the line and ends the lifting.
  214. {[]string{"--", "identity", "-config", "x"}, []string{"-config", "x"}, []string{"identity"}},
  215. {[]string{"identity", "--", "-config", "x"}, nil, []string{"identity", "--", "-config", "x"}},
  216. } {
  217. t.Run(strings.Join(tc.args, " "), func(t *testing.T) {
  218. own, cmd := splitLine(newFS(), tc.args)
  219. assert.Equal(t, tc.wantOwn, own)
  220. assert.Equal(t, tc.wantCmd, cmd)
  221. })
  222. }
  223. }
  224. // TestTrailingHandlesWordsAfterTheDaemonsFlags: what flag.Parse leaves behind is never
  225. // a daemon to start. A leading ctl runs the client on the rest, with the daemon's
  226. // -config carried over; anything else is a mistyped subcommand. Only -h is run here,
  227. // since the client's help needs no daemon: it proves the words reach Run and that a
  228. // carried -config is accepted by it.
  229. func TestTrailingHandlesWordsAfterTheDaemonsFlags(t *testing.T) {
  230. code, done := Trailing(nil, "")
  231. assert.False(t, done)
  232. assert.Equal(t, 0, code)
  233. code, done = Trailing([]string{"clt"}, "")
  234. assert.True(t, done)
  235. assert.Equal(t, 1, code)
  236. code, done = Trailing([]string{Word, "-h"}, "")
  237. assert.True(t, done)
  238. assert.Equal(t, 0, code)
  239. code, done = Trailing([]string{Word, "-h"}, "/etc/nebula/config.yml")
  240. assert.True(t, done)
  241. assert.Equal(t, 0, code)
  242. }
  243. // TestSubcommandOnlyReadsTheFirstWord: a flag first means the line is the daemon's to
  244. // parse, and whatever follows the flags is Trailing's to judge.
  245. func TestSubcommandOnlyReadsTheFirstWord(t *testing.T) {
  246. _, done := Subcommand([]string{"nebula", "-config", "x", Word})
  247. assert.False(t, done)
  248. code, done := Subcommand([]string{"nebula", "clt"})
  249. assert.True(t, done)
  250. assert.Equal(t, 1, code)
  251. code, done = Subcommand([]string{"nebula", Word, "-h"})
  252. assert.True(t, done)
  253. assert.Equal(t, 0, code)
  254. }