1
0

ctl_test.go 13 KB

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