Răsfoiți Sursa

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 findCertificateForVpnAddr 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 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>
Matt Richardson 6 zile în urmă
părinte
comite
24cb5fd9b4

+ 10 - 0
CHANGELOG.md

@@ -42,6 +42,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
   `print-tunnel 10.0.0.1 -json` asks for JSON rather than dropping the flag, and `--` ends the flags as it
   always has. A negative number is a flag, as everywhere else in nebula; an operand that looks like one goes
   after `--`.
+- `identity -address <vpn addr>[:port]` prints the certificate identity of the host owning the address when a
+  tunnel to it is up, and `whoami` prints this node's own: name, vpn addresses, networks, unsafe networks, groups,
+  fingerprint, issuer, validity window and cert version. The port is ignored so a connection's remote address
+  can be passed through as is; `identity` without an address is refused rather than answered with this node, so
+  a program that failed to pass one cannot authorize against the daemon's own identity. Both are available over
+  the ssh debug server and `nebula ctl`, and are what the new opt-in info API serves, and all it serves:
+  `local_api.info` names a unix socket and a mode, and `GET /v1/identity?address=<addr>` and `GET /v1/whoami` on
+  it answer with the same JSON, 404 when no tunnel to the address is up, so a program on the host can resolve the
+  source of a connection arriving over the overlay to an identity without a client library. The socket's mode is
+  the access control; the section is reloadable and the API is not yet served on Windows.
 
 ### Removed
 

+ 4 - 4
cmd/nebula-service/main.go

@@ -117,15 +117,15 @@ func main() {
 			os.Exit(1)
 		}
 
-		ctl := localapi.Wire(ctrl.Context(), l, c, ctrl.Commands())
+		local := localapi.Wire(ctrl.Context(), l, c, ctrl.Commands())
 
 		go ctrl.ShutdownBlock()
 
 		err := ctrl.Wait()
 
-		// os.Exit skips deferred calls, so the socket is removed here, before either exit
-		// below, rather than left to a deferred Stop that would never run.
-		ctl.Stop()
+		// os.Exit skips deferred calls, so the sockets are removed here, before either
+		// exit below, rather than left to a deferred Stop that would never run.
+		local.Stop()
 
 		if err != nil {
 			l.Error("Nebula stopped due to fatal error", "error", err)

+ 8 - 8
cmd/nebula-service/service.go

@@ -18,7 +18,7 @@ type program struct {
 	configPath *string
 	build      string
 	control    *nebula.Control
-	ctl        *localapi.Server
+	local      *localapi.Endpoints
 }
 
 func (p *program) Start(s service.Service) error {
@@ -52,16 +52,16 @@ func (p *program) Start(s service.Service) error {
 		return err
 	}
 
-	// Now that we're started, stand up the local control socket.
-	p.ctl = localapi.Wire(p.control.Context(), l, c, p.control.Commands())
+	// Now that we're started, stand up the local APIs.
+	p.local = localapi.Wire(p.control.Context(), l, c, p.control.Commands())
 
 	// Nebula can stop itself on a fatal packet reader error, make sure to log it if it happens.
 	go func() {
 		if err := p.control.Wait(); err != nil {
 			logger.Error(fmt.Sprintf("Nebula stopped due to fatal error: %v", err))
-			// os.Exit skips deferred calls and races the socket's own context watcher,
-			// so the socket is removed here, as cmd/nebula does.
-			p.ctl.Stop()
+			// os.Exit skips deferred calls and races the sockets' own context watchers,
+			// so they are removed here, as cmd/nebula does.
+			p.local.Stop()
 			os.Exit(2)
 		}
 	}()
@@ -72,8 +72,8 @@ func (p *program) Start(s service.Service) error {
 func (p *program) Stop(s service.Service) error {
 	logger.Info("Nebula service stopping.")
 
-	if p.ctl != nil {
-		p.ctl.Stop()
+	if p.local != nil {
+		p.local.Stop()
 	}
 
 	if p.control == nil {

+ 4 - 4
cmd/nebula/main.go

@@ -103,16 +103,16 @@ func main() {
 			os.Exit(1)
 		}
 
-		ctl := localapi.Wire(ctrl.Context(), l, c, ctrl.Commands())
+		local := localapi.Wire(ctrl.Context(), l, c, ctrl.Commands())
 
 		go ctrl.ShutdownBlock()
 		notifyReady(l)
 
 		err := ctrl.Wait()
 
-		// os.Exit skips deferred calls, so the socket is removed here, before either exit
-		// below, rather than left to a deferred Stop that would never run.
-		ctl.Stop()
+		// os.Exit skips deferred calls, so the sockets are removed here, before either
+		// exit below, rather than left to a deferred Stop that would never run.
+		local.Stop()
 
 		if err != nil {
 			l.Error("Nebula stopped due to fatal error", "error", err)

+ 26 - 0
commands.go

@@ -178,6 +178,32 @@ func attachCommands(l *slog.Logger, c *config.C, reg *commands.Registry, f *Inte
 		return runPrintCert(f, s, a)
 	}))
 
+	reg.Register(commands.Flagged(&commands.Command{
+		Name:             commands.Identity,
+		ShortDescription: "Prints the certificate identity of the host owning the provided vpn addr",
+		Help:             "Identity is what the certificate says: name, vpn addrs, networks, groups, fingerprint, issuer and validity. The address may carry a port, so a connection's remote address can be passed through as is. For this node's own identity use whoami.",
+		Identity:         true,
+		Text:             commands.Typed(textIdentity),
+	}, func(fl *flag.FlagSet, s *identityFlags) {
+		fl.Func("address", "The vpn addr to look up, optionally with a port", s.setAddress)
+	}, func(s *identityFlags, a []string) (commands.Result, error) {
+		return runIdentity(f, s, a)
+	}))
+
+	reg.Register(&commands.Command{
+		Name:             commands.Whoami,
+		ShortDescription: "Prints the certificate identity of this node",
+		Help:             "The fields identity prints, read from this node's own certificate.",
+		Identity:         true,
+		Text:             commands.Typed(textIdentity),
+		Run: func(fs any, a []string) (commands.Result, error) {
+			if len(a) > 0 {
+				return commands.Failed("whoami takes no arguments"), nil
+			}
+			return runWhoami(f)
+		},
+	})
+
 	reg.Register(&commands.Command{
 		Name:             commands.PrintTunnel,
 		ShortDescription: "Prints json details about a tunnel for the provided vpn addr",

+ 7 - 0
commands/command.go

@@ -36,6 +36,13 @@ type Command struct {
 
 	ForProfiling bool
 
+	// Identity declares that the command reports only what a certificate says about a
+	// host: its name, addresses, groups and validity. Nothing operational: no underlay
+	// addresses, no configuration, nothing a command could change. The info API, which
+	// an operator may open to other users on the host, serves these and help alone; see
+	// localapi.
+	Identity bool
+
 	Flags CommandFlags
 
 	// Run produces the command's Result, leaving presentation to Render.

+ 2 - 0
commands/names.go

@@ -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"
 )

+ 58 - 25
commands/registry.go

@@ -2,6 +2,7 @@ package commands
 
 import (
 	"cmp"
+	"errors"
 	"flag"
 	"fmt"
 	"io"
@@ -63,15 +64,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
-			}
-
-			if err := r.unavailable(Name(args[0])); err != nil {
-				return Failed("%s", err), 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
 			}
 
-			return Failed("Command not available %s", args[0]), nil
+			return Value(r.schemaFor(c)), nil
 		},
 		Text: renderHelp,
 	})
@@ -218,10 +220,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}
 
@@ -263,9 +264,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)
@@ -306,9 +308,19 @@ 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)}
 }
 
+// WithheldError is what Resolve returns for a command that exists and this surface does
+// not serve. It is the filter's refusal rather than a typo, and a surface with status
+// codes tells the two apart by it, so no surface has to look past its own registry to
+// find out which it was told.
+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
 // command this surface withholds is reported with the filter's reason, and one that
 // does not exist at all as unknown, so a client that misspells and one that asks for a
