Files
nebula/commands/registry_test.go
T
Matt RichardsonandClaude Fable 5.1 613c7e3061 Add the identity command and serve it on an opt-in HTTP info API
Build slackhq/nebula#1821 ("who is this vpn address?" over a local
HTTP+JSON socket) on the command registry, so the lookup is one command
on every surface and the socket is one more local API.

identity
- `identity [-address <vpn addr>[:port]]`: no address reports this node;
  with one, this node or the peer behind an established tunnel
- Certificate fields only: name, vpn addresses, networks, unsafe
  networks, groups, fingerprint, issuer, validity, cert version; a peer's
  fingerprint is the one nebula computed when it verified the cert
- Port accepted and stripped, 4in6 unmapped, so a RemoteAddr passes
  straight through
- No tunnel is a commands.NotFound; an empty -address is refused
- Lighthouse TXT lookup shares the owner and peer lookups, keeps answering
  with the initiating certificate, and no longer panics without a default cert
- Served on ssh and nebula ctl too

Info API
- Off until `local_api.info.enabled` with `socket` and optional quoted
  octal `socket_mode` (default "0600"); nothing derived
- Serves commands declaring Command.Identity plus help; others 403 naming
  their surfaces. TestInfoAPISurface pins the set to identity
- GET /v1 is the schema; GET /v1/<cmd>?<flag>=<value> runs one; body is
  the -json rendering via commands.Render; commands.PresentationFormat
  reads json/pretty from the query the way a line is read
- 400 commands.ParamError, 403 commands.WithheldError (what Resolve now
  returns for a withheld name, so help and dispatch type-check it too),
  404 NotFound or unknown, 500 Run error, refusal with no reason, or
  recovered panic with no detail; no redirects; every miss is JSON
- listen takes a mode: bind under umask 0077 then chmod; "0660" plus a
  searchable dir opens it to a group
- The chmod, at bind and on a mode-only reload, goes through chmodSocket,
  which never follows a link: a user who can write the socket's directory
  could otherwise swap the node for a link and have a root daemon put
  0660 or 0666 on any file. Only a socket we own is chmoded. On linux the
  node is held open O_PATH|O_NOFOLLOW, checked by fstat, and chmoded
  through /proc/self/fd, so the check and the chmod land on one inode;
  fchmodat2 is the fallback without /proc. On darwin and the BSDs it is
  an Lstat check and fchmodat(AT_SYMLINK_NOFOLLOW). clearStale Lstats
  too, so a dangling link is refused rather than followed by bind on
  darwin
- One endpoint type owns bind under mutex, bind-before-release move,
  context watch and stop/unlink; each server supplies what it serves
- Reloadable through the same reconcile shape as the control socket:
  mode applied in place, unparsable section keeps what runs, control
  socket path refused by name
- Wire returns Endpoints{Control, Info} with one Stop
- Windows unsupported for now (a pipe has no mode); warned once

Tests: identity lookups and shapes; API filter, status codes, panic
recovery, socket mode, rebind, cancellation; config parsing incl. bare
0660; Wire transitions; e2e of two nebulas with the info API resolving
the peer by address and port, through shared test.HTTPDo/HTTPGet helpers.
Goldens gain identity.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-21 12:26:09 -04:00

477 lines
17 KiB
Go

