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:
Matt RichardsonandClaude Fable 5.1 committed 2026-10-21 12:26:09 -04:00
1 parent 92c67a52b2
commit 613c7e3061
38 files changed
+2937 -332

No files matched your search

+3
View File
@@ -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.
+2
View File
@@ -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
View File
@@ -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)
}
+56
View File
@@ -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) {
+15
View File
@@ -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...)}}
+18
View File
@@ -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
View File
@@ -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
}