mirror of
https://github.com/slackhq/nebula.git
synced 2026-11-15 15:56:52 +01:00
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>
This commit is contained in:
1 parent
92c67a52b2
commit
613c7e3061
38 files changed
+2937
-332
No files matched your search
@@ -34,6 +34,9 @@ type Command struct {
|
||||
ShortDescription string
|
||||
Help string
|
||||
|
||||
// Identity indicates this command should be made available via the info socket.
|
||||
Identity bool
|
||||
|
||||
Flags CommandFlags
|
||||
|
||||
// Run produces the command's Result, leaving presentation to Render.
|
||||
|
||||
@@ -13,6 +13,7 @@ const (
|
||||
CreateTunnel Name = "create-tunnel"
|
||||
DeviceInfo Name = "device-info"
|
||||
Help Name = "help"
|
||||
Identity Name = "identity"
|
||||
ListHostmap Name = "list-hostmap"
|
||||
ListLighthouseAddrmap Name = "list-lighthouse-addrmap"
|
||||
ListPendingHostmap Name = "list-pending-hostmap"
|
||||
@@ -25,4 +26,5 @@ const (
|
||||
QueryLighthouse Name = "query-lighthouse"
|
||||
Reload Name = "reload"
|
||||
Version Name = "version"
|
||||
Whoami Name = "whoami"
|
||||
)
|
||||
+62
-34
@@ -2,6 +2,7 @@ package commands
|
||||
|
||||
import (
|
||||
"cmp"
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
@@ -15,9 +16,8 @@ import (
|
||||
|
||||
// Registry holds a set of commands.
|
||||
//
|
||||
// It is safe for concurrent use. Commands may be registered while surfaces are serving
|
||||
// from the registry: the ssh server clones it per session and the control socket filters
|
||||
// it per request, and either may overlap a Register.
|
||||
// It is safe for concurrent use: the ssh server clones it per session and the control
|
||||
// socket serves it as it is, while the daemon fills it once at startup.
|
||||
type Registry struct {
|
||||
mu sync.RWMutex
|
||||
commands map[Name]*Command
|
||||
@@ -63,15 +63,16 @@ func (r *Registry) registerHelp() {
|
||||
return Value(r.Schema()), nil
|
||||
}
|
||||
|
||||
if c := r.Get(Name(args[0])); c != nil {
|
||||
return Value(r.schemaFor(c)), nil
|
||||
c, err := r.Resolve(Name(args[0]))
|
||||
if err != nil {
|
||||
var withheld *WithheldError
|
||||
if errors.As(err, &withheld) {
|
||||
return Failed("%s", err), nil
|
||||
}
|
||||
return Failed("Command not available %s", args[0]), nil
|
||||
}
|
||||
|
||||
if err := r.unavailable(Name(args[0])); err != nil {
|
||||
return Failed("%s", err), nil
|
||||
}
|
||||
|
||||
return Failed("Command not available %s", args[0]), nil
|
||||
return Value(r.SchemaFor(c)), nil
|
||||
},
|
||||
Text: renderHelp,
|
||||
})
|
||||
@@ -222,10 +223,9 @@ func (r *Registry) Clone() *Registry {
|
||||
|
||||
// Filter returns a filtered copy of the registry.
|
||||
//
|
||||
// Keep should return true for commands that should be included in the copy.
|
||||
//
|
||||
// reason says, to an operator who names a command the copy left out, why this surface
|
||||
// does not serve it; "" leaves the registry to say only that it does not.
|
||||
// Keep should return true for commands that should be included in the copy. reason
|
||||
// says, to an operator who names a command the copy left out, why this surface does
|
||||
// not serve it; "" leaves the registry to say only that it does not.
|
||||
func (r *Registry) Filter(keep func(*Command) bool, reason string) *Registry {
|
||||
out := &Registry{commands: map[Name]*Command{}, full: r, reason: reason, DefaultFormat: r.DefaultFormat}
|
||||
|
||||
@@ -267,9 +267,10 @@ func (r *Registry) DispatchArgs(args []string, w Output) error {
|
||||
args = []string{"help"}
|
||||
}
|
||||
|
||||
c := r.Get(Name(args[0]))
|
||||
if c == nil {
|
||||
if err := r.unavailable(Name(args[0])); err != nil {
|
||||
c, err := r.Resolve(Name(args[0]))
|
||||
if err != nil {
|
||||
var withheld *WithheldError
|
||||
if errors.As(err, &withheld) {
|
||||
// Saying why, rather than "did not understand", keeps the rule legible and
|
||||
// points at a surface that will actually run the command.
|
||||
_, _ = fmt.Fprintln(w.Err, err)
|
||||
@@ -310,12 +311,18 @@ func (r *Registry) unavailable(name Name) error {
|
||||
reason = "this surface does not serve it"
|
||||
}
|
||||
|
||||
return fmt.Errorf("%s is not available on this surface: %s", c.Name, reason)
|
||||
return &WithheldError{msg: fmt.Sprintf("%s is not available on this surface: %s", c.Name, reason)}
|
||||
}
|
||||
|
||||
// Resolve returns the command registered under name, or says why there is none: the
|
||||
// filter's reason for one this surface withholds, or unknown for a name that does not
|
||||
// exist.
|
||||
// WithheldError indicates that the command is supported by Nebula, but not via this path.
|
||||
type WithheldError struct {
|
||||
msg string
|
||||
}
|
||||
|
||||
func (e *WithheldError) Error() string { return e.msg }
|
||||
|
||||
// Resolve returns the command registered under name, or says why there is none: a
|
||||
// WithheldError with the filter's reason, or unknown for a name that does not exist.
|
||||
func (r *Registry) Resolve(name Name) (*Command, error) {
|
||||
if c := r.Get(name); c != nil {
|
||||
return c, nil
|
||||
@@ -328,6 +335,20 @@ func (r *Registry) Resolve(name Name) (*Command, error) {
|
||||
return nil, fmt.Errorf("unknown command %q", name)
|
||||
}
|
||||
|
||||
// ParamError is what Invoke returns for a parameter the command would not take: one it
|
||||
// does not have, or a value its flag would not parse. It is the caller's mistake rather
|
||||
// than the command's failure, and a surface with status codes tells the two apart by
|
||||
// it; every other error from Invoke came out of Run.
|
||||
type ParamError struct {
|
||||
msg string
|
||||
}
|
||||
|
||||
func (e *ParamError) Error() string { return e.msg }
|
||||
|
||||
func paramErrorf(format string, args ...any) error {
|
||||
return &ParamError{msg: fmt.Sprintf(format, args...)}
|
||||
}
|
||||
|
||||
// setParams sets each named parameter on fl, in the order of names, and stops at the
|
||||
// first that is unknown or does not parse. own says whether the command declared flags
|
||||
// of its own, which decides between "takes no parameters" and "has no parameter".
|
||||
@@ -339,44 +360,51 @@ func (r *Registry) Resolve(name Name) (*Command, error) {
|
||||
func setParams(fl *flag.FlagSet, name string, names []string, params map[string]string, own bool) error {
|
||||
for _, k := range names {
|
||||
if isPresentationFlag(k) {
|
||||
return fmt.Errorf("%s is a command line flag, not a parameter; choose the rendering with the request's format field", k)
|
||||
return paramErrorf("%s is a command line flag, not a parameter; choose the rendering with the request's format field", k)
|
||||
}
|
||||
|
||||
f := fl.Lookup(k)
|
||||
if f == nil {
|
||||
if !own {
|
||||
return fmt.Errorf("%s takes no parameters", name)
|
||||
return paramErrorf("%s takes no parameters", name)
|
||||
}
|
||||
return fmt.Errorf("%s has no parameter %q", name, k)
|
||||
return paramErrorf("%s has no parameter %q", name, k)
|
||||
}
|
||||
|
||||
v := params[k]
|
||||
if err := fl.Set(k, v); err != nil {
|
||||
if IsBoolFlag(f) {
|
||||
// The flag package's own words for this are "parse error".
|
||||
return fmt.Errorf("%s: expected a boolean, got %q", k, v)
|
||||
}
|
||||
return fmt.Errorf("%s: %w", k, err)
|
||||
if err := setFlag(fl, f, params[k]); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// setFlag applies one value to f, reporting a refusal as a ParamError in words that
|
||||
// name the value: the flag package's own words for a bad boolean are "parse error".
|
||||
func setFlag(fl *flag.FlagSet, f *flag.Flag, v string) error {
|
||||
if err := fl.Set(f.Name, v); err != nil {
|
||||
if IsBoolFlag(f) {
|
||||
return paramErrorf("%s: expected a boolean, got %q", f.Name, v)
|
||||
}
|
||||
return paramErrorf("%s: %v", f.Name, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Schema describes every command in the registry, so a client can offer them without
|
||||
// having been built against this daemon.
|
||||
func (r *Registry) Schema() []CommandSchema {
|
||||
all := r.All()
|
||||
out := make([]CommandSchema, 0, len(all))
|
||||
for _, c := range all {
|
||||
out = append(out, r.schemaFor(c))
|
||||
out = append(out, r.SchemaFor(c))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// schemaFor describes one command. It is the one derivation, for the whole schema and
|
||||
// SchemaFor describes one command. It is the one derivation, for the whole schema and
|
||||
// for `help <command>` alike.
|
||||
func (r *Registry) schemaFor(c *Command) CommandSchema {
|
||||
func (r *Registry) SchemaFor(c *Command) CommandSchema {
|
||||
fl, _, _ := commandFlags(c)
|
||||
return schemaOf(c, fl)
|
||||
}
|
||||
|
||||
@@ -87,6 +87,22 @@ func TestWithheldIsNotReportedAsUnknown(t *testing.T) {
|
||||
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) {
|
||||
@@ -401,6 +417,46 @@ func TestBadFlagIsAnsweredWithTheCommandsHelp(t *testing.T) {
|
||||
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) {
|
||||
|
||||
@@ -112,6 +112,13 @@ func renderJSON(v any, w io.Writer, indent bool) error {
|
||||
type Result struct {
|
||||
Outcome
|
||||
Value any
|
||||
|
||||
// NotFound marks a refusal that is about the thing asked for rather than the asking:
|
||||
// a vpn address with no tunnel behind it, as opposed to one that would not parse. It
|
||||
// renders exactly as any other refusal, and never reaches the wire; the distinction
|
||||
// is for a surface whose protocol has a word for it, so an HTTP client is told 404
|
||||
// rather than 400 and never has to parse the sentence.
|
||||
NotFound bool
|
||||
}
|
||||
|
||||
// Outcome is the part of a Result that says whether the command worked and, in words a
|
||||
@@ -137,6 +144,14 @@ func Failed(format string, args ...any) Result {
|
||||
return Result{Outcome: Outcome{Reason: fmt.Sprintf(format, args...)}}
|
||||
}
|
||||
|
||||
// NotFound is Failed for a command asked about something that does not exist. See
|
||||
// Result.NotFound.
|
||||
func NotFound(format string, args ...any) Result {
|
||||
r := Failed(format, args...)
|
||||
r.NotFound = true
|
||||
return r
|
||||
}
|
||||
|
||||
// Did is a convenience for the common "it worked, here is what happened" case.
|
||||
func Did(format string, args ...any) Result {
|
||||
return Result{Outcome: Outcome{OK: true, Reason: fmt.Sprintf(format, args...)}}
|
||||
|
||||
@@ -103,3 +103,21 @@ func TestResultShapes(t *testing.T) {
|
||||
assert.Equal(t, Outcome{OK: true, Reason: "done"}, Did("done").Payload())
|
||||
assert.Equal(t, Outcome{Reason: "no"}, Failed("no").Payload())
|
||||
}
|
||||
|
||||
// TestNotFoundIsARefusalLikeAnyOther: NotFound renders and marshals exactly as Failed
|
||||
// does, so no surface's output changes by a command adopting it. The mark is on the
|
||||
// Result alone, for a surface whose protocol has a word for it.
|
||||
func TestNotFoundIsARefusalLikeAnyOther(t *testing.T) {
|
||||
r := NotFound("No active tunnel for vpn addr: %s", "10.0.0.5")
|
||||
assert.False(t, r.OK)
|
||||
assert.True(t, r.NotFound)
|
||||
assert.False(t, Failed("nope").NotFound)
|
||||
|
||||
out := &bytes.Buffer{}
|
||||
require.NoError(t, Render(&Command{Name: "look"}, r, FormatText, out))
|
||||
assert.Equal(t, "No active tunnel for vpn addr: 10.0.0.5\n", out.String())
|
||||
|
||||
out.Reset()
|
||||
require.NoError(t, Render(&Command{Name: "look"}, r, FormatJSON, out))
|
||||
assert.JSONEq(t, `{"ok":false,"reason":"No active tunnel for vpn addr: 10.0.0.5"}`, out.String(), "the mark never reaches the wire")
|
||||
}
|
||||
+26
-2
@@ -80,6 +80,31 @@ func presentationFormat(fl *flag.FlagSet) Format {
|
||||
}
|
||||
}
|
||||
|
||||
// PresentationFormat is presentationFormat for a surface that takes a command's options
|
||||
// by name rather than from a line: the info API's query string. Only the two presentation
|
||||
// flags are read from params; the command's own parameters are the caller's to pass to
|
||||
// Invoke, which refuses these two among them. A flag given with no value counts as set,
|
||||
// as -pretty does on a line, and one that is not a boolean is a ParamError.
|
||||
func PresentationFormat(params map[string]string) (Format, error) {
|
||||
fl := flag.NewFlagSet("", flag.ContinueOnError)
|
||||
addPresentationFlags(fl)
|
||||
|
||||
for _, k := range []string{FlagJSON, FlagPretty} {
|
||||
v, ok := params[k]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if v == "" {
|
||||
v = "true"
|
||||
}
|
||||
if err := setFlag(fl, fl.Lookup(k), v); err != nil {
|
||||
return FormatUnset, err
|
||||
}
|
||||
}
|
||||
|
||||
return presentationFormat(fl), nil
|
||||
}
|
||||
|
||||
// flagIsTrue reports whether a bool flag was set to true. The flag package's bool value
|
||||
// is a flag.Getter whose Get returns the bool, so this never falls back to parsing text.
|
||||
func flagIsTrue(f *flag.Flag) bool {
|
||||
@@ -108,10 +133,9 @@ func flagIsTrue(f *flag.Flag) bool {
|
||||
// parameters is refused rather than read as a preference.
|
||||
func Invoke(req Request) (Result, error) {
|
||||
c := req.Command
|
||||
names := slices.Sorted(maps.Keys(req.Params))
|
||||
|
||||
fl, fs, own := commandFlags(c)
|
||||
if err := setParams(fl, string(c.Name), names, req.Params, own); err != nil {
|
||||
if err := setParams(fl, string(c.Name), slices.Sorted(maps.Keys(req.Params)), req.Params, own); err != nil {
|
||||
return Result{}, err
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user