@@ -326,6 +338,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".
@@ -337,30 +363,37 @@ 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 {

+ 57 - 0
commands/registry_test.go

@@ -2,6 +2,7 @@ package commands
 
 import (
 	"bytes"
+	"errors"
 	"flag"
 	"io"
 	"strings"
@@ -113,6 +114,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")
+}
+
 // TestSplitWriterKeepsOutputParseable is why the writer grew Err(): usage text on the
 // same stream as -json output would corrupt it.
 func TestSplitWriterKeepsOutputParseable(t *testing.T) {
@@ -458,3 +475,43 @@ func TestBadFlagIsAnsweredWithTheCommandsHelp(t *testing.T) {
 	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")
+}

+ 15 - 0
commands/render.go

@@ -115,6 +115,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
@@ -140,6 +147,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 - 0
commands/render_test.go

@@ -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
commands/request.go

@@ -83,6 +83,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 {
@@ -111,10 +136,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
 	}
 

+ 3 - 22
dns_server.go

@@ -259,31 +259,12 @@ func (d *dnsServer) QueryCert(data string) string {
 		return ""
 	}
 
-	// The hostmap only ever contains peers we have handshaked with, so it never carries an entry for ourselves.
-	// Answer self lookups straight from the local cert state.
-	if cs := d.certState(); cs != nil && cs.myVpnAddrsTable != nil && cs.myVpnAddrsTable.Contains(ip) {
-		c := cs.GetDefaultCertificate()
-		if c == nil {
-			return ""
-		}
-		b, err := c.MarshalJSON()
-		if err != nil {
-			return ""
-		}
-		return string(b)
-	}
-
-	hostinfo := d.hostMap.QueryVpnAddr(ip)
-	if hostinfo == nil {
-		return ""
-	}
-
-	q := hostinfo.GetCert()
-	if q == nil {
+	crt, _ := findCertificateForVpnAddr(d.certState(), d.hostMap, ip)
+	if crt == nil {
 		return ""
 	}
 
-	b, err := q.Certificate.MarshalJSON()
+	b, err := crt.MarshalJSON()
 	if err != nil {
 		return ""
 	}

+ 12 - 0
e2e/commands_output_test.go

@@ -101,6 +101,18 @@ func TestCommandOutput(t *testing.T) {
 		{"print-cert", []string{"10.222.0.9", "-json"}},
 		{"print-cert", []string{"10.222.0.250"}},
 		{"print-cert", []string{"not-an-address"}},
+		{"whoami", nil},
+		{"whoami", []string{"x"}},
+		// No address is a refusal that names whoami, never this node's identity.
+		{"identity", nil},
+		// Our own address, which the hostmap never holds, answers from our certificate.
+		{"identity", []string{"-address", "10.222.0.1"}},
+		{"identity", []string{"-address", "10.222.0.1:4242"}},
+		// A pending tunnel has no certificate yet.
+		{"identity", []string{"-address", "10.222.0.9"}},
+		{"identity", []string{"-address", "not-an-address"}},
+		{"identity", []string{"-address", ""}},
+		{"identity", []string{"10.222.0.9"}},
 		{"print-tunnel", nil},
 		{"print-tunnel", []string{"10.222.0.9"}},
 		{"print-tunnel", []string{"10.222.0.250"}},

+ 118 - 0
e2e/info_api_test.go

@@ -0,0 +1,118 @@
+//go:build e2e_testing
+
+package e2e
+
+import (
+	"context"
+	"encoding/json"
+	"net/http"
+	"net/netip"
+	"path/filepath"
+	"runtime"
+	"testing"
+	"time"
+
+	"github.com/slackhq/nebula/cert"
+	"github.com/slackhq/nebula/cert_test"
+	"github.com/slackhq/nebula/e2e/router"
+	"github.com/slackhq/nebula/localapi"
+	"github.com/slackhq/nebula/test"
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+)
+
+// TestIdentityOverTheInfoAPI is the info API's reason to exist, end to end: a program on
+// this host has a connection arriving over the overlay and asks who is behind its
+// source address, and the answer is the identity in the certificate the tunnel was
+// established with.
+func TestIdentityOverTheInfoAPI(t *testing.T) {
+	if runtime.GOOS == "windows" {
+		t.Skip("the info API is not served on windows")
+	}
+
+	ca, _, caKey, _ := cert_test.NewTestCaCert(cert.Version2, cert.Curve_CURVE25519, time.Now(), time.Now().Add(10*time.Minute), nil, nil, []string{})
+	myControl, myVpnIpNet, _, _ := newSimpleServer(cert.Version2, ca, caKey, "me", "10.128.0.1/24", nil)
+	theirControl, theirVpnIpNet, theirUdpAddr, _ := newSimpleServer(cert.Version2, ca, caKey, "them", "10.128.0.2/24", nil)
+
+	myControl.InjectLightHouseAddr(theirVpnIpNet[0].Addr(), theirUdpAddr)
+
+	r := router.NewR(t, myControl, theirControl)
+	defer r.RenderFlow()
+
+	myControl.Start()
+	theirControl.Start()
+	t.Cleanup(func() {
+		myControl.Stop()
+		theirControl.Stop()
+	})
+
+	// Before the tunnel is up their address is unknown to us.
+	path := infoSocket(t)
+	info := localapi.NewInfoServer(NewTestLogger(), myControl.Commands())
+	require.NoError(t, info.Rebind(context.Background(), path, localapi.Access{Mode: 0600}))
+	t.Cleanup(info.Stop)
+
+	status, body := test.HTTPGet(t, path, "/v1/identity?address="+theirVpnIpNet[0].Addr().String())
+	assert.Equal(t, http.StatusNotFound, status)
+	assert.JSONEq(t, `{"error":"No active tunnel for vpn addr: 10.128.0.2"}`, body)
+
+	// Only we know how to reach them, so the first packet has to be ours; once the tunnel
+	// is up both directions work and assertTunnel can check it.
+	myControl.InjectTunPacket(BuildTunUDPPacket(theirVpnIpNet[0].Addr(), 80, myVpnIpNet[0].Addr(), 80, []byte("Hi from me")))
+	p := r.RouteForAllUntilTxTun(theirControl)
+	assertUdpPacket(t, []byte("Hi from me"), p, myVpnIpNet[0].Addr(), theirVpnIpNet[0].Addr(), 80, 80)
+	assertTunnel(t, myVpnIpNet[0].Addr(), theirVpnIpNet[0].Addr(), myControl, theirControl, r)
+
+	type identity struct {
+		Name     string         `json:"name"`
+		VpnAddrs []netip.Addr   `json:"vpnAddrs"`
+		Networks []netip.Prefix `json:"networks"`
+		Groups   []string       `json:"groups"`
+		Issuer   string         `json:"issuer"`
+	}
+
+	// A connection's RemoteAddr, port and all, resolves to the peer.
+	status, body = test.HTTPGet(t, path, "/v1/identity?address="+netip.AddrPortFrom(theirVpnIpNet[0].Addr(), 51234).String())
+	require.Equal(t, http.StatusOK, status, body)
+	var them identity
+	require.NoError(t, json.Unmarshal([]byte(body), &them))
+	assert.Equal(t, "them", them.Name)
+	assert.Equal(t, []netip.Addr{theirVpnIpNet[0].Addr()}, them.VpnAddrs)
+	assert.Equal(t, theirVpnIpNet, them.Networks)
+	assert.Equal(t, []string{}, them.Groups)
+	caFP, err := ca.Fingerprint()
+	require.NoError(t, err)
+	assert.Equal(t, caFP, them.Issuer)
+
+	// No address is a caller that forgot the parameter, and is refused rather than handed
+	// this node's identity to authorize against; whoami is the command for that.
+	status, body = test.HTTPGet(t, path, "/v1/identity")
+	assert.Equal(t, http.StatusBadRequest, status, body)
+	assert.Contains(t, body, "whoami")
+
+	status, body = test.HTTPGet(t, path, "/v1/whoami")
+	require.Equal(t, http.StatusOK, status, body)
+	var me identity
+	require.NoError(t, json.Unmarshal([]byte(body), &me))
+	assert.Equal(t, "me", me.Name)
+	assert.Equal(t, []netip.Addr{myVpnIpNet[0].Addr()}, me.VpnAddrs)
+
+	// An empty address is a caller's bug, and is told so rather than handed our identity.
+	status, body = test.HTTPGet(t, path, "/v1/identity?address=")
+	assert.Equal(t, http.StatusBadRequest, status)
+	assert.JSONEq(t, `{"error":"The provided vpn addr could not be parsed: "}`, body)
+
+	// Nothing else nebula serves is reachable here.
+	status, body = test.HTTPGet(t, path, "/v1/list-hostmap")
+	assert.Equal(t, http.StatusForbidden, status)
+	assert.Contains(t, body, "identity lookups only")
+
+	r.RenderHostmaps("Final hostmaps", myControl, theirControl)
+}
+
+// infoSocket returns a short socket path in a private directory, one level below it
+// so that listen has a directory of its own to create.
+func infoSocket(t *testing.T) string {
+	t.Helper()
+	return filepath.Join(test.ShortTempDir(t), "n", "info.sock")
+}

Fișier diff suprimat deoarece este prea mare
+ 191 - 0
e2e/testdata/commands_output.golden


+ 18 - 0
examples/config.yml

@@ -254,6 +254,24 @@ punchy:
     # socket.
     #group: nebula
 
+  # The info socket is not made available by default. If you enable it, it makes some
+  # internal state available over HTTP. It's not available on Windows.
+  #
+  # The currently supported HTTP requests are:
+  #   GET /v1/identity?address=<vpn addr>[:port]  the host owning the address: a peer with
+  #                                               an established tunnel, or this node. The
+  #                                               port is ignored. 404 when no tunnel is
+  #                                               up, 400 when no address is given.
+  #   GET /v1/whoami                              this node's identity
+  #   GET /v1                                     describes what is served
+  #
+  #info:
+    #enabled: false
+    # The absolute path to listen on.
+    #socket: /var/run/nebula/info.sock
+    # Octal mode for the socket, as a quoted string. Defaults to "0600".
+    #socket_mode: "0600"
+
 # EXPERIMENTAL: relay support for networks that can't establish direct connections.
 relay:
   # Relays are a list of Nebula IP's that peers can use to relay packets to me.

+ 225 - 0
identity.go

@@ -0,0 +1,225 @@
+package nebula
+
+import (
+	"fmt"
+	"io"
+	"net/netip"
+	"strings"
+	"time"
+
+	"github.com/slackhq/nebula/cert"
+	"github.com/slackhq/nebula/commands"
+)
+
+type identityFlags struct {
+	Address string
+
+	// addressSet records that -address was given at all, so a line that left it out is
+	// told to pass it and one that passed it empty is told the value did not parse. Neither
+	// is answered with this node's identity, which is whoami's to report: a program
+	// passing a connection's remote address through must get an error for a value it
+	// failed to fill in, not the daemon's own identity to authorize against.
+	addressSet bool
+}
+
+// setAddress is -address's setter. flag.Func rather than StringVar so that presence is
+// known; its String is empty, which is what a client building a line from the schema
+// sees as the default.
+func (f *identityFlags) setAddress(v string) error {
+	f.Address = v
+	f.addressSet = true
+	return nil
+}
+
+// hostIdentity is what identity reports: the identity a certificate asserts for a host,
+// and nothing else. Every field is read from a certificate nebula verified when the
+// tunnel came up, or from our own, which is what lets a program on this host make an
+// authorization decision from it, within two limits. Nebula drops an inbound packet
+// whose source address is not in the sender's certificate, so for a connection that
+// arrived on the nebula interface the source address maps to a certificate that claims
+// it. That certificate is the sender's only while no peer's unsafe networks overlap the
+// overlay: a peer whose certificate carries an unsafe network containing another node's
+// vpn address may send as that address, and the lookup then reports the node, not the
+// sender. And a connection that arrived on any other interface with an overlay source
+// address was never checked by nebula at all. A program authorizing on this answer
+// should confirm the connection came in on the nebula interface and know whether its
+// network lets unsafe networks overlap the overlay. The field names are an API: the
+// info API serves this shape to programs that were not built against nebula.
+type hostIdentity struct {
+	Name           string         `json:"name"`
+	VpnAddrs       []netip.Addr   `json:"vpnAddrs"`
+	Networks       []netip.Prefix `json:"networks"`
+	UnsafeNetworks []netip.Prefix `json:"unsafeNetworks"`
+	Groups         []string       `json:"groups"`
+	Fingerprint    string         `json:"fingerprint"`
+	Issuer         string         `json:"issuer"`
+	NotBefore      time.Time      `json:"notBefore"`
+	NotAfter       time.Time      `json:"notAfter"`
+	CertVersion    int            `json:"certVersion"`
+}
+
+// runIdentity answers for whoever owns the address: this node, when it is one of ours, or
+// the peer behind an established tunnel. The address is required. This node's own
+// identity is whoami's to report, so a caller that failed to pass an address is told so
+// rather than handed an identity it could mistake for its peer's.
+func runIdentity(ifce *Interface, args *identityFlags, a []string) (commands.Result, error) {
+	// print-cert takes its address as an operand; this takes a flag, because the query
+	// string of the info API names its parameters and an operand has no name there. One
+	// spelling on every surface, so the mistake is named rather than ignored.
+	if len(a) > 0 {
+		return commands.Failed("identity takes the vpn addr as -address, not as an operand: identity -address %s", a[0]), nil
+	}
+
+	if !args.addressSet {
+		return commands.Failed("No vpn addr was provided, use -address <vpn addr>[:port]; whoami prints this node's own identity"), nil
+	}
+
+	vpnAddr, err := parseVpnAddrWithOptionalPort(args.Address)
+	if err != nil {
+		return commands.Failed("The provided vpn addr could not be parsed: %s", args.Address), nil
+	}
+
+	crt, fingerprint := findCertificateForVpnAddr(ifce.pki.getCertState(), ifce.hostMap, vpnAddr)
+	if crt == nil {
+		return commands.NotFound("No active tunnel for vpn addr: %s", vpnAddr), nil
+	}
+
+	id, err := newHostIdentity(crt, fingerprint)
+	if err != nil {
+		return commands.Result{}, err
+	}
+
+	return commands.Value(id), nil
+}
+
+// runWhoami answers with this node's own identity, from its own certificate.
+func runWhoami(ifce *Interface) (commands.Result, error) {
+	crt := ownCertificate(ifce.pki.getCertState())
+	if crt == nil {
+		return commands.Result{}, fmt.Errorf("no certificate is loaded")
+	}
+
+	id, err := newHostIdentity(crt, "")
+	if err != nil {
+		return commands.Result{}, err
+	}
+
+	return commands.Value(id), nil
+}
+
+// textIdentity is the human rendering, shared by every surface.
+func textIdentity(id hostIdentity, w io.Writer) error {
+	_, err := fmt.Fprintf(w,
+		"name: %s\nvpn addrs: %s\nnetworks: %s\nunsafe networks: %s\ngroups: %s\nfingerprint: %s\nissuer: %s\nnot before: %s\nnot after: %s\ncert version: %d\n",
+		id.Name,
+		joinStringers(id.VpnAddrs),
+		joinStringers(id.Networks),
+		joinStringers(id.UnsafeNetworks),
+		strings.Join(id.Groups, ", "),
+		id.Fingerprint,
+		id.Issuer,
+		id.NotBefore.Format(time.RFC3339),
+		id.NotAfter.Format(time.RFC3339),
+		id.CertVersion,
+	)
+	return err
+}
+
+func joinStringers[T fmt.Stringer](in []T) string {
+	parts := make([]string, 0, len(in))
+	for _, v := range in {
+		parts = append(parts, v.String())
+	}
+	return strings.Join(parts, ", ")
+}
+
+// newHostIdentity reads the identity out of a certificate. fingerprint is the
+// certificate's when the caller already holds it, as a verified peer certificate does,
+// and "" to have it hashed here. Slices are always allocated so a host with no groups
+// reports [] rather than null.
+func newHostIdentity(crt cert.Certificate, fingerprint string) (hostIdentity, error) {
+	if fingerprint == "" {
+		fp, err := crt.Fingerprint()
+		if err != nil {
+			return hostIdentity{}, err
+		}
+		fingerprint = fp
+	}
+
+	networks, unsafeNetworks, groups := crt.Networks(), crt.UnsafeNetworks(), crt.Groups()
+	id := hostIdentity{
+		Name:           crt.Name(),
+		VpnAddrs:       make([]netip.Addr, 0, len(networks)),
+		Networks:       append(make([]netip.Prefix, 0, len(networks)), networks...),
+		UnsafeNetworks: append(make([]netip.Prefix, 0, len(unsafeNetworks)), unsafeNetworks...),
+		Groups:         append(make([]string, 0, len(groups)), groups...),
+		Fingerprint:    fingerprint,
+		Issuer:         crt.Issuer(),
+		NotBefore:      crt.NotBefore(),
+		NotAfter:       crt.NotAfter(),
+		CertVersion:    int(crt.Version()),
+	}
+	for _, n := range networks {
+		id.VpnAddrs = append(id.VpnAddrs, n.Addr())
+	}
+
+	return id, nil
+}
+
+// ownCertificate is this node's certificate: the v2 one when it is loaded, else the v1.
+// A v2 certificate is a superset of the v1 and may carry more networks, and it is the
+// one newCertState builds myVpnAddrsTable from, so an address that matched that table is
+// always listed in the certificate reported here. It is nil only when no certificate is
+// loaded, which a running nebula never has.
+func ownCertificate(cs *CertState) cert.Certificate {
+	if cs == nil {
+		return nil
+	}
+	if crt := cs.getCertificate(cert.Version2); crt != nil {
+		return crt
+	}
+	return cs.getCertificate(cert.Version1)
+}
+
+// findCertificateForVpnAddr answers who owns a vpn address: this node, from its own
+// certificate state, since the hostmap never carries an entry for ourselves, or the peer
+// behind an established tunnel. The certificate is nil when the address is unknown,
+// when the tunnel is still pending and when it is mid teardown. A peer's comes with the
+// fingerprint nebula computed when it verified the certificate; our own comes with "",
+// since none is kept for it. The lighthouse's DNS TXT lookup and identity both go
+// through it, so the two surfaces cannot disagree about whose address it is.
+func findCertificateForVpnAddr(cs *CertState, hostMap *HostMap, ip netip.Addr) (crt cert.Certificate, fingerprint string) {
+	if cs != nil && cs.myVpnAddrsTable != nil && cs.myVpnAddrsTable.Contains(ip) {
+		return ownCertificate(cs), ""
+	}
+
+	hostinfo := hostMap.QueryVpnAddr(ip)
+	if hostinfo == nil {
+		return nil, ""
+	}
+
+	cc := hostinfo.GetCert()
+	if cc == nil {
+		return nil, ""
+	}
+
+	return cc.Certificate, cc.Fingerprint
+}
+
+// parseVpnAddrWithOptionalPort reads a vpn address alone or with a port,
+// 192.168.100.7:54321 or [fd00::1]:443, so a program can pass a connection's remote
+// address through as its runtime reported it. A 4in6 address (::ffff:a.b.c.d) is
+// unmapped, which is how an IPv4 peer arrives on a dual stack listener, and a zone is
+// dropped, since no vpn address carries one.
+func parseVpnAddrWithOptionalPort(s string) (netip.Addr, error) {
+	ip, err := netip.ParseAddr(s)
+	if err != nil {
+		ap, err := netip.ParseAddrPort(s)
+		if err != nil {
+			return netip.Addr{}, err
+		}
+		ip = ap.Addr()
+	}
+
+	return ip.Unmap().WithZone(""), nil
+}

+ 215 - 0
identity_test.go

@@ -0,0 +1,215 @@
+package nebula
+
+import (
+	"bytes"
+	"encoding/json"
+	"net/netip"
+	"testing"
+	"time"
+
+	"github.com/slackhq/nebula/cert"
+	"github.com/slackhq/nebula/cert_test"
+	"github.com/slackhq/nebula/commands"
+	"github.com/slackhq/nebula/test"
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+)
+
+// identityFixture is an Interface with our own certificate loaded and one peer behind an
+// established tunnel, which is all identity reads.
+func identityFixture(t *testing.T) (*Interface, cert.Certificate) {
+	t.Helper()
+
+	l := test.NewLogger()
+	me := netip.MustParseAddr("10.0.0.1")
+	peer := netip.MustParsePrefix("10.0.0.2/24")
+
+	ca, _, caKey, _ := cert_test.NewTestCaCert(cert.Version2, cert.Curve_CURVE25519, time.Time{}, time.Time{}, nil, nil, nil)
+	peerCert, _, _, _ := cert_test.NewTestCert(cert.Version2, cert.Curve_CURVE25519, ca, caKey, "peer", time.Time{}, time.Time{},
+		[]netip.Prefix{peer}, []netip.Prefix{netip.MustParsePrefix("192.168.1.0/24")}, []string{"web", "db"})
+
+	hm := newHostMap(l)
+	hm.Hosts[peer.Addr()] = &HostInfo{
+		vpnAddrs:        []netip.Addr{peer.Addr()},
+		ConnectionState: &ConnectionState{peerCert: &cert.CachedCertificate{Certificate: peerCert}},
+	}
+	// A tunnel still handshaking has no certificate yet, and must not be reported.
+	hm.Hosts[netip.MustParseAddr("10.0.0.3")] = &HostInfo{
+		vpnAddrs:        []netip.Addr{netip.MustParseAddr("10.0.0.3")},
+		ConnectionState: &ConnectionState{},
+	}
+
+	return &Interface{pki: newTestPKI(t, "me", []netip.Addr{me}), hostMap: hm}, peerCert
+}
+
+// identity runs the command with -address given as address.
+func identity(t *testing.T, f *Interface, address string, operands ...string) commands.Result {
+	t.Helper()
+	fs := &identityFlags{}
+	require.NoError(t, fs.setAddress(address))
+	r, err := runIdentity(f, fs, operands)
+	require.NoError(t, err)
+	return r
+}
+
+// whoami runs the whoami command.
+func whoami(t *testing.T, f *Interface) commands.Result {
+	t.Helper()
+	r, err := runWhoami(f)
+	require.NoError(t, err)
+	return r
+}
+
+func TestWhoamiReportsThisNode(t *testing.T) {
+	f, _ := identityFixture(t)
+
+	r := whoami(t, f)
+	require.True(t, r.OK)
+	id, ok := r.Value.(hostIdentity)
+	require.True(t, ok, "got %T", r.Value)
+	assert.Equal(t, "me", id.Name)
+	assert.Equal(t, []netip.Addr{netip.MustParseAddr("10.0.0.1")}, id.VpnAddrs)
+	assert.Equal(t, 2, id.CertVersion)
+	assert.NotEmpty(t, id.Fingerprint)
+	assert.NotEmpty(t, id.Issuer)
+
+	// Our own address answers the same way: the hostmap never holds ourselves.
+	self := identity(t, f, "10.0.0.1")
+	assert.Equal(t, id, self.Value)
+}
+
+func TestIdentityReportsAPeerFromItsCertificate(t *testing.T) {
+	f, peerCert := identityFixture(t)
+
+	for _, addr := range []string{
+		"10.0.0.2",
+		"10.0.0.2:54321",      // a connection's RemoteAddr, as a server has it
+		"::ffff:10.0.0.2",     // the same peer seen through a dual stack listener
+		"[::ffff:10.0.0.2]:8", // and with a port
+	} {
+		r := identity(t, f, addr)
+		require.True(t, r.OK, "%s: %s", addr, r.Reason)
+		id := r.Value.(hostIdentity)
+		assert.Equal(t, "peer", id.Name, addr)
+		assert.Equal(t, []netip.Addr{netip.MustParseAddr("10.0.0.2")}, id.VpnAddrs, addr)
+		assert.Equal(t, []netip.Prefix{netip.MustParsePrefix("10.0.0.2/24")}, id.Networks, addr)
+		assert.Equal(t, []netip.Prefix{netip.MustParsePrefix("192.168.1.0/24")}, id.UnsafeNetworks, addr)
+		assert.Equal(t, []string{"web", "db"}, id.Groups, addr)
+		assert.Equal(t, peerCert.Issuer(), id.Issuer, addr)
+		assert.Equal(t, peerCert.NotAfter(), id.NotAfter, addr)
+	}
+}
+
+// TestIdentityRefusals: the ways a lookup fails are told apart, because a program
+// deciding whether to let a connection in treats them differently.
+func TestIdentityRefusals(t *testing.T) {
+	f, _ := identityFixture(t)
+
+	unknown := identity(t, f, "10.0.0.9")
+	assert.False(t, unknown.OK)
+	assert.True(t, unknown.NotFound, "no tunnel is a not found")
+	assert.Equal(t, "No active tunnel for vpn addr: 10.0.0.9", unknown.Reason)
+
+	pending := identity(t, f, "10.0.0.3:1")
+	assert.True(t, pending.NotFound, "a tunnel without a certificate yet is not active")
+	assert.Equal(t, "No active tunnel for vpn addr: 10.0.0.3", pending.Reason, "the reason names the address, not the port")
+
+	garbage := identity(t, f, "not-an-address")
+	assert.False(t, garbage.OK)
+	assert.False(t, garbage.NotFound, "an address that does not parse is the caller's mistake")
+	assert.Equal(t, "The provided vpn addr could not be parsed: not-an-address", garbage.Reason)
+
+	// An address that was given and is empty is a caller that failed to fill it in, and
+	// must not be answered with our own identity.
+	empty := identity(t, f, "")
+	assert.False(t, empty.OK)
+	assert.False(t, empty.NotFound)
+	assert.Equal(t, "The provided vpn addr could not be parsed: ", empty.Reason)
+
+	operand := identity(t, f, "", "10.0.0.2")
+	assert.False(t, operand.OK)
+	assert.Contains(t, operand.Reason, "identity -address 10.0.0.2")
+
+	// No address at all is a caller that forgot the parameter, and is pointed at whoami
+	// rather than handed this node's identity.
+	omitted, err := runIdentity(f, &identityFlags{}, nil)
+	require.NoError(t, err)
+	assert.False(t, omitted.OK)
+	assert.False(t, omitted.NotFound)
+	assert.Contains(t, omitted.Reason, "-address")
+	assert.Contains(t, omitted.Reason, "whoami")
+}
+
+// TestOwnCertificatePrefersV2: with both versions loaded, the v2 certificate is the one
+// myVpnAddrsTable is built from and may carry addresses the v1 does not, so it is the one
+// whoami and an identity lookup of our own address report, whichever version handshakes
+// are initiated with.
+func TestOwnCertificatePrefersV2(t *testing.T) {
+	v4 := netip.MustParsePrefix("10.0.0.1/24")
+	ca1, _, caKey1, _ := cert_test.NewTestCaCert(cert.Version1, cert.Curve_CURVE25519, time.Time{}, time.Time{}, nil, nil, nil)
+	v1, _, _, _ := cert_test.NewTestCert(cert.Version1, cert.Curve_CURVE25519, ca1, caKey1, "me", time.Time{}, time.Time{}, []netip.Prefix{v4}, nil, nil)
+	ca2, _, caKey2, _ := cert_test.NewTestCaCert(cert.Version2, cert.Curve_CURVE25519, time.Time{}, time.Time{}, nil, nil, nil)
+	v2, _, _, _ := cert_test.NewTestCert(cert.Version2, cert.Curve_CURVE25519, ca2, caKey2, "me", time.Time{}, time.Time{},
+		[]netip.Prefix{v4, netip.MustParsePrefix("fd00::1/64")}, nil, nil)
+
+	assert.Nil(t, ownCertificate(nil))
+	assert.Equal(t, v1, ownCertificate(&CertState{v1Cert: v1, initiatingVersion: cert.Version1}))
+	assert.Equal(t, v2, ownCertificate(&CertState{v2Cert: v2, initiatingVersion: cert.Version2}))
+	assert.Equal(t, v2, ownCertificate(&CertState{v1Cert: v1, v2Cert: v2, initiatingVersion: cert.Version1}),
+		"the initiating version is v1, the reported certificate is still the v2")
+}
+
+// TestIdentityShapes pins the JSON a program reads and the text a person reads. The
+// field names are an API served to programs not built against nebula.
+func TestIdentityShapes(t *testing.T) {
+	f, _ := identityFixture(t)
+	r := identity(t, f, "10.0.0.2")
+
+	b, err := json.Marshal(r.Payload())
+	require.NoError(t, err)
+	var got map[string]any
+	require.NoError(t, json.Unmarshal(b, &got))
+	var keys []string
+	for k := range got {
+		keys = append(keys, k)
+	}
+	assert.ElementsMatch(t, []string{
+		"name", "vpnAddrs", "networks", "unsafeNetworks", "groups",
+		"fingerprint", "issuer", "notBefore", "notAfter", "certVersion",
+	}, keys)
+
+	// Empty lists marshal as [], never null.
+	self := whoami(t, f)
+	b, err = json.Marshal(self.Payload())
+	require.NoError(t, err)
+	assert.Contains(t, string(b), `"groups":[]`)
+	assert.Contains(t, string(b), `"unsafeNetworks":[]`)
+
+	out := &bytes.Buffer{}
+	require.NoError(t, commands.Render(&commands.Command{Name: commands.Identity, Text: commands.Typed(textIdentity)}, r, commands.FormatText, out))
+	assert.Contains(t, out.String(), "name: peer\n")
+	assert.Contains(t, out.String(), "vpn addrs: 10.0.0.2\n")
+	assert.Contains(t, out.String(), "groups: web, db\n")
+	assert.Contains(t, out.String(), "cert version: 2\n")
+}
+
+func TestParseVpnAddrWithOptionalPort(t *testing.T) {
+	for in, want := range map[string]string{
+		"10.0.0.2":            "10.0.0.2",
+		"10.0.0.2:443":        "10.0.0.2",
+		"fd00::1":             "fd00::1",
+		"[fd00::1]:443":       "fd00::1",
+		"::ffff:10.0.0.2":     "10.0.0.2",
+		"[::ffff:10.0.0.2]:1": "10.0.0.2",
+		"fe80::1%eth0":        "fe80::1",
+	} {
+		got, err := parseVpnAddrWithOptionalPort(in)
+		require.NoError(t, err, in)
+		assert.Equal(t, netip.MustParseAddr(want), got, in)
+	}
+
+	for _, in := range []string{"", "10.0.0.2:", ":443", "10.0.0", "example.com:80"} {
+		_, err := parseVpnAddrWithOptionalPort(in)
+		assert.Error(t, err, in)
+	}
+}

+ 45 - 0
localapi/chmod_bsd.go

@@ -0,0 +1,45 @@
+//go:build darwin || freebsd || netbsd || openbsd
+
+package localapi
+
+import (
+	"os"
+
+	"golang.org/x/sys/unix"
+)
+
+// applyAccess sets the owning group and the mode of the socket node at path, and of
+// nothing a link at path names. The node is looked at without following links and must
+// be a socket of ours; the chown and the chmod are fchownat and fchmodat with
+// AT_SYMLINK_NOFOLLOW, which these kernels honour, so a symlink put at path in the one
+// syscall between the look and either call gets its own group or mode changed, which
+// means nothing, and not its target's. A socket cannot be opened, and there is no O_PATH
+// here to hold the inode across the calls the way the linux side does, so a hard link
+// to a file of somebody's choosing swapped in during that one syscall would be changed;
+// that needs the file on the socket's filesystem and the right to link to it, which
+// darwin and the BSDs grant to any user by default.
+func applyAccess(path string, acc Access) error {
+	fi, err := os.Lstat(path)
+	if err != nil {
+		return err
+	}
+	if err := checkOwnSocket(path, fi); err != nil {
+		return err
+	}
+
+	if acc.Group != nil {
+		gid, err := gidOf(acc.Group)
+		if err != nil {
+			return err
+		}
+		if err := unix.Fchownat(unix.AT_FDCWD, path, -1, gid, unix.AT_SYMLINK_NOFOLLOW); err != nil {
+			return &os.PathError{Op: "chown", Path: path, Err: err}
+		}
+	}
+
+	if err := unix.Fchmodat(unix.AT_FDCWD, path, uint32(acc.Mode.Perm()), unix.AT_SYMLINK_NOFOLLOW); err != nil {
+		return &os.PathError{Op: "chmod", Path: path, Err: err}
+	}
+
+	return nil
+}

+ 83 - 0
localapi/chmod_linux.go

@@ -0,0 +1,83 @@
+//go:build linux
+
+package localapi
+
+import (
+	"fmt"
+	"os"
+	"strconv"
+
+	"golang.org/x/sys/unix"
+)
+
+// applyAccess sets the owning group and the mode of the socket node at path, and of
+// nothing else. The node is opened O_PATH, which takes a reference to the inode without
+// opening the socket itself (a socket cannot be opened, and O_PATH does not try), and
+// O_NOFOLLOW, so a symlink at path is a refusal rather than a target. What the
+// descriptor holds is checked to be a socket of ours, and the chown and the chmod are
+// then made through /proc/self/fd, which names that inode whatever is at path by then:
+// a node swapped between the look and the change, for a link or for a hard link to a
+// file of somebody's choosing, is not what gets the group or the mode. This is how
+// glibc implements fchmodat(AT_SYMLINK_NOFOLLOW) too.
+//
+// Without /proc the change falls back to fchownat and fchmodat2, which do not follow a
+// link but are by path again, so a swap in the one syscall between the check and each
+// would be changed. Older than kernel 6.6 with no /proc, the mode is not set and the
+// socket is not served.
+func applyAccess(path string, acc Access) error {
+	// The look is for the message: a link or a plain file is named as what it is, which
+	// the O_PATH open's ELOOP or the fstat below would say less well.
+	fi, err := os.Lstat(path)
+	if err != nil {
+		return err
+	}
+	if err := checkOwnSocket(path, fi); err != nil {
+		return err
+	}
+
+	fd, err := unix.Open(path, unix.O_PATH|unix.O_NOFOLLOW|unix.O_CLOEXEC, 0)
+	if err != nil {
+		return &os.PathError{Op: "open", Path: path, Err: err}
+	}
+	defer unix.Close(fd)
+
+	// What the descriptor holds is what the changes land on; the Lstat above looked at
+	// the path, which may have changed since.
+	var st unix.Stat_t
+	if err := unix.Fstat(fd, &st); err != nil {
+		return &os.PathError{Op: "fstat", Path: path, Err: err}
+	}
+	if st.Mode&unix.S_IFMT != unix.S_IFSOCK || st.Uid != currentUID() {
+		return fmt.Errorf("%s was replaced while its access was being set, refusing to change it", path)
+	}
+
+	// The /proc name of the inode the descriptor holds. ENOENT from it means no /proc.
+	held := "/proc/self/fd/" + strconv.Itoa(fd)
+
+	// The group first: a chown by root clears the setgid bit, which a socket never has,
+	// but the mode is pinned afterwards either way.
+	if acc.Group != nil {
+		gid, err := gidOf(acc.Group)
+		if err != nil {
+			return err
+		}
+		err = unix.Chown(held, -1, gid)
+		if err == unix.ENOENT {
+			err = unix.Fchownat(unix.AT_FDCWD, path, -1, gid, unix.AT_SYMLINK_NOFOLLOW)
+		}
+		if err != nil {
+			return &os.PathError{Op: "chown", Path: path, Err: err}
+		}
+	}
+
+	perm := uint32(acc.Mode.Perm())
+	err = unix.Chmod(held, perm)
+	if err == unix.ENOENT {
+		err = unix.Fchmodat(unix.AT_FDCWD, path, perm, unix.AT_SYMLINK_NOFOLLOW)
+	}
+	if err != nil {
+		return &os.PathError{Op: "chmod", Path: path, Err: err}
+	}
+
+	return nil
+}

+ 197 - 0
localapi/endpoint.go

@@ -0,0 +1,197 @@
+package localapi
+
+import (
+	"context"
+	"errors"
+	"fmt"
+	"io"
+	"log/slog"
+	"net"
+	"sync"
+)
+
+// endpoint is the lifecycle the two local APIs share: one socket at a path, bound and
+// given up under a mutex, moved by binding the new path before the old one is released,
+// and stopped when the context that brought it up ends. What is served on the socket,
+// and by what, is the embedding server's: it hands rebindLocked a serve function and
+// gets back the thing to close.
+type endpoint struct {
+	l *slog.Logger
+
+	// name is what the endpoint is called in its own log lines and errors, so the two
+	// read apart.
+	name string
+
+	mu sync.Mutex
+
+	// closer stops the serving when closed: the listener itself, or the server that owns
+	// it. It is nil while the endpoint is stopped, which is what stopped means here.
+	closer  io.Closer
+	path    string
+	access  Access
+	unwatch func() bool
+
+	// id identifies the node listen created at path, so a reload can tell whether the
+	// socket is still there and Stop whether the node it would unlink is still ours.
+	id socketID
+}
+
+// socketID identifies a socket's node on the filesystem, by device and inode on unix.
+// The zero value is no identity: a platform with no node to identify, or one that could
+// not be read, and removeSocket then unlinks without looking.
+type socketID struct {
+	dev, ino uint64
+}
+
+// Path returns the socket path currently being served, or "" when stopped.
+func (e *endpoint) Path() string {
+	e.mu.Lock()
+	defer e.mu.Unlock()
+	return e.path
+}
+
+// rebindLocked brings the endpoint up at path with acc, moves it there, or, when it is
+// already there, applies a changed access. It is the one place a socket is installed and
+// the one way an endpoint comes up, so Wire's first attempt and its reload callback are
+// the same call, and an endpoint already at path is left as it is: Wire registers the
+// callback before the first attempt, so a HUP that lands between the two has the
+// callback do the first bind, and the first attempt then finds the socket it was about
+// to create already up.
+//
+// The new socket is bound before the old one is given up: a bad path in a reloaded
+// config leaves the operator with the endpoint they already had rather than none. serve
+// is handed the new listener once it is certain to be kept and returns what to close to
+// stop serving it; what becomes of the old socket's connections is that closer's.
+//
+// The caller holds e.mu for the whole operation, bind included. Releasing it around
+// listen would let a Stop land in between, and this would then install a fresh socket on
+// an endpoint that had just shut down, leaving a live socket on disk until the process
+// exits. listen does not take e.mu, so holding it costs the other methods one bind's
+// wait, at most the stale socket probe, once per reload.
+func (e *endpoint) rebindLocked(ctx context.Context, path string, acc Access, serve func(net.Listener) io.Closer) error {
+	reopening := false
+	if e.closer != nil && e.path == path {
+		if cur, ok := identifySocket(path); ok && cur == e.id {
+			if e.access.equal(acc) {
+				return nil
+			}
+			err := applyAccess(path, acc)
+			if err == nil {
+				e.access = acc
+				e.l.Info("Changed the "+e.name+"'s socket access", "path", path, "access", acc.String())
+				return nil
+			}
+			if !errors.Is(err, errReopenForAccess) {
+				return fmt.Errorf("could not change the access of %s: %w", path, err)
+			}
+
+			// The platform fixes access when the endpoint is created, so it is created
+			// again. The name cannot be held twice, so the old one goes first, and a bind
+			// that then fails leaves the endpoint down rather than on the old access the
+			// operator asked to change; that is reported below.
+			reopening = true
+			old := e.closer
+			e.closer, e.path, e.access, e.id = nil, "", Access{}, socketID{}
+			_ = old.Close()
+		}
+
+		// The node at the path is not the one this endpoint bound: something outside
+		// nebula removed it, logind clearing /run/user/<uid> on logout or a unit cleaning a
+		// RuntimeDirectory= it shares, or replaced it. The listener is still open and
+		// nothing can reach it, so it is bound afresh below; listen's stale check decides
+		// whether whatever is there now may be cleared.
+		e.l.Warn("The "+e.name+"'s socket is no longer at its path, binding it again", "path", path)
+	}
+
+	ln, err := listen(path, acc)
+	if err != nil {
+		if reopening {
+			return fmt.Errorf("the %s was closed to change its access and could not be created again: %w", e.name, err)
+		}
+		return err
+	}
+	id, _ := identifySocket(path)
+
+	// A reload that arrives during shutdown must not bring the endpoint back. Stop is
+	// not terminal (a Rebind after Stop is allowed, and is how an endpoint whose first
+	// bind failed comes up), so whether it is running is no guide; the context is what
+	// shutdown cancels, and it is checked here, while nothing else can change the state.
+	if err := ctx.Err(); err != nil {
+		release(ln, path, id)
+		return fmt.Errorf("not starting the %s, nebula is shutting down: %w", e.name, err)
+	}
+
+	old, oldPath, oldID := e.closer, e.path, e.id
+	e.closer = serve(ln)
+	e.path = path
+	e.access = acc
+	e.id = id
+	e.watchLocked(ctx)
+
+	// Stop serving the old socket. When it was on this same path its node is already
+	// gone, and the node there now is the new socket's, so only the closer goes.
+	if old != nil {
+		if oldPath == path {
+			_ = old.Close()
+		} else {
+			release(old, oldPath, oldID)
+		}
+	}
+
+	return nil
+}
+
+// watchLocked stops the endpoint when ctx is cancelled. One watcher covers a run of the
+// endpoint, from the first bind that succeeds until Stop: a daemon whose initial listen
+// failed and which later Rebinds onto a good path from a reloaded config still needs its
+// socket removed on shutdown, and a Rebind on a running endpoint must not stack a second
+// watcher on the same ctx. Stop detaches the watcher, so an endpoint started again
+// afterwards is watched on the ctx that started it, and the ctx of an earlier run no
+// longer reaches it whenever that one ends. Callers hold e.mu.
+func (e *endpoint) watchLocked(ctx context.Context) {
+	if e.unwatch != nil {
+		return
+	}
+	e.unwatch = context.AfterFunc(ctx, e.Stop)
+}
+
+// Stop closes the endpoint and removes its socket. Work in flight is the closer's
+// business: a listener leaves the connections it already accepted to finish, a server
+// cuts them off.
+//
+// The whole of it runs under e.mu, closer and socket included, so a Rebind cannot land
+// between the state change and the close and find a path that is still bound by the
+// socket it is replacing.
+func (e *endpoint) Stop() {
+	e.mu.Lock()
+	defer e.mu.Unlock()
+
+	if e.closer == nil {
+		return
+	}
+
+	release(e.closer, e.path, e.id)
+	e.closer = nil
+	e.path = ""
+	e.access = Access{}
+	e.id = socketID{}
+
+	// Set whenever closer is. When this Stop is the watcher's own, stopping a watcher
+	// that has already fired is a no-op.
+	e.unwatch()
+	e.unwatch = nil
+}
+
+// release gives up a socket's closer and the node at its path. The node goes first,
+// while the socket is still open: for as long as it is, the node is this endpoint's,
+// since a starting daemon's stale check refuses to remove a socket that answers. The
+// other order opens a window between the close and the unlink in which a new daemon can
+// find the node stale, clear it and bind its own at the same path, which the unlink then
+// takes from under it, leaving that daemon serving a socket nothing can reach. listen
+// turns off the listener's own unlink on close so this is the only unlink. id is the
+// node listen created, and the only node removeSocket will remove: one that replaced it,
+// another daemon's socket bound after ours was taken from under us, is left alone.
+func release(c io.Closer, path string, id socketID) {
+	removeSocket(path, id)
+	_ = c.Close()
+}

+ 410 - 0
localapi/info.go

@@ -0,0 +1,410 @@
+package localapi
+
+import (
+	"bytes"
+	"context"
+	"encoding/json"
+	"errors"
+	"fmt"
+	"io"
+	"log/slog"
+	"maps"
+	"net"
+	"net/http"
+	"net/url"
+	"os"
+	"path"
+	"runtime/debug"
+	"slices"
+	"strconv"
+	"time"
+
+	"github.com/slackhq/nebula/commands"
+	"github.com/slackhq/nebula/config"
+	"golang.org/x/net/netutil"
+)
+
+// The info API's config keys. It is off until InfoEnabledKey is true and InfoSocketKey
+// names a socket; nothing is derived, because the programs that will dial it have to be
+// told where it is anyway.
+const (
+	InfoEnabledKey = "local_api.info.enabled"
+	InfoSocketKey  = "local_api.info.socket"
+	InfoModeKey    = "local_api.info.socket_mode"
+)
+
+const (
+	// defaultInfoMode is the socket's mode when the config names none: the daemon's
+	// user alone, so that enabling the API does not open it to anyone until the operator
+	// says who.
+	defaultInfoMode os.FileMode = 0600
+
+	// infoMaxConns bounds the connections held open at once. Beyond it the kernel
+	// queues, which is what an HTTP client expects of a busy server; the control socket
+	// refuses instead because its clients are people at a prompt.
+	infoMaxConns = 64
+
+	infoWriteTimeout   = 30 * time.Second
+	infoIdleTimeout    = 60 * time.Second
+	infoMaxHeaderBytes = 64 * 1024
+)
+
+// The read deadlines are variables so a test can shorten them. infoReadTimeout bounds
+// the whole request, not only its headers: a request is a GET with a query string and
+// the handlers never read a body, but net/http drains an unread body after the handler
+// returns, and without a deadline on that read a client sending a body a byte at a time
+// would hold its connection slot for as long as it liked, and sixty-four of them would
+// hold every slot. A body is refused as well, see refuseBody, but net/http drains it
+// before it closes, so this deadline is what ends a client that announced a body and
+// never sent it.
+var (
+	infoReadHeaderTimeout = 10 * time.Second
+	infoReadTimeout       = 15 * time.Second
+)
+
+// errInfoUnsupported is returned by Rebind on a platform without a socket mode to open.
+var errInfoUnsupported = errors.New("the info API is not available on this platform")
+
+// servedOnInfo decides what the info API will run: the commands that report identity
+// and nothing else, which the commands declare about themselves. Filter adds help to
+// the result, so a client can ask what is served; help on a filtered registry describes
+// only that registry.
+func servedOnInfo(c *commands.Command) bool {
+	return c.Identity
+}
+
+// notServedOnInfo explains a refusal, naming the surfaces that will run the command.
+const notServedOnInfo = "the info API serves identity lookups only; the ssh debug server and nebula ctl serve the rest"
+
+// InfoServer serves the identity commands over HTTP, on a socket an operator may open to
+// other users on the host.
+//
+// It is the second local API, and the opposite of the control socket in who it is for.
+// The control socket is for the user who runs nebula, serves nearly everything and needs
+// no configuration. This one is for every other program on the machine, a web server
+// that wants to know who is on the other end of a connection arriving over the overlay,
+// say, and it is off until an operator names a socket and a mode for it. What it serves
+// is decided by the commands themselves, Command.Identity: the identity a certificate
+// asserts and nothing operational, so opening it to a group of services, or to everyone
+// on the host, gives away nothing the overlay did not already tell them.
+//
+// HTTP, so that no client library is needed. GET /v1/<command>?<flag>=<value> runs a
+// command with its options as the query string and answers with the command's JSON, the
+// same value the control socket and -json produce. GET /v1 describes what is served. A
+// refusal is a 4xx with {"error": reason}: 404 when the command said the thing asked
+// about does not exist (commands.NotFound), 400 for a parameter or an argument the
+// command would not take, 403 for a command this surface withholds.
+type InfoServer struct {
+	endpoint
+	src *commands.Registry
+}
+
+// NewInfoServer returns an InfoServer that will serve the identity commands in reg. reg
+// is kept rather than filtered here, for the same reason the control socket keeps its
+// registry: a command registered on it later is served too.
+func NewInfoServer(l *slog.Logger, reg *commands.Registry) *InfoServer {
+	return &InfoServer{endpoint: endpoint{l: l, name: "local info API"}, src: reg}
+}
+
+// registry returns the commands this surface serves, filtered at the time of the call.
+func (s *InfoServer) registry() *commands.Registry {
+	return s.src.Filter(servedOnInfo, notServedOnInfo)
+}
+
+// Rebind brings the server up at path with mode, moves it there, or, when it is already
+// there, applies a changed mode; endpoint.rebindLocked says what that promises. It is
+// the one way the server comes up, so Wire's reload callback and its first attempt are
+// the same call.
+func (s *InfoServer) Rebind(ctx context.Context, path string, acc Access) error {
+	s.mu.Lock()
+	defer s.mu.Unlock()
+
+	if !infoSupported {
+		return errInfoUnsupported
+	}
+
+	return s.rebindLocked(ctx, path, acc, func(ln net.Listener) io.Closer {
+		srv := &http.Server{
+			Handler:           s.handler(),
+			ReadHeaderTimeout: infoReadHeaderTimeout,
+			ReadTimeout:       infoReadTimeout,
+			WriteTimeout:      infoWriteTimeout,
+			IdleTimeout:       infoIdleTimeout,
+			MaxHeaderBytes:    infoMaxHeaderBytes,
+			ErrorLog:          slog.NewLogLogger(s.l.Handler(), slog.LevelDebug),
+		}
+
+		s.l.Info("Local info API listening", "path", path, "access", acc.String())
+		go s.serve(srv, ln)
+
+		// Closing the server closes the listener Serve was handed, and a Serve that has
+		// not started yet closes it on its way out, so the node is never left bound by a
+		// listener nothing is accepting from. Requests in flight are cut off rather than
+		// waited for: a reload must not hang on a slow client, and the answer is a lookup
+		// the client can simply repeat.
+		return srv
+	})
+}
+
+// serve runs the HTTP server on ln until it is closed. Only an error other than the
+// close is worth a line: Stop and Rebind are the two things that close it.
+func (s *InfoServer) serve(srv *http.Server, ln net.Listener) {
+	err := srv.Serve(netutil.LimitListener(ln, infoMaxConns))
+	if err != nil && !errors.Is(err, http.ErrServerClosed) && !errors.Is(err, net.ErrClosed) {
+		s.l.Error("Local info API stopped serving", "error", err)
+	}
+}
+
+// handler builds the routes. Every answer is JSON, the misses included, so a client
+// never has to parse net/http's text for a path or method it did not expect. That
+// includes a path ServeMux would clean and redirect, /v1/../v1/x or //v1/x, which is
+// refused outright: nothing legitimate sends one, and a redirect is one more thing a
+// client would have to follow.
+func (s *InfoServer) handler() http.Handler {
+	mux := http.NewServeMux()
+	mux.HandleFunc("GET /v1", s.handleDescribe)
+	mux.HandleFunc("GET /v1/{command}", s.handleCommand)
+	mux.HandleFunc("/v1", methodNotAllowed)
+	mux.HandleFunc("/v1/{command}", methodNotAllowed)
+	mux.HandleFunc("/", notFoundRoute)
+	return s.recovering(refuseBody(mux))
+}
+
+// refuseBody answers a request that carries a body with 400, before any handler sees
+// it, and has the connection closed after the reply rather than kept for another
+// request. Nothing here reads a body; one is a mistake, and the client is told what to
+// do instead. net/http still drains what the body it announced it would send, up to the
+// read deadline, so the deadline is what bounds a client that never sends it.
+func refuseBody(next http.Handler) http.Handler {
+	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		if r.ContentLength != 0 || len(r.TransferEncoding) > 0 {
+			w.Header().Set("Connection", "close")
+			writeJSONError(w, http.StatusBadRequest, "the info API takes no request body; put the command's parameters in the query string")
+			return
+		}
+		next.ServeHTTP(w, r)
+	})
+}
+
+// recovering turns a panic in a command into a 500 and a log line rather than a dropped
+// connection, which is all net/http would offer, and keeps the daemon up, which is what
+// matters: this surface is reachable by whoever the operator opened it to. The handlers
+// produce their whole answer before writing any of it, so a panic in Run always finds
+// the response unwritten.
+func (s *InfoServer) recovering(next http.Handler) http.Handler {
+	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		defer func() {
+			if rec := recover(); rec != nil {
+				s.l.Error("Recovered from panic serving a local info API request",
+					"panic", rec,
+					"path", r.URL.Path,
+					"query", r.URL.RawQuery,
+					"stack", string(debug.Stack()),
+				)
+				writeJSONError(w, http.StatusInternalServerError, "command failed")
+			}
+		}()
+
+		if path.Clean(r.URL.Path) != r.URL.Path {
+			notFoundRoute(w, r)
+			return
+		}
+		next.ServeHTTP(w, r)
+	})
+}
+
+// handleDescribe answers GET /v1 with the schema of every command served here, the same
+// document the control socket's describe returns for its own surface.
+func (s *InfoServer) handleDescribe(w http.ResponseWriter, _ *http.Request) {
+	body, err := json.Marshal(s.registry().Schema())
+	if err != nil {
+		writeJSONError(w, http.StatusInternalServerError, err.Error())
+		return
+	}
+	writeJSON(w, http.StatusOK, body)
+}
+
+// handleCommand answers GET /v1/<command>: the query string carries the command's
+// parameters, and the response body is the command's JSON. A request body is never
+// read. It is the control socket's serve with HTTP's vocabulary for the outcomes.
+func (s *InfoServer) handleCommand(w http.ResponseWriter, r *http.Request) {
+	name := commands.Name(r.PathValue("command"))
+
+	cmd, err := s.registry().Resolve(name)
+	if err != nil {
+		// A command this surface withholds is a different answer from one that does not
+		// exist, and Resolve says which; only the status is picked here.
+		status := http.StatusNotFound
+		var withheld *commands.WithheldError
+		if errors.As(err, &withheld) {
+			status = http.StatusForbidden
+		}
+		writeJSONError(w, status, err.Error())
+		return
+	}
+
+	params, format, err := queryParams(r.URL.Query())
+	if err != nil {
+		writeJSONError(w, http.StatusBadRequest, err.Error())
+		return
+	}
+
+	res, err := commands.Invoke(commands.Request{Command: cmd, Params: params})
+	if err != nil {
+		var pe *commands.ParamError
+		if errors.As(err, &pe) {
+			writeJSONError(w, http.StatusBadRequest, err.Error())
+			return
+		}
+
+		// Run failed. The sentence is for the operator's log; the caller, who may be
+		// anyone the operator admitted, is told that it failed and no more.
+		s.l.Warn("Local info API command failed", "command", name, "error", err)
+		writeJSONError(w, http.StatusInternalServerError, "command failed")
+		return
+	}
+
+	if cmd.AfterReply != nil {
+		// The reply sits in the server's buffer until the handler returns or flushes;
+		// AfterReply's contract is that the surface has delivered it, so flush first.
+		// writeJSON sets Content-Length, so a flushed reply is complete to its reader.
+		defer func() {
+			_ = http.NewResponseController(w).Flush()
+			cmd.AfterReply()
+		}()
+	}
+
+	if res.Empty() {
+		w.WriteHeader(http.StatusNoContent)
+		return
+	}
+
+	if !res.OK {
+		status := http.StatusBadRequest
+		if res.NotFound {
+			status = http.StatusNotFound
+		}
+		writeJSONError(w, status, res.Reason)
+		return
+	}
+
+	// The same rendering function every surface uses, so the value here is byte for
+	// byte what -json prints at an ssh prompt.
+	buf := &bytes.Buffer{}
+	if err := commands.Render(cmd, res, format, buf); err != nil {
+		s.l.Warn("Local info API could not render a result", "command", name, "error", err)
+		writeJSONError(w, http.StatusInternalServerError, "command failed")
+		return
+	}
+
+	writeJSON(w, http.StatusOK, buf.Bytes())
+}
+
+// queryParams reads a query string as a command's parameters. Each key is a flag of the
+// command, by name, in the form the control socket takes them; one given twice is
+// refused rather than silently picking either. The presentation flags are read as a
+// command line reads them, and set apart: the answer is JSON regardless, and pretty
+// indents it.
+func queryParams(q url.Values) (map[string]string, commands.Format, error) {
+	var params, presentation map[string]string
+	for _, k := range slices.Sorted(maps.Keys(q)) {
+		vs := q[k]
+		if len(vs) > 1 {
+			return nil, commands.FormatUnset, fmt.Errorf("parameter %q was given more than once", k)
+		}
+
+		into := &params
+		if k == commands.FlagJSON || k == commands.FlagPretty {
+			into = &presentation
+		}
+		if *into == nil {
+			*into = map[string]string{}
+		}
+		(*into)[k] = vs[0]
+	}
+
+	format, err := commands.PresentationFormat(presentation)
+	if err != nil {
+		return nil, commands.FormatUnset, err
+	}
+	if format == commands.FormatUnset {
+		format = commands.FormatJSON
+	}
+
+	return params, format, nil
+}
+
+func methodNotAllowed(w http.ResponseWriter, r *http.Request) {
+	w.Header().Set("Allow", http.MethodGet)
+	writeJSONError(w, http.StatusMethodNotAllowed, fmt.Sprintf("%s is not allowed; the info API is read only, use GET", r.Method))
+}
+
+func notFoundRoute(w http.ResponseWriter, r *http.Request) {
+	writeJSONError(w, http.StatusNotFound, fmt.Sprintf("no such path %s; GET /v1 lists the commands and GET /v1/<command> runs one", r.URL.Path))
+}
+
+func writeJSON(w http.ResponseWriter, status int, body []byte) {
+	w.Header().Set("Content-Type", "application/json")
+	w.Header().Set("X-Content-Type-Options", "nosniff")
+	w.Header().Set("Content-Length", strconv.Itoa(len(body)))
+	w.WriteHeader(status)
+	_, _ = w.Write(body)
+}
+
+func writeJSONError(w http.ResponseWriter, status int, msg string) {
+	body, _ := json.Marshal(map[string]string{"error": msg})
+	writeJSON(w, status, append(body, '\n'))
+}
+
+// infoConfig is the info API section of a config, read and checked.
+type infoConfig struct {
+	Enabled bool
+	Path    string
+	Access  Access
+}
+
+// infoConfigFrom reads the info API section. A disabled section needs nothing else to be
+// right; an enabled one needs a socket path and, if it names a mode, one that parses.
+func infoConfigFrom(c *config.C) (infoConfig, error) {
+	cfg := infoConfig{Enabled: c.GetBool(InfoEnabledKey, false)}
+	if !cfg.Enabled {
+		return cfg, nil
+	}
+
+	p := c.GetString(InfoSocketKey, "")
+	if p == "" {
+		return cfg, fmt.Errorf("%s is true but %s is not set; the info API needs a socket path", InfoEnabledKey, InfoSocketKey)
+	}
+	path, err := configuredSocketPath(InfoSocketKey, p)
+	if err != nil {
+		return cfg, err
+	}
+	cfg.Path = path
+
+	mode, err := parseSocketMode(c.Get(InfoModeKey))
+	if err != nil {
+		return cfg, fmt.Errorf("%s: %w", InfoModeKey, err)
+	}
+	cfg.Access = Access{Mode: mode}
+
+	return cfg, nil
+}
+
+// parseSocketMode reads an octal mode written as a string. A bare 0660 in yaml is an
+// integer, and which integer depends on the parser (octal 432 under YAML 1.1, decimal
+// 660 under 1.2), so a number is refused with the fix rather than quietly applied as
+// whichever mode its digits happen to spell in octal.
+func parseSocketMode(raw any) (os.FileMode, error) {
+	switch v := raw.(type) {
+	case nil:
+		return defaultInfoMode, nil
+	case string:
+		mode, err := strconv.ParseUint(v, 8, 32)
+		if err != nil || mode&^0777 != 0 {
+			return 0, fmt.Errorf("%q is not an octal file mode such as \"0660\"", v)
+		}
+		return os.FileMode(mode), nil
+	default:
+		return 0, fmt.Errorf("must be a quoted octal string such as \"0660\", not the number %v; yaml reads a bare mode as a decimal or octal integer depending on the parser", v)
+	}
+}