package commands
import (
"bytes"
"errors"
"flag"
"io"
"strings"
"sync"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// notWithheld is a stand in for a surface's own policy, which the registry knows nothing
// of; see Command.Identity for the one the info API applies.
func notWithheld(c *Command) bool { return c.Name != "withheld" }
const testReason = "this surface does not run commands that write"
// textLine is a Text renderer for test commands whose result is one string.
func textLine(v any, w io.Writer) error {
_, err := io.WriteString(w, v.(string)+"\n")
return err
}
func testRegistry() *Registry {
r := New()
r.Register(&Command{
Name: "served",
ShortDescription: "a command this surface serves",
Run: func(fs any, a []string) (Result, error) {
return Value("read " + strings.Join(a, ",")), nil
},
Text: textLine,
})
r.Register(&Command{
Name: "withheld",
ShortDescription: "a command this surface withholds",
Run: func(fs any, a []string) (Result, error) {
return Value("wrote"), nil
},
Text: textLine,
})
return r
}
// TestFilterKeepsWhatKeepSays: the copy holds the commands keep accepted, and help.
func TestFilterKeepsWhatKeepSays(t *testing.T) {
ro := testRegistry().Filter(notWithheld, testReason)
names := make([]string, 0)
for _, c := range ro.All() {
names = append(names, string(c.Name))
}
assert.ElementsMatch(t, []string{"help", "served"}, names)
}
// TestRefusesWithheldByName covers a client that knows a command exists and asks for it
// anyway. The filter lives on the serving side, so the answer must not depend on what
// the client was told was available.
func TestRefusesWithheldByName(t *testing.T) {
out, errOut := &bytes.Buffer{}, &bytes.Buffer{}
w := Output{Out: out, Err: errOut}
err := testRegistry().Filter(notWithheld, testReason).DispatchArgs([]string{"withheld"}, w)
require.Error(t, err)
assert.Contains(t, err.Error(), "withheld is not available on this surface")
assert.Empty(t, out.String(), "a withheld command must produce no output")
assert.Contains(t, errOut.String(), "withheld is not available on this surface")
assert.Contains(t, errOut.String(), testReason,
"the registry should render the explanation the filter supplied, not one of its own")
}
// TestWithheldIsNotReportedAsUnknown keeps the two cases distinct: an operator who
// mistypes and one who asked for a real command should not get the same answer.
func TestWithheldIsNotReportedAsUnknown(t *testing.T) {
out, errOut := &bytes.Buffer{}, &bytes.Buffer{}
err := testRegistry().Filter(notWithheld, testReason).DispatchArgs([]string{"no-such-command"}, Output{Out: out, Err: errOut})
var f *Failure
require.ErrorAs(t, err, &f, "an unknown command is a failure to the surface")
assert.Contains(t, out.String(), "did not understand: no-such-command")
}
// TestResolveTypesTheWithheldRefusal: a surface with status codes tells a withheld
// command from an unknown one by the error's type, never by asking the unfiltered
// registry, which is the one thing a filtered surface must not consult.
func TestResolveTypesTheWithheldRefusal(t *testing.T) {
r := testRegistry().Filter(notWithheld, testReason)
_, err := r.Resolve("withheld")
var withheld *WithheldError
require.ErrorAs(t, err, &withheld)
assert.Contains(t, err.Error(), testReason)
_, err = r.Resolve("no-such-command")
require.Error(t, err)
assert.NotErrorAs(t, err, &withheld, "an unknown name is not a withheld one")
}
// paramRegistry holds one command with a bool flag and a duration flag, and reports what
// the command saw when it ran.
func paramRegistry(t *testing.T) (*Registry, *[]paramsSeen) {
t.Helper()
var seen []paramsSeen
r := New()
r.Register(&Command{
Name: "set",
ShortDescription: "sets things",
Flags: func() (*flag.FlagSet, any) {
fl := flag.NewFlagSet("", flag.ContinueOnError)
s := paramsSeen{}
fl.BoolVar(&s.Verbose, "verbose", false, "say more")
fl.DurationVar(&s.Wait, "wait", 0, "how long")
return fl, &s
},
Run: func(fs any, a []string) (Result, error) {
seen = append(seen, *fs.(*paramsSeen))
return Value("ok"), nil
},
})
return r, &seen
}
type paramsSeen struct {
Verbose bool
Wait time.Duration
}
// TestInvokeReportsParametersInNameOrder: parameters arrive as an unordered object, so
// with two bad ones the error must not depend on map iteration. Name order decides.
func TestInvokeReportsParametersInNameOrder(t *testing.T) {
r, _ := paramRegistry(t)
cases := map[string]struct {
params map[string]string
want string
}{
"two unknown": {map[string]string{"zed": "1", "alpha": "1"}, `set has no parameter "alpha"`},
"unknown before bad bool": {map[string]string{"aaa": "1", "verbose": "maybe"}, `set has no parameter "aaa"`},
"bad bool before unknown": {map[string]string{"verbose": "maybe", "zzz": "1"}, `verbose: expected a boolean, got "maybe"`},
"bad value before bad bool": {map[string]string{"wait": "soon", "verbose": "maybe"}, `verbose: expected a boolean`},
}
for name, tc := range cases {
t.Run(name, func(t *testing.T) {
// Enough runs that a map-order dependence would show.
for i := 0; i < 50; i++ {
_, err := Invoke(Request{Command: r.Get("set"), Params: tc.params})
require.Error(t, err)
assert.ErrorContains(t, err, tc.want)
}
})
}
}
// TestInvokeWithABadParameterSetsNothing: a request with any bad parameter must not run
// the command, and must leave nothing behind for the next request to see. The good
// parameter sorts before the bad one, so a one-pass Invoke would have applied it.
func TestInvokeWithABadParameterSetsNothing(t *testing.T) {
r, seen := paramRegistry(t)
_, err := Invoke(Request{Command: r.Get("set"), Params: map[string]string{"verbose": "true", "wait": "soon"}})
require.ErrorContains(t, err, "wait:")
assert.Empty(t, *seen, "the command must not run with a partial parameter set")
v, err := Invoke(Request{Command: r.Get("set")})
require.NoError(t, err)
assert.Equal(t, "ok", v.Value)
require.Len(t, *seen, 1)
assert.Equal(t, paramsSeen{}, (*seen)[0], "nothing from the failed request carried over")
_, err = Invoke(Request{Command: r.Get("set"), Params: map[string]string{"verbose": "true", "wait": "2s"}})
require.NoError(t, err)
require.Len(t, *seen, 2)
assert.Equal(t, paramsSeen{Verbose: true, Wait: 2 * time.Second}, (*seen)[1])
}
func TestHelpListsOnlyServedCommands(t *testing.T) {
out := &bytes.Buffer{}
require.NoError(t, testRegistry().Filter(notWithheld, testReason).DispatchArgs(nil, MergedOutput(out)))
assert.Contains(t, out.String(), "served")
assert.NotContains(t, out.String(), "withheld")
}
// TestDispatchReportsUnparseableLine pins that a line shlex rejects says so on Err rather
// than coming back silent, so an operator with an unbalanced quote learns the quote is the
// problem instead of assuming the command is broken.
func TestDispatchReportsUnparseableLine(t *testing.T) {
out, errOut := &bytes.Buffer{}, &bytes.Buffer{}
testRegistry().Dispatch(`served "unterminated`, Output{Out: out, Err: errOut})
assert.Empty(t, out.String())
assert.Contains(t, errOut.String(), "could not parse the command line")
}
// TestRegisterRejectsReservedFlags: a command that declares a reserved flag is refused
// at Register rather than when an operator first runs it; see reservedFlags.
func TestRegisterRejectsReservedFlags(t *testing.T) {
for _, name := range []string{"json", "pretty", "h", "help", "config", "socket"} {
t.Run(name, func(t *testing.T) {
r := New()
assert.PanicsWithValue(t, "commands: clash declares reserved flag -"+name, func() {
r.Register(&Command{
Name: "clash",
Flags: func() (*flag.FlagSet, any) {
fl := flag.NewFlagSet("clash", flag.ContinueOnError)
fl.Bool(name, false, "")
return fl, nil
},
Run: func(fs any, a []string) (Result, error) { return Nothing(), nil },
})
})
})
}
}
// TestRegistryIsSafeForConcurrentUse runs the operations the surfaces perform, filtering
// and cloning per request or session, against a stream of registrations. It exists to
// be run under the race detector: the map underneath is not goroutine safe.
func TestRegistryIsSafeForConcurrentUse(t *testing.T) {
r := testRegistry()
var wg sync.WaitGroup
for i := 0; i < 8; i++ {
wg.Add(1)
go func(i int) {
defer wg.Done()
for j := 0; j < 50; j++ {
r.Register(&Command{
Name: Name("late-" + strings.Repeat("a", i) + "-" + strings.Repeat("b", j)),
ShortDescription: "registered after the surfaces started",
Run: func(fs any, a []string) (Result, error) { return Nothing(), nil },
})
}
}(i)
}
for i := 0; i < 8; i++ {
wg.Add(1)
go func() {
defer wg.Done()
for j := 0; j < 50; j++ {
f := r.Filter(notWithheld, testReason)
_ = f.Get("served")
_ = f.Schema()
_ = r.Clone().Match("late-")
}
}()
}
wg.Wait()
f := r.Filter(notWithheld, testReason)
assert.Len(t, f.Match("late-"), 8*50, "every late registration is visible once the writers finish")
}
// TestCloneHelpSeesCloneCommands: the ssh session registers logout on its clone, and
// the source does not learn of it. Help on the clone answers for the clone, and -h on a
// command prints what `help <name>` does, on a clone as on the source: both render
// through WriteCommandHelp, and Clone rebinds help to the copy.
func TestCloneHelpSeesCloneCommands(t *testing.T) {
r := testRegistry()
c := r.Clone()
c.Register(&Command{
Name: Logout,
ShortDescription: "ends the session",
Run: func(fs any, a []string) (Result, error) { return Nothing(), nil },
})
assert.Nil(t, r.Get(Logout), "the source is not affected")
out := &bytes.Buffer{}
require.NoError(t, c.DispatchArgs([]string{"help"}, MergedOutput(out)))
assert.Contains(t, out.String(), "logout")
for _, name := range []string{"logout", "served"} {
flag := &bytes.Buffer{}
c.Dispatch(name+" -h", MergedOutput(flag))
cmd := &bytes.Buffer{}
c.Dispatch("help "+name, MergedOutput(cmd))
assert.Equal(t, cmd.String(), flag.String(), name)
assert.Contains(t, flag.String(), name+" - ", name)
}
}
// TestThenRunsAfterTheReply pins the order Then exists for: Run's line is on the writer
// before the action begins, Then's Result follows it, and Then's outcome is the
// command's.
func TestThenRunsAfterTheReply(t *testing.T) {
out := &bytes.Buffer{}
seen := "never ran"
outcome := Did("acted")
r := New()
r.Register(&Command{
Name: "act",
ShortDescription: "replies, then acts",
Run: func(fs any, a []string) (Result, error) {
return Did("about to act"), nil
},
Then: func() Result {
seen = out.String()
return outcome
},
})
require.NoError(t, r.DispatchArgs([]string{"act"}, MergedOutput(out)))
assert.Equal(t, "about to act\n", seen, "the line was written before the action")
assert.Equal(t, "about to act\nacted\n", out.String())
out.Reset()
outcome = Failed("could not act")
var f *Failure
require.ErrorAs(t, r.DispatchArgs([]string{"act"}, MergedOutput(out)), &f)
assert.Equal(t, "about to act\ncould not act\n", out.String())
// In JSON only the outcome travels: one document for the program that asked.
out.Reset()
require.ErrorAs(t, r.DispatchArgs([]string{"act", "-json"}, MergedOutput(out)), &f)
assert.JSONEq(t, `{"ok":false,"reason":"could not act"}`, out.String())
// A Run that refused is the outcome; the action does not happen.
ran := false
r.Register(&Command{
Name: "refuse",
ShortDescription: "refuses, so never acts",
Run: func(fs any, a []string) (Result, error) {
return Failed("not like that"), nil
},
Then: func() Result {
ran = true
return Did("acted anyway")
},
})
out.Reset()
require.ErrorAs(t, r.DispatchArgs([]string{"refuse"}, MergedOutput(out)), &f)
assert.Equal(t, "not like that\n", out.String())
assert.False(t, ran)
out.Reset()
require.ErrorAs(t, r.DispatchArgs([]string{"refuse", "-json"}, MergedOutput(out)), &f)
assert.JSONEq(t, `{"ok":false,"reason":"not like that"}`, out.String())
assert.False(t, ran)
}
// TestWriteCommandHelpIsTheOneRendering pins the layout both `help <command>` on the
// daemon and `-h` in the client print, so the two cannot drift apart again.
func TestWriteCommandHelpIsTheOneRendering(t *testing.T) {
cs := CommandSchema{
Name: "change-remote",
Short: "changes a remote",
Help: "Give it an address.",
Flags: []FlagSchema{
{Name: "address", Usage: "the new remote", Default: "1.2.3.4:4242"},
{Name: "by-index", Bool: true, Usage: "match by index"},
{Name: "json", Bool: true, Usage: "outputs as json"},
},
}
out := &bytes.Buffer{}
require.NoError(t, WriteCommandHelp(cs, out))
assert.Equal(t, "change-remote - changes a remote\n"+
" Give it an address.\n"+
" -address string\n \tthe new remote (default 1.2.3.4:4242)\n"+
" -by-index\n \tmatch by index\n"+
" -json\n \toutputs as json\n", out.String())
}
// addrFlags is the flag struct of a test command that takes -address.
type addrFlags struct{ Address string }
// TestHelpFlagIsReadWhereParseReadsIt: -h asks for help only where the line's parser
// reads it as a flag. As a flag's value or after -- it reaches the command as the
// operand it is, and help is rendered by the same function `help <command>` uses.
func TestHelpFlagIsReadWhereParseReadsIt(t *testing.T) {
var seen []string
r := New()
r.Register(Flagged(&Command{Name: "x", ShortDescription: "takes an address"},
func(fl *flag.FlagSet, s *addrFlags) { fl.StringVar(&s.Address, "address", "", "an address") },
func(s *addrFlags, a []string) (Result, error) {
seen = append(seen, s.Address+"|"+strings.Join(a, ","))
return Nothing(), nil
}))
for _, line := range []string{"x -h", "x -help", "x --help", "x 10.0.0.1 -h", "x -address=1 -h"} {
out := &bytes.Buffer{}
r.Dispatch(line, MergedOutput(out))
assert.Contains(t, out.String(), "x - takes an address\n", line)
assert.Contains(t, out.String(), "-address string", line)
}
assert.Empty(t, seen, "asking for help must not run the command")
for _, line := range []string{"x -address -h", "x -- -h"} {
out := &bytes.Buffer{}
r.Dispatch(line, MergedOutput(out))
assert.Empty(t, out.String(), line)
}
assert.Equal(t, []string{"-h|", "|-h"}, seen)
}
// TestBadFlagIsAnsweredWithTheCommandsHelp: a flag the command does not have is reported
// on the error stream followed by the command's usage, in the rendering help uses.
func TestBadFlagIsAnsweredWithTheCommandsHelp(t *testing.T) {
out, errOut := &bytes.Buffer{}, &bytes.Buffer{}
r := New()
r.Register(&Command{Name: "x", ShortDescription: "has no flags", Run: func(fs any, a []string) (Result, error) { return Nothing(), nil }})
require.Error(t, r.DispatchArgs([]string{"x", "-nope"}, Output{Out: out, Err: errOut}))
assert.Empty(t, out.String())
assert.Equal(t, "flag provided but not defined: -nope\nx - has no flags\n -json\n \toutputs as json\n -pretty\n \tpretty prints json, assumes -json\n", errOut.String())
}
// TestParameterMistakesAreParamErrors: a surface with status codes needs to tell a
// parameter the caller got wrong from a command that failed, and Invoke says which by
// the error's type rather than its words.
func TestParameterMistakesAreParamErrors(t *testing.T) {
r := New()
r.Register(&Command{
Name: "flagged",
Flags: func() (*flag.FlagSet, any) {
fl := flag.NewFlagSet("", flag.ContinueOnError)
s := struct{ N int }{}
fl.IntVar(&s.N, "n", 0, "")
return fl, &s
},
Run: func(fs any, a []string) (Result, error) { return Result{}, errors.New("ran and failed") },
})
r.Register(&Command{
Name: "bare",
Run: func(fs any, a []string) (Result, error) { return Nothing(), nil },
})
var pe *ParamError
for _, tc := range []struct {
name string
params map[string]string
}{
{"flagged", map[string]string{"nope": "1"}},
{"flagged", map[string]string{"n": "x"}},
{"flagged", map[string]string{FlagJSON: "true"}},
{"bare", map[string]string{"n": "1"}},
} {
_, err := Invoke(Request{Command: r.Get(Name(tc.name)), Params: tc.params})
require.Error(t, err, "%s %v", tc.name, tc.params)
require.ErrorAs(t, err, &pe, "%s %v: %v", tc.name, tc.params, err)
}
_, err := Invoke(Request{Command: r.Get("flagged"), Params: map[string]string{"n": "1"}})
require.EqualError(t, err, "ran and failed")
assert.NotErrorAs(t, err, &pe, "a Run error is the command's, not the caller's")
}
// TestRunErrorIsShown: an error Run returns reaches the person at the prompt on the
// error stream, as well as the surface that logs it.
func TestRunErrorIsShown(t *testing.T) {
r := New()
r.Register(&Command{
Name: "broken",
ShortDescription: "cannot do what it is asked",
Run: func(fs any, a []string) (Result, error) {
return Result{}, errors.New("no certificate is loaded")
},
})
out, errOut := &bytes.Buffer{}, &bytes.Buffer{}
require.EqualError(t, r.DispatchArgs([]string{"broken"}, Output{Out: out, Err: errOut}), "no certificate is loaded")
assert.Empty(t, out.String())
assert.Equal(t, "no certificate is loaded\n", errOut.String())
}