+ 529 - 0
localapi/info_test.go

@@ -0,0 +1,529 @@
+//go:build !windows
+
+package localapi
+
+import (
+	"bufio"
+	"context"
+	"encoding/json"
+	"errors"
+	"flag"
+	"fmt"
+	"io"
+	"net"
+	"net/http"
+	"os"
+	"path/filepath"
+	"strings"
+	"testing"
+	"time"
+
+	"github.com/slackhq/nebula/commands"
+	"github.com/slackhq/nebula/config"
+	"github.com/slackhq/nebula/test"
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+	"golang.org/x/sys/unix"
+)
+
+type whoamiFlags struct {
+	Name string
+}
+
+// infoRegistry holds one identity command, whoami, whose -name selects an outcome, and
+// one command the info API must withhold.
+func infoRegistry() *commands.Registry {
+	reg := commands.New()
+	reg.Register(&commands.Command{
+		Name:             "whoami",
+		ShortDescription: "reports who a name belongs to",
+		Identity:         true,
+		Flags: func() (*flag.FlagSet, any) {
+			fl := flag.NewFlagSet("", flag.ContinueOnError)
+			s := &whoamiFlags{}
+			fl.StringVar(&s.Name, "name", "", "the name to look up")
+			return fl, s
+		},
+		Run: func(fs any, a []string) (commands.Result, error) {
+			switch name := fs.(*whoamiFlags).Name; name {
+			case "":
+				return commands.Value(map[string]any{"name": "me", "groups": []string{}}), nil
+			case "nobody":
+				return commands.NotFound("No active tunnel for vpn addr: %s", name), nil
+			case "bad":
+				return commands.Failed("The provided vpn addr could not be parsed: %s", name), nil
+			case "boom":
+				panic("this command is broken")
+			case "err":
+				return commands.Result{}, errors.New("an internal detail")
+			default:
+				return commands.Value(map[string]any{"name": name}), nil
+			}
+		},
+	})
+	reg.Register(echoCommand())
+	return reg
+}
+
+func serveInfo(t *testing.T, reg *commands.Registry, mode os.FileMode) (*InfoServer, string) {
+	t.Helper()
+
+	path := testSocket(t)
+	s := NewInfoServer(test.NewLogger(), reg)
+	require.NoError(t, s.Rebind(context.Background(), path, Access{Mode: mode}))
+	t.Cleanup(s.Stop)
+	return s, path
+}
+
+// httpDo sends one request over the socket at path and returns the status, body and
+// headers. The host in the URL is a placeholder: the dialer ignores it.
+func TestInfoServesIdentityCommands(t *testing.T) {
+	_, path := serveInfo(t, infoRegistry(), 0600)
+
+	status, body, hdr := test.HTTPDo(t, path, http.MethodGet, "/v1/whoami?name=alice")
+	assert.Equal(t, http.StatusOK, status)
+	assert.JSONEq(t, `{"name":"alice"}`, body)
+	assert.Equal(t, "application/json", hdr.Get("Content-Type"))
+
+	status, body = test.HTTPGet(t, path, "/v1/whoami")
+	assert.Equal(t, http.StatusOK, status)
+	assert.JSONEq(t, `{"name":"me","groups":[]}`, body, "a parameter left out is the command's default")
+}
+
+// TestInfoWithholdsTheRest checks the filter at the wire: a command that exists but is
+// not an identity command is refused with the reason, and a name nothing is registered
+// under is unknown, so a caller can tell a typo from a closed door.
+func TestInfoWithholdsTheRest(t *testing.T) {
+	_, path := serveInfo(t, infoRegistry(), 0600)
+
+	status, body := test.HTTPGet(t, path, "/v1/echo?x=1")
+	assert.Equal(t, http.StatusForbidden, status)
+	assert.Contains(t, body, "echo is not available on this surface")
+	assert.Contains(t, body, "identity lookups only")
+
+	status, body = test.HTTPGet(t, path, "/v1/nope")
+	assert.Equal(t, http.StatusNotFound, status)
+	assert.Contains(t, body, `unknown command \"nope\"`)
+
+	for _, target := range []string{"/", "/v2/whoami", "/v1/", "/v1/whoami/extra", "//v1/whoami", "/v1/x/../whoami", "/v1/../v1/whoami"} {
+		status, body = test.HTTPGet(t, path, target)
+		assert.Equal(t, http.StatusNotFound, status, target)
+		assert.Contains(t, body, `"error"`, "%s: misses are JSON too", target)
+	}
+}
+
+func TestInfoStatusCodes(t *testing.T) {
+	_, path := serveInfo(t, infoRegistry(), 0600)
+
+	for _, tc := range []struct {
+		target string
+		status int
+		body   string
+	}{
+		{"/v1/whoami?name=nobody", http.StatusNotFound, `{"error":"No active tunnel for vpn addr: nobody"}`},
+		{"/v1/whoami?name=bad", http.StatusBadRequest, `{"error":"The provided vpn addr could not be parsed: bad"}`},
+		{"/v1/whoami?nope=1", http.StatusBadRequest, `{"error":"whoami has no parameter \"nope\""}`},
+		{"/v1/whoami?name=a&name=b", http.StatusBadRequest, `{"error":"parameter \"name\" was given more than once"}`},
+		{"/v1/whoami?pretty=maybe", http.StatusBadRequest, `{"error":"pretty: expected a boolean, got \"maybe\""}`},
+		// Run failed: the caller hears that it did and no more; the detail is for the log.
+		{"/v1/whoami?name=err", http.StatusInternalServerError, `{"error":"command failed"}`},
+	} {
+		status, body := test.HTTPGet(t, path, tc.target)
+		assert.Equal(t, tc.status, status, tc.target)
+		assert.JSONEq(t, tc.body, body, tc.target)
+	}
+
+	status, body, hdr := test.HTTPDo(t, path, http.MethodPost, "/v1/whoami?name=alice")
+	assert.Equal(t, http.StatusMethodNotAllowed, status)
+	assert.Equal(t, http.MethodGet, hdr.Get("Allow"))
+	assert.Contains(t, body, "read only")
+
+	status, _, _ = test.HTTPDo(t, path, http.MethodDelete, "/v1")
+	assert.Equal(t, http.StatusMethodNotAllowed, status)
+}
+
+// TestInfoPanicDoesNotKillTheDaemon: a broken command costs a request, not the overlay.
+// This surface may be open to anyone on the host, so that holds against them too.
+func TestInfoPanicDoesNotKillTheDaemon(t *testing.T) {
+	_, path := serveInfo(t, infoRegistry(), 0600)
+
+	status, body := test.HTTPGet(t, path, "/v1/whoami?name=boom")
+	assert.Equal(t, http.StatusInternalServerError, status)
+	assert.JSONEq(t, `{"error":"command failed"}`, body)
+
+	status, body = test.HTTPGet(t, path, "/v1/whoami?name=alice")
+	assert.Equal(t, http.StatusOK, status)
+	assert.JSONEq(t, `{"name":"alice"}`, body)
+}
+
+func TestInfoDescribe(t *testing.T) {
+	_, path := serveInfo(t, infoRegistry(), 0600)
+
+	status, body := test.HTTPGet(t, path, "/v1")
+	require.Equal(t, http.StatusOK, status)
+
+	var schema []commands.CommandSchema
+	require.NoError(t, json.Unmarshal([]byte(body), &schema))
+
+	var names []commands.Name
+	flags := map[commands.Name][]string{}
+	for _, c := range schema {
+		names = append(names, c.Name)
+		for _, f := range c.Flags {
+			flags[c.Name] = append(flags[c.Name], f.Name)
+		}
+	}
+	assert.ElementsMatch(t, []commands.Name{"help", "whoami"}, names, "only what is served is described")
+	assert.Equal(t, []string{"name", commands.FlagJSON, commands.FlagPretty}, flags["whoami"])
+}
+
+// TestInfoPresentationFlags: the query string takes -json and -pretty as a command line
+// would. The answer is JSON either way; pretty indents it.
+func TestInfoPresentationFlags(t *testing.T) {
+	_, path := serveInfo(t, infoRegistry(), 0600)
+
+	_, compact := test.HTTPGet(t, path, "/v1/whoami?name=alice&json=true")
+	//nolint:testifylint // the exact bytes, newline included, are what is under test
+	assert.Equal(t, "{\"name\":\"alice\"}\n", compact)
+
+	_, pretty := test.HTTPGet(t, path, "/v1/whoami?name=alice&pretty")
+	//nolint:testifylint // the layout is what is under test
+	assert.Equal(t, "{\n    \"name\": \"alice\"\n}\n", pretty)
+
+	_, off := test.HTTPGet(t, path, "/v1/whoami?name=alice&pretty=false")
+	assert.Equal(t, compact, off)
+}
+
+// TestInfoSocketMode: the mode is the operator's, pinned regardless of the umask, and a
+// changed mode on a reload is applied in place rather than by rebinding.
+func TestInfoSocketMode(t *testing.T) {
+	old := unix.Umask(0077)
+	defer unix.Umask(old)
+
+	s, path := serveInfo(t, infoRegistry(), 0660)
+
+	fi, err := os.Stat(path)
+	require.NoError(t, err)
+	assert.Equal(t, os.FileMode(0660), fi.Mode().Perm())
+
+	require.NoError(t, s.Rebind(context.Background(), path, Access{Mode: 0640}))
+	fi, err = os.Stat(path)
+	require.NoError(t, err)
+	assert.Equal(t, os.FileMode(0640), fi.Mode().Perm())
+	assert.Equal(t, path, s.Path())
+
+	status, _ := test.HTTPGet(t, path, "/v1/whoami")
+	assert.Equal(t, http.StatusOK, status, "the same listener is still serving")
+}
+
+// TestInfoModeChangeRefusesASwappedSocket: a mode change on a reload is applied to the
+// socket in place, and only to it. Whoever can write the directory can replace the node
+// with a link to a file of their choosing; the daemon must not set the operator's mode
+// on that file. The change is refused, the file is untouched, and the endpoint is still
+// the one that was serving.
+func TestInfoModeChangeRefusesASwappedSocket(t *testing.T) {
+	s, path := serveInfo(t, infoRegistry(), 0600)
+
+	target := filepath.Join(filepath.Dir(path), "target")
+	require.NoError(t, os.WriteFile(target, nil, 0600))
+	require.NoError(t, os.Remove(path))
+	require.NoError(t, os.Symlink(target, path))
+
+	err := s.Rebind(context.Background(), path, Access{Mode: 0666})
+	require.ErrorContains(t, err, "is not a socket")
+	fi, err := os.Stat(target)
+	require.NoError(t, err)
+	assert.Equal(t, os.FileMode(0600), fi.Mode().Perm(), "the file behind the link keeps its mode")
+	assert.Equal(t, path, s.Path())
+}
+
+func TestInfoRebindMovesAndStopRemoves(t *testing.T) {
+	s, first := serveInfo(t, infoRegistry(), 0600)
+
+	second := testSocket(t)
+	require.NoError(t, s.Rebind(context.Background(), second, Access{Mode: 0600}))
+	assert.Equal(t, second, s.Path())
+	_, err := os.Stat(first)
+	assert.True(t, os.IsNotExist(err), "the old socket is removed")
+
+	status, body := test.HTTPGet(t, second, "/v1/whoami?name=alice")
+	assert.Equal(t, http.StatusOK, status)
+	assert.JSONEq(t, `{"name":"alice"}`, body)
+
+	s.Stop()
+	assert.Empty(t, s.Path())
+	_, err = os.Stat(second)
+	assert.True(t, os.IsNotExist(err), "Stop removes the socket")
+}
+
+func TestInfoRebindFailureKeepsTheOldSocket(t *testing.T) {
+	s, first := serveInfo(t, infoRegistry(), 0600)
+
+	err := s.Rebind(context.Background(), filepath.Join(privateBase(t), "missing", "n", "s.sock"), Access{Mode: 0600})
+	require.Error(t, err)
+	assert.Equal(t, first, s.Path())
+
+	status, _ := test.HTTPGet(t, first, "/v1/whoami")
+	assert.Equal(t, http.StatusOK, status)
+}
+
+func TestInfoContextCancellationStops(t *testing.T) {
+	ctx, cancel := context.WithCancel(context.Background())
+	path := testSocket(t)
+	s := NewInfoServer(test.NewLogger(), infoRegistry())
+	require.NoError(t, s.Rebind(ctx, path, Access{Mode: 0600}))
+	t.Cleanup(s.Stop)
+
+	cancel()
+	require.Eventually(t, func() bool { return s.Path() == "" }, time.Second, 10*time.Millisecond)
+	_, err := os.Stat(path)
+	assert.True(t, os.IsNotExist(err))
+
+	// And a reload that lands during shutdown must not bring it back.
+	err = s.Rebind(ctx, path, Access{Mode: 0600})
+	require.Error(t, err)
+	assert.ErrorContains(t, err, "shutting down")
+}
+
+func TestInfoConfig(t *testing.T) {
+	load := func(t *testing.T, yaml string) (infoConfig, error) {
+		t.Helper()
+		c := config.NewC(test.NewLogger())
+		require.NoError(t, c.LoadString(yaml))
+		return infoConfigFrom(c)
+	}
+
+	cfg, err := load(t, "local_api: {}\n")
+	require.NoError(t, err)
+	assert.False(t, cfg.Enabled)
+
+	cfg, err = load(t, "local_api:\n  info:\n    socket: /run/nebula/info.sock\n")
+	require.NoError(t, err)
+	assert.False(t, cfg.Enabled, "a socket alone does not enable it")
+
+	_, err = load(t, "local_api:\n  info:\n    enabled: true\n")
+	require.Error(t, err)
+	require.ErrorContains(t, err, "needs a socket path")
+
+	cfg, err = load(t, "local_api:\n  info:\n    enabled: true\n    socket: /run/nebula/info.sock\n")
+	require.NoError(t, err)
+	assert.Equal(t, infoConfig{Enabled: true, Path: "/run/nebula/info.sock", Access: Access{Mode: 0600}}, cfg)
+
+	cfg, err = load(t, "local_api:\n  info:\n    enabled: true\n    socket: /run/nebula/info.sock\n    socket_mode: \"0660\"\n")
+	require.NoError(t, err)
+	assert.Equal(t, infoConfig{Enabled: true, Path: "/run/nebula/info.sock", Access: Access{Mode: 0660}}, cfg)
+
+	_, err = load(t, "local_api:\n  info:\n    enabled: true\n    socket: run/info.sock\n")
+	require.Error(t, err, "a relative socket path is refused rather than resolved against anything")
+	require.ErrorContains(t, err, "must be an absolute path")
+	require.ErrorContains(t, err, InfoSocketKey)
+
+	_, err = load(t, "local_api:\n  info:\n    enabled: true\n    socket: /run/nebula/info.sock\n    socket_mode: 0660\n")
+	require.Error(t, err, "a bare number is whichever integer the yaml parser made of it")
+	require.ErrorContains(t, err, "quoted octal string")
+
+	for _, bad := range []string{`"abc"`, `"1777"`, `"08"`} {
+		_, err = load(t, "local_api:\n  info:\n    enabled: true\n    socket: /run/nebula/info.sock\n    socket_mode: "+bad+"\n")
+		require.Error(t, err, bad)
+		require.ErrorContains(t, err, "not an octal file mode", bad)
+	}
+
+	_, err = load(t, fmt.Sprintf("local_api:\n  info:\n    enabled: true\n    socket: /%s\n", strings.Repeat("x", sunPathMax)))
+	require.Error(t, err)
+	assert.ErrorContains(t, err, "too long")
+}
+
+// TestWireInfoFollowsConfig: off by default, up when enabled, down when disabled, and
+// never on the control socket's path.
+func TestWireInfoFollowsConfig(t *testing.T) {
+	ctx, cancel := context.WithCancel(context.Background())
+	t.Cleanup(cancel)
+
+	ctlPath := testSocket(t)
+	infoPath := testSocket(t)
+	sectionWithMode := func(enabled bool, socket, mode string) string {
+		return fmt.Sprintf("local_api:\n  control:\n    socket: %s\n  info:\n    enabled: %t\n    socket: %s\n    socket_mode: \"%s\"\n", ctlPath, enabled, socket, mode)
+	}
+	section := func(enabled bool, socket string) string {
+		return sectionWithMode(enabled, socket, "0600")
+	}
+
+	c := config.NewC(test.NewLogger())
+	require.NoError(t, c.LoadString(section(false, infoPath)))
+
+	var logs lockedBuffer
+	eps := Wire(ctx, test.NewLoggerWithOutput(&logs), c, infoRegistry())
+	t.Cleanup(eps.Stop)
+	assert.Equal(t, ctlPath, eps.Control.Path())
+	assert.Empty(t, eps.Info.Path(), "off until enabled")
+
+	require.NoError(t, c.ReloadConfigString(section(true, infoPath)).Err)
+	assert.Equal(t, infoPath, eps.Info.Path())
+	status, body := test.HTTPGet(t, infoPath, "/v1/whoami?name=alice")
+	assert.Equal(t, http.StatusOK, status)
+	assert.JSONEq(t, `{"name":"alice"}`, body)
+
+	// A reload that changes nothing leaves it where it is.
+	require.NoError(t, c.ReloadConfigString(section(true, infoPath)).Err)
+	assert.Equal(t, infoPath, eps.Info.Path())
+
+	// A reload that changes only the mode applies it in place.
+	require.NoError(t, c.ReloadConfigString(sectionWithMode(true, infoPath, "0660")).Err)
+	assert.Equal(t, infoPath, eps.Info.Path())
+	fi, err := os.Stat(infoPath)
+	require.NoError(t, err)
+	assert.Equal(t, os.FileMode(0660), fi.Mode().Perm())
+
+	// A reload that moves the socket moves it, and removes the old one.
+	moved := testSocket(t)
+	require.NoError(t, c.ReloadConfigString(section(true, moved)).Err)
+	assert.Equal(t, moved, eps.Info.Path())
+	_, err = os.Stat(infoPath)
+	assert.True(t, os.IsNotExist(err))
+	status, body = test.HTTPGet(t, moved, "/v1/whoami?name=bob")
+	assert.Equal(t, http.StatusOK, status)
+	assert.JSONEq(t, `{"name":"bob"}`, body)
+	require.NoError(t, c.ReloadConfigString(section(true, infoPath)).Err)
+	require.Equal(t, infoPath, eps.Info.Path())
+
+	// The control socket's path is refused, and the running endpoint is kept, which is
+	// what the log says.
+	require.NoError(t, c.ReloadConfigString(section(true, ctlPath)).Err)
+	assert.Equal(t, infoPath, eps.Info.Path())
+	assert.Equal(t, ctlPath, eps.Control.Path())
+	assert.Contains(t, logs.String(), "keeping the current endpoint")
+	assert.NotContains(t, logs.String(), "Local info API is not available")
+
+	require.NoError(t, c.ReloadConfigString(section(false, infoPath)).Err)
+	assert.Empty(t, eps.Info.Path())
+	_, err = os.Stat(infoPath)
+	assert.True(t, os.IsNotExist(err))
+
+	// One Stop takes both down.
+	require.NoError(t, c.ReloadConfigString(section(true, infoPath)).Err)
+	eps.Stop()
+	assert.Empty(t, eps.Info.Path())
+	assert.Empty(t, eps.Control.Path())
+}
+
+// TestWireInfoKeepsRunningOnABadReload: a typo in the section must not cut off the
+// programs relying on the endpoint; the operator is told and the endpoint stays.
+func TestWireInfoKeepsRunningOnABadReload(t *testing.T) {
+	ctx, cancel := context.WithCancel(context.Background())
+	t.Cleanup(cancel)
+
+	infoPath := testSocket(t)
+	good := fmt.Sprintf("local_api:\n  info:\n    enabled: true\n    socket: %s\n", infoPath)
+
+	c := config.NewC(test.NewLogger())
+	require.NoError(t, c.LoadString(good))
+
+	srv := wireInfo(ctx, test.NewLogger(), c, infoRegistry())
+	t.Cleanup(srv.Stop)
+	require.Equal(t, infoPath, srv.Path())
+
+	require.NoError(t, c.ReloadConfigString(good+"    socket_mode: 0660\n").Err)
+	assert.Equal(t, infoPath, srv.Path())
+	status, _ := test.HTTPGet(t, infoPath, "/v1/whoami")
+	assert.Equal(t, http.StatusOK, status)
+}
+
+// TestInfoAfterReplyRunsOnceTheResponseIsOut: the hook blocks until the test releases
+// it, so the only way the response can arrive is if it left the server before the hook
+// began. The reply carries a Content-Length, so a flushed reply is complete to its reader.
+func TestInfoAfterReplyRunsOnceTheResponseIsOut(t *testing.T) {
+	release := make(chan struct{})
+	acted := make(chan struct{})
+
+	reg := commands.New()
+	reg.Register(&commands.Command{
+		Name:             "act",
+		ShortDescription: "replies, then acts",
+		Identity:         true,
+		Run: func(fs any, a []string) (commands.Result, error) {
+			return commands.Did("about to act"), nil
+		},
+		AfterReply: func() {
+			<-release
+			close(acted)
+		},
+	})
+	_, path := serveInfo(t, reg, 0o600)
+
+	status, body := test.HTTPGet(t, path, "/v1/act")
+	assert.Equal(t, http.StatusOK, status)
+	assert.JSONEq(t, `{"ok":true,"reason":"about to act"}`, body)
+
+	select {
+	case <-acted:
+		t.Fatal("AfterReply ran before the response was delivered")
+	default:
+	}
+
+	close(release)
+	select {
+	case <-acted:
+	case <-time.After(2 * time.Second):
+		t.Fatal("AfterReply never ran")
+	}
+}
+
+// rawInfoRequest opens the socket and writes req as it is, so a test can send what an
+// HTTP client would not: a body that never arrives. It returns the connection.
+func rawInfoRequest(t *testing.T, path, req string) net.Conn {
+	t.Helper()
+	conn, err := net.DialTimeout("unix", path, time.Second)
+	require.NoError(t, err)
+	t.Cleanup(func() { _ = conn.Close() })
+	_, err = conn.Write([]byte(req))
+	require.NoError(t, err)
+	return conn
+}
+
+// TestInfoRefusesARequestBody: a request that announces a body is answered 400 at once,
+// before the body arrives, and the connection is closed once the read deadline has ended
+// the wait for it, so a client that never sends the body holds its slot for the deadline
+// and no longer.
+func TestInfoRefusesARequestBody(t *testing.T) {
+	oldHeader, oldRead := infoReadHeaderTimeout, infoReadTimeout
+	infoReadHeaderTimeout, infoReadTimeout = 200*time.Millisecond, 300*time.Millisecond
+	t.Cleanup(func() { infoReadHeaderTimeout, infoReadTimeout = oldHeader, oldRead })
+	_, path := serveInfo(t, infoRegistry(), 0600)
+
+	start := time.Now()
+	conn := rawInfoRequest(t, path, "GET /v1/whoami HTTP/1.1\r\nHost: nebula\r\nContent-Length: 10\r\n\r\n")
+	require.NoError(t, conn.SetReadDeadline(time.Now().Add(2*time.Second)))
+	res, err := http.ReadResponse(bufio.NewReader(conn), nil)
+	require.NoError(t, err)
+	body, _ := io.ReadAll(res.Body)
+	res.Body.Close()
+	assert.Equal(t, http.StatusBadRequest, res.StatusCode)
+	assert.Contains(t, string(body), "takes no request body")
+	assert.True(t, res.Close, "the connection is not kept for another request")
+	assert.Less(t, time.Since(start), 200*time.Millisecond, "answered before the body, which never comes")
+
+	_, err = conn.Read(make([]byte, 1))
+	require.ErrorIs(t, err, io.EOF, "the server closed the connection")
+	assert.Less(t, time.Since(start), time.Second, "and did so at the read deadline, not later")
+}
+
+// TestInfoDropsASlowClient: a client that sends its headers and then nothing loses its
+// connection at the read timeout rather than holding a slot for as long as it likes.
+func TestInfoDropsASlowClient(t *testing.T) {
+	oldHeader, oldRead := infoReadHeaderTimeout, infoReadTimeout
+	infoReadHeaderTimeout, infoReadTimeout = 200*time.Millisecond, 300*time.Millisecond
+	t.Cleanup(func() { infoReadHeaderTimeout, infoReadTimeout = oldHeader, oldRead })
+	_, path := serveInfo(t, infoRegistry(), 0600)
+
+	// Half a request line, then silence: the header deadline ends it.
+	conn := rawInfoRequest(t, path, "GET /v1/whoami HTTP/1.1\r\nHost: neb")
+	require.NoError(t, conn.SetReadDeadline(time.Now().Add(2*time.Second)))
+	start := time.Now()
+	_, err := conn.Read(make([]byte, 1))
+	require.Error(t, err, "the server closed the connection")
+	assert.Less(t, time.Since(start), time.Second)
+
+	// And the server still answers the next client.
+	status, _ := test.HTTPGet(t, path, "/v1/whoami")
+	assert.Equal(t, http.StatusOK, status)
+}

+ 23 - 0
localapi/info_windows_test.go

@@ -0,0 +1,23 @@
+//go:build windows
+
+package localapi
+
+import (
+	"context"
+	"testing"
+
+	"github.com/slackhq/nebula/commands"
+	"github.com/slackhq/nebula/test"
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+)
+
+// TestInfoIsNotServedOnWindows: a pipe has no mode to open to other accounts, so Rebind
+// says so and leaves nothing listening, whatever the config asked for.
+func TestInfoIsNotServedOnWindows(t *testing.T) {
+	s := NewInfoServer(test.NewLogger(), commands.New())
+	err := s.Rebind(context.Background(), pipePrefix+"nebula-info-test", Access{Mode: 0600})
+	require.ErrorIs(t, err, errInfoUnsupported)
+	assert.Empty(t, s.Path())
+	s.Stop()
+}

+ 80 - 28
localapi/platform_unix.go

@@ -44,19 +44,23 @@ func withPeerFD(conn net.Conn, f func(fd int) error) error {
 // in for root, or for a stranger, without owning a second account on the host.
 var currentUID = func() uint32 { return uint32(os.Geteuid()) }
 
+// infoSupported reports that the info API can be served here: a unix socket takes the
+// mode an operator configures, which is the whole of its access control.
+const infoSupported = true
+
 // listen binds the socket at path with the given access.
 //
 // Access control is the socket's own mode and group, which the node is created no
-// looser than and which are set after bind, together with the peer check the server
-// applies on accept unless a group shares the socket, in which case the kernel's check
-// is the whole of it: connect(2) needs write permission on the socket file on every
-// unix we build for. The
-// directory is not part of that, and is never chmoded: a directory a service manager
-// made for us is served from at whatever mode it came with (systemd's
-// RuntimeDirectoryMode= is 0755 by default), and one an operator pointed us into keeps
-// whatever else it holds readable. The one we make ourselves is 0755, searchable by
-// everyone, so that an unprivileged nebula ctl can see a root daemon's socket and say
-// so; see ServedByRootFor.
+// looser than and which are set after bind: connect(2) needs write permission on the
+// socket file on every unix we build for. The control socket is 0600 and adds the peer
+// check the server applies on accept, unless a group shares it, in which case it is
+// 0660 with that group and the kernel's check is the whole of it; the info socket is
+// whatever the operator configured, since opening it to others is its purpose. The directory is not part of that, and is never
+// chmoded: a directory a service manager made for us is served from at whatever mode it
+// came with (systemd's RuntimeDirectoryMode= is 0755 by default), and one an operator
+// pointed us into keeps whatever else it holds readable. The one we make ourselves is
+// 0755, searchable by everyone, so that an unprivileged nebula ctl can see a root
+// daemon's socket and say so; see ServedByRootFor.
 func listen(path string, acc Access) (net.Listener, error) {
 	// The kernel silently truncates an over long sun_path and bind then fails with
 	// "invalid argument", which tells an operator nothing. Say what is actually wrong.
@@ -97,9 +101,10 @@ func listen(path string, acc Access) (net.Listener, error) {
 
 	// bind creates the socket node at the process umask, and a connect(2) between that
 	// and a later chmod would be queued for accept. Tighten the umask around the bind so
-	// the node is never looser than 0600, however briefly. umask is process wide, so
-	// another goroutine creating a file during this one syscall gets 0600 too: stricter
-	// than it asked for, never looser, and nothing here creates files on a hot path.
+	// the node is never looser than 0600, however briefly, whatever mode it ends up at.
+	// umask is process wide, so another goroutine creating a file during this one syscall
+	// gets 0600 too: stricter than it asked for, never looser, and nothing here creates
+	// files on a hot path.
 	old := syscall.Umask(0077)
 	ln, err := net.Listen(network, path)
 	syscall.Umask(old)
@@ -118,26 +123,48 @@ func listen(path string, acc Access) (net.Listener, error) {
 	// The umask only promises no looser than 0600; this pins the mode and the group
 	// exactly, and is the one place a failure to secure the socket is reported.
 	if err := applyAccess(path, acc); err != nil {
-		release(ln, path)
+		// The node is ours, bound a moment ago, so it goes without being identified.
+		release(ln, path, socketID{})
 		return nil, fmt.Errorf("could not secure %s: %w", path, err)
 	}
 
 	return ln, nil
 }
 
-// applyAccess sets the owning group, when there is one, and the mode of the socket node
-// at path. The chown is Lchown, so a link at the path is not followed to its target.
-func applyAccess(path string, acc Access) error {
-	if acc.Group != nil {
-		gid, err := gidOf(acc.Group)
-		if err != nil {
-			return err
-		}
-		if err := os.Lchown(path, -1, gid); err != nil {
-			return err
-		}
+// checkOwnSocket refuses what fi found at path unless it is a socket owned by the user
+// this process runs as. fi comes from a look that does not follow links, so a symlink is
+// seen as one, and the mode is only ever applied to a socket of ours. checkSocketDir
+// vouched for the directory at bind, but that was then: a directory loosened since, or a
+// reload that moves the socket, is a chance for whoever can write it to replace the node
+// with a link, or a hard link to a file of their choosing, and have a root daemon set the
+// configured mode, 0660 or 0666 for an info socket, on that file. A link, another type
+// of file, or somebody else's socket is reported and left as it is. applyAccess, per
+// platform, is what makes the look and the change land on the same node.
+func checkOwnSocket(path string, fi os.FileInfo) error {
+	if !isSocket(fi) {
+		return fmt.Errorf("%s is not a socket but %s, refusing to change its mode", path, describeNode(fi))
+	}
+
+	if uid, ok := owner(fi); ok && uid != currentUID() {
+		return fmt.Errorf("%s is owned by %s, not by us, refusing to change its mode", path, describeUID(uid))
+	}
+
+	return nil
+}
+
+// describeNode names what kind of file fi describes, for a message about something found
+// where a socket was expected.
+func describeNode(fi os.FileInfo) string {
+	switch m := fi.Mode(); {
+	case m&os.ModeSymlink != 0:
+		return "a symlink"
+	case m.IsDir():
+		return "a directory"
+	case m.IsRegular():
+		return "a regular file"
+	default:
+		return "a " + m.Type().String() + " file"
 	}
-	return os.Chmod(path, acc.Mode.Perm())
 }
 
 // checkSocketDir refuses to serve from a directory that somebody other than the user
@@ -219,8 +246,12 @@ func describeUID(uid uint32) string {
 // Liveness is a plain connect. Only a refused connect proves there is nothing behind
 // the file. Anything else, a timeout or a permission error, is a socket we cannot vouch
 // for, and it is left alone with the reason in the error.
+//
+// The look is an Lstat: a symlink at path is refused as not a socket, whether or not it
+// resolves. Stat would report a dangling one as nothing there, and bind(2) on darwin
+// and the BSDs follows a dangling link and creates the socket node at its target.
 func clearStale(path string) error {
-	fi, err := os.Stat(path)
+	fi, err := os.Lstat(path)
 	if err != nil {
 		if os.IsNotExist(err) {
 			return nil
@@ -255,11 +286,32 @@ func clearStale(path string) error {
 // leaves nothing behind. A directory that came with the environment, a service
 // manager's runtime directory or one the operator pointed us into, is not ours to
 // remove and is left as it was.
-func removeSocket(path string) {
+//
+// Only the node id names is removed. One that replaced it is another daemon's, bound
+// after ours was taken from under us, and is left where it is. A zero id, a node that
+// could not be identified when it was bound, is removed as it always was.
+func removeSocket(path string, id socketID) {
+	if cur, ok := identifySocket(path); ok && id != (socketID{}) && cur != id {
+		return
+	}
 	_ = os.Remove(path)
 	madeDirs.remove(filepath.Dir(path))
 }
 
+// identifySocket returns the identity of the node at path, looked at without following
+// links, and false when there is nothing there or it cannot be read.
+func identifySocket(path string) (socketID, bool) {
+	fi, err := os.Lstat(path)
+	if err != nil {
+		return socketID{}, false
+	}
+	st, ok := fi.Sys().(*syscall.Stat_t)
+	if !ok {
+		return socketID{}, false
+	}
+	return socketID{dev: uint64(st.Dev), ino: uint64(st.Ino)}, true
+}
+
 // madeDirs is the set of directories listen created and has not yet removed. It is
 // package state because the socket's path is all release and listen share.
 var madeDirs = dirSet{made: map[string]bool{}}

+ 110 - 6
localapi/platform_unix_test.go

@@ -86,16 +86,16 @@ func TestReleaseRemovesTheDirectoryItMade(t *testing.T) {
 	second, err := listen(filepath.Join(made, "b.sock"), ownerOnly())
 	require.NoError(t, err)
 
-	release(first, filepath.Join(made, "a.sock"))
+	release(first, filepath.Join(made, "a.sock"), socketID{})
 	assert.DirExists(t, made, "b.sock is still in it")
-	release(second, filepath.Join(made, "b.sock"))
+	release(second, filepath.Join(made, "b.sock"), socketID{})
 	assert.NoDirExists(t, made)
 
 	given := filepath.Join(base, "given")
 	require.NoError(t, os.Mkdir(given, 0755))
 	ln, err := listen(filepath.Join(given, "s.sock"), ownerOnly())
 	require.NoError(t, err)
-	release(ln, filepath.Join(given, "s.sock"))
+	release(ln, filepath.Join(given, "s.sock"), socketID{})
 	assert.DirExists(t, given)
 	assert.NoFileExists(t, filepath.Join(given, "s.sock"))
 }
@@ -266,7 +266,7 @@ func TestPeerCheckRefusesAnotherUser(t *testing.T) {
 // bound its own, and a second unlink would take that daemon's socket from under it.
 func TestListenerDoesNotUnlinkOnClose(t *testing.T) {
 	path := filepath.Join(privateBase(t), DirName, "s.sock")
-	old, err := listen(path, ownerOnly())
+	old, err := listen(path, Access{Mode: ctlSocketMode})
 	require.NoError(t, err)
 
 	// A newer daemon's start, with the old node gone and the path bound afresh.
@@ -280,6 +280,52 @@ func TestListenerDoesNotUnlinkOnClose(t *testing.T) {
 	assert.NoError(t, err, "closing the old listener left the newer daemon's socket in place")
 }
 
+// TestRebindRecreatesASocketRemovedFromUnderIt: logind clearing /run/user/<uid> on
+// logout, or a unit cleaning a RuntimeDirectory= it shares, takes the node away while
+// the listener stays open and unreachable. The next reload, which Rebinds onto the same
+// path, has to notice and bind the socket again rather than return early because the
+// path has not changed.
+func TestRebindRecreatesASocketRemovedFromUnderIt(t *testing.T) {
+	path := testSocket(t)
+	s := NewServer(test.NewLogger(), basicRegistry())
+	require.NoError(t, s.Rebind(context.Background(), path, ownerOnly()))
+	t.Cleanup(s.Stop)
+
+	require.NoError(t, os.Remove(path))
+	_, err := (&Client{Path: path}).Describe()
+	require.Error(t, err, "nothing can reach the daemon with its node gone")
+
+	require.NoError(t, s.Rebind(context.Background(), path, ownerOnly()))
+	assert.Equal(t, path, s.Path())
+	_, err = (&Client{Path: path}).Describe()
+	require.NoError(t, err, "the plain reload brought the socket back")
+
+	// And a Rebind with the socket in place is still a no-op.
+	before, ok := identifySocket(path)
+	require.True(t, ok)
+	require.NoError(t, s.Rebind(context.Background(), path, ownerOnly()))
+	after, _ := identifySocket(path)
+	assert.Equal(t, before, after, "the node was left alone")
+}
+
+// TestStopLeavesAStrangersSocketAlone: once our node has been taken from under us and
+// another daemon has bound the path, the node there is that daemon's, and our Stop must
+// not unlink it by path.
+func TestStopLeavesAStrangersSocketAlone(t *testing.T) {
+	path := testSocket(t)
+	s := NewServer(test.NewLogger(), basicRegistry())
+	require.NoError(t, s.Rebind(context.Background(), path, ownerOnly()))
+
+	require.NoError(t, os.Remove(path))
+	other, err := net.Listen(network, path)
+	require.NoError(t, err)
+	t.Cleanup(func() { other.Close() })
+
+	s.Stop()
+	_, err = os.Stat(path)
+	assert.NoError(t, err, "the newer daemon's socket is still in place")
+}
+
 // TestMadeDirStaysRememberedUntilItIsGone: a directory listen created that cannot be
 // removed right now is still ours to remove, so the record survives the failure and the
 // next release tries again. Only a directory that is gone is forgotten.
@@ -348,7 +394,7 @@ func TestListenRefusesADirectoryOwnedBySomebodyElse(t *testing.T) {
 	asUID(t, me)
 	ln, err := listen(filepath.Join(dir, "s.sock"), ownerOnly())
 	require.NoError(t, err, "the same directory, seen as our own, serves")
-	release(ln, filepath.Join(dir, "s.sock"))
+	release(ln, filepath.Join(dir, "s.sock"), socketID{})
 }
 
 // TestListenThroughASymlinkedDirectory: a link where the directory should be is judged
@@ -370,7 +416,7 @@ func TestListenThroughASymlinkedDirectory(t *testing.T) {
 
 	ln, err := listen(path, ownerOnly())
 	require.NoError(t, err, "our link to our directory")
-	release(ln, path)
+	release(ln, path, socketID{})
 	assert.NoFileExists(t, filepath.Join(real, "s.sock"))
 
 	asUID(t, me+1)
@@ -382,3 +428,61 @@ func TestListenThroughASymlinkedDirectory(t *testing.T) {
 	_, err = listen(path, ownerOnly())
 	require.ErrorContains(t, err, "writable by everyone")
 }
+
+// TestListenRefusesASymlinkAtThePath: a link where the socket should be is somebody
+// else's doing, and it is refused before bind rather than followed. bind(2) on darwin
+// follows a dangling link and creates the socket at its target, and the chmod after it
+// would follow a link to anything, so neither the target nor the link is touched.
+func TestListenRefusesASymlinkAtThePath(t *testing.T) {
+	path := testSocket(t)
+	require.NoError(t, os.Mkdir(filepath.Dir(path), 0700))
+	target := filepath.Join(filepath.Dir(path), "target")
+
+	require.NoError(t, os.Symlink(target, path))
+	_, err := listen(path, Access{Mode: 0666})
+	require.ErrorContains(t, err, "is not a socket")
+	_, statErr := os.Lstat(target)
+	assert.True(t, os.IsNotExist(statErr), "a dangling link's target is not created")
+
+	require.NoError(t, os.WriteFile(target, nil, 0600))
+	_, err = listen(path, Access{Mode: 0666})
+	require.ErrorContains(t, err, "is not a socket")
+	fi, err := os.Stat(target)
+	require.NoError(t, err)
+	assert.Equal(t, os.FileMode(0600), fi.Mode().Perm(), "the file the link names keeps its mode")
+	assert.True(t, fi.Mode().IsRegular())
+}
+
+// TestChmodSocketRefusesWhatIsNotOurSocket: the mode is set through a look that does not
+// follow links, so a link, a plain file, or a socket of another user's at the path is
+// reported instead of chmoded. The last is seen as another user by changing who we are.
+func TestChmodSocketRefusesWhatIsNotOurSocket(t *testing.T) {
+	dir := filepath.Join(privateBase(t), "n")
+	require.NoError(t, os.Mkdir(dir, 0700))
+
+	file := filepath.Join(dir, "file")
+	require.NoError(t, os.WriteFile(file, nil, 0600))
+	link := filepath.Join(dir, "link")
+	require.NoError(t, os.Symlink(file, link))
+
+	require.ErrorContains(t, applyAccess(link, Access{Mode: 0666}), "a symlink")
+	require.ErrorContains(t, applyAccess(file, Access{Mode: 0666}), "a regular file")
+	fi, err := os.Stat(file)
+	require.NoError(t, err)
+	assert.Equal(t, os.FileMode(0600), fi.Mode().Perm())
+
+	sock := filepath.Join(dir, "s.sock")
+	ln, err := net.Listen(network, sock)
+	require.NoError(t, err)
+	defer ln.Close()
+	require.NoError(t, applyAccess(sock, Access{Mode: 0660}))
+	fi, err = os.Stat(sock)
+	require.NoError(t, err)
+	assert.Equal(t, os.FileMode(0660), fi.Mode().Perm())
+
+	asUID(t, currentUID()+1)
+	require.ErrorContains(t, applyAccess(sock, Access{Mode: 0666}), "not by us")
+	fi, err = os.Stat(sock)
+	require.NoError(t, err)
+	assert.Equal(t, os.FileMode(0660), fi.Mode().Perm())
+}

+ 14 - 4
localapi/platform_windows.go

@@ -45,6 +45,12 @@ func logListening(l *slog.Logger, path string, acc Access) {
 	)
 }
 
+// infoSupported is false here. A pipe has no mode to open to other accounts, and the
+// security descriptor listen builds admits the daemon's account alone, which is the
+// opposite of what the info API is for. Serving it on windows means designing which
+// accounts a pipe admits, and that has not been done.
+const infoSupported = false
+
 // listen creates the named pipe at path with a security descriptor that admits the user
 // nebula runs as, SYSTEM and Administrators, and the group acc names, if any, for
 // reading and writing, which is what connecting to a pipe takes. acc's mode means
@@ -142,14 +148,18 @@ func sddlFor(acc Access) (string, error) {
 	return sddl, nil
 }
 
+// removeSocket is a no-op: a named pipe disappears when its last handle closes, so
+// there is nothing to unlink and no stale instance to clean up.
+func removeSocket(path string, id socketID) {}
+
+// identifySocket has no node to identify: a pipe is its handle, and it cannot be
+// removed from under the daemon, so an endpoint on its path is always still there.
+func identifySocket(path string) (socketID, bool) { return socketID{}, true }
+
 // applyAccess cannot change a pipe's access in place: its security descriptor is fixed
 // when it is created, so a change means creating it again, which the caller does.
 func applyAccess(path string, acc Access) error { return errReopenForAccess }
 
-// removeSocket is a no-op: a named pipe disappears when its last handle closes, so
-// there is nothing to unlink and no stale instance to clean up.
-func removeSocket(path string) {}
-
 // dial connects to the pipe and, before the connection carries anything, checks who
 // serves it. The request names a command and its arguments, so it must not reach a
 // pipe that some other account put under nebula's name. For the default name the

+ 4 - 4
localapi/platform_windows_test.go

@@ -123,7 +123,7 @@ func TestListenRefused(t *testing.T) {
 // promises a non-administrator.
 func TestListenProtectedName(t *testing.T) {
 	path := protectedPrefix + "nebula-test-" + t.Name()
-	ln, err := listen(path, ownerOnly())
+	ln, err := listen(path, Access{Mode: ctlSocketMode})
 	if !isAdministrator() {
 		require.Error(t, err)
 		assert.Contains(t, err.Error(), "requires an administrator")
@@ -152,7 +152,7 @@ func TestListenProtectedName(t *testing.T) {
 // is this account, or Administrators when the test is elevated.
 func TestDialAcceptsOwnPipe(t *testing.T) {
 	path := pipePrefix + "nebula-test-" + t.Name()
-	ln, err := listen(path, ownerOnly())
+	ln, err := listen(path, Access{Mode: ctlSocketMode})
 	require.NoError(t, err)
 	t.Cleanup(func() { ln.Close() })
 
@@ -176,12 +176,12 @@ func TestDialAcceptsOwnPipe(t *testing.T) {
 // served alongside, or in place of, the daemon.
 func TestListenRefusesHeldName(t *testing.T) {
 	path := pipePrefix + "nebula-test-" + t.Name()
-	first, err := listen(path, ownerOnly())
+	first, err := listen(path, Access{Mode: ctlSocketMode})
 	require.NoError(t, err)
 	t.Cleanup(func() { first.Close() })
 
 	var second net.Listener
-	second, err = listen(path, ownerOnly())
+	second, err = listen(path, Access{Mode: ctlSocketMode})
 	if second != nil {
 		second.Close()
 	}

+ 8 - 6
localapi/protocol.go

@@ -1,10 +1,12 @@
-// Package localapi serves the nebula command registry over a local IPC endpoint so the
-// user who ran nebula can inspect and control it from a CLI on the same machine.
+// Package localapi serves the nebula command registry over local IPC endpoints: the
+// control socket, so the user who ran nebula can inspect and control it from a CLI on
+// the same machine, and the info API, so other programs on the machine can ask who is
+// behind a vpn address.
 //
-// Access is restricted by the filesystem (a 0600 socket) and by a peer credential check
-// on every connection. Which commands are served is decided by the caller that builds
-// the Server, and the filtering happens here, on the serving side, rather than in the
-// client.
+// The control socket's access is restricted by the filesystem (a 0600 socket) and by a
+// peer credential check on every connection. The info API is opt in, HTTP, and as open as the operator makes its
+// socket; see InfoServer for what it serves and why that is safe. Which commands each
+// serves is decided here, on the serving side, rather than in any client.
 //
 // # Protocol
 //

+ 21 - 160
localapi/server.go

@@ -6,13 +6,13 @@ import (
 	"encoding/json"
 	"errors"
 	"fmt"
+	"io"
 	"log/slog"
 	"math"
 	"net"
 	"os"
 	"runtime/debug"
 	"strconv"
-	"sync"
 	"sync/atomic"
 	"time"
 
@@ -43,31 +43,21 @@ const (
 // choose is a group, CtlGroupKey, which makes it groupSocketMode.
 const ctlSocketMode os.FileMode = 0600
 
-// Server serves the read only portion of a command registry over a local socket.
+// Server serves a command registry over a local socket to the user who ran nebula.
 type Server struct {
-	l   *slog.Logger
+	endpoint
 	src *commands.Registry
 
 	maxConns  int
 	slowAfter time.Duration
 
-	mu      sync.Mutex
-	ln      net.Listener
-	path    string
-	running bool
-	unwatch func() bool
-
 	sem  chan struct{}
 	euid uint32
 
-	// access is what the socket at path was bound with, so a reload that changes only
-	// the group or the mode is applied without moving the socket.
-	access Access
-
 	// trustSocket is set while a group shares the socket. The kernel admitted the peer
 	// through the socket's mode and group, which is the whole access control then, and
-	// the peer check in serve stands aside: on unix it sees the peer's uid and primary
-	// gid only, so it could not tell a member of the group from anyone else.
+	// the peer check below stands aside: on unix it sees the peer's uid and primary gid
+	// only, so it could not tell a member of the group from anyone else.
 	trustSocket atomic.Bool
 }
 
@@ -95,7 +85,7 @@ const notServedHere = "profiling is driven from the ssh debug server, where the
 // treats the same registry, cloning it per session.
 func NewServer(l *slog.Logger, reg *commands.Registry) *Server {
 	return &Server{
-		l:         l,
+		endpoint:  endpoint{l: l, name: "local control socket"},
 		src:       reg,
 		maxConns:  defaultMaxConns,
 		slowAfter: defaultSlowAfter,
@@ -106,109 +96,25 @@ func NewServer(l *slog.Logger, reg *commands.Registry) *Server {
 }
 
 // Rebind brings the server up at path, moves a running server there, and serves it until
-// Stop or ctx cancellation. It is the one way the server comes up, so Wire's first
-// attempt and its reload callback are the same call, and a server already serving path
-// is left as it is: Wire registers the callback before the first attempt, so a HUP that
-// lands between the two has the callback do the first bind, and the first attempt then
-// finds the socket it was about to create already up.
-//
-// The new socket is bound before the old one is given up: a bad path in a reloaded
-// config leaves the operator with the endpoint they already had rather than none.
-//
-// s.mu is held for the whole operation, bind included. Releasing it around listen would
-// let a Stop land in between, and this would then install a fresh listener on a server
-// that had just shut down, leaving a live socket on disk until the process exits. listen
-// does not take s.mu, so holding it costs the other methods one bind's wait, at most the
-// stale socket probe, once per reload.
-//
-// An error here is never fatal to the caller: nebula keeps forwarding packets without a
-// control endpoint, and the caller is expected to log and carry on.
+// Stop or ctx cancellation; endpoint.rebindLocked says what that promises. An error here
+// is never fatal to the caller: nebula keeps forwarding packets without a control
+// endpoint, and the caller is expected to log and carry on.
 func (s *Server) Rebind(ctx context.Context, path string, acc Access) error {
 	s.mu.Lock()
 	defer s.mu.Unlock()
 
-	reopening := false
-	if s.running && s.path == path {
-		if s.access.equal(acc) {
-			return nil
-		}
-
-		// Only the access changed. On unix it is set on the socket in place; on windows
-		// the descriptor is fixed when the pipe is created, so the pipe is created again.
-		// The name cannot be held twice, so the old one goes first, and a bind that then
-		// fails leaves the server down rather than on the access the operator asked to
-		// change; that is reported.
-		err := applyAccess(path, acc)
-		if err == nil {
-			s.access = acc
-			s.trustSocket.Store(acc.Group != nil)
-			s.l.Info("Changed the local control socket's access", "path", path, "access", acc.String())
-			return nil
-		}
-		if !errors.Is(err, errReopenForAccess) {
-			return fmt.Errorf("could not change the access of %s: %w", path, err)
-		}
-		reopening = true
-		release(s.ln, s.path)
-		s.ln, s.path, s.running, s.access = nil, "", false, Access{}
-	}
-
-	ln, err := listen(path, acc)
-	if err != nil {
-		if reopening {
-			return fmt.Errorf("the local control socket was closed to change its access and could not be created again: %w", err)
-		}
-		return err
-	}
-
-	// A reload that arrives during shutdown must not bring the endpoint back. Stop is
-	// not terminal (Rebind after Stop is allowed, and is how a server whose first bind
-	// failed comes up), so running is no guide; the context is what shutdown cancels,
-	// and it is checked here, while nothing else can change the server's state.
-	if err := ctx.Err(); err != nil {
-		release(ln, path)
-		return fmt.Errorf("not starting the local control socket, nebula is shutting down: %w", err)
-	}
-
-	oldLn, oldPath := s.ln, s.path
-	s.ln = ln
-	s.path = path
-	s.access = acc
-	s.running = true
-	s.trustSocket.Store(acc.Group != nil)
-	sem := s.semaphore()
-
-	s.watchLocked(ctx)
-
-	// Stop accepting on the old listener. In flight commands keep their connections,
-	// which stay usable until they finish. Its accept loop exits on the closed-listener
-	// error, so it is gone as soon as this returns. oldPath differs from path whenever
-	// oldLn is set: a running server on the same path returned above.
-	if oldLn != nil {
-		release(oldLn, oldPath)
-	}
-
-	logListening(s.l, path, acc)
-
-	go func() {
-		s.accept(ln, sem)
-	}()
-
-	return nil
-}
-
-// watchLocked stops the server when ctx is cancelled. One watcher covers a run of the
-// server, from the first bind that succeeds until Stop: a daemon whose initial listen
-// failed and which later Rebinds onto a good path from a reloaded config still needs its
-// socket removed on shutdown, and a Rebind on a running server must not stack a second
-// watcher on the same ctx. Stop detaches the watcher, so a server started again
-// afterwards is watched on the ctx that started it, and the ctx of an earlier run no
-// longer reaches it whenever that one ends. Callers hold s.mu.
-func (s *Server) watchLocked(ctx context.Context) {
-	if s.unwatch != nil {
-		return
+	err := s.rebindLocked(ctx, path, acc, func(ln net.Listener) io.Closer {
+		logListening(s.l, path, acc)
+		// Closing the listener, when it is given up, ends the accept loop on the
+		// closed-listener error; in flight commands keep their connections, which stay
+		// usable until they finish.
+		go s.accept(ln, s.semaphore())
+		return ln
+	})
+	if err == nil {
+		s.trustSocket.Store(acc.Group != nil)
 	}
-	s.unwatch = context.AfterFunc(ctx, s.Stop)
+	return err
 }
 
 // registry returns the commands this surface serves, filtered from the source at the
@@ -218,54 +124,9 @@ func (s *Server) registry() *commands.Registry {
 	return s.src.Filter(servedHere, notServedHere)
 }
 
-// Path returns the socket path currently being served, or "" when stopped.
-func (s *Server) Path() string {
-	s.mu.Lock()
-	defer s.mu.Unlock()
-	return s.path
-}
-
-// Stop closes the listener and removes the socket. In flight commands are allowed to
-// finish; Stop does not wait for them.
-//
-// The whole of it runs under s.mu, listener and socket included, so a Rebind cannot land
-// between the state change and the close and find a path that is still bound by the
-// listener it is replacing.
-func (s *Server) Stop() {
-	s.mu.Lock()
-	defer s.mu.Unlock()
-
-	if !s.running {
-		return
-	}
-
-	release(s.ln, s.path)
-	s.running = false
-	s.access = Access{}
-	s.ln = nil
-	s.path = ""
-
-	// Set whenever running is. When this Stop is the watcher's own, stopping a watcher
-	// that has already fired is a no-op.
-	s.unwatch()
-	s.unwatch = nil
-}
-
-// release gives up a listener and the socket node at its path. The node goes first,
-// while the listener is still open: for as long as it is, the node is this server's,
-// since a starting daemon's stale check refuses to remove a socket that answers. The
-// other order opens a window between the close and the unlink in which a new daemon can
-// find the node stale, clear it and bind its own at the same path, which the unlink
-// then takes from under it, leaving that daemon serving a socket nothing can reach.
-// listen turns off the listener's own unlink on close so this is the only unlink.
-func release(ln net.Listener, path string) {
-	removeSocket(path)
-	_ = ln.Close()
-}
-
 // semaphore returns the connection limiter, creating it on first use. It is made once
 // and never replaced: a handler releases into the channel it acquired from, so swapping
-// in a fresh one across a Stop and Start would leave any handler still in flight
+// in a fresh one across a Stop and Rebind would leave any handler still in flight
 // blocked forever on a channel nothing had filled. Callers hold s.mu.
 func (s *Server) semaphore() chan struct{} {
 	if s.sem == nil {

+ 1 - 1
localapi/server_test.go

@@ -502,7 +502,7 @@ func (f *flakyListener) Accept() (net.Conn, error) {
 func TestAcceptSurvivesTransientErrors(t *testing.T) {
 	path := testSocket(t)
 	require.NoError(t, os.Mkdir(filepath.Dir(path), 0700))
-	ln, err := listen(path, ownerOnly())
+	ln, err := listen(path, Access{Mode: ctlSocketMode})
 	require.NoError(t, err)
 
 	s := NewServer(test.NewLogger(), basicRegistry())

+ 143 - 21
localapi/wire.go

@@ -9,23 +9,69 @@ import (
 	"github.com/slackhq/nebula/config"
 )
 
-// Wire brings up the local control socket for a running nebula, unless the config turns
-// it off, and keeps it following the config across reloads.
+// Endpoints is every local API Wire brought up, with the one Stop the daemon's exit
+// path calls.
+type Endpoints struct {
+	Control *Server
+	Info    *InfoServer
+}
+
+// Stop closes both endpoints and removes their sockets.
+func (e *Endpoints) Stop() {
+	e.Info.Stop()
+	e.Control.Stop()
+}
+
+// Wire brings up the local APIs for a running nebula and keeps them following the
+// config across reloads: the control socket unless the config turns it off, and the
+// info API when the config asks for it.
 //
-// Every entry point that runs nebula as a daemon calls this, so the endpoint behaves the
+// Every entry point that runs nebula as a daemon calls this, so the endpoints behave the
 // same whether nebula was started from a shell or by a service manager. It deliberately
-// does not live in nebula.Main: the endpoint is on by default, so an embedder inside an
-// app sandbox would have to know to turn off a socket it cannot use. Wiring it at the
-// daemon entry points means embedders get the command registry and no listener.
+// does not live in nebula.Main: the control socket is on by default, so an embedder
+// inside an app sandbox would have to know to turn off a socket it cannot use. Wiring it
+// at the daemon entry points means embedders get the command registry and no listener.
 //
 // Every failure below is a warning, bar one. Nebula is expected to keep forwarding
 // packets whether or not anyone can reach it; but a name that another process holds is
 // not a misconfiguration, it is a process that will be answering nebula ctl in the
 // daemon's place, and that is logged as an error.
 //
-// Call it after Control.Start, so the listener only exists once the interface is up.
-func Wire(ctx context.Context, l *slog.Logger, c *config.C, reg *commands.Registry) *Server {
+// Call it after Control.Start, so the listeners only exist once the interface is up.
+func Wire(ctx context.Context, l *slog.Logger, c *config.C, reg *commands.Registry) *Endpoints {
 	l = l.With("subsystem", "localapi")
+	return &Endpoints{
+		Control: wireControl(ctx, l, c, reg),
+		Info:    wireInfo(ctx, l, c, reg),
+	}
+}
+
+// sharedSocketPath reports the path the config gives to both the control socket and the
+// info API, when it gives them one path. The two speak different protocols, so neither
+// binds it: each side says so and leaves it, and the operator fixes the config. The
+// configured paths are compared, not the ones being served, so the case is caught on
+// whichever side reconciles first, when the other side's bind has failed, and when a
+// reload moves one socket onto the other's path.
+func sharedSocketPath(c *config.C) (string, bool) {
+	if !ControlEnabled(c) {
+		return "", false
+	}
+
+	ctl, err := SocketPathFromConfig(c)
+	if err != nil {
+		return "", false
+	}
+
+	info, err := infoConfigFrom(c)
+	if err != nil || !info.Enabled || info.Path != ctl {
+		return "", false
+	}
+
+	return ctl, true
+}
+
+// wireControl brings up the control socket and keeps it pointed at the configured path.
+func wireControl(ctx context.Context, l *slog.Logger, c *config.C, reg *commands.Registry) *Server {
 	srv := NewServer(l, reg)
 
 	// Registered before the first attempt, so that a path or bind that is bad at startup
@@ -55,10 +101,6 @@ func Wire(ctx context.Context, l *slog.Logger, c *config.C, reg *commands.Regist
 // could not bind is one the client, reading the new path from the same file, cannot
 // reach, and the operator's fix is the directory and a reload, not an edit to an
 // unrelated line to make the key read as changed.
-//
-// The callback recovers from its own panics rather than relying on the config package,
-// whose callback loop has no isolation of its own. A panic there would take the daemon
-// down and skip every callback queued behind this one.
 func followControlConfig(ctx context.Context, l *slog.Logger, c *config.C, srv *Server) func(*config.C) {
 	reconcile := func(c *config.C) {
 		if !ControlEnabled(c) {
@@ -89,39 +131,119 @@ func followControlConfig(ctx context.Context, l *slog.Logger, c *config.C, srv *
 			return
 		}
 
-		reportRebind(l, srv.Path(), want, srv.Rebind(ctx, want, acc))
+		if p, shared := sharedSocketPath(c); shared {
+			l.Warn("Local control socket is configured on the info API's socket path and will not bind it",
+				"path", p, "current", srv.Path(), "fix", "set "+InfoSocketKey+" to another path")
+			return
+		}
+
+		reportRebind(l, "local control socket", srv.Path(), want, srv.Rebind(ctx, want, acc))
 	}
 
+	onReload(l, c, "local control socket", reconcile)
+	return reconcile
+}
+
+// onReload registers fn to run on every reload. The callback recovers from its own
+// panics rather than relying on the config package, whose callback loop has no isolation
+// of its own: a panic there would take the daemon down and skip every callback queued
+// behind this one.
+func onReload(l *slog.Logger, c *config.C, what string, fn func(*config.C)) {
 	c.RegisterReloadCallback(func(c *config.C, _ *config.Reload) {
 		defer func() {
 			if r := recover(); r != nil {
-				l.Error("Recovered from panic reloading the local control socket", "panic", r)
+				l.Error("Recovered from panic reloading the "+what, "panic", r)
 			}
 		}()
-		reconcile(c)
+		fn(c)
 	})
-	return reconcile
 }
 
 // reportRebind logs what a Rebind did beyond the listening line the bind itself writes:
-// nothing for a server left where it was, a move with both paths, and a failure with
+// nothing for an endpoint left where it was, a move with both paths, and a failure with
 // what the operator is left with. old is the path served before the call, "" for none,
 // and want the one asked for.
-func reportRebind(l *slog.Logger, old, want string, err error) {
+func reportRebind(l *slog.Logger, what, old, want string, err error) {
 	switch {
 	case err == nil:
 		if old != "" && old != want {
-			l.Info("Moved the local control socket", "from", old, "to", want)
+			l.Info("Moved the "+what, "from", old, "to", want)
 		}
 	case old == "":
-		bindFailure(l, err)("Could not start the local control socket, control commands will not be available until a reload binds it", "error", err, "path", want)
+		bindFailure(l, err)("Could not start the "+what+", it will not be available until a reload binds it", "error", err, "path", want)
 	default:
-		bindFailure(l, err)("Could not move the local control socket, keeping the current one",
+		bindFailure(l, err)("Could not move the "+what+", keeping the current one",
 			"error", err, "current", old, "requested", want,
 		)
 	}
 }
 
+// wireInfo brings up the info API when the config asks for one and follows the config
+// across reloads: enabling it, disabling it, moving its socket and changing its mode.
+//
+// Every reload is reconciled rather than gated on the section having changed. Rebind on
+// a server already where the config says is a no-op, so that costs nothing, and a bind
+// that failed for a reason outside the config, a runtime directory that was not there
+// yet, is retried by the plain reload that follows the fix, the same as the control
+// socket's. A section that does not parse keeps whatever is running: the operator is
+// told, and the programs relying on the endpoint are not cut off by a typo.
+func wireInfo(ctx context.Context, l *slog.Logger, c *config.C, reg *commands.Registry) *InfoServer {
+	srv := NewInfoServer(l, reg)
+
+	// Said once per process rather than on every reload: it is a platform limit, not
+	// something the next reload could fix.
+	saidUnsupported := false
+
+	reconcile := func(c *config.C) {
+		cfg, err := infoConfigFrom(c)
+		if err != nil {
+			if srv.Path() == "" {
+				l.Warn("Local info API is not available, its config is unusable", "error", err)
+			} else {
+				l.Warn("Ignoring unusable local info API config, keeping the current endpoint", "error", err)
+			}
+			return
+		}
+
+		if !cfg.Enabled {
+			if srv.Path() != "" {
+				srv.Stop()
+				l.Info("Stopped the local info API, it is disabled in the reloaded config")
+			}
+			return
+		}
+
+		if !infoSupported {
+			if !saidUnsupported {
+				l.Warn("Local info API is enabled in the config but is not served on this platform yet", "key", InfoEnabledKey)
+				saidUnsupported = true
+			}
+			return
+		}
+
+		// The two endpoints speak different protocols; one socket cannot be both. Caught
+		// here, from the configured paths, because listen's own answer, that the socket is
+		// in use by another nebula, would send the operator looking for a second process.
+		if p, shared := sharedSocketPath(c); shared {
+			if cur := srv.Path(); cur != "" {
+				l.Warn("Ignoring the local info API's new socket path, it is also the control socket's; keeping the current endpoint",
+					"path", p, "current", cur, "fix", "set "+InfoSocketKey+" to another path")
+			} else {
+				l.Warn("Local info API is not available, its socket path is also the control socket's", "path", p, "fix", "set "+InfoSocketKey+" to another path")
+			}
+			return
+		}
+
+		old := srv.Path()
+		reportRebind(l, "local info API", old, cfg.Path, srv.Rebind(ctx, cfg.Path, cfg.Access))
+	}
+
+	// Registered before the first attempt, as the control socket's is; see wireControl.
+	onReload(l, c, "local info API", reconcile)
+	reconcile(c)
+	return srv
+}
+
 // errEndpointHeld is returned by listen when the endpoint's name is already held by
 // some other process, which nebula will not serve alongside: whoever holds it would be
 // answering nebula ctl. It is only raised on windows, where the default name can be

+ 45 - 6
localapi/wire_test.go

@@ -26,7 +26,7 @@ func TestWireRecoversFromBadPathAtStartup(t *testing.T) {
 	c := config.NewC(test.NewLogger())
 	require.NoError(t, c.LoadString(fmt.Sprintf("local_api:\n  control:\n    socket: /%s\n", strings.Repeat("x", sunPathMax))))
 
-	srv := Wire(ctx, test.NewLogger(), c, basicRegistry())
+	srv := Wire(ctx, test.NewLogger(), c, basicRegistry()).Control
 	t.Cleanup(srv.Stop)
 	assert.Empty(t, srv.Path(), "an over-long path must not produce a socket")
 
@@ -85,7 +85,7 @@ func TestWireRetriesAFailedMoveOnPlainReload(t *testing.T) {
 	c := config.NewC(test.NewLogger())
 	require.NoError(t, c.LoadString(section(first)))
 
-	srv := Wire(ctx, test.NewLogger(), c, basicRegistry())
+	srv := Wire(ctx, test.NewLogger(), c, basicRegistry()).Control
 	t.Cleanup(srv.Stop)
 	require.Equal(t, first, srv.Path())
 
@@ -122,7 +122,7 @@ func TestWireRecoversOnPlainReloadAfterBadBind(t *testing.T) {
 	c := config.NewC(test.NewLogger())
 	require.NoError(t, c.LoadString(cfg))
 
-	srv := Wire(ctx, test.NewLogger(), c, basicRegistry())
+	srv := Wire(ctx, test.NewLogger(), c, basicRegistry()).Control
 	t.Cleanup(srv.Stop)
 	assert.Empty(t, srv.Path(), "listen creates one level only, so a missing runtime dir fails the bind")
 
@@ -144,7 +144,8 @@ func TestWireRecoversOnPlainReloadAfterBadBind(t *testing.T) {
 }
 
 // TestWireControlCanBeDisabled: local_api.control.enabled false serves no socket, and
-// the key follows reloads both ways.
+// the key follows reloads both ways. With the control socket off, its path is free for
+// the info API to use.
 func TestWireControlCanBeDisabled(t *testing.T) {
 	ctx, cancel := context.WithCancel(context.Background())
 	t.Cleanup(cancel)
@@ -156,8 +157,9 @@ func TestWireControlCanBeDisabled(t *testing.T) {
 
 	c := config.NewC(test.NewLogger())
 	require.NoError(t, c.LoadString(section(false)))
-	srv := Wire(ctx, test.NewLogger(), c, basicRegistry())
-	t.Cleanup(srv.Stop)
+	eps := Wire(ctx, test.NewLogger(), c, basicRegistry())
+	t.Cleanup(eps.Stop)
+	srv := eps.Control
 	assert.Empty(t, srv.Path())
 	_, err := os.Lstat(path)
 	assert.True(t, os.IsNotExist(err), "no socket is bound")
@@ -172,4 +174,41 @@ func TestWireControlCanBeDisabled(t *testing.T) {
 	assert.Empty(t, srv.Path())
 	_, err = os.Lstat(path)
 	assert.True(t, os.IsNotExist(err), "the socket is removed")
+
+	require.NoError(t, c.ReloadConfigString(section(false)+fmt.Sprintf("  info:\n    enabled: true\n    socket: %s\n", path)).Err)
+	assert.Equal(t, path, eps.Info.Path(), "a disabled control socket does not hold its path")
+}
+
+// TestWireRefusesOnePathForBothAPIs: a config that gives the control socket and the info
+// API one path is refused by both sides, from the configured paths, so it is caught
+// however the binds would have ordered themselves. Moving one off the path on a reload
+// brings both up.
+func TestWireRefusesOnePathForBothAPIs(t *testing.T) {
+	ctx, cancel := context.WithCancel(context.Background())
+	t.Cleanup(cancel)
+
+	section := func(ctl, info string) string {
+		return fmt.Sprintf("local_api:\n  control:\n    socket: %s\n  info:\n    enabled: true\n    socket: %s\n", ctl, info)
+	}
+	shared := testSocket(t)
+	c := config.NewC(test.NewLogger())
+	require.NoError(t, c.LoadString(section(shared, shared)))
+
+	eps := Wire(ctx, test.NewLogger(), c, basicRegistry())
+	t.Cleanup(eps.Stop)
+	assert.Empty(t, eps.Control.Path(), "the control socket does not take the info API's path")
+	assert.Empty(t, eps.Info.Path(), "the info API does not take the control socket's path")
+	_, err := os.Stat(shared)
+	assert.True(t, os.IsNotExist(err), "neither side bound the shared path")
+
+	// A reload that moves the control socket onto the info API's path is refused the same
+	// way, and the control socket keeps the one it has.
+	other := testSocket(t)
+	require.NoError(t, c.ReloadConfigString(section(other, shared)).Err)
+	require.Equal(t, other, eps.Control.Path())
+	require.Equal(t, shared, eps.Info.Path())
+
+	require.NoError(t, c.ReloadConfigString(section(shared, shared)).Err)
+	assert.Equal(t, other, eps.Control.Path(), "a move onto the info API's path keeps the current socket")
+	assert.Equal(t, shared, eps.Info.Path())
 }

+ 19 - 0
ssh_commands_test.go

@@ -91,6 +91,7 @@ func TestLocalSocketSurface(t *testing.T) {
 
 	assert.ElementsMatch(t, []commands.Name{
 		commands.Help,
+		commands.Identity,
 		commands.ListHostmap,
 		commands.ListPendingHostmap,
 		commands.ListLighthouseAddrmap,
@@ -106,5 +107,23 @@ func TestLocalSocketSurface(t *testing.T) {
 		commands.ChangeRemote,
 		commands.CloseTunnel,
 		commands.CreateTunnel,
+		commands.Whoami,
 	}, served)
 }
+
+// TestInfoAPISurface pins which commands the info API serves. That surface may be open
+// to every user on the host, so a command joins it by declaring Identity, and this test
+// is where that declaration is reviewed: anything listed here hands whoever can reach
+// the socket what the command reports.
+func TestInfoAPISurface(t *testing.T) {
+	reg := registeredCommands(t)
+
+	var served []commands.Name
+	for _, c := range reg.All() {
+		if c.Identity {
+			served = append(served, c.Name)
+		}
+	}
+
+	assert.ElementsMatch(t, []commands.Name{commands.Identity, commands.Whoami}, served)
+}

+ 35 - 0
test/helpers.go

@@ -1,7 +1,11 @@
 package test
 
 import (
+	"context"
 	"flag"
+	"io"
+	"net"
+	"net/http"
 	"os"
 	"path/filepath"
 	"testing"
@@ -39,3 +43,34 @@ func Golden(t *testing.T, path, got string) {
 	require.NoError(t, err, "missing golden file, run with -update")
 	assert.Equal(t, string(want), got)
 }
+
+// HTTPDo sends one request to the HTTP server on the unix socket at socket and returns
+// the status, body and headers of its answer. The host in the URL is a placeholder:
+// every connection goes to the socket.
+func HTTPDo(t *testing.T, socket, method, target string) (int, string, http.Header) {
+	t.Helper()
+
+	client := &http.Client{Transport: &http.Transport{
+		DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) {
+			return (&net.Dialer{}).DialContext(ctx, "unix", socket)
+		},
+	}}
+	t.Cleanup(client.CloseIdleConnections)
+
+	req, err := http.NewRequest(method, "http://nebula"+target, nil)
+	require.NoError(t, err)
+	res, err := client.Do(req)
+	require.NoError(t, err)
+	defer res.Body.Close()
+
+	body, err := io.ReadAll(res.Body)
+	require.NoError(t, err)
+	return res.StatusCode, string(body), res.Header
+}
+
+// HTTPGet is HTTPDo for a GET whose headers the test does not read.
+func HTTPGet(t *testing.T, socket, target string) (int, string) {
+	t.Helper()
+	status, body, _ := HTTPDo(t, socket, http.MethodGet, target)
+	return status, body
+}

+ 18 - 0
testdata/ssh_commands.golden

@@ -44,6 +44,16 @@ help
     	outputs as json
   -pretty
     	pretty prints json, assumes -json
+identity
+  short: Prints the certificate identity of the host owning the provided vpn addr
+  help: Identity is what the certificate says: name, vpn addrs, networks, groups, fingerprint, issuer and validity. The address may carry a port, so a connection's remote address can be passed through as is. For this node's own identity use whoami.
+  flags:
+  -address string
+    	The vpn addr to look up, optionally with a port
+  -json
+    	outputs as json
+  -pretty
+    	pretty prints json, assumes -json
 list-hostmap
   short: List all known previously connected hosts
   help: 
@@ -138,3 +148,11 @@ version
     	outputs as json
   -pretty
     	pretty prints json, assumes -json
+whoami
+  short: Prints the certificate identity of this node
+  help: The fields identity prints, read from this node's own certificate.
+  flags:
+  -json
+    	outputs as json
+  -pretty
+    	pretty prints json, assumes -json

Unele fișiere nu au fost afișate deoarece prea multe fișiere au fost modificate în acest diff