فهرست منبع

Add the local control socket and nebula ctl

A running nebula serves its ssh commands on a local endpoint the user
who ran it can drive with `nebula ctl <command>`, with no configuration
on either side.

Endpoint
- Unix socket (named pipe on Windows) at a path derived from the resolved
  config path; `local_api.control.socket` overrides it and is reloadable
- Unix: socket is 0600; peer uid checked per platform (SO_PEERCRED,
  LOCAL_PEERCRED, LOCAL_PEEREID; OpenBSD via GetsockoptInt); owner and
  root admitted
- Directory never chmoded; derived path never under a world-writable dir;
  a dir listen created is removed when it is empty again
- Windows: pipe under \\.\pipe\ProtectedPrefix\Administrators\, created
  not opened; descriptor admits daemon account, SYSTEM, Administrators
- Fails soft, recovers command panics, bounds connections, fixed
  read/write deadlines
- Reload callback registered first and run once for the initial bind, so
  startup and reload share one reconcile
- Wired in cmd/nebula and cmd/nebula-service, not nebula.Main, so
  embedders get no listener; go-winio becomes a direct dep

Protocol
- JSON both ways, one newline terminated frame each direction via
  readFrame/writeFrame: request names a command and passes options by
  name; reply is a value, an error, or the schema (`describe`)
- Unknown parameters are errors; command bytes carried as JSON when they
  parse as it
- commands.Request is the shared form: ssh builds one via ParseLine, ctl
  sends one, Registry.Resolve tells unknown from withheld, Invoke runs it
- StringWriter gains Err(): the socket carries a command's output and its
  usage or refusal text in separate frames, so -json output stays parseable;
  sshd points both at its channel and sees one stream as before. Usage from
  a failed parse, help's flag block and a line shlex cannot split go to
  Err; a refusal is still a commands.Failure to the surface

Withheld
- Registry.Filter takes the predicate and a reason string from localapi;
  ForProfiling marks a command the socket must not serve, and a withheld
  name is refused with the reason rather than reported as unknown
- Nothing carries the mark today: the profiling commands it was for were
  removed upstream. A test pins the served set by name so a new command
  is a visible decision

nebula ctl
- Lives in cmd/internal/ctl; both daemon binaries carry it
- Connects, fetches the schema, maps flags to named params, prints the
  result; stdout/stderr split, non-zero exit on failure
- Socket: -socket, else the config (-config or next to the exe) via
  local_api.control.socket or the derived path; nothing searched. Dial
  errors say to pass -config/-socket, that the daemon has not reloaded,
  or that a root daemon serves this config (ServedByRootFor)
- Config is loaded with a discarding logger so -json output stays clean
- Refuses a socket not owned by the caller before sending; Windows checks
  the pipe owner is own account, SYSTEM, Administrators or a service
  account
- -config and -socket may come before or after the command: splitLine
  lifts them from anywhere on the line, and a "--" after the command
  ends the lifting so an operand may be spelled like one
- A bare word that is neither a flag nor `ctl`, first (Subcommand) or
  after the daemon's flags (Trailing), exits with usage, so neither
  `nebula clt` nor `nebula -config x clt` starts a second daemon;
  `nebula -config x ctl <command>` runs the client on that config

Tests use test.ShortTempDir for socket paths that fit sun_path. Both ssh
goldens unchanged. OpenBSD and Windows paths untested on their hosts
beyond the localapi tests.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Matt Richardson 1 روز پیش
والد
کامیت
526cb39419

+ 21 - 0
CHANGELOG.md

@@ -7,6 +7,27 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
 
 ## [Unreleased]
 
+### Added
+
+- `nebula ctl <command>` provides access to all of the ssh debug server's commands.
+  It is built into both the `nebula` and `nebula-service` binaries, and a bare argument that is not `ctl`,
+  whether first or after the daemon's flags, is reported as a mistyped subcommand rather than ignored, which
+  used to start a second daemon. Access is via a unix domain socket everywhere except windows, which uses a
+  named pipe, and is limited to the user nebula runs as: the socket is 0600 and the daemon checks the peer's
+  credentials on every connection, so `nebula ctl` is run as that user, or with sudo when nebula runs as root.
+  The socket is `/var/run/nebula/ctl.sock` unless `local_api.control.socket` names another absolute path,
+  which a daemon that does not run as root, or a second daemon on one host, does. `nebula ctl` talks to the
+  socket named with `-socket`, or the one named in the config given with `-config` or found next to the
+  executable, else the default; both flags may come before or after the command, and
+  `nebula -config <path> ctl <command>` carries the daemon's config over. It never searches for a socket. On
+  windows the pipe is created under
+  `\\.\pipe\ProtectedPrefix\Administrators\`, where only administrators and SYSTEM may create names, so an
+  ordinary user cannot put something there ahead of the daemon; a daemon that is not an administrator says so
+  and names the fix. `nebula ctl` reads the owner of the pipe it connected to before sending anything and
+  refuses one that is not its own account, SYSTEM, Administrators or a service account, which is what
+  protects an override outside that namespace; the daemon refuses to serve a name another process already
+  holds and logs that as an error.
+
 ### Removed
 
 - The SSH debug server's `start-cpu-profile`, `stop-cpu-profile`, `save-heap-profile`, `save-mutex-profile`, and

+ 415 - 0
cmd/internal/ctl/ctl.go

@@ -0,0 +1,415 @@
+package ctl
+
+import (
+	"errors"
+	"flag"
+	"fmt"
+	"io"
+	"log/slog"
+	"os"
+	"strings"
+
+	"github.com/slackhq/nebula/commands"
+	"github.com/slackhq/nebula/config"
+	"github.com/slackhq/nebula/localapi"
+)
+
+// Word is the argument that selects the control client instead of the daemon.
+const Word = "ctl"
+
+// Subcommand handles the one bare word a daemon binary accepts before its flags. `nebula
+// ctl <command>` runs the client. Any other first argument that is not a flag is a
+// mistyped subcommand, reported with a usage line: the daemon's flag parser would ignore
+// the stray word and start nebula, so `nebula clt` would bring up a second daemon
+// instead of reporting a typo. It returns the exit code and true when the process is
+// done, and false when argv is the daemon's to parse. Both daemon binaries, cmd/nebula
+// and cmd/nebula-service, call it before anything else, so each has a nebula ctl and
+// neither has the hazard. A word that follows the daemon's flags instead is Trailing's.
+func Subcommand(argv []string) (code int, done bool) {
+	if len(argv) < 2 || strings.HasPrefix(argv[1], "-") {
+		return 0, false
+	}
+
+	if argv[1] == Word {
+		return Run(argv[2:]), true
+	}
+
+	return unknownCommand(argv[1]), true
+}
+
+// Trailing handles the words the daemon's flag parser leaves unparsed: flag.Parse stops
+// at the first argument that is not a flag and hands the rest back. rest is flag.Args()
+// after the daemon has parsed, and configPath is the config the daemon's -config named,
+// "" when it did not. Both daemon binaries call it right after flag.Parse, so that
+// `nebula -config x ctl identity` runs the client and `nebula -config x clt` reports a
+// typo, as Subcommand does for a word at the front of the line; a daemon that ignored
+// the words would start in their place.
+//
+// A leading ctl word runs the client on what follows, with the daemon's -config carried
+// over: an operator who typed it meant the daemon running on that config, and the client
+// derives the same socket from it. Any other word is a mistake, reported as Subcommand
+// reports one.
+func Trailing(rest []string, configPath string) (code int, done bool) {
+	if len(rest) == 0 {
+		return 0, false
+	}
+
+	if rest[0] != Word {
+		return unknownCommand(rest[0]), true
+	}
+
+	args := rest[1:]
+	if configPath != "" {
+		// First, so a -config on the ctl side of the line still wins: Parse applies
+		// flags in order and the last value set is the one read.
+		args = append([]string{"-config", configPath}, args...)
+	}
+
+	return Run(args), true
+}
+
+// unknownCommand reports a bare word that is neither a flag nor ctl, with the one usage
+// line that says what the two forms of the command line are.
+func unknownCommand(word string) int {
+	fmt.Fprintf(os.Stderr, "nebula: unknown command %q\n\n", word)
+	fmt.Fprintf(os.Stderr, "Run nebula with flags to start it, or `nebula %s <command>` to control one that is already running.\n", Word)
+	return 1
+}
+
+// Run implements `nebula ctl <command> [args]`
+//
+// args is everything after the `ctl` word.
+func Run(args []string) int {
+	fs := flag.NewFlagSet("nebula ctl", flag.ContinueOnError)
+	fs.SetOutput(os.Stderr)
+
+	configPath := fs.String("config", "", "Path to the config of the running nebula, used to locate its control socket")
+	socketPath := fs.String("socket", "", "Path to the control socket, instead of the one the config implies")
+	fs.Usage = func() {
+		fmt.Fprintf(os.Stderr, "Usage: nebula ctl [flags] <command> [command flags]\n\n")
+		fmt.Fprintf(os.Stderr, "Inspects and controls a nebula already running on this machine.\n")
+		fmt.Fprintf(os.Stderr, "Run it as the user who ran nebula; nobody else can connect.\n\n")
+		fmt.Fprintf(os.Stderr, "Flags, which may come before or after the command:\n")
+		fs.PrintDefaults()
+		fmt.Fprintf(os.Stderr, "\nRun `nebula ctl help` for the available commands.\n")
+	}
+
+	own, cmdArgs := splitLine(fs, args)
+	if err := fs.Parse(own); err != nil {
+		// fs has already printed the usage, or the problem. Asking for help is not a
+		// failure, and a script that chains on `nebula ctl -h` should not see one.
+		if errors.Is(err, flag.ErrHelp) {
+			return 0
+		}
+		return 1
+	}
+
+	if len(cmdArgs) == 0 {
+		cmdArgs = []string{"help"}
+	}
+
+	path, _, overrideIn, err := resolveSocket(*socketPath, *configPath)
+	if err != nil {
+		fmt.Fprintf(os.Stderr, "%s\n", err)
+		return 1
+	}
+
+	client := &localapi.Client{Path: path}
+
+	// Ask the daemon what it serves before parsing anything. The flags belong to the
+	// command, which lives over there, so this is what lets the CLI keep its usual shape
+	// while the wire protocol stays a named-parameter API rather than a shipped argv.
+	schema, err := client.Describe()
+	if err != nil {
+		// A daemon that answered was reached; what it said is the whole message, and the
+		// advice below, which is about finding the daemon, would send the operator after
+		// the wrong fix.
+		var refused *localapi.DaemonError
+		if errors.As(err, &refused) {
+			fmt.Fprintf(os.Stderr, "nebula: %s\n", err)
+			return 1
+		}
+
+		fmt.Fprintf(os.Stderr, "nebula: could not reach the control socket at %s: %s\n", path, err)
+		switch {
+		case errors.Is(err, os.ErrPermission):
+			// The kernel refused the connect: the socket is another user's. Nebula runs as
+			// root for the tun device and this did not, as a rule.
+			fmt.Fprintf(os.Stderr, "The socket is not yours to connect to. Run nebula ctl as the user who ran nebula, or with sudo when nebula runs as root.\n")
+		case *socketPath != "":
+			// The operator named the path; there is nothing to add.
+		case overrideIn != "":
+			// When the config names the path, the usual way the two disagree is time: the
+			// key was edited and the daemon has not reloaded yet, which is exactly what
+			// `nebula ctl reload` against that config runs into.
+			fmt.Fprintf(os.Stderr, "The path is %s in %s. A daemon that has not reloaded since that key changed is still serving the old path: pass it with -socket <old path>, or reload the daemon another way, such as SIGHUP.\n", localapi.CtlSocketKey, overrideIn)
+		default:
+			// The path was computed, and the daemon computes its own from its environment.
+			// Failing all else, the daemon's log says where its socket is.
+			fmt.Fprintf(os.Stderr, "If nebula runs as root, run nebula ctl with sudo. If it runs with another config, pass -config <path>. The daemon logs its socket path at startup; pass it with -socket <path>.\n")
+		}
+		return 1
+	}
+
+	req, printedHelp, err := parseCommand(schema, cmdArgs)
+	if err != nil {
+		fmt.Fprintf(os.Stderr, "%s\n", err)
+		return 1
+	}
+
+	if printedHelp {
+		return 0
+	}
+
+	res, err := client.Invoke(string(req.name), req.Params, req.Args)
+	if err != nil {
+		fmt.Fprintf(os.Stderr, "nebula: %s\n", err)
+		return 1
+	}
+
+	out := localapi.Text(res.Result)
+
+	// Indentation is the client's business: whitespace does not survive a JSON envelope,
+	// and the daemon has no reason to care how its answer is laid out.
+	if req.Params["pretty"] == "true" {
+		if indented, ok := localapi.Pretty(res.Result); ok {
+			out = indented
+		}
+	}
+
+	if out != "" {
+		fmt.Fprint(os.Stdout, out)
+		if !strings.HasSuffix(out, "\n") {
+			fmt.Fprintln(os.Stdout)
+		}
+	}
+
+	if res.Error == "" {
+		return 0
+	}
+
+	msg := res.Error
+	if !strings.HasSuffix(msg, "\n") {
+		msg += "\n"
+	}
+	fmt.Fprint(os.Stderr, msg)
+
+	return 1
+}
+
+// ctlRequest is one parsed nebula ctl command line: the command's name, which only the
+// daemon can resolve, and the line as every surface parses it. The Request's Command is
+// nil here, since the CLI holds a schema rather than the command.
+type ctlRequest struct {
+	name commands.Name
+	commands.Request
+}
+
+// parseCommand turns the user's command line into a ctlRequest, using the schema the
+// daemon supplied. The line is parsed with the same rules the daemon applies to an ssh
+// line, through ParseLine, so a line means the same on both.
+//
+// printedHelp reports that help was written for the command instead, and there is
+// nothing to send.
+func parseCommand(schema []commands.CommandSchema, argv []string) (req ctlRequest, printedHelp bool, err error) {
+	name := commands.Name(argv[0])
+
+	var found *commands.CommandSchema
+	for i := range schema {
+		if schema[i].Name == name {
+			found = &schema[i]
+			break
+		}
+	}
+
+	if found == nil {
+		// Let the daemon answer. It knows whether this is a typo or a command it holds
+		// back, and its message for the second case is better than anything guessable
+		// from the schema alone.
+		return ctlRequest{name: name, Request: commands.Request{Args: argv[1:]}}, false, nil
+	}
+
+	if commands.IsHelpRequest(argv[1:]) {
+		printCommandHelp(found)
+		return ctlRequest{}, true, nil
+	}
+
+	// The flag package's own report is a bare message followed by a usage dump in a layout
+	// that does not match WriteCommandHelp, so it is silenced and the error is reported
+	// once, below, with a pointer at the help this command actually prints.
+	fs := flag.NewFlagSet(string(name), flag.ContinueOnError)
+	fs.SetOutput(io.Discard)
+
+	for _, f := range found.Flags {
+		if f.Bool {
+			fs.Bool(f.Name, f.Default == "true", f.Usage)
+			continue
+		}
+		fs.String(f.Name, f.Default, f.Usage)
+	}
+
+	if err := fs.Parse(argv[1:]); err != nil {
+		// Say what flag did: an unknown flag, a missing value and a malformed value are
+		// different mistakes, and "does not take" would be wrong for two of them.
+		return ctlRequest{}, false, fmt.Errorf("nebula: %s: %s (see 'nebula ctl %s -h')", name, err, name)
+	}
+
+	// Only what the user actually set travels. A parameter left alone is the daemon's
+	// default to apply, not the client's to assert.
+	return ctlRequest{name: name, Request: commands.ParseLine(fs, fs.Args())}, false, nil
+}
+
+// splitLine divides a nebula ctl line into the client's own flags, for fs to parse, and
+// the command line to send, which begins with the command word. The client's flags may
+// come before the command or after it: `nebula ctl -config x identity` and
+// `nebula ctl identity -config x` mean the same, so an operator who has typed a command
+// and then remembers which daemon they meant does not have to retype the line.
+//
+// Before the command word every flag is the client's, whatever it is, so an unknown one
+// is reported by fs as it always was, and -h is the client's usage. After it only the
+// flags fs defines are lifted out, and only those: the command's own flags are unknown
+// here, since they come from the daemon the line has yet to name, and -h after the
+// command asks for that command's help. Which command flags take a value is unknown too,
+// so the client's names are its alone: a command must not give one a value here. A "--"
+// before the command is consumed, as fs.Parse would have; one after it belongs to the
+// command line and ends the lifting, so an operand spelled like a client flag can still
+// be passed.
+func splitLine(fs *flag.FlagSet, args []string) (own, cmd []string) {
+	seenCommand := false
+	for i := 0; i < len(args); i++ {
+		a := args[i]
+
+		if a == "--" {
+			if seenCommand {
+				return own, append(cmd, args[i:]...)
+			}
+			seenCommand = true
+			continue
+		}
+
+		if !seenCommand {
+			if len(a) < 2 || a[0] != '-' {
+				seenCommand = true
+				cmd = append(cmd, a)
+				continue
+			}
+
+			own = append(own, a)
+			if f, hasValue := ownFlag(fs, a); f != nil && !hasValue && takesValue(f) && i+1 < len(args) {
+				i++
+				own = append(own, args[i])
+			}
+			continue
+		}
+
+		if len(a) >= 2 && a[0] == '-' {
+			if f, hasValue := ownFlag(fs, a); f != nil {
+				own = append(own, a)
+				if !hasValue && takesValue(f) && i+1 < len(args) {
+					i++
+					own = append(own, args[i])
+				}
+				continue
+			}
+		}
+
+		cmd = append(cmd, a)
+	}
+
+	return own, cmd
+}
+
+// ownFlag returns the flag of fs that the argument a names, nil when it names none, and
+// whether a carries its value after an "=".
+func ownFlag(fs *flag.FlagSet, a string) (f *flag.Flag, hasValue bool) {
+	name := strings.TrimPrefix(strings.TrimPrefix(a, "-"), "-")
+	if eq := strings.IndexByte(name, '='); eq >= 0 {
+		name, hasValue = name[:eq], true
+	}
+	return fs.Lookup(name), hasValue
+}
+
+// takesValue reports whether f reads the argument after it as its value, which every
+// flag does except a boolean.
+func takesValue(f *flag.Flag) bool {
+	bf, ok := f.Value.(interface{ IsBoolFlag() bool })
+	return !ok || !bf.IsBoolFlag()
+}
+
+// resolveSocket decides which socket to talk to. It never searches: the answer is the
+// socket the operator named, or the one the config implies, and nothing else.
+//
+// An explicit flag wins. Otherwise the config is read, the one the operator named or the
+// one found next to the executable, and the path is the socket that config implies: its
+// local_api.control.socket if set, else the same derivation the daemon applies.
+// configPath is the config that was read, "" when none was; overrideIn is that config
+// when its key set the path, and "" when the path is the default or was named on the command
+// line.
+func resolveSocket(socketFlag, configFlag string) (path, configPath, overrideIn string, err error) {
+	if socketFlag != "" {
+		return socketFlag, "", "", nil
+	}
+
+	return pathFromConfig(configFlag)
+}
+
+// pathFromConfig returns the socket path the daemon would be serving: the one
+// local_api.control.socket names in the config, so an operator who moved the socket does
+// not have to repeat themselves on every invocation, else the default. configPath is the
+// config that was read, "" when none was, and overrideIn is the same path when the key
+// is what answered.
+//
+// A config the operator did not name is a guess: config.DefaultPath looks next to the
+// executable, and a package that installs a default config there while systemd starts
+// the daemon with another will make it the wrong guess. It is still the only answer
+// that involves no searching. With no config there either, the answer is the default
+// path, which is where a daemon with no override serves.
+func pathFromConfig(configFlag string) (path, configPath, overrideIn string, err error) {
+	configPath = configFlag
+	if configPath == "" {
+		p, err := config.DefaultPath()
+		if err != nil {
+			return localapi.DefaultSocketPath(), "", "", nil
+		}
+		configPath = p
+	}
+
+	// The config is loaded only to find the socket path. Loading logs lines that belong
+	// to the daemon, not to someone running a read only command, and mixing them into
+	// the CLI's output would corrupt a -json result, so they are discarded.
+	c := config.NewC(slog.New(slog.DiscardHandler))
+	if err := c.Load(configPath); err != nil {
+		return "", "", "", fmt.Errorf("nebula: could not read %s: %s", configPath, err)
+	}
+
+	// The same call the daemon makes, so the two agree on the path and on what makes a
+	// path unusable.
+	p, err := localapi.SocketPathFromConfig(c)
+	if err != nil {
+		return "", "", "", fmt.Errorf("nebula: %s", err)
+	}
+
+	if c.GetString(localapi.CtlSocketKey, "") != "" {
+		overrideIn = configPath
+	}
+	return p, configPath, overrideIn, nil
+}
+
+func printCommandHelp(c *commands.CommandSchema) {
+	fmt.Fprintf(os.Stdout, "%s - %s\n", c.Name, c.Short)
+	if c.Help != "" {
+		fmt.Fprintf(os.Stdout, "  %s\n", c.Help)
+	}
+
+	for _, f := range c.Flags {
+		fmt.Fprintf(os.Stdout, "  -%s", f.Name)
+		if !f.Bool {
+			fmt.Fprintf(os.Stdout, " string")
+		}
+		fmt.Fprintf(os.Stdout, "\n    \t%s", f.Usage)
+		if f.Default != "" && f.Default != "false" {
+			fmt.Fprintf(os.Stdout, " (default %s)", f.Default)
+		}
+		fmt.Fprintln(os.Stdout)
+	}
+}

+ 211 - 0
cmd/internal/ctl/ctl_test.go

@@ -0,0 +1,211 @@
+package ctl
+
+import (
+	"flag"
+	"os"
+	"path/filepath"
+	"runtime"
+	"strings"
+	"testing"
+
+	"github.com/slackhq/nebula/commands"
+	"github.com/slackhq/nebula/localapi"
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+)
+
+// TestResolveSocketReportsAnUnreadableConfig: an operator who named a config must hear
+// that it could not be read, rather than have the command steered anywhere else.
+func TestResolveSocketReportsAnUnreadableConfig(t *testing.T) {
+	missing := filepath.Join(t.TempDir(), "typo.yml")
+
+	_, _, _, err := resolveSocket("", missing)
+	require.Error(t, err)
+	assert.Contains(t, err.Error(), missing)
+}
+
+// nowhereSocket is a socket name nothing listens on, spelled the way the platform
+// requires: a full pipe name on windows, an absolute path elsewhere.
+var nowhereSocket = map[bool]string{true: `\\.\pipe\nebula-nowhere`, false: "/nowhere/ctl.sock"}[runtime.GOOS == "windows"]
+
+// namedSocketConfig is a config that names nowhereSocket as the control socket. Single
+// quotes keep a pipe name's backslashes literal in YAML.
+var namedSocketConfig = "local_api:\n  control:\n    socket: '" + nowhereSocket + "'\n"
+
+// TestResolveSocketTrustsANamedConfig: a config the operator named yields the socket it
+// implies even when nothing is listening there, so a dial failure names that socket.
+func TestResolveSocketTrustsANamedConfig(t *testing.T) {
+	cfg := filepath.Join(t.TempDir(), "config.yml")
+	require.NoError(t, os.WriteFile(cfg, []byte(namedSocketConfig), 0600))
+
+	p, _, _, err := resolveSocket("", cfg)
+	require.NoError(t, err)
+	assert.Equal(t, nowhereSocket, p)
+}
+
+// TestResolveSocketUsesTheDefaultWithoutAnOverride: a config that does not name the
+// socket means the default path, the same one the daemon serves with that config, and
+// no config at all means the default too, since that is where a daemon started without
+// an override serves. Nothing is searched for.
+func TestResolveSocketUsesTheDefaultWithoutAnOverride(t *testing.T) {
+	cfg := filepath.Join(t.TempDir(), "config.yml")
+	require.NoError(t, os.WriteFile(cfg, []byte("static_host_map: {}\n"), 0600))
+
+	p, readConfig, overrideIn, err := resolveSocket("", cfg)
+	require.NoError(t, err)
+	assert.Equal(t, localapi.DefaultSocketPath(), p)
+	assert.Equal(t, cfg, readConfig)
+	assert.Empty(t, overrideIn)
+
+	// The test binary has no config next to it.
+	p, readConfig, overrideIn, err = resolveSocket("", "")
+	require.NoError(t, err)
+	assert.Equal(t, localapi.DefaultSocketPath(), p)
+	assert.Empty(t, readConfig)
+	assert.Empty(t, overrideIn)
+}
+
+func TestResolveSocketFlagWins(t *testing.T) {
+	p, _, overrideIn, err := resolveSocket("/explicit.sock", "/does/not/matter.yml")
+	require.NoError(t, err)
+	assert.Equal(t, "/explicit.sock", p)
+	assert.Empty(t, overrideIn)
+}
+
+// TestResolveSocketSaysWhenTheConfigNamedThePath: a path read from the override key is
+// reported with the file it came from, so a dial failure can say that a daemon which
+// has not reloaded since the key changed is still on the old path. A derived path is
+// not an override.
+func TestResolveSocketSaysWhenTheConfigNamedThePath(t *testing.T) {
+	base := t.TempDir()
+
+	named := filepath.Join(base, "named.yml")
+	require.NoError(t, os.WriteFile(named, []byte(namedSocketConfig), 0600))
+	_, _, overrideIn, err := resolveSocket("", named)
+	require.NoError(t, err)
+	assert.Equal(t, named, overrideIn)
+
+	derived := filepath.Join(base, "derived.yml")
+	require.NoError(t, os.WriteFile(derived, []byte("static_host_map: {}\n"), 0600))
+	_, _, overrideIn, err = resolveSocket("", derived)
+	require.NoError(t, err)
+	assert.Empty(t, overrideIn)
+}
+
+// TestParseCommandReportsWhatFlagSaid covers the three ways a flag line goes wrong. They
+// are different mistakes, and the old blanket "does not take" was wrong for two of them:
+// a valid flag with a missing value, and a valid flag with a value it cannot parse.
+func TestParseCommandReportsWhatFlagSaid(t *testing.T) {
+	schema := []commands.CommandSchema{{
+		Name: "change-remote",
+		Flags: []commands.FlagSchema{
+			{Name: "address"},
+			{Name: "by-index", Bool: true},
+		},
+	}}
+
+	cases := map[string]struct {
+		argv []string
+		want string
+	}{
+		"missing value": {[]string{"change-remote", "-address"}, "flag needs an argument: -address"},
+		"bad bool":      {[]string{"change-remote", "-by-index=maybe"}, "invalid boolean value \"maybe\" for -by-index"},
+		"unknown flag":  {[]string{"change-remote", "-nope"}, "flag provided but not defined: -nope"},
+	}
+
+	for name, tc := range cases {
+		t.Run(name, func(t *testing.T) {
+			_, _, err := parseCommand(schema, tc.argv)
+			require.Error(t, err)
+			require.ErrorContains(t, err, "nebula: change-remote: "+tc.want)
+			require.ErrorContains(t, err, "see 'nebula ctl change-remote -h'")
+			assert.NotContains(t, err.Error(), "does not take")
+		})
+	}
+}
+
+// TestSplitLineLiftsOwnFlagsFromAnywhere: -config and -socket are the client's wherever
+// they appear, before the command or after it, in either spelling, so the two orders
+// mean the same line. Everything else after the command word stays in order for the
+// command's own parse, including flags and operands that happen to look like values.
+func TestSplitLineLiftsOwnFlagsFromAnywhere(t *testing.T) {
+	newFS := func() *flag.FlagSet {
+		fs := flag.NewFlagSet("nebula ctl", flag.ContinueOnError)
+		fs.String("config", "", "")
+		fs.String("socket", "", "")
+		return fs
+	}
+
+	for _, tc := range []struct {
+		args    []string
+		wantOwn []string
+		wantCmd []string
+	}{
+		{nil, nil, nil},
+		{[]string{"identity"}, nil, []string{"identity"}},
+		{[]string{"-config", "x", "identity"}, []string{"-config", "x"}, []string{"identity"}},
+		{[]string{"identity", "-config", "x"}, []string{"-config", "x"}, []string{"identity"}},
+		{[]string{"identity", "-config=x"}, []string{"-config=x"}, []string{"identity"}},
+		{[]string{"identity", "--socket", "s"}, []string{"--socket", "s"}, []string{"identity"}},
+		{[]string{"-socket", "s", "identity", "-config", "x"}, []string{"-socket", "s", "-config", "x"}, []string{"identity"}},
+		// The command's flags and operands are left where they are, around the lifted ones.
+		{[]string{"print-tunnel", "10.0.0.1", "-config", "x", "-json"}, []string{"-config", "x"}, []string{"print-tunnel", "10.0.0.1", "-json"}},
+		{[]string{"change-remote", "-address", "1.2.3.4:5", "-socket", "s", "10.0.0.1"}, []string{"-socket", "s"}, []string{"change-remote", "-address", "1.2.3.4:5", "10.0.0.1"}},
+		// -h before the command is the client's usage; after it, the command's help.
+		{[]string{"-h"}, []string{"-h"}, nil},
+		{[]string{"identity", "-h"}, nil, []string{"identity", "-h"}},
+		// An unknown flag before the command is the client's to report, and does not
+		// swallow the command word as its value.
+		{[]string{"-nope", "identity"}, []string{"-nope"}, []string{"identity"}},
+		// A -config with nothing after it is left for fs.Parse to report.
+		{[]string{"identity", "-config"}, []string{"-config"}, []string{"identity"}},
+		// "--" before the command is consumed, as fs.Parse consumed it; after the command
+		// it belongs to the line and ends the lifting.
+		{[]string{"--", "identity", "-config", "x"}, []string{"-config", "x"}, []string{"identity"}},
+		{[]string{"identity", "--", "-config", "x"}, nil, []string{"identity", "--", "-config", "x"}},
+	} {
+		t.Run(strings.Join(tc.args, " "), func(t *testing.T) {
+			own, cmd := splitLine(newFS(), tc.args)
+			assert.Equal(t, tc.wantOwn, own)
+			assert.Equal(t, tc.wantCmd, cmd)
+		})
+	}
+}
+
+// TestTrailingHandlesWordsAfterTheDaemonsFlags: what flag.Parse leaves behind is never
+// a daemon to start. A leading ctl runs the client on the rest, with the daemon's
+// -config carried over; anything else is a mistyped subcommand. Only -h is run here,
+// since the client's help needs no daemon: it proves the words reach Run and that a
+// carried -config is accepted by it.
+func TestTrailingHandlesWordsAfterTheDaemonsFlags(t *testing.T) {
+	code, done := Trailing(nil, "")
+	assert.False(t, done)
+	assert.Equal(t, 0, code)
+
+	code, done = Trailing([]string{"clt"}, "")
+	assert.True(t, done)
+	assert.Equal(t, 1, code)
+
+	code, done = Trailing([]string{Word, "-h"}, "")
+	assert.True(t, done)
+	assert.Equal(t, 0, code)
+
+	code, done = Trailing([]string{Word, "-h"}, "/etc/nebula/config.yml")
+	assert.True(t, done)
+	assert.Equal(t, 0, code)
+}
+
+// TestSubcommandOnlyReadsTheFirstWord: a flag first means the line is the daemon's to
+// parse, and whatever follows the flags is Trailing's to judge.
+func TestSubcommandOnlyReadsTheFirstWord(t *testing.T) {
+	_, done := Subcommand([]string{"nebula", "-config", "x", Word})
+	assert.False(t, done)
+
+	code, done := Subcommand([]string{"nebula", "clt"})
+	assert.True(t, done)
+	assert.Equal(t, 1, code)
+
+	code, done = Subcommand([]string{"nebula", Word, "-h"})
+	assert.True(t, done)
+	assert.Equal(t, 0, code)
+}

+ 42 - 0
cmd/internal/ctl/ctl_unix_test.go

@@ -0,0 +1,42 @@
+//go:build !windows
+
+package ctl
+
+import (
+	"os"
+	"path/filepath"
+	"strings"
+	"testing"
+
+	"github.com/slackhq/nebula/localapi"
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+)
+
+// TestResolveSocketDiagnosesAnOverlongOverride: the CLI applies the daemon's own rule to
+// the override, so it says what the daemon's log says rather than dialing a path the
+// kernel would truncate and reporting the generic failure. The rule is sun_path's, which
+// a named pipe does not have, so this is a unix test.
+func TestResolveSocketDiagnosesAnOverlongOverride(t *testing.T) {
+	cfg := filepath.Join(t.TempDir(), "config.yml")
+	long := "/" + strings.Repeat("x", 120)
+	require.NoError(t, os.WriteFile(cfg, []byte("local_api:\n  control:\n    socket: "+long+"\n"), 0600))
+
+	_, _, _, err := resolveSocket("", cfg)
+	require.Error(t, err)
+	require.ErrorContains(t, err, "too long for this platform")
+	assert.ErrorContains(t, err, localapi.CtlSocketKey)
+}
+
+// TestResolveSocketRefusesARelativeOverride: the CLI applies the daemon's rule here too,
+// so a relative path in the config is reported as the daemon's log reports it rather
+// than resolved against wherever nebula ctl happened to be run from.
+func TestResolveSocketRefusesARelativeOverride(t *testing.T) {
+	cfg := filepath.Join(t.TempDir(), "config.yml")
+	require.NoError(t, os.WriteFile(cfg, []byte("local_api:\n  control:\n    socket: run/ctl.sock\n"), 0600))
+
+	_, _, _, err := resolveSocket("", cfg)
+	require.Error(t, err)
+	require.ErrorContains(t, err, "must be an absolute path")
+	assert.ErrorContains(t, err, localapi.CtlSocketKey)
+}

+ 22 - 1
cmd/nebula-service/main.go

@@ -8,7 +8,9 @@ import (
 	"strings"
 
 	"github.com/slackhq/nebula"
+	"github.com/slackhq/nebula/cmd/internal/ctl"
 	"github.com/slackhq/nebula/config"
+	"github.com/slackhq/nebula/localapi"
 	"github.com/slackhq/nebula/logging"
 	"github.com/slackhq/nebula/util"
 )
@@ -32,6 +34,11 @@ func init() {
 }
 
 func main() {
+	// commands are either `nebula ctl ...` or `nebula -<flag> ...`
+	if code, done := ctl.Subcommand(os.Args); done {
+		os.Exit(code)
+	}
+
 	serviceFlag := flag.String("service", "", "Control the system service.")
 	configPath := flag.String("config", "", "Path to either a file or directory to load configuration from")
 	configTest := flag.Bool("test", false, "Test the config and print the end result. Non zero exit indicates a faulty config")
@@ -40,6 +47,12 @@ func main() {
 
 	flag.Parse()
 
+	// A word after the daemon's flags is `ctl` in the other place an operator puts it,
+	// or a mistyped subcommand, and either way not a daemon to start; see Trailing.
+	if code, done := ctl.Trailing(flag.Args(), *configPath); done {
+		os.Exit(code)
+	}
+
 	if *printVersion {
 		fmt.Printf("Version: %s\n", Build)
 		os.Exit(0)
@@ -104,9 +117,17 @@ func main() {
 			os.Exit(1)
 		}
 
+		ctl := localapi.Wire(ctrl.Context(), l, c, ctrl.Commands())
+
 		go ctrl.ShutdownBlock()
 
-		if err := ctrl.Wait(); err != nil {
+		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()
+
+		if err != nil {
 			l.Error("Nebula stopped due to fatal error", "error", err)
 			os.Exit(2)
 		}

+ 13 - 0
cmd/nebula-service/service.go

@@ -8,6 +8,7 @@ import (
 	"github.com/kardianos/service"
 	"github.com/slackhq/nebula"
 	"github.com/slackhq/nebula/config"
+	"github.com/slackhq/nebula/localapi"
 	"github.com/slackhq/nebula/logging"
 )
 
@@ -17,6 +18,7 @@ type program struct {
 	configPath *string
 	build      string
 	control    *nebula.Control
+	ctl        *localapi.Server
 }
 
 func (p *program) Start(s service.Service) error {
@@ -50,10 +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())
+
 	// 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(2)
 		}
 	}()
@@ -63,6 +71,11 @@ 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.control == nil {
 		return nil
 	}

+ 22 - 1
cmd/nebula/main.go

@@ -8,7 +8,9 @@ import (
 	"strings"
 
 	"github.com/slackhq/nebula"
+	"github.com/slackhq/nebula/cmd/internal/ctl"
 	"github.com/slackhq/nebula/config"
+	"github.com/slackhq/nebula/localapi"
 	"github.com/slackhq/nebula/logging"
 	"github.com/slackhq/nebula/util"
 )
@@ -32,6 +34,11 @@ func init() {
 }
 
 func main() {
+	// commands are either `nebula ctl ...` or `nebula -<flag> ...`
+	if code, done := ctl.Subcommand(os.Args); done {
+		os.Exit(code)
+	}
+
 	configPath := flag.String("config", "", "Path to either a file or directory to load configuration from")
 	configTest := flag.Bool("test", false, "Test the config and print the end result. Non zero exit indicates a faulty config")
 	printVersion := flag.Bool("version", false, "Print version")
@@ -39,6 +46,12 @@ func main() {
 
 	flag.Parse()
 
+	// A word after the daemon's flags is `ctl` in the other place an operator puts it,
+	// or a mistyped subcommand, and either way not a daemon to start; see Trailing.
+	if code, done := ctl.Trailing(flag.Args(), *configPath); done {
+		os.Exit(code)
+	}
+
 	if *printVersion {
 		fmt.Printf("Version: %s\n", Build)
 		os.Exit(0)
@@ -90,10 +103,18 @@ func main() {
 			os.Exit(1)
 		}
 
+		ctl := localapi.Wire(ctrl.Context(), l, c, ctrl.Commands())
+
 		go ctrl.ShutdownBlock()
 		notifyReady(l)
 
-		if err := ctrl.Wait(); err != nil {
+		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()
+
+		if err != nil {
 			l.Error("Nebula stopped due to fatal error", "error", err)
 			os.Exit(2)
 		}

+ 18 - 13
commands/command.go

@@ -24,6 +24,8 @@ type Command struct {
 	ShortDescription string
 	Help             string
 
+	ForProfiling bool
+
 	Flags    CommandFlags
 	Callback CommandCallback
 }
@@ -49,32 +51,35 @@ func Fail(w StringWriter, msg string) error {
 	return &Failure{Msg: msg}
 }
 
+// execCommand runs a command line against c, reporting problems to w. It is the path
+// from an ssh line to output: the line is parsed against the command's own FlagSet,
+// becomes a Request, and Invoke runs it exactly as it runs the control socket's.
 func execCommand(c *Command, args []string, w StringWriter) error {
-	var (
-		fl *flag.FlagSet
-		fs any
-	)
+	req := Request{Command: c, Args: args}
 
 	if c.Flags != nil {
-		fl, fs = c.Flags()
-		if fl != nil {
+		if fl, _ := c.Flags(); fl != nil {
 			// SetOutput() here in case fl.Parse dumps usage.
-			fl.SetOutput(w.GetWriter())
-			err := fl.Parse(args)
-			if err != nil {
+			fl.SetOutput(w.Err())
+			if err := fl.Parse(args); err != nil {
 				// fl.Parse has dumped error information to the user via the w writer.
 				return err
 			}
-			args = fl.Args()
+			req = ParseLine(fl, fl.Args())
+			req.Command = c
 		}
 	}
 
-	return c.Callback(fs, args, w)
+	return Invoke(req, w)
 }
 
-func checkHelpArgs(args []string) bool {
+// IsHelpRequest reports whether args ask for a command's help rather than running it.
+// Both surfaces use it, so what counts as asking for help cannot drift between them: the
+// ssh server answers with Registry.Help, and nebula ctl renders the same help locally
+// from the schema.
+func IsHelpRequest(args []string) bool {
 	for _, a := range args {
-		if a == "-h" || a == "-help" {
+		if a == "-h" || a == "-help" || a == "--help" {
 			return true
 		}
 	}

+ 152 - 5
commands/registry.go

@@ -2,6 +2,8 @@ package commands
 
 import (
 	"cmp"
+	"errors"
+	"flag"
 	"fmt"
 	"maps"
 	"slices"
@@ -19,6 +21,15 @@ import (
 type Registry struct {
 	mu       sync.RWMutex
 	commands map[Name]*Command
+
+	// full is set on a filtered registry and points at the registry it was filtered
+	// from, so a command that's filtered can be reported as such.
+	full *Registry
+
+	// reason explains, in the terms an operator cares about, why this registry withholds
+	// a command they can name. It is supplied by whoever built the filter, because why a
+	// surface declines a command is that surface's business, not the registry's.
+	reason string
 }
 
 // New returns an empty Registry with only `help` available.
@@ -84,19 +95,49 @@ func (r *Registry) All() []*Command {
 // the source, built-in or not.
 func (r *Registry) Clone() *Registry {
 	r.mu.RLock()
-	c := &Registry{commands: maps.Clone(r.commands)}
+	c := &Registry{commands: maps.Clone(r.commands), full: r.full, reason: r.reason}
 	r.mu.RUnlock()
 	c.registerHelp()
 	return c
 }
 
+// ErrWithheld is returned when a command exists but this surface does not serve it.
+var ErrWithheld = errors.New("command unavailable")
+
+// Filter returns a copy holding the commands keep returns true for.
+//
+// A surface decides for itself what it will serve; the registry only applies the
+// decision. Filtering here, on the side that serves requests, is what stops a client
+// reaching a withheld command by naming it directly.
+//
+// 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}
+
+	for _, cmd := range r.All() {
+		if keep(cmd) {
+			out.Register(cmd)
+		}
+	}
+
+	// Bind help to the filtered set so it only advertises what this surface serves.
+	out.registerHelp()
+	return out
+}
+
 // Dispatch parses a command line and runs it, reporting problems to w. The error is for
 // the caller to log, not to show; see CommandCallback.
 func (r *Registry) Dispatch(line string, w StringWriter) error {
-	// A line shlex cannot split, an unbalanced quote.
+	// A line shlex cannot split, an unbalanced quote. Reported on Err so the output
+	// stream stays parseable.
 	args, err := shlex.Split(line, true)
 	if err != nil {
-		return Fail(w, "could not parse the command line: "+err.Error())
+		msg := "could not parse the command line: " + err.Error()
+		if _, err := fmt.Fprintln(w.Err(), msg); err != nil {
+			return err
+		}
+		return &Failure{Msg: msg}
 	}
 
 	return r.DispatchArgs(args, w)
@@ -111,6 +152,13 @@ func (r *Registry) DispatchArgs(args []string, w StringWriter) error {
 
 	c := r.Get(Name(args[0]))
 	if c == nil {
+		if withheld := r.withheld(Name(args[0])); withheld != nil {
+			// Saying why, rather than "did not understand", keeps the rule legible and
+			// points at a surface that will actually run the command.
+			_, _ = fmt.Fprintf(w.Err(), "%s is not available on this surface: %s\n", withheld.Name, r.withheldReason(withheld))
+			return ErrWithheld
+		}
+
 		// Only the first word was looked up, so only it is named.
 		msg := fmt.Sprintf("did not understand: %s", args[0])
 		if err := w.WriteLine(msg); err != nil {
@@ -121,7 +169,7 @@ func (r *Registry) DispatchArgs(args []string, w StringWriter) error {
 		return &Failure{Msg: msg}
 	}
 
-	if checkHelpArgs(args) {
+	if IsHelpRequest(args) {
 		// Through the registered help command, so -h and `help <name>` are one path.
 		return execCommand(r.Get(Help), []string{string(c.Name)}, w)
 	}
@@ -156,6 +204,13 @@ func (r *Registry) help(a []string, w StringWriter) error {
 	// We are printing a specific commands help text
 	cmd := r.Get(Name(a[0]))
 	if cmd == nil {
+		if withheld := r.withheld(Name(a[0])); withheld != nil {
+			msg := fmt.Sprintf("%s is not available on this surface: %s", withheld.Name, r.withheldReason(withheld))
+			if _, err := fmt.Fprintln(w.Err(), msg); err != nil {
+				return err
+			}
+			return &Failure{Msg: msg}
+		}
 		return Fail(w, "Command not available "+a[0])
 	}
 
@@ -172,10 +227,102 @@ func (r *Registry) help(a []string, w StringWriter) error {
 	if cmd.Flags != nil {
 		fs, _ := cmd.Flags()
 		if fs != nil {
-			fs.SetOutput(w.GetWriter())
+			fs.SetOutput(w.Err())
 			fs.PrintDefaults()
 		}
 	}
 
 	return nil
 }
+
+// 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
+// real command it cannot have here are told different things. It is the edge where a
+// name from the wire becomes a Command for a Request.
+func (r *Registry) Resolve(name Name) (*Command, error) {
+	if c := r.Get(name); c != nil {
+		return c, nil
+	}
+
+	if withheld := r.withheld(name); withheld != nil {
+		return nil, fmt.Errorf("%s is not available on this surface: %s", withheld.Name, r.withheldReason(withheld))
+	}
+
+	return nil, fmt.Errorf("unknown command %q", name)
+}
+
+// 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 {
+	// In the order All gives, which is the display order.
+	out := make([]CommandSchema, 0, len(r.All()))
+
+	for _, c := range r.All() {
+		cs := CommandSchema{Name: c.Name, Short: c.ShortDescription, Help: c.Help}
+
+		if c.Flags != nil {
+			if fl, _ := c.Flags(); fl != nil {
+				fl.VisitAll(func(f *flag.Flag) {
+					cs.Flags = append(cs.Flags, FlagSchema{
+						Name:    f.Name,
+						Bool:    isBoolFlag(f),
+						Default: f.DefValue,
+						Usage:   f.Usage,
+					})
+				})
+			}
+		}
+
+		out = append(out, cs)
+	}
+
+	return out
+}
+
+// CommandSchema describes one command well enough for a client to offer it.
+type CommandSchema struct {
+	Name  Name         `json:"name"`
+	Short string       `json:"short,omitempty"`
+	Help  string       `json:"help,omitempty"`
+	Flags []FlagSchema `json:"flags,omitempty"`
+}
+
+// FlagSchema describes one of a command's named parameters.
+type FlagSchema struct {
+	Name string `json:"name"`
+
+	// Bool reports a parameter that takes no value, which is what tells a command line
+	// client whether `-pretty` swallows the word after it.
+	Bool bool `json:"bool,omitempty"`
+
+	Default string `json:"default,omitempty"`
+	Usage   string `json:"usage,omitempty"`
+}
+
+// isBoolFlag reports whether f is satisfied by its presence alone. The flag package marks
+// these with an unexported interface, which is the only way to tell.
+func isBoolFlag(f *flag.Flag) bool {
+	bf, ok := f.Value.(interface{ IsBoolFlag() bool })
+	return ok && bf.IsBoolFlag()
+}
+
+// withheld returns the command named name if it exists in the unfiltered registry but
+// not in this one.
+func (r *Registry) withheld(name Name) *Command {
+	if r.full == nil {
+		return nil
+	}
+
+	return r.full.Get(name)
+}
+
+// withheldReason renders the explanation supplied to Filter, falling back to something
+// true but unhelpful when no explanation was given.
+func (r *Registry) withheldReason(c *Command) string {
+	if r.reason == "" {
+		return "this surface does not serve it"
+	}
+
+	return r.reason
+}

+ 126 - 0
commands/registry_test.go

@@ -11,6 +11,12 @@ import (
 	"github.com/stretchr/testify/require"
 )
 
+// notWithheld is a stand in for a surface's own policy; these tests use ForProfiling
+// because it is the only such flag today, not because the registry knows about it.
+func notWithheld(c *Command) bool { return !c.ForProfiling }
+
+const testReason = "this surface does not run profiling commands"
+
 func testRegistry() *Registry {
 	r := New()
 	r.Register(&Command{
@@ -23,6 +29,7 @@ func testRegistry() *Registry {
 	r.Register(&Command{
 		Name:             "withheld",
 		ShortDescription: "a command this surface withholds",
+		ForProfiling:     true,
 		Callback: func(fs any, a []string, w StringWriter) error {
 			return w.WriteLine("wrote")
 		},
@@ -30,6 +37,99 @@ func testRegistry() *Registry {
 	return r
 }
 
+// TestLocalSocketServesEverythingElse: the surface serves commands by default and
+// withholds only those that write files on the host.
+func TestLocalSocketServesEverythingElse(t *testing.T) {
+	ro := testRegistry().Filter(notWithheld, testReason)
+
+	names := make([]string, 0)
+	for _, c := range ro.All() {
+		names = append(names, string(c.Name))
+	}
+
+	assert.ElementsMatch(t, []string{"help", "served"}, names)
+}
+
+func TestUnannotatedCommandIsServed(t *testing.T) {
+	r := New()
+	r.Register(&Command{
+		Name:             "plain",
+		ShortDescription: "a command with no annotations",
+		Callback: func(fs any, a []string, w StringWriter) error {
+			return nil
+		},
+	})
+
+	cmd := r.Filter(notWithheld, testReason).Get("plain")
+	assert.NotNil(t, cmd, "commands reach the local socket unless they say otherwise")
+}
+
+func TestProfilingCommandIsWithheld(t *testing.T) {
+	r := New()
+	r.Register(&Command{
+		Name:             "save-a-profile",
+		ShortDescription: "drives the profiler",
+		ForProfiling:     true,
+		Callback: func(fs any, a []string, w StringWriter) error {
+			return nil
+		},
+	})
+
+	cmd := r.Filter(notWithheld, testReason).Get("save-a-profile")
+	assert.Nil(t, cmd, "a profiling command must not reach the local socket")
+}
+
+// TestRefusesWithheldByName covers a client that knows a command exists and asks for it
+// anyway. The filter lives on the serving side, so the answer must not depend on what
+// the client was told was available.
+func TestRefusesWithheldByName(t *testing.T) {
+	out, errOut := &bytes.Buffer{}, &bytes.Buffer{}
+	w := NewSplitStringWriter(out, errOut)
+
+	err := testRegistry().Filter(notWithheld, testReason).DispatchArgs([]string{"withheld"}, w)
+
+	require.ErrorIs(t, err, ErrWithheld)
+	assert.Empty(t, out.String(), "a withheld command must produce no output")
+	assert.Contains(t, errOut.String(), "withheld is not available on this surface")
+	assert.Contains(t, errOut.String(), testReason,
+		"the registry should render the explanation the filter supplied, not one of its own")
+}
+
+// TestWithheldIsNotReportedAsUnknown keeps the two cases distinct: an operator who
+// mistypes and one who asked for a real command should not get the same answer.
+func TestWithheldIsNotReportedAsUnknown(t *testing.T) {
+	out, errOut := &bytes.Buffer{}, &bytes.Buffer{}
+	err := testRegistry().Filter(notWithheld, testReason).DispatchArgs([]string{"no-such-command"}, NewSplitStringWriter(out, errOut))
+
+	var f *Failure
+	require.ErrorAs(t, err, &f)
+	assert.Contains(t, out.String(), "did not understand: no-such-command")
+}
+
+// TestSplitWriterKeepsOutputParseable is why the writer grew Err(): usage text on the
+// same stream as -json output would corrupt it.
+func TestSplitWriterKeepsOutputParseable(t *testing.T) {
+	r := New()
+	r.Register(&Command{
+		Name:             "emit",
+		ShortDescription: "emits json",
+		Flags: func() (*flag.FlagSet, any) {
+			fl := flag.NewFlagSet("", flag.ContinueOnError)
+			fl.Bool("json", false, "emit json")
+			return fl, nil
+		},
+		Callback: func(fs any, a []string, w StringWriter) error {
+			return w.Write(`{"ok":true}`)
+		},
+	})
+
+	out, errOut := &bytes.Buffer{}, &bytes.Buffer{}
+	require.Error(t, r.DispatchArgs([]string{"emit", "-nope"}, NewSplitStringWriter(out, errOut)))
+
+	assert.Empty(t, out.String(), "flag errors must not land on the output stream")
+	assert.Contains(t, errOut.String(), "flag provided but not defined")
+}
+
 // TestFlagUsageGoesToTheWriter pins that a parse failure's usage lands on the command's
 // writer.
 func TestFlagUsageGoesToTheWriter(t *testing.T) {
@@ -50,6 +150,14 @@ func TestFlagUsageGoesToTheWriter(t *testing.T) {
 	assert.Contains(t, merged.String(), "flag provided but not defined")
 }
 
+func TestHelpListsOnlyServedCommands(t *testing.T) {
+	out := &bytes.Buffer{}
+	require.NoError(t, testRegistry().Filter(notWithheld, testReason).Dispatch("help", NewStringWriter(out)))
+
+	assert.Contains(t, out.String(), "served")
+	assert.NotContains(t, out.String(), "withheld")
+}
+
 // TestDispatchReportsUnparseableLine: a line shlex rejects, an unbalanced quote, is a
 // failure the user is told about.
 func TestDispatchReportsUnparseableLine(t *testing.T) {
@@ -62,6 +170,15 @@ func TestDispatchReportsUnparseableLine(t *testing.T) {
 	assert.Contains(t, out.String(), "could not parse the command line: ")
 }
 
+// TestDispatchReportsUnparseableLineOnErr: the refusal lands on Err, so a surface that
+// separates the streams keeps its output parseable.
+func TestDispatchReportsUnparseableLineOnErr(t *testing.T) {
+	out, errOut := &bytes.Buffer{}, &bytes.Buffer{}
+	_ = testRegistry().Dispatch(`served "unterminated`, NewSplitStringWriter(out, errOut))
+	assert.Empty(t, out.String())
+	assert.Contains(t, errOut.String(), "could not parse the command line")
+}
+
 // TestFailTellsTheUserAndTheSurface: Fail writes the message and returns it as a Failure;
 // when the write fails, the user heard nothing, so the write error is what comes back.
 func TestFailTellsTheUserAndTheSurface(t *testing.T) {
@@ -174,3 +291,12 @@ func TestUnknownCommandNamesTheWord(t *testing.T) {
 	assert.Equal(t, "did not understand: nope", f.Msg)
 	assert.True(t, strings.HasPrefix(out.String(), "did not understand: nope\nAvailable commands:\n"), out.String())
 }
+
+func TestIsHelpRequestAcceptsEveryHelpSpelling(t *testing.T) {
+	for _, args := range [][]string{{"-h"}, {"-help"}, {"--help"}, {"x", "-h"}} {
+		assert.True(t, IsHelpRequest(args), "%v", args)
+	}
+	for _, args := range [][]string{nil, {}, {"help"}, {"-hx"}, {"x"}} {
+		assert.False(t, IsHelpRequest(args), "%v", args)
+	}
+}

+ 84 - 0
commands/request.go

@@ -0,0 +1,84 @@
+package commands
+
+import (
+	"flag"
+	"fmt"
+	"maps"
+	"slices"
+)
+
+// Request is one command to run, as every surface hands it to Invoke: the command, its
+// options by name in the form their flags parse, and its operands.
+//
+// It is where the surfaces meet. An ssh line is parsed against the command's own FlagSet
+// and becomes one of these through ParseLine; nebula ctl parses its line the same way
+// against the schema and sends the pieces over the wire, where the control socket turns
+// them back into one. From here on there is one path to the command.
+type Request struct {
+	// Command is the command to run, resolved by the surface at its edge. See
+	// Registry.Resolve for the one that tells an unknown name from a withheld one.
+	Command *Command
+
+	// Params carries the options the line set, by flag name, each in the form its flag
+	// parses from. An option left alone is absent, so the command's default applies.
+	Params map[string]string
+
+	// Args carries the operands: whatever the line did not consume as a flag.
+	Args []string
+}
+
+// ParseLine reads a FlagSet that Parse has filled, and the operands it returned, into a
+// Request. Command is left for the caller to set: it resolved the command before it
+// could build the FlagSet. Every flag the line set travels by name in the form it parses
+// from, which is what Invoke applies.
+func ParseLine(fl *flag.FlagSet, operands []string) Request {
+	req := Request{Args: operands}
+
+	fl.Visit(func(f *flag.Flag) {
+		if req.Params == nil {
+			req.Params = map[string]string{}
+		}
+		req.Params[f.Name] = f.Value.String()
+	})
+
+	return req
+}
+
+// Invoke runs one Request, with w as the command's output.
+//
+// The parameters are applied by flag name, in name order: a request from the wire is a
+// JSON object with no order of its own, and which of two bad parameters is reported must
+// not change from one request to the next. An unknown parameter is an error rather than
+// something quietly ignored, because a caller that misspells one deserves to hear about
+// it instead of getting a default. A bad parameter means the command is never called,
+// and the FlagSet is fresh for this call, so nothing set before it is seen by anyone.
+func Invoke(req Request, w StringWriter) error {
+	c := req.Command
+	names := slices.Sorted(maps.Keys(req.Params))
+
+	var fs any
+	if c.Flags != nil {
+		fl, box := c.Flags()
+		fs = box
+
+		if fl != nil {
+			fl.SetOutput(w.Err())
+
+			for _, k := range names {
+				if fl.Lookup(k) == nil {
+					return fmt.Errorf("%s has no parameter %q", c.Name, k)
+				}
+
+				if err := fl.Set(k, req.Params[k]); err != nil {
+					return fmt.Errorf("%s: %w", k, err)
+				}
+			}
+		} else if len(names) > 0 {
+			return fmt.Errorf("%s takes no parameters", c.Name)
+		}
+	} else if len(names) > 0 {
+		return fmt.Errorf("%s takes no parameters", c.Name)
+	}
+
+	return c.Callback(fs, req.Args, w)
+}

+ 67 - 0
commands/request_test.go

@@ -0,0 +1,67 @@
+package commands
+
+import (
+	"bytes"
+	"flag"
+	"testing"
+
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+)
+
+// TestParseLineCarriesWhatWasSetInParseableForm: only the flags the line set travel,
+// each as the string its flag parses from, so Invoke can apply them to a fresh FlagSet
+// and get the same values back. Operands come through untouched.
+func TestParseLineCarriesWhatWasSetInParseableForm(t *testing.T) {
+	type seen struct {
+		Verbose bool
+		Label   string
+	}
+	var got []seen
+
+	r := New()
+	r.Register(&Command{
+		Name: "set",
+		Flags: func() (*flag.FlagSet, any) {
+			fl := flag.NewFlagSet("", flag.ContinueOnError)
+			s := seen{}
+			fl.BoolVar(&s.Verbose, "verbose", false, "say more")
+			fl.StringVar(&s.Label, "label", "none", "a label")
+			return fl, &s
+		},
+		Callback: func(fs any, a []string, w StringWriter) error {
+			got = append(got, *fs.(*seen))
+			return w.WriteLine("ran")
+		},
+	})
+
+	fl, _ := r.Get("set").Flags()
+	require.NoError(t, fl.Parse([]string{"-verbose", "x", "y"}))
+
+	req := ParseLine(fl, fl.Args())
+	assert.Equal(t, map[string]string{"verbose": "true"}, req.Params, "label was left alone and must be absent")
+	assert.Equal(t, []string{"x", "y"}, req.Args)
+
+	// The round trip: what ParseLine produced is what Invoke applies.
+	req.Command = r.Get("set")
+	out := &bytes.Buffer{}
+	require.NoError(t, Invoke(req, NewStringWriter(out)))
+	assert.Equal(t, "ran\n", out.String())
+	assert.Equal(t, []seen{{Verbose: true, Label: "none"}}, got)
+}
+
+// TestResolveTellsUnknownFromWithheld: the two cases read differently to an operator,
+// and Resolve is the edge that decides which one a name from the wire is.
+func TestResolveTellsUnknownFromWithheld(t *testing.T) {
+	r := testRegistry().Filter(notWithheld, testReason)
+
+	c, err := r.Resolve("served")
+	require.NoError(t, err)
+	assert.Equal(t, Name("served"), c.Name)
+
+	_, err = r.Resolve("withheld")
+	require.ErrorContains(t, err, "withheld is not available on this surface")
+
+	_, err = r.Resolve("no-such-command")
+	require.ErrorContains(t, err, `unknown command "no-such-command"`)
+}

+ 20 - 4
commands/writer.go

@@ -2,22 +2,34 @@ package commands
 
 import "io"
 
-// StringWriter is how a command talks back to whoever invoked it: one stream, which the
-// ssh surface points at its channel.
+// StringWriter is how a command talks back to whoever invoked it.
+//
+// Out and Err are distinct so a command's primary output stays machine readable on
+// surfaces that separate the two (the local control socket maps them to different
+// frames). The ssh surface points both at the same channel, which is why it sees a
+// single merged stream exactly as it always has.
 type StringWriter interface {
 	WriteLine(string) error
 	Write(string) error
 	WriteBytes([]byte) error
 	GetWriter() io.Writer
+	Err() io.Writer
 }
 
 type stringWriter struct {
 	w io.Writer
+	e io.Writer
 }
 
-// NewStringWriter returns a StringWriter that writes to w.
+// NewStringWriter returns a StringWriter that merges output and errors onto w.
 func NewStringWriter(w io.Writer) StringWriter {
-	return &stringWriter{w: w}
+	return &stringWriter{w: w, e: w}
+}
+
+// NewSplitStringWriter returns a StringWriter that sends command output to out and
+// usage/error text to err.
+func NewSplitStringWriter(out, err io.Writer) StringWriter {
+	return &stringWriter{w: out, e: err}
 }
 
 func (w *stringWriter) WriteLine(s string) error {
@@ -37,3 +49,7 @@ func (w *stringWriter) WriteBytes(b []byte) error {
 func (w *stringWriter) GetWriter() io.Writer {
 	return w.w
 }
+
+func (w *stringWriter) Err() io.Writer {
+	return w.e
+}

+ 19 - 1
config/config.go

@@ -32,6 +32,11 @@ type C struct {
 
 	// Set by the first reload that needs a restart and never cleared. Guarded by reloadLock
 	needsRestart bool
+
+	// callbackLock guards callbacks. Registration is not confined to startup: CatchHUP is
+	// running from inside Main, and anything wired up after Main returns registers while
+	// a HUP could already be reloading, so append and iteration have to be serialized.
+	callbackLock sync.Mutex
 }
 
 func NewC(l *slog.Logger) *C {
@@ -78,9 +83,22 @@ func (c *C) LoadString(raw string) error {
 // These functions should return quickly or spawn their own go routine if they will take a while.
 // Use r to report anything that failed or needs a restart to apply.
 func (c *C) RegisterReloadCallback(f func(c *C, r *Reload)) {
+	c.callbackLock.Lock()
+	defer c.callbackLock.Unlock()
 	c.callbacks = append(c.callbacks, f)
 }
 
+// reloadCallbacks returns a snapshot of the registered callbacks. Reloads iterate the
+// snapshot rather than the live slice, so a registration from another goroutine cannot
+// race the loop; a callback registered while a reload is in progress runs from the next
+// reload on. callbackLock is not held during the calls themselves, so a callback that
+// registers another callback does not deadlock.
+func (c *C) reloadCallbacks() []func(*C, *Reload) {
+	c.callbackLock.Lock()
+	defer c.callbackLock.Unlock()
+	return append(make([]func(*C, *Reload), 0, len(c.callbacks)), c.callbacks...)
+}
+
 // InitialLoad returns true if this is the first load of the config, and ReloadConfig has not been called yet.
 func (c *C) InitialLoad() bool {
 	return c.oldSettings == nil
@@ -189,7 +207,7 @@ func (c *C) ReloadConfigString(raw string) ReloadResult {
 
 func (c *C) runCallbacks() ReloadResult {
 	r := &Reload{c: c}
-	for _, v := range c.callbacks {
+	for _, v := range c.reloadCallbacks() {
 		v(c, r)
 	}
 

+ 39 - 0
config/config_test.go

@@ -3,6 +3,7 @@ package config
 import (
 	"os"
 	"path/filepath"
+	"sync"
 	"testing"
 	"time"
 
@@ -220,3 +221,41 @@ firewall:
 	}
 	assert.Equal(t, expected, m)
 }
+
+// TestRegisterReloadCallbackDuringReload: registration is not confined to startup. The
+// HUP handler runs from inside nebula.Main, and anything wired after Main returns
+// registers while a reload may already be iterating the callbacks. Under -race this is
+// the data race on the slice; without it, it checks a callback registered mid-reload is
+// picked up by the next one.
+func TestRegisterReloadCallbackDuringReload(t *testing.T) {
+	l := test.NewLogger()
+	c := NewC(l)
+	require.NoError(t, c.LoadString("a: 1"))
+
+	var wg sync.WaitGroup
+	wg.Add(2)
+	go func() {
+		defer wg.Done()
+		for i := 0; i < 200; i++ {
+			c.RegisterReloadCallback(func(*C, *Reload) {})
+		}
+	}()
+	go func() {
+		defer wg.Done()
+		for i := 0; i < 200; i++ {
+			assert.NoError(t, c.ReloadConfigString("a: 1").Err)
+		}
+	}()
+	wg.Wait()
+
+	// A callback that registers another callback must not deadlock, and the new one
+	// runs from the following reload on.
+	var ran int
+	c.RegisterReloadCallback(func(c *C, _ *Reload) {
+		c.RegisterReloadCallback(func(*C, *Reload) { ran++ })
+	})
+	require.NoError(t, c.ReloadConfigString("a: 1").Err)
+	assert.Equal(t, 0, ran, "registered during this reload, so not run by it")
+	require.NoError(t, c.ReloadConfigString("a: 1").Err)
+	assert.Equal(t, 1, ran, "and run by the next")
+}

+ 14 - 0
examples/config.yml

@@ -234,6 +234,20 @@ punchy:
   #trusted_cas:
     #- "ssh public key string"
 
+# Local APIs allow accessing running nebula state.
+#local_api:
+  # There are two types of sockets available: a control socket intended for use on the
+  # local machine, primarily for debugging, and an info socket intended to make
+  # nebula's internal state available to other consumers.
+  #
+  # The control socket is made available by default, and can be accessed via
+  # `nebula ctl <command>`. It defaults to /var/run/nebula/ctl.sock on Linux, macOS and
+  # the BSDs; on Windows it is a named pipe under \\.\pipe\ProtectedPrefix\Administrators.
+  #control:
+    # The absolute path where the control socket should live, or a full pipe name on
+    # Windows. The last directory is created if missing.
+    #socket: /var/run/nebula/ctl.sock
+
 # 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.

+ 1 - 0
go.mod

@@ -6,6 +6,7 @@ require (
 	dario.cat/mergo v1.0.2
 	filippo.io/bigmod v0.1.0
 	github.com/DefinedNet/netlink v1.3.2-dn.1
+	github.com/Microsoft/go-winio v0.6.2
 	github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be
 	github.com/flynn/noise v1.1.0
 	github.com/gaissmai/bart v0.29.0

+ 2 - 0
go.sum

@@ -5,6 +5,8 @@ filippo.io/bigmod v0.1.0 h1:UNzDk7y9ADKST+axd9skUpBQeW7fG2KrTZyOE4uGQy8=
 filippo.io/bigmod v0.1.0/go.mod h1:OjOXDNlClLblvXdwgFFOQFJEocLhhtai8vGLy0JCZlI=
 github.com/DefinedNet/netlink v1.3.2-dn.1 h1:Md2PsUE1Xuy1Zxs/Bm6356n1kt5bq2HspXlPJ6pEspg=
 github.com/DefinedNet/netlink v1.3.2-dn.1/go.mod h1:o4YSn8Jw7zoDcYXDmV77TRd4eS5sSLuE+vu2zAN67C8=
+github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
+github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
 github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc=
 github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc=
 github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0=

+ 184 - 0
localapi/client.go

@@ -0,0 +1,184 @@
+package localapi
+
+import (
+	"bytes"
+	"encoding/json"
+	"errors"
+	"fmt"
+	"io"
+	"net"
+	"time"
+
+	"github.com/slackhq/nebula/commands"
+)
+
+// DialTimeout bounds the connect to a socket that exists but is not being served.
+const DialTimeout = 5 * time.Second
+
+// ResponseTimeout bounds the exchange once connected: sending the request and waiting
+// for the daemon's answer. The daemon drops a client that is slow to send in 10s and one
+// that is slow to read in 30s, so this is the other side of the same bargain, with room
+// for the slowest command (a hostmap dump on a large network) before a stalled daemon is
+// given up on. Client.Timeout overrides it.
+const ResponseTimeout = 60 * time.Second
+
+// MaxResponseBytes bounds what a client will read back. A response carries one command's
+// output, and a hostmap dump on a large network is the realistic worst case.
+const MaxResponseBytes = 64 << 20
+
+// Client talks to a nebula listening at Path.
+type Client struct {
+	Path string
+
+	// Timeout bounds the request and response once connected. Zero means
+	// ResponseTimeout.
+	Timeout time.Duration
+}
+
+// DaemonError is a refusal the daemon answered with: the connection worked, the request
+// was read, and the daemon said no. It is told apart from a failure to reach the daemon
+// so that a client does not send its user after a socket path, or a sudo, when the daemon
+// was right there and answered. A protocol version the daemon does not speak, a socket at
+// its connection limit and a peer the daemon will not serve all arrive as one of these.
+type DaemonError struct {
+	Msg string
+}
+
+func (e *DaemonError) Error() string { return e.Msg }
+
+// Describe asks the daemon which commands it serves and what parameters they take, so a
+// client can offer them without having been built against this daemon.
+func (c *Client) Describe() ([]commands.CommandSchema, error) {
+	res, err := c.do(&Request{Version: ProtocolVersion, Describe: true})
+	if err != nil {
+		return nil, err
+	}
+
+	if res.Error != "" {
+		return nil, &DaemonError{Msg: res.Error}
+	}
+
+	schema := []commands.CommandSchema{}
+	if err := json.Unmarshal(res.Result, &schema); err != nil {
+		return nil, fmt.Errorf("could not parse the command schema: %w", err)
+	}
+
+	return schema, nil
+}
+
+// Invoke runs one command and returns what the daemon said. params are the command's
+// options by name, as the strings a command line parsed them from; a program speaking
+// the protocol directly may send JSON's own types instead, see Request.Params.
+func (c *Client) Invoke(command string, params map[string]string, args []string) (*Response, error) {
+	var wire map[string]any
+	if len(params) > 0 {
+		wire = make(map[string]any, len(params))
+		for k, v := range params {
+			wire[k] = v
+		}
+	}
+
+	return c.do(&Request{
+		Version: ProtocolVersion,
+		Command: command,
+		Params:  wire,
+		Args:    args,
+	})
+}
+
+// do sends one request and reads the one response.
+//
+// Nothing is checked about the socket before the connect: its mode is the kernel's to
+// enforce, and a socket that is not ours to connect to is refused there with a
+// permission error, which the caller turns into advice.
+func (c *Client) do(req *Request) (*Response, error) {
+	conn, err := dial(c.Path, DialTimeout)
+	if err != nil {
+		return nil, err
+	}
+	defer conn.Close()
+
+	// Without this a daemon that accepts and then stalls, blocked inside a command or
+	// stopped with SIGSTOP, hangs the client for good, and a script wrapping it never
+	// completes. One deadline covers the write and the read: the answer is due within
+	// the budget, however the time is spent.
+	timeout := c.Timeout
+	if timeout <= 0 {
+		timeout = ResponseTimeout
+	}
+	_ = conn.SetDeadline(time.Now().Add(timeout))
+
+	// If we get a write error, save that fact but then look for a response from the server.
+	// Some error conditions manifest as the server accepting our connection, writing an
+	// error, and then closing the connection. Our write races against that close.
+	writeErr := error(nil)
+	if err := writeFrame(conn, req); err != nil {
+		writeErr = fmt.Errorf("could not send request: %w", err)
+	}
+
+	res, err := readResponse(conn)
+	if err != nil {
+		if writeErr != nil {
+			return nil, writeErr
+		}
+		var ne net.Error
+		if errors.As(err, &ne) && ne.Timeout() {
+			return nil, fmt.Errorf("the daemon did not answer within %s", timeout)
+		}
+		return nil, err
+	}
+
+	return res, nil
+}
+
+func readResponse(conn net.Conn) (*Response, error) {
+	res := &Response{}
+	if err := readFrame(conn, MaxResponseBytes, "response", res); err != nil {
+		if errors.Is(err, io.EOF) {
+			return nil, errors.New("the daemon closed the connection without answering")
+		}
+		return nil, err
+	}
+
+	return res, nil
+}
+
+// Pretty renders a JSON Result indented for a human reader, reporting false when the
+// result is not a JSON value and there is nothing to indent.
+//
+// The bytes are re-indented rather than decoded and re-encoded: decoding into `any`
+// sorts object keys and routes every number through float64, so the layout would
+// differ from what the daemon wrote and from the ssh -pretty path.
+func Pretty(raw json.RawMessage) (string, bool) {
+	trimmed := bytes.TrimSpace(raw)
+	if len(trimmed) == 0 || trimmed[0] == '"' {
+		return "", false
+	}
+
+	var buf bytes.Buffer
+	if err := json.Indent(&buf, trimmed, "", "    "); err != nil {
+		return "", false
+	}
+
+	return buf.String(), true
+}
+
+// Text renders a Result for a human reader. A JSON value is written through unchanged so
+// the command's own formatting survives; a JSON string is unquoted back into the text the
+// command actually wrote.
+func Text(raw json.RawMessage) string {
+	if len(raw) == 0 {
+		return ""
+	}
+
+	// Only an actual JSON string is unquoted. Unmarshalling into a string also accepts
+	// null, which would turn a command's `null` answer into no output at all.
+	if raw[0] == '"' {
+		var s string
+		if err := json.Unmarshal(raw, &s); err == nil {
+			return s
+		}
+	}
+
+	return string(raw)
+}

+ 50 - 0
localapi/client_test.go

@@ -0,0 +1,50 @@
+package localapi
+
+import (
+	"encoding/json"
+	"testing"
+
+	"github.com/stretchr/testify/assert"
+)
+
+func TestPrettyPreservesKeyOrderAndPrecision(t *testing.T) {
+	// Keys arrive in the order the daemon wrote them and a uint64 past 2^53 must not be
+	// rounded, so the output has to come from re-indenting the bytes rather than from
+	// decoding into `any` and encoding again.
+	raw := json.RawMessage(`{"z":18446744073709551615,"a":{"y":[1,2],"b":true},"m":"text"}`)
+
+	out, ok := Pretty(raw)
+	assert.True(t, ok)
+	//nolint:testifylint // the layout is what is under test, which JSONEq would not see
+	assert.Equal(t, `{
+    "z": 18446744073709551615,
+    "a": {
+        "y": [
+            1,
+            2
+        ],
+        "b": true
+    },
+    "m": "text"
+}`, out)
+}
+
+func TestPrettyLeavesTextAlone(t *testing.T) {
+	// A JSON string is a command's plain text output; it is rendered by Text, not indented.
+	_, ok := Pretty(json.RawMessage(`"Log level is: info"`))
+	assert.False(t, ok)
+
+	_, ok = Pretty(json.RawMessage(`  "padded"`))
+	assert.False(t, ok)
+}
+
+func TestPrettyRejectsEmptyAndInvalid(t *testing.T) {
+	_, ok := Pretty(nil)
+	assert.False(t, ok)
+
+	_, ok = Pretty(json.RawMessage("   "))
+	assert.False(t, ok)
+
+	_, ok = Pretty(json.RawMessage(`{"a":`))
+	assert.False(t, ok)
+}

+ 37 - 0
localapi/path.go

@@ -0,0 +1,37 @@
+package localapi
+
+// DirName is the directory under /var/run that holds nebula's sockets by default.
+const DirName = "nebula"
+
+// CtlSocketKey is the optional config key naming the control socket. Unset, the socket
+// is DefaultSocketPath, one fixed path that suits a daemon running as root, which is
+// what the tun device needs anyway. A daemon run as another user, or a second daemon on
+// one host, names its own. The CLI reads the same key, so when both sides see the same
+// config file they agree on the socket with nothing more said.
+const CtlSocketKey = "local_api.control.socket"
+
+// DefaultSocketPath is the control socket when the config names none: one fixed path per
+// platform, see defaultSocketPath. Both the daemon and nebula ctl use it, which is what
+// lets the endpoint work with no configuration on either side.
+func DefaultSocketPath() string { return defaultSocketPath }
+
+// SocketPathFromConfig returns the control socket path a config implies: CtlSocketKey
+// when it is set, else DefaultSocketPath.
+func SocketPathFromConfig(c interface{ GetString(string, string) string }) (string, error) {
+	if override := c.GetString(CtlSocketKey, ""); override != "" {
+		return configuredSocketPath(CtlSocketKey, override)
+	}
+
+	return DefaultSocketPath(), nil
+}
+
+// configuredSocketPath checks the value of a socket path key and returns it as given. It
+// must be absolute, so that the daemon and nebula ctl, started from wherever the
+// operator happens to be, name the same socket from one config, and it must be a path
+// this platform can bind. key names the setting in the error.
+func configuredSocketPath(key, value string) (string, error) {
+	if err := checkSocketPath(key, value); err != nil {
+		return "", err
+	}
+	return value, nil
+}

+ 52 - 0
localapi/path_test.go

@@ -0,0 +1,52 @@
+//go:build !windows
+
+package localapi
+
+import (
+	"strings"
+	"testing"
+
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+)
+
+type fakeConfig map[string]string
+
+func (f fakeConfig) GetString(k, d string) string {
+	if v, ok := f[k]; ok {
+		return v
+	}
+	return d
+}
+
+// TestSocketPathFromConfigUsesTheDefault is what makes the endpoint work with no
+// configuration: the daemon and the CLI both land on the one fixed path.
+func TestSocketPathFromConfigUsesTheDefault(t *testing.T) {
+	p, err := SocketPathFromConfig(fakeConfig{})
+	require.NoError(t, err)
+	assert.Equal(t, "/var/run/nebula/ctl.sock", p)
+	assert.Equal(t, DefaultSocketPath(), p)
+}
+
+func TestSocketPathFromConfigHonoursOverride(t *testing.T) {
+	p, err := SocketPathFromConfig(fakeConfig{CtlSocketKey: "/tmp/custom.sock"})
+	require.NoError(t, err)
+	assert.Equal(t, "/tmp/custom.sock", p, "the path is used as given")
+}
+
+// TestSocketPathFromConfigRefusesARelativeOverride: the daemon and the CLI are started
+// from different directories, so a relative path would name two sockets from one config.
+// It is refused rather than resolved against anything.
+func TestSocketPathFromConfigRefusesARelativeOverride(t *testing.T) {
+	for _, rel := range []string{"run/ctl.sock", "./ctl.sock", "ctl.sock", "../ctl.sock"} {
+		_, err := SocketPathFromConfig(fakeConfig{CtlSocketKey: rel})
+		require.Error(t, err, rel)
+		require.ErrorContains(t, err, "must be an absolute path", rel)
+		assert.ErrorContains(t, err, CtlSocketKey, rel)
+	}
+}
+
+func TestSocketPathFromConfigRejectsOverlongOverride(t *testing.T) {
+	_, err := SocketPathFromConfig(fakeConfig{CtlSocketKey: "/" + strings.Repeat("x", sunPathMax)})
+	assert.ErrorContains(t, err, "too long for this platform")
+}

+ 25 - 0
localapi/peercred_bsd.go

@@ -0,0 +1,25 @@
+//go:build darwin || freebsd
+
+package localapi
+
+import (
+	"net"
+
+	"golang.org/x/sys/unix"
+)
+
+// peerCheckSupported reports that this platform can identify a connecting process.
+const peerCheckSupported = true
+
+// peerUID returns the uid of the process on the other end of conn.
+func peerUID(conn net.Conn) (uid uint32, err error) {
+	err = withPeerFD(conn, func(fd int) error {
+		xu, credErr := unix.GetsockoptXucred(fd, unix.SOL_LOCAL, unix.LOCAL_PEERCRED)
+		if credErr != nil {
+			return credErr
+		}
+		uid = xu.Uid
+		return nil
+	})
+	return uid, err
+}

+ 25 - 0
localapi/peercred_linux.go

@@ -0,0 +1,25 @@
+//go:build linux
+
+package localapi
+
+import (
+	"net"
+
+	"golang.org/x/sys/unix"
+)
+
+// peerCheckSupported reports that this platform can identify a connecting process.
+const peerCheckSupported = true
+
+// peerUID returns the uid of the process on the other end of conn.
+func peerUID(conn net.Conn) (uid uint32, err error) {
+	err = withPeerFD(conn, func(fd int) error {
+		cred, credErr := unix.GetsockoptUcred(fd, unix.SOL_SOCKET, unix.SO_PEERCRED)
+		if credErr != nil {
+			return credErr
+		}
+		uid = cred.Uid
+		return nil
+	})
+	return uid, err
+}

+ 59 - 0
localapi/peercred_netbsd.go

@@ -0,0 +1,59 @@
+//go:build netbsd
+
+package localapi
+
+import (
+	"fmt"
+	"net"
+	"unsafe"
+
+	"golang.org/x/sys/unix"
+)
+
+const peerCheckSupported = true
+
+// NetBSD reports the peer's identity through LOCAL_PEEREID at the SOL_LOCAL level,
+// filling a `struct unpcbid { pid_t unp_pid; uid_t unp_euid; gid_t unp_egid; }`.
+// golang.org/x/sys/unix has no typed wrapper for it, so the getsockopt goes out by hand
+// exactly the way that package's own generated wrappers do on this platform.
+const (
+	solLocal     = 0
+	localPeerEID = 0x0003
+
+	unpcbidSize     = 12
+	unpcbidEUIDOffs = 4
+)
+
+// peerUID returns the uid of the process on the other end of conn.
+//
+// Anything unexpected here is an error rather than a default, so a wrong constant or an
+// unsupported kernel refuses connections instead of silently admitting them.
+func peerUID(conn net.Conn) (uid uint32, err error) {
+	err = withPeerFD(conn, func(fd int) error {
+		var (
+			buf  [unpcbidSize]byte
+			size = uint32(unpcbidSize)
+		)
+
+		_, _, errno := unix.Syscall6(
+			unix.SYS_GETSOCKOPT,
+			uintptr(fd),
+			uintptr(solLocal),
+			uintptr(localPeerEID),
+			uintptr(unsafe.Pointer(&buf[0])),
+			uintptr(unsafe.Pointer(&size)),
+			0,
+		)
+		if errno != 0 {
+			return errno
+		}
+
+		if size != unpcbidSize {
+			return fmt.Errorf("LOCAL_PEEREID returned %d bytes, expected %d", size, unpcbidSize)
+		}
+
+		uid = *(*uint32)(unsafe.Pointer(&buf[unpcbidEUIDOffs]))
+		return nil
+	})
+	return uid, err
+}

+ 34 - 0
localapi/peercred_openbsd.go

@@ -0,0 +1,34 @@
+//go:build openbsd
+
+package localapi
+
+import (
+	"net"
+
+	"golang.org/x/sys/unix"
+)
+
+// peerCheckSupported reports that this platform can identify a connecting process.
+const peerCheckSupported = true
+
+// peerUID returns the uid of the process on the other end of conn.
+//
+// OpenBSD reports the peer through SO_PEERCRED at SOL_SOCKET, filling a
+// `struct sockpeercred { uid_t uid; gid_t gid; pid_t pid; }`. System calls here have to
+// go through libc, so the raw getsockopt netbsd uses is not an option, and
+// golang.org/x/sys/unix has no typed wrapper for this struct. It does export
+// GetsockoptInt, which goes through its libc trampoline with a 4 byte buffer, and the
+// kernel copies out min(caller's length, option length): the first four bytes, which are
+// the uid. That is a dependence on the field order, the same one netbsd's offset carries.
+func peerUID(conn net.Conn) (uid uint32, err error) {
+	err = withPeerFD(conn, func(fd int) error {
+		v, credErr := unix.GetsockoptInt(fd, unix.SOL_SOCKET, unix.SO_PEERCRED)
+		if credErr != nil {
+			return credErr
+		}
+		// A uid_t is 32 bits; GetsockoptInt read exactly that many and widened it.
+		uid = uint32(v)
+		return nil
+	})
+	return uid, err
+}

+ 20 - 0
localapi/peercred_windows.go

@@ -0,0 +1,20 @@
+//go:build windows
+
+package localapi
+
+import (
+	"errors"
+	"net"
+)
+
+// peerCheckSupported is false where the connecting process is not identified separately.
+//
+// On windows there is nothing to add: the pipe's security descriptor already admits
+// only the account nebula runs as, which is the check, not a second opinion on it.
+const peerCheckSupported = false
+
+var errNoPeerCheck = errors.New("peer credentials are not available on this platform")
+
+func peerUID(conn net.Conn) (uint32, error) {
+	return 0, errNoPeerCheck
+}

+ 261 - 0
localapi/platform_unix.go

@@ -0,0 +1,261 @@
+//go:build !windows
+
+package localapi
+
+import (
+	"errors"
+	"fmt"
+	"log/slog"
+	"net"
+	"os"
+	"os/user"
+	"path/filepath"
+	"strconv"
+	"sync"
+	"syscall"
+	"time"
+)
+
+const network = "unix"
+
+// withPeerFD runs f on the descriptor behind conn, which must be a unix socket, and
+// returns whichever error came first: not a unix socket, no raw access to it, or f's
+// own. It is the scaffolding every platform's peerUID shares; only the getsockopt inside
+// differs.
+func withPeerFD(conn net.Conn, f func(fd int) error) error {
+	uc, ok := conn.(*net.UnixConn)
+	if !ok {
+		return fmt.Errorf("not a unix connection")
+	}
+
+	raw, err := uc.SyscallConn()
+	if err != nil {
+		return err
+	}
+
+	var inner error
+	if err := raw.Control(func(fd uintptr) { inner = f(int(fd)) }); err != nil {
+		return err
+	}
+	return inner
+}
+
+// currentUID is the user the owner checks are made for. A variable so a test can stand
+// in for root, or for a stranger, without owning a second account on the host.
+var currentUID = func() uint32 { return uint32(os.Geteuid()) }
+
+// listen binds the socket at path.
+//
+// Access control is the socket's own mode, 0600, which the node is created with and
+// which is pinned after bind, together with the peer check the server applies on accept:
+// 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.
+func listen(path string) (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.
+	if pathTooLong(path) {
+		return nil, fmt.Errorf("socket path is %d bytes, over the %d byte limit for a unix socket: %s", len(path), maxSocketPath, path)
+	}
+
+	dir := filepath.Dir(path)
+
+	// One level only. Every directory defaultPathFor offers sits directly under one that
+	// exists, and an operator who names a deeper path creates it; a missing parent is a
+	// mistake to report, not a tree to build. A directory made here is remembered, so
+	// that removeSocket can take it away again once nothing is left in it.
+	//
+	// 0755 gives nothing away: the socket is 0600. What it gives is a directory others
+	// can look into, which is what ServedByRootFor needs. Mkdir applies the umask, so
+	// the mode is pinned afterwards, as the socket's is; only on a directory this call
+	// created.
+	switch err := os.Mkdir(dir, 0755); {
+	case err == nil:
+		if err := os.Chmod(dir, 0755); err != nil {
+			return nil, fmt.Errorf("could not set the mode of %s: %w", dir, err)
+		}
+		madeDirs.add(dir)
+	case !errors.Is(err, os.ErrExist):
+		return nil, fmt.Errorf("could not create %s: %w", dir, err)
+	}
+
+	if err := clearStale(path); err != nil {
+		return nil, err
+	}
+
+	// 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.
+	old := syscall.Umask(0077)
+	ln, err := net.Listen(network, path)
+	syscall.Umask(old)
+	if err != nil {
+		return nil, err
+	}
+
+	// The node at path is given up by release, which unlinks it before the listener
+	// closes; see there for why the order matters. Go's listener would unlink on Close
+	// as well, and an unlink after the close could only be of whatever bound the path
+	// next, so that is turned off and release is the one place the node is removed.
+	if ul, ok := ln.(*net.UnixListener); ok {
+		ul.SetUnlinkOnClose(false)
+	}
+
+	// The umask only promises no looser than 0600; this pins the mode exactly, and is
+	// the one place a failure to secure the socket is reported.
+	if err := os.Chmod(path, 0600); err != nil {
+		release(ln, path)
+		return nil, fmt.Errorf("could not secure %s: %w", path, err)
+	}
+
+	return ln, nil
+}
+
+// owner returns the uid that owns the file fi describes.
+func owner(fi os.FileInfo) (uint32, bool) {
+	st, ok := fi.Sys().(*syscall.Stat_t)
+	if !ok {
+		return 0, false
+	}
+	return uint32(st.Uid), true
+}
+
+// describeUID renders a uid with its account name when the host can say what it is, so
+// a message about somebody else's socket tells the operator who that is.
+func describeUID(uid uint32) string {
+	id := strconv.FormatUint(uint64(uid), 10)
+	if u, err := user.LookupId(id); err == nil && u.Username != "" {
+		return "uid " + id + " (" + u.Username + ")"
+	}
+
+	return "uid " + id
+}
+
+// clearStale removes a leftover socket file, but only after proving nothing is
+// listening on it. An unclean restart leaves one behind every time, so this is a
+// routine path; blindly unlinking would let a second daemon steal a live endpoint.
+//
+// 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.
+func clearStale(path string) error {
+	fi, err := os.Stat(path)
+	if err != nil {
+		if os.IsNotExist(err) {
+			return nil
+		}
+		return err
+	}
+
+	if !isSocket(fi) {
+		return fmt.Errorf("%s exists and is not a socket", path)
+	}
+
+	conn, err := dial(path, 250*time.Millisecond)
+	if err == nil {
+		_ = conn.Close()
+		return fmt.Errorf("%s is already in use by another nebula instance", path)
+	}
+
+	if errors.Is(err, os.ErrNotExist) {
+		// Gone between the stat and the connect; nothing to clear.
+		return nil
+	}
+
+	if !errors.Is(err, syscall.ECONNREFUSED) {
+		return fmt.Errorf("%s exists and could not be shown to be stale, refusing to remove it: %w", path, err)
+	}
+
+	return os.Remove(path)
+}
+
+// removeSocket unlinks the socket node and, when listen made the directory it is in,
+// removes that too, so a socket that moves off an override or a daemon that stops
+// 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) {
+	_ = os.Remove(path)
+	madeDirs.remove(filepath.Dir(path))
+}
+
+// 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{}}
+
+type dirSet struct {
+	mu   sync.Mutex
+	made map[string]bool
+}
+
+func (d *dirSet) add(dir string) {
+	d.mu.Lock()
+	defer d.mu.Unlock()
+	d.made[dir] = true
+}
+
+// remove takes dir away if listen made it and it is empty. One that still holds
+// something, another socket of ours or whatever else arrived, is kept and stays
+// remembered, so the release of the last socket in it is the one that removes it. The
+// same goes for any other failure: a directory that is still there is still ours to
+// remove, and the next release tries again. Only one that is gone is forgotten.
+func (d *dirSet) remove(dir string) {
+	d.mu.Lock()
+	defer d.mu.Unlock()
+	if !d.made[dir] {
+		return
+	}
+	if err := os.Remove(dir); err != nil && !errors.Is(err, os.ErrNotExist) {
+		return
+	}
+	delete(d.made, dir)
+}
+
+func dial(path string, timeout time.Duration) (net.Conn, error) {
+	return net.DialTimeout(network, path, timeout)
+}
+
+// sunPathMax is the smallest sun_path any platform we build for offers (104 on darwin,
+// 108 on linux). Overrunning it truncates the path rather than failing cleanly, so we
+// check against the tighter bound everywhere.
+const sunPathMax = 104
+
+// maxSocketPath is the longest path that actually binds. sun_path holds the trailing
+// NUL as well, and Go's syscall package refuses a path that fills the whole buffer
+// with a bare EINVAL, so the usable limit is one byte short of the buffer.
+const maxSocketPath = sunPathMax - 1
+
+func pathTooLong(path string) bool { return len(path) > maxSocketPath }
+
+// defaultSocketPath is the control socket when the config names none. It is root's to
+// create, as the tun device is root's to open; a daemon run as anyone else names a path
+// of its own with CtlSocketKey.
+const defaultSocketPath = "/var/run/" + DirName + "/ctl.sock"
+
+// checkSocketPath refuses a configured socket path that is relative, or too long to
+// bind. A relative path would mean a different socket to the daemon and to nebula ctl,
+// each resolving it against the directory it happened to be started from.
+func checkSocketPath(key, value string) error {
+	if !filepath.IsAbs(value) {
+		return fmt.Errorf("%s must be an absolute path, got %q", key, value)
+	}
+	if pathTooLong(value) {
+		return fmt.Errorf("%s is %d bytes, too long for this platform: %s", key, len(value), value)
+	}
+	return nil
+}
+
+// logListening is the line written when the socket is up. Every unix this builds for
+// checks the peer's credentials on each connection, on top of the socket's mode, so the
+// line has only the path to add.
+func logListening(l *slog.Logger, path string) {
+	l.Info("Local control socket listening", "path", path)
+}
+
+func isSocket(fi os.FileInfo) bool { return fi.Mode()&os.ModeSocket != 0 }

+ 315 - 0
localapi/platform_unix_test.go

@@ -0,0 +1,315 @@
+//go:build !windows
+
+package localapi
+
+import (
+	"context"
+	"net"
+	"os"
+	"path/filepath"
+	"strings"
+	"syscall"
+	"testing"
+	"time"
+
+	"github.com/slackhq/nebula/test"
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+)
+
+// privateBase returns a short directory of our own, 0700, for tests to lay sockets out
+// under, short enough that a path under it fits sun_path.
+func privateBase(t *testing.T) string {
+	t.Helper()
+	return test.ShortTempDir(t)
+}
+
+// openDir returns a world writable directory under base, the shape of /tmp.
+func openDir(t *testing.T, base string) string {
+	t.Helper()
+
+	open := filepath.Join(base, "open")
+	require.NoError(t, os.Mkdir(open, 0777))
+	require.NoError(t, os.Chmod(open, 0777|os.ModeSticky))
+	return open
+}
+
+// TestListenLeavesAServiceManagersDirectoryAlone covers the directory a service manager
+// pre-creates: systemd's RuntimeDirectory is 0755 by default. The socket is 0600, which
+// is what refuses other users, so the directory is served from as it came and nothing
+// is chmoded that the operator did not set.
+func TestListenLeavesAServiceManagersDirectoryAlone(t *testing.T) {
+	base := privateBase(t)
+	dir := filepath.Join(base, DirName)
+	require.NoError(t, os.Mkdir(dir, 0755))
+
+	s := NewServer(test.NewLogger(), basicRegistry())
+	require.NoError(t, s.Rebind(context.Background(), filepath.Join(dir, "s.sock")))
+	t.Cleanup(s.Stop)
+
+	fi, err := os.Stat(dir)
+	require.NoError(t, err)
+	assert.Equal(t, os.FileMode(0755), fi.Mode().Perm(), "the directory is left exactly as it was")
+
+	fi, err = os.Stat(s.Path())
+	require.NoError(t, err)
+	assert.Equal(t, os.FileMode(0600), fi.Mode().Perm(), "the socket is what is private")
+	_, err = (&Client{Path: s.Path()}).Describe()
+	require.NoError(t, err)
+}
+
+// TestListenCreatesOneLevelOnly: the socket directory is created when it is missing,
+// but nothing above it is. A runtime directory that does not exist is the operator's to
+// provide, and the failure says which directory could not be created.
+func TestListenCreatesOneLevelOnly(t *testing.T) {
+	base := privateBase(t)
+
+	s := NewServer(test.NewLogger(), basicRegistry())
+	require.NoError(t, s.Rebind(context.Background(), filepath.Join(base, DirName, "s.sock")))
+	s.Stop()
+
+	dir := filepath.Join(base, "missing", DirName)
+	err := s.Rebind(context.Background(), filepath.Join(dir, "s.sock"))
+	require.Error(t, err)
+	require.ErrorContains(t, err, "could not create "+dir)
+	assert.NoDirExists(t, filepath.Join(base, "missing"))
+}
+
+// TestReleaseRemovesTheDirectoryItMade: a directory listen created goes away with the
+// last socket in it, and one that was already there is left alone, whatever it holds.
+func TestReleaseRemovesTheDirectoryItMade(t *testing.T) {
+	base := privateBase(t)
+
+	made := filepath.Join(base, DirName)
+	first, err := listen(filepath.Join(made, "a.sock"))
+	require.NoError(t, err)
+	second, err := listen(filepath.Join(made, "b.sock"))
+	require.NoError(t, err)
+
+	release(first, filepath.Join(made, "a.sock"))
+	assert.DirExists(t, made, "b.sock is still in it")
+	release(second, filepath.Join(made, "b.sock"))
+	assert.NoDirExists(t, made)
+
+	given := filepath.Join(base, "given")
+	require.NoError(t, os.Mkdir(given, 0755))
+	ln, err := listen(filepath.Join(given, "s.sock"))
+	require.NoError(t, err)
+	release(ln, filepath.Join(given, "s.sock"))
+	assert.DirExists(t, given)
+	assert.NoFileExists(t, filepath.Join(given, "s.sock"))
+}
+
+// TestListenRestoresTheUmask: listen tightens the process umask around bind so the socket
+// node is never created looser than 0600. That is process wide state, so it must come
+// back exactly as it was, on success and on failure alike.
+func TestListenRestoresTheUmask(t *testing.T) {
+	old := syscall.Umask(0022)
+	t.Cleanup(func() { syscall.Umask(old) })
+
+	base := privateBase(t)
+	ln, err := listen(filepath.Join(base, DirName, "s.sock"))
+	require.NoError(t, err)
+	t.Cleanup(func() { ln.Close() })
+	assert.Equal(t, 0022, syscall.Umask(0022), "umask after a successful listen")
+
+	_, err = listen(filepath.Join(base, DirName, "s.sock"))
+	require.Error(t, err, "the path is already bound")
+	assert.Equal(t, 0022, syscall.Umask(0022), "umask after a failed listen")
+}
+
+// TestListenServesFromASharedDirectory: an operator who points local_api.control.socket
+// into a directory holding other files, /etc/nebula say, gets a socket there and keeps
+// the config and certs readable by everyone who could read them before.
+func TestListenServesFromASharedDirectory(t *testing.T) {
+	base := privateBase(t)
+	dir := filepath.Join(base, "etc-nebula")
+	require.NoError(t, os.Mkdir(dir, 0755))
+	cfg := filepath.Join(dir, "config.yml")
+	require.NoError(t, os.WriteFile(cfg, []byte("{}\n"), 0644))
+
+	s := NewServer(test.NewLogger(), basicRegistry())
+	require.NoError(t, s.Rebind(context.Background(), filepath.Join(dir, "ctl.sock")))
+	t.Cleanup(s.Stop)
+
+	fi, err := os.Stat(dir)
+	require.NoError(t, err)
+	assert.Equal(t, os.FileMode(0755), fi.Mode().Perm())
+	fi, err = os.Stat(cfg)
+	require.NoError(t, err)
+	assert.Equal(t, os.FileMode(0644), fi.Mode().Perm())
+}
+
+func TestOverlongPathIsDiagnosed(t *testing.T) {
+	long := filepath.Join(os.TempDir(), string(make([]byte, 0)))
+	for len(long) <= sunPathMax {
+		long = filepath.Join(long, "abcdefghij")
+	}
+
+	s := NewServer(test.NewLogger(), basicRegistry())
+	err := s.Rebind(context.Background(), filepath.Join(long, "s.sock"))
+
+	require.Error(t, err)
+	assert.ErrorContains(t, err, "byte limit for a unix socket")
+}
+
+// TestPathLimitIsExact pins the boundary: sun_path has room for the NUL as well, and Go
+// refuses a path that fills the whole buffer, so the last usable length is one short.
+func TestPathLimitIsExact(t *testing.T) {
+	assert.False(t, pathTooLong(strings.Repeat("a", maxSocketPath)))
+	assert.True(t, pathTooLong(strings.Repeat("a", maxSocketPath+1)))
+	assert.True(t, pathTooLong(strings.Repeat("a", sunPathMax)))
+}
+
+// TestNamedPathIsUsedAsGiven: a path the operator named is served from, and dialed,
+// wherever it is, a directory other users can write to included; the socket's own mode
+// is what keeps them out.
+func TestNamedPathIsUsedAsGiven(t *testing.T) {
+	base := privateBase(t)
+	path := filepath.Join(openDir(t, base), DirName, "s.sock")
+
+	s := NewServer(test.NewLogger(), basicRegistry())
+	require.NoError(t, s.Rebind(context.Background(), path))
+	t.Cleanup(s.Stop)
+
+	_, err := (&Client{Path: path}).Describe()
+	require.NoError(t, err)
+}
+
+// TestStaleSocketIsCleared: a dead socket in a good directory is the routine leftover of
+// a hard kill, and the daemon's stale check must see it as free to replace.
+func TestStaleSocketIsCleared(t *testing.T) {
+	path := testSocket(t)
+	require.NoError(t, os.Mkdir(filepath.Dir(path), 0700))
+	ln, err := net.Listen(network, path)
+	require.NoError(t, err)
+	require.NoError(t, os.Chmod(path, 0600))
+	// Close the listener but keep the file, the way a hard kill does.
+	ln.(*net.UnixListener).SetUnlinkOnClose(false)
+	require.NoError(t, ln.Close())
+	_, statErr := os.Stat(path)
+	require.NoError(t, statErr, "the socket file is still there")
+
+	require.NoError(t, clearStale(path), "a stale socket is cleared, not reported as in use")
+	_, statErr = os.Stat(path)
+	assert.True(t, os.IsNotExist(statErr))
+}
+
+// TestClientGivesUpOnASilentDaemon: a daemon that accepts and then never answers must
+// not hang the client for good. A script wrapping nebula ctl has no other way out.
+func TestClientGivesUpOnASilentDaemon(t *testing.T) {
+	path := testSocket(t)
+	require.NoError(t, os.Mkdir(filepath.Dir(path), 0700))
+	ln, err := net.Listen(network, path)
+	require.NoError(t, err)
+	defer ln.Close()
+	require.NoError(t, os.Chmod(path, 0600))
+
+	accepted := make(chan net.Conn, 1)
+	go func() {
+		c, err := ln.Accept()
+		if err == nil {
+			accepted <- c // held open, never written to
+		}
+	}()
+
+	start := time.Now()
+	_, err = (&Client{Path: path, Timeout: 200 * time.Millisecond}).Describe()
+	require.Error(t, err)
+	require.ErrorContains(t, err, "did not answer within")
+	assert.Less(t, time.Since(start), 5*time.Second)
+
+	select {
+	case c := <-accepted:
+		_ = c.Close()
+	default:
+	}
+}
+
+// asUID makes the owner checks run as uid for the rest of the test. The socket the test
+// serves is still owned by the real user, so from uid's point of view it belongs to
+// somebody else: the shape of a service account's daemon, and of an impostor's.
+func asUID(t *testing.T, uid uint32) {
+	t.Helper()
+	real := currentUID
+	currentUID = func() uint32 { return uid }
+	t.Cleanup(func() { currentUID = real })
+}
+
+// TestPeerCheckRefusesAnotherUser: a server built as one user and serving as another
+// refuses the real user at the peer check, and the refusal reaches the client as the
+// daemon's answer rather than as a failure to reach it.
+func TestPeerCheckRefusesAnotherUser(t *testing.T) {
+	if !peerCheckSupported {
+		t.Skip("no peer credentials on this platform")
+	}
+
+	me := currentUID()
+	asUID(t, me+1)
+	s := NewServer(test.NewLogger(), basicRegistry())
+	asUID(t, me)
+
+	path := testSocket(t)
+	require.NoError(t, s.Rebind(context.Background(), path))
+	defer s.Stop()
+
+	_, err := (&Client{Path: path}).Describe()
+	require.Error(t, err)
+	require.ErrorContains(t, err, "not permitted")
+	var refused *DaemonError
+	assert.ErrorAs(t, err, &refused, "the daemon answered; this is not a reachability failure")
+}
+
+// TestListenerDoesNotUnlinkOnClose: the server unlinks its socket node itself, before
+// the listener closes, and the listener must not unlink again afterwards. By the time a
+// close happens the path may belong to a newer daemon that found the node stale and
+// 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)
+	require.NoError(t, err)
+
+	// A newer daemon's start, with the old node gone and the path bound afresh.
+	require.NoError(t, os.Remove(path))
+	next, err := net.Listen(network, path)
+	require.NoError(t, err)
+	t.Cleanup(func() { next.Close() })
+
+	require.NoError(t, old.Close())
+	_, err = os.Stat(path)
+	assert.NoError(t, err, "closing the old listener left the newer daemon's socket 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.
+func TestMadeDirStaysRememberedUntilItIsGone(t *testing.T) {
+	if os.Geteuid() == 0 {
+		t.Skip("root is not refused by directory permissions")
+	}
+
+	parent := t.TempDir()
+	dir := filepath.Join(parent, "nebula")
+	require.NoError(t, os.Mkdir(dir, 0o755))
+
+	d := dirSet{made: map[string]bool{}}
+	d.add(dir)
+
+	// A parent without write permission refuses the rmdir.
+	require.NoError(t, os.Chmod(parent, 0o500))
+	t.Cleanup(func() { _ = os.Chmod(parent, 0o700) })
+	d.remove(dir)
+	assert.True(t, d.made[dir], "a directory that is still there must stay remembered")
+	assert.DirExists(t, dir)
+
+	require.NoError(t, os.Chmod(parent, 0o700))
+	d.remove(dir)
+	assert.False(t, d.made[dir])
+	assert.NoDirExists(t, dir)
+
+	// One that something else already removed is simply forgotten.
+	gone := filepath.Join(parent, "gone")
+	d.add(gone)
+	d.remove(gone)
+	assert.False(t, d.made[gone])
+}

+ 256 - 0
localapi/platform_windows.go

@@ -0,0 +1,256 @@
+//go:build windows
+
+package localapi
+
+import (
+	"errors"
+	"fmt"
+	"log/slog"
+	"net"
+	"strings"
+	"time"
+
+	"github.com/Microsoft/go-winio"
+	"golang.org/x/sys/windows"
+)
+
+// pipePrefix is where windows named pipes live. The pipe's own security descriptor
+// carries the access control that the socket's mode carries on unix.
+const pipePrefix = `\\.\pipe\`
+
+// protectedPrefix is the pipe namespace that only administrators and SYSTEM may create
+// names under; the kernel refuses everyone else. The default name lives there, so the
+// name itself says who could have created the pipe answering it: no ordinary user can
+// have put anything under it ahead of the daemon. Tailscale, WireGuard and NetBird keep
+// their daemon pipes there for the same reason. nebula on windows is already an
+// administrator, since creating the tun adapter requires it.
+const protectedPrefix = pipePrefix + `ProtectedPrefix\Administrators\`
+
+const network = "pipe"
+
+// currentUID is the identity the unix owner checks are made for. There is no uid here
+// and no per-connection check that would read one, so the value is never consulted; it
+// exists so a Server is built the same way on every platform.
+var currentUID = func() uint32 { return 0 }
+
+// logListening is the line written when the pipe is up. It says which layer is doing
+// the access control, so nobody assumes a per-connection check that is not running:
+// there is none here, the pipe's security descriptor admits only the accounts listed in
+// ownerOnlySDDL, and the kernel enforces that at connect time.
+func logListening(l *slog.Logger, path string) {
+	l.Info("Local control socket listening, access restricted by the pipe's security descriptor",
+		"path", path,
+		"reason", "the security descriptor built at listen time is the access check; the connecting process is not identified separately",
+	)
+}
+
+// listen creates the named pipe at path with a security descriptor that admits only the
+// user nebula runs as, SYSTEM and Administrators.
+//
+// The first instance is created, never opened, so a name some other process already
+// holds is refused rather than joined: that process would otherwise answer clients in
+// nebula's place. Under protectedPrefix only an administrator could hold it; anywhere
+// else any account could. Either way the refusal is reported as errEndpointHeld for the
+// caller to log as the event it is, except when the refusal is the namespace's own: a
+// daemon that is not an administrator cannot create under protectedPrefix at all, and
+// that is told apart so the operator is sent to elevate or to name a pipe elsewhere
+// rather than to look for a process that is not there.
+func listen(path string) (net.Listener, error) {
+	sddl, err := ownerOnlySDDL()
+	if err != nil {
+		return nil, fmt.Errorf("could not build a security descriptor for %s: %w", path, err)
+	}
+
+	ln, err := winio.ListenPipe(path, &winio.PipeConfig{
+		SecurityDescriptor: sddl,
+		InputBufferSize:    int32(MaxRequestBytes),
+		OutputBufferSize:   64 * 1024,
+	})
+	if err != nil {
+		return nil, listenRefused(path, isAdministrator(), err)
+	}
+
+	return ln, nil
+}
+
+// listenRefused turns a failure to create the pipe at path into the error the caller
+// logs. admin says whether this process may create under protectedPrefix.
+//
+// ERROR_ACCESS_DENIED is ambiguous: it is what the kernel says to a non-administrator
+// creating under protectedPrefix, and also what an existing instance whose DACL does
+// not admit us says to a second create. A process that is an administrator cannot be
+// getting the first, so for it, and for every name outside the prefix, access denied
+// means held.
+func listenRefused(path string, admin bool, err error) error {
+	if errors.Is(err, windows.ERROR_ACCESS_DENIED) && isProtectedPath(path) && !admin {
+		return fmt.Errorf("creating %s requires an administrator, as only administrators may create pipes under %s; run nebula elevated, or set %s to a pipe name outside it: %w", path, protectedPrefix, CtlSocketKey, err)
+	}
+	if errors.Is(err, windows.ERROR_ALREADY_EXISTS) || errors.Is(err, windows.ERROR_ACCESS_DENIED) {
+		return fmt.Errorf("%w: %s: %w", errEndpointHeld, path, err)
+	}
+	return err
+}
+
+// isProtectedPath says whether path is a name under protectedPrefix. Pipe names are
+// case insensitive, so the comparison is too.
+func isProtectedPath(path string) bool {
+	return len(path) > len(protectedPrefix) && strings.EqualFold(path[:len(protectedPrefix)], protectedPrefix)
+}
+
+// isAdministrator says whether this process holds Administrators as an enabled group,
+// which is what creating under protectedPrefix takes. SYSTEM holds it; an elevated
+// administrator holds it; an administrator's unelevated token carries it deny-only,
+// which the membership check correctly reads as no. The membership is checked against
+// the thread's own token: CheckTokenMembership wants an impersonation token and makes
+// one from the primary token when handed none. A check that cannot be made is read as
+// not an administrator, which only changes the wording of a failure that has already
+// happened.
+func isAdministrator() bool {
+	admins, err := windows.CreateWellKnownSid(windows.WinBuiltinAdministratorsSid)
+	if err != nil {
+		return false
+	}
+	member, err := windows.Token(0).IsMember(admins)
+	return err == nil && member
+}
+
+// ownerOnlySDDL grants full access to the account nebula is running as, to SYSTEM and
+// to Administrators, and to nobody else. P makes the DACL protected, so it does not
+// inherit entries that would widen it.
+//
+// Administrators is the counterpart of the uid 0 allowance in the unix peer check. The
+// service normally runs as LocalSystem, so without it an operator in an elevated shell
+// could not reach the daemon at all; and an administrator can already stop the service,
+// so this grants no authority they did not have.
+func ownerOnlySDDL() (string, error) {
+	token := windows.GetCurrentProcessToken()
+
+	user, err := token.GetTokenUser()
+	if err != nil {
+		return "", err
+	}
+
+	sid := user.User.Sid.String()
+	return fmt.Sprintf("D:P(A;;GA;;;%s)(A;;GA;;;SY)(A;;GA;;;BA)", sid), 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) {}
+
+// 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
+// namespace already promises that, since only an administrator could have created it;
+// the check is what covers a CtlSocketKey override outside protectedPrefix, and costs
+// nothing to make on every name.
+func dial(path string, timeout time.Duration) (net.Conn, error) {
+	conn, err := winio.DialPipe(path, &timeout)
+	if err != nil {
+		return nil, err
+	}
+
+	if err := checkPipeOwner(conn, path); err != nil {
+		conn.Close()
+		return nil, err
+	}
+
+	return conn, nil
+}
+
+// checkPipeOwner refuses a pipe whose owner is not an account that could be running
+// nebula. It is the unix owner check made from the other side: the handle winio dialed
+// exposes the pipe object, and the object's owner is whoever created it.
+//
+// The owner is what makes this sound. An account can only create an object owned by
+// itself, or by a SID it holds with the owner flag, unless it has SeRestorePrivilege,
+// which ordinary users do not; so a pipe owned by SYSTEM, Administrators or a service
+// account was put there by one of them, and never by a user squatting the name. winio
+// dials with SECURITY_ANONYMOUS, so the impostor could not have impersonated the client
+// either, but it could have read the command and answered it with whatever it liked.
+func checkPipeOwner(conn net.Conn, path string) error {
+	h, ok := conn.(interface{ Fd() uintptr })
+	if !ok {
+		return fmt.Errorf("%s: cannot identify who serves the pipe", path)
+	}
+
+	sd, err := windows.GetSecurityInfo(windows.Handle(h.Fd()), windows.SE_KERNEL_OBJECT, windows.OWNER_SECURITY_INFORMATION)
+	if err != nil {
+		return fmt.Errorf("%s: could not read the pipe's owner: %w", path, err)
+	}
+
+	owner, _, err := sd.Owner()
+	if err != nil {
+		return fmt.Errorf("%s: could not read the pipe's owner: %w", path, err)
+	}
+	if owner == nil {
+		return fmt.Errorf("%s has no owner; refusing to send to it", path)
+	}
+
+	me, err := windows.GetCurrentProcessToken().GetTokenUser()
+	if err != nil {
+		return fmt.Errorf("%s: could not read own identity: %w", path, err)
+	}
+
+	if ownerAccepted(owner, me.User.Sid) {
+		return nil
+	}
+
+	return fmt.Errorf("%s is served by %s, not by nebula; run nebula ctl as that user, or pass -config or -socket to name the daemon's pipe", path, describeSID(owner))
+}
+
+// ownerAccepted says whether a pipe owned by owner may be sent a request by a client
+// running as me. Accepted are the client's own account and the accounts a daemon runs
+// under that no ordinary user can create an object as: SYSTEM, Administrators, the
+// LocalService and NetworkService accounts, and the per-service virtual accounts under
+// NT SERVICE (S-1-5-80-...). Another user's account is refused, whoever the client is;
+// the daemon's DACL decides who may connect, and this decides who may answer.
+func ownerAccepted(owner, me *windows.SID) bool {
+	if owner.Equals(me) {
+		return true
+	}
+
+	if owner.IsWellKnown(windows.WinLocalSystemSid) ||
+		owner.IsWellKnown(windows.WinBuiltinAdministratorsSid) ||
+		owner.IsWellKnown(windows.WinLocalServiceSid) ||
+		owner.IsWellKnown(windows.WinNetworkServiceSid) {
+		return true
+	}
+
+	// NT SERVICE\<name>: S-1-5-80 followed by the five sub-authorities of the service's
+	// name hash. The well-known SID table has no entry for them, as each is distinct.
+	const serviceBaseRID = 80
+	return owner.IdentifierAuthority() == windows.SECURITY_NT_AUTHORITY &&
+		owner.SubAuthorityCount() == 6 &&
+		owner.SubAuthority(0) == serviceBaseRID
+}
+
+// describeSID renders a SID with its account name when the host can say what it is, so
+// a message about somebody else's pipe tells the operator who that is.
+func describeSID(sid *windows.SID) string {
+	account, domain, _, err := sid.LookupAccount("")
+	if err != nil || account == "" {
+		return sid.String()
+	}
+	if domain == "" {
+		return account
+	}
+	return domain + `\` + account
+}
+
+// defaultSocketPath is the control pipe when the config names none. It is under
+// protectedPrefix, so whatever answers it was created by an administrator or SYSTEM; see
+// there.
+const defaultSocketPath = protectedPrefix + "nebula-ctl"
+
+// checkSocketPath refuses a configured name that is not a full pipe name. A pipe lives
+// under pipePrefix and nowhere else, so a bare name has no meaning here, and one under
+// pipePrefix is used as given. An override outside protectedPrefix is a name any
+// account could have created first; the owner check in dial is what protects a client
+// of one.
+func checkSocketPath(key, value string) error {
+	if len(value) <= len(pipePrefix) || !strings.EqualFold(value[:len(pipePrefix)], pipePrefix) {
+		return fmt.Errorf(`%s must be a full pipe name under %s, got %q`, key, pipePrefix, value)
+	}
+	return nil
+}

+ 189 - 0
localapi/platform_windows_test.go

@@ -0,0 +1,189 @@
+//go:build windows
+
+package localapi
+
+import (
+	"errors"
+	"net"
+	"testing"
+	"time"
+
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+	"golang.org/x/sys/windows"
+)
+
+// TestOwnerAccepted pins which accounts may answer a nebula ctl request: the client's
+// own, and the accounts a daemon runs as that an ordinary user cannot create an object
+// owned by. Any other user is refused, since any user can create any pipe name.
+func TestOwnerAccepted(t *testing.T) {
+	me, err := windows.GetCurrentProcessToken().GetTokenUser()
+	require.NoError(t, err)
+
+	wellKnown := func(typ windows.WELL_KNOWN_SID_TYPE) *windows.SID {
+		sid, err := windows.CreateWellKnownSid(typ)
+		require.NoError(t, err)
+		return sid
+	}
+	parse := func(s string) *windows.SID {
+		sid, err := windows.StringToSid(s)
+		require.NoError(t, err)
+		return sid
+	}
+
+	accepted := map[string]*windows.SID{
+		"self":            me.User.Sid,
+		"SYSTEM":          wellKnown(windows.WinLocalSystemSid),
+		"Administrators":  wellKnown(windows.WinBuiltinAdministratorsSid),
+		"LocalService":    wellKnown(windows.WinLocalServiceSid),
+		"NetworkService":  wellKnown(windows.WinNetworkServiceSid),
+		"virtual account": parse("S-1-5-80-1-2-3-4-5"),
+	}
+	for name, sid := range accepted {
+		assert.True(t, ownerAccepted(sid, me.User.Sid), name)
+	}
+
+	refused := map[string]*windows.SID{
+		"Everyone":                 wellKnown(windows.WinWorldSid),
+		"Users":                    wellKnown(windows.WinBuiltinUsersSid),
+		"another user":             parse("S-1-5-21-1-2-3-1001"),
+		"service RID, wrong shape": parse("S-1-5-80"),
+		"service RID elsewhere":    parse("S-1-5-21-80-1-2-3-4"),
+	}
+	for name, sid := range refused {
+		assert.False(t, ownerAccepted(sid, me.User.Sid), name)
+	}
+}
+
+// TestDefaultPathIsProtected pins the default name to the namespace only administrators
+// and SYSTEM can create under, which is what lets nebula ctl trust the default name
+// from its spelling alone.
+func TestDefaultPathIsProtected(t *testing.T) {
+	p := DefaultSocketPath()
+	assert.True(t, isProtectedPath(p), p)
+	assert.Equal(t, `\\.\pipe\ProtectedPrefix\Administrators\nebula-ctl`, p)
+}
+
+// TestConfiguredPipeNameMustBeFull: a configured name is used as given, and only a full
+// pipe name means anything here.
+func TestConfiguredPipeNameMustBeFull(t *testing.T) {
+	p, err := SocketPathFromConfig(fakeWinConfig{CtlSocketKey: `\\.\pipe\nebula-ctl`})
+	require.NoError(t, err)
+	assert.Equal(t, `\\.\pipe\nebula-ctl`, p)
+
+	for _, bad := range []string{"nebula-ctl", `C:\nebula\ctl.sock`, `\\.\pipe\`} {
+		_, err := SocketPathFromConfig(fakeWinConfig{CtlSocketKey: bad})
+		require.Error(t, err, bad)
+		assert.ErrorContains(t, err, "full pipe name", bad)
+	}
+}
+
+type fakeWinConfig map[string]string
+
+func (f fakeWinConfig) GetString(k, d string) string {
+	if v, ok := f[k]; ok {
+		return v
+	}
+	return d
+}
+
+func TestIsProtectedPath(t *testing.T) {
+	assert.True(t, isProtectedPath(`\\.\pipe\ProtectedPrefix\Administrators\nebula-1`))
+	assert.True(t, isProtectedPath(`\\.\pipe\protectedprefix\administrators\nebula-1`), "pipe names are case insensitive")
+	assert.False(t, isProtectedPath(`\\.\pipe\ProtectedPrefix\Administrators\`), "the prefix alone names nothing")
+	assert.False(t, isProtectedPath(`\\.\pipe\nebula-1`))
+	assert.False(t, isProtectedPath(`\\.\pipe\ProtectedPrefix\Users\nebula-1`))
+}
+
+// TestListenRefused pins how a failed create is reported. Access denied under the
+// protected prefix is the namespace refusing a non-administrator, and says so; the same
+// error for an administrator, or for any name outside the prefix, is a name some other
+// process holds, and is errEndpointHeld so the daemon logs it as an error.
+func TestListenRefused(t *testing.T) {
+	protected := `\\.\pipe\ProtectedPrefix\Administrators\nebula-1`
+	plain := `\\.\pipe\nebula-1`
+
+	err := listenRefused(protected, false, windows.ERROR_ACCESS_DENIED)
+	assert.NotErrorIs(t, err, errEndpointHeld)
+	assert.ErrorIs(t, err, windows.ERROR_ACCESS_DENIED)
+	assert.Contains(t, err.Error(), "requires an administrator")
+	assert.Contains(t, err.Error(), CtlSocketKey)
+
+	assert.ErrorIs(t, listenRefused(protected, true, windows.ERROR_ACCESS_DENIED), errEndpointHeld)
+	assert.ErrorIs(t, listenRefused(plain, false, windows.ERROR_ACCESS_DENIED), errEndpointHeld)
+	assert.ErrorIs(t, listenRefused(protected, false, windows.ERROR_ALREADY_EXISTS), errEndpointHeld)
+	assert.ErrorIs(t, listenRefused(plain, true, windows.ERROR_ALREADY_EXISTS), errEndpointHeld)
+
+	other := errors.New("something else")
+	assert.Same(t, other, listenRefused(protected, false, other))
+}
+
+// TestListenProtectedName creates and dials a name under the protected prefix. It needs
+// an administrator, and otherwise checks only that the refusal is the one listen
+// promises a non-administrator.
+func TestListenProtectedName(t *testing.T) {
+	path := protectedPrefix + "nebula-test-" + t.Name()
+	ln, err := listen(path)
+	if !isAdministrator() {
+		require.Error(t, err)
+		assert.Contains(t, err.Error(), "requires an administrator")
+		t.Skip("not an administrator; the protected namespace refused the create as it should")
+	}
+	require.NoError(t, err)
+	t.Cleanup(func() { ln.Close() })
+
+	accepted := make(chan error, 1)
+	go func() {
+		conn, err := ln.Accept()
+		if err == nil {
+			conn.Close()
+		}
+		accepted <- err
+	}()
+
+	conn, err := dial(path, time.Second)
+	require.NoError(t, err)
+	conn.Close()
+	require.NoError(t, <-accepted)
+}
+
+// TestDialAcceptsOwnPipe dials a pipe this process created outside the protected
+// prefix, as an override might name, which the owner check must pass: the pipe's owner
+// 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)
+	require.NoError(t, err)
+	t.Cleanup(func() { ln.Close() })
+
+	accepted := make(chan error, 1)
+	go func() {
+		conn, err := ln.Accept()
+		if err == nil {
+			conn.Close()
+		}
+		accepted <- err
+	}()
+
+	conn, err := dial(path, time.Second)
+	require.NoError(t, err)
+	conn.Close()
+	require.NoError(t, <-accepted)
+}
+
+// TestListenRefusesHeldName pins that a name another listener already holds is refused
+// with errEndpointHeld rather than joined, which is what keeps an impostor from being
+// served alongside, or in place of, the daemon.
+func TestListenRefusesHeldName(t *testing.T) {
+	path := pipePrefix + "nebula-test-" + t.Name()
+	first, err := listen(path)
+	require.NoError(t, err)
+	t.Cleanup(func() { first.Close() })
+
+	var second net.Listener
+	second, err = listen(path)
+	if second != nil {
+		second.Close()
+	}
+	require.ErrorIs(t, err, errEndpointHeld)
+}

+ 143 - 0
localapi/protocol.go

@@ -0,0 +1,143 @@
+// 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.
+//
+// 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.
+//
+// # Protocol
+//
+// One JSON object in, one JSON object out, both newline terminated. It is an API, not a
+// command line: a request names a command and passes its options as named parameters,
+// rather than shipping an argv the daemon has to parse. A client that is itself a CLI
+// asks for the schema first (Request.Describe) and turns the user's flags into those
+// parameters; a client that is not need never think in flags at all.
+package localapi
+
+import (
+	"bufio"
+	"bytes"
+	"encoding/json"
+	"errors"
+	"fmt"
+	"io"
+)
+
+// ProtocolVersion is the version of the request and response envelopes. A client sending
+// a version this server does not know gets a sentence back rather than a parse error,
+// which matters because the daemon and the CLI are upgraded independently.
+const ProtocolVersion = 1
+
+// MaxRequestBytes bounds the request. A request names a command and its parameters, so it
+// has no business being large, and an unbounded read is a way for a local process to
+// consume memory the packet path needs.
+const MaxRequestBytes = 64 * 1024
+
+// Request is the single JSON object a client sends.
+type Request struct {
+	Version int `json:"version"`
+
+	// Describe asks for the schema of every command this surface serves, in place of
+	// running one. Command, Params and Args are ignored.
+	Describe bool `json:"describe,omitempty"`
+
+	// Command names the command to run.
+	Command string `json:"command,omitempty"`
+
+	// Params carries the command's options by name, with JSON's own types, true rather
+	// than "true", 3 rather than "3", or as the strings a command line parsed them from,
+	// which is what nebula ctl sends. Unknown names are an error, so a typo is reported
+	// instead of ignored.
+	Params map[string]any `json:"params,omitempty"`
+
+	// Args carries the command's operands, the values that are not options: the vpn
+	// address `print-tunnel` prints, for instance. They stay positional because the
+	// commands do not name them.
+	Args []string `json:"args,omitempty"`
+}
+
+// Response is the single JSON object a server sends back.
+type Response struct {
+	Version int `json:"version"`
+
+	// Result is what the request produced: the command's output, or the schema when the
+	// request was a Describe. It is a JSON value when the command emitted JSON, so a
+	// caller reads it directly instead of parsing a string that happens to contain JSON,
+	// and a JSON string otherwise.
+	//
+	// Whitespace inside a JSON result is not preserved: an envelope normalises it, and
+	// in an API it carries no meaning anyway. Formatting a result for a human to read is
+	// the client's job, which is what Pretty is for.
+	Result json.RawMessage `json:"result,omitempty"`
+
+	// Error explains a failure in a sentence, and is absent on success. It is the only
+	// signal a caller needs: there is no separate status field, because a command that
+	// failed always says why and one that succeeded never does. A command line client
+	// turns its presence into a non-zero exit.
+	Error string `json:"error,omitempty"`
+}
+
+// jsonOrString renders b as a JSON value when it is one, and as a JSON string when it is
+// not. A command writes bytes; when those bytes are JSON a caller reads them directly
+// rather than parsing a string that happens to contain JSON.
+func jsonOrString(b []byte) json.RawMessage {
+	if len(b) == 0 {
+		return nil
+	}
+
+	if json.Valid(b) {
+		return json.RawMessage(b)
+	}
+
+	quoted, err := json.Marshal(string(b))
+	if err != nil {
+		return nil
+	}
+
+	return quoted
+}
+
+// readFrame reads one frame, a newline terminated JSON object, from r into v. With
+// writeFrame it is where the framing rule lives, for both directions: a frame is a
+// single line, a line over max bytes is refused rather than buffered, and a final line
+// with no newline before EOF still counts. what names the frame in errors ("request", "response"); a
+// read failure is returned wrapped, so a caller can still see io.EOF or a timeout in it.
+func readFrame(r io.Reader, max int, what string, v any) error {
+	br := bufio.NewReader(io.LimitReader(r, int64(max)+1))
+
+	line, err := br.ReadBytes('\n')
+	if err != nil && (len(line) == 0 || !errors.Is(err, io.EOF)) {
+		// EOF after a partial line is the unterminated final line the rule allows, and
+		// an oversized line looks the same once the LimitReader cuts it off, so both go
+		// on to be judged by their length. Anything else is a failed read, whatever was
+		// buffered first: a deadline that lands mid frame is a timeout, not a parse
+		// error in half a document.
+		return fmt.Errorf("could not read %s: %w", what, err)
+	}
+
+	// The limit is on the body, not the frame, so the terminator does not count
+	// against it. A body that exceeds max is cut off by the LimitReader before its
+	// newline, so a missing terminator is also what an oversized line looks like.
+	body := bytes.TrimSuffix(line, []byte{'\n'})
+	if len(body) > max {
+		return fmt.Errorf("%s exceeded %d bytes", what, max)
+	}
+
+	if err := json.Unmarshal(body, v); err != nil {
+		return fmt.Errorf("could not parse %s: %w", what, err)
+	}
+
+	return nil
+}
+
+// writeFrame writes v to w as one frame: the JSON object and the newline that ends it.
+func writeFrame(w io.Writer, v any) error {
+	body, err := json.Marshal(v)
+	if err != nil {
+		return fmt.Errorf("could not encode frame: %w", err)
+	}
+
+	_, err = w.Write(append(body, '\n'))
+	return err
+}

+ 89 - 0
localapi/protocol_test.go

@@ -0,0 +1,89 @@
+package localapi
+
+import (
+	"bytes"
+	"fmt"
+	"io"
+	"net"
+	"strings"
+	"testing"
+
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+)
+
+// TestReadFrameLimitIsOnTheBody pins the framing rule at its edge: the limit bounds the
+// JSON body, so the newline that terminates the frame must not count against it.
+func TestReadFrameLimitIsOnTheBody(t *testing.T) {
+	// A JSON string whose encoded form is exactly n bytes, with or without a terminator.
+	body := func(n int) string {
+		return fmt.Sprintf(`"%s"`, strings.Repeat("a", n-2))
+	}
+
+	var v string
+
+	// Exactly at the limit, terminated: accepted.
+	v = ""
+	require.NoError(t, readFrame(bytes.NewReader([]byte(body(64)+"\n")), 64, "request", &v))
+	assert.Len(t, v, 62)
+
+	// Exactly at the limit, EOF instead of a newline: still accepted.
+	v = ""
+	require.NoError(t, readFrame(bytes.NewReader([]byte(body(64))), 64, "request", &v))
+	assert.Len(t, v, 62)
+
+	// One byte over: refused as oversized, not as a parse failure.
+	err := readFrame(bytes.NewReader([]byte(body(65)+"\n")), 64, "request", &v)
+	require.Error(t, err)
+	assert.Contains(t, err.Error(), "exceeded 64 bytes")
+}
+
+// stallingReader hands out its bytes and then fails with err, the shape of a peer that
+// sends part of a frame and then stops, until the deadline on the connection lands.
+type stallingReader struct {
+	data []byte
+	err  error
+}
+
+func (r *stallingReader) Read(p []byte) (int, error) {
+	if len(r.data) == 0 {
+		return 0, r.err
+	}
+	n := copy(p, r.data)
+	r.data = r.data[n:]
+	return n, nil
+}
+
+type timeoutError struct{}
+
+func (timeoutError) Error() string   { return "i/o timeout" }
+func (timeoutError) Timeout() bool   { return true }
+func (timeoutError) Temporary() bool { return true }
+
+// TestReadFramePartialLineThenReadErrorIsTheError: a read that fails after part of a
+// frame has arrived is a failed read, and the caller sees that error. The old behaviour
+// treated the fragment as the whole frame and reported a parse failure, so a client
+// that stalled mid request was told its JSON was malformed, and a daemon that stalled
+// mid response hid the timeout the client's error path looks for.
+func TestReadFramePartialLineThenReadErrorIsTheError(t *testing.T) {
+	var v map[string]any
+
+	err := readFrame(&stallingReader{data: []byte(`{"command": "hel`), err: timeoutError{}}, 1024, "request", &v)
+	require.Error(t, err)
+	var ne net.Error
+	require.ErrorAs(t, err, &ne, "the timeout is still visible through the wrapping: %v", err)
+	assert.True(t, ne.Timeout())
+	assert.NotContains(t, err.Error(), "parse", "a stalled peer is not a parse failure")
+
+	// EOF after an unterminated line is the one exception: a final line with no newline
+	// is still a frame.
+	v = nil
+	require.NoError(t, readFrame(&stallingReader{data: []byte(`{"command": "help"}`), err: io.EOF}, 1024, "request", &v))
+	assert.Equal(t, "help", v["command"])
+
+	// And an unterminated line that is EOF only because the limit cut it off is still
+	// reported as oversized.
+	err = readFrame(bytes.NewReader([]byte(strings.Repeat("a", 70)+"\n")), 64, "request", &v)
+	require.Error(t, err)
+	assert.Contains(t, err.Error(), "exceeded 64 bytes")
+}

+ 482 - 0
localapi/server.go

@@ -0,0 +1,482 @@
+package localapi
+
+import (
+	"bytes"
+	"context"
+	"encoding/json"
+	"errors"
+	"fmt"
+	"log/slog"
+	"math"
+	"net"
+	"runtime/debug"
+	"strconv"
+	"sync"
+	"time"
+
+	"github.com/slackhq/nebula/commands"
+)
+
+const (
+	// defaultMaxConns caps concurrent connections. Exceeding it is rejected rather than
+	// queued: a queue turns a load problem into a latency problem and hides it.
+	defaultMaxConns = 8
+
+	// readTimeout bounds how long a client may take to send its request line.
+	readTimeout = 10 * time.Second
+
+	// writeTimeout bounds a single write. A client that stops reading is dropped rather
+	// than waited on, so a paused terminal cannot pin daemon resources.
+	writeTimeout = 30 * time.Second
+
+	// defaultSlowAfter is how long a command may run before the daemon says so. A
+	// command holds a connection slot until it returns, and one that never returns
+	// holds it for good; after enough of those the socket answers only "busy". The
+	// warning is what tells an operator that from load, and names the command.
+	defaultSlowAfter = 30 * time.Second
+)
+
+// Server serves the read only portion of a command registry over a local socket.
+type Server struct {
+	l   *slog.Logger
+	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
+}
+
+// servedHere decides what this surface will run.
+//
+// Everything except the profiling commands, which stay on the ssh debug server. Profile
+// output is written on the host running nebula, under sshd.sandbox_dir, so a path typed
+// here would not mean what the person typing it expects; and the profiling commands are
+// one workflow, so splitting them would leave someone able to start a CPU profile in one
+// place and stop it only in another.
+func servedHere(c *commands.Command) bool {
+	return !c.ForProfiling
+}
+
+// notServedHere explains a refusal in terms an operator can act on, naming the surface
+// that will run the command instead.
+const notServedHere = "profiling is driven from the ssh debug server, where the sshd.sandbox_dir " +
+	"setting governs where profile output is written on the host running nebula"
+
+// NewServer returns a Server that will serve the commands in reg that this surface
+// accepts.
+//
+// reg is kept rather than filtered here, so a command registered on it after the server
+// is built is served too. Filtering happens per request, which is how the ssh server
+// treats the same registry, cloning it per session.
+func NewServer(l *slog.Logger, reg *commands.Registry) *Server {
+	return &Server{
+		l:         l,
+		src:       reg,
+		maxConns:  defaultMaxConns,
+		slowAfter: defaultSlowAfter,
+		// The same identity the client's owner check is made for, so the peer check
+		// cannot disagree with it, and so a test can stand in for it the same way.
+		euid: currentUID(),
+	}
+}
+
+// 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.
+func (s *Server) Rebind(ctx context.Context, path string) error {
+	s.mu.Lock()
+	defer s.mu.Unlock()
+
+	if s.running && s.path == path {
+		return nil
+	}
+
+	ln, err := listen(path)
+	if err != nil {
+		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.running = true
+	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)
+
+	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
+	}
+	s.unwatch = context.AfterFunc(ctx, s.Stop)
+}
+
+// registry returns the commands this surface serves, filtered from the source at the
+// time of the call. It is a few allocations per request, which a control socket that
+// sees a request every few seconds at most never notices.
+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.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
+// blocked forever on a channel nothing had filled. Callers hold s.mu.
+func (s *Server) semaphore() chan struct{} {
+	if s.sem == nil {
+		s.sem = make(chan struct{}, s.maxConns)
+	}
+	return s.sem
+}
+
+// Accept failures that are not the listener closing are retried after a pause that
+// doubles from acceptRetryMin up to acceptRetryMax, the same shape as net/http.Serve.
+const (
+	acceptRetryMin = 5 * time.Millisecond
+	acceptRetryMax = time.Second
+)
+
+// accept serves connections from ln until it is closed. sem is the limiter to acquire
+// from and release into; it is passed rather than read from s so the handlers spawned
+// here never touch s.sem without the lock.
+//
+// Only a closed listener ends the loop; Stop and Rebind are the two things that close
+// it. Every other error is retried: the process being out of descriptors (EMFILE,
+// ENFILE) or a client that hung up between the kernel's queue and us (ECONNABORTED)
+// are passing conditions, and giving up on them would leave a socket on disk that
+// nothing answers, with the reason buried in a log line from minutes or days earlier.
+func (s *Server) accept(ln net.Listener, sem chan struct{}) {
+	var delay time.Duration
+	for {
+		conn, err := ln.Accept()
+		if err != nil {
+			if errors.Is(err, net.ErrClosed) {
+				return
+			}
+
+			if ne, ok := err.(net.Error); ok && ne.Timeout() {
+				continue
+			}
+
+			if delay == 0 {
+				delay = acceptRetryMin
+			} else {
+				delay = min(delay*2, acceptRetryMax)
+			}
+			s.l.Warn("Local control socket could not accept a connection, retrying", "error", err, "retry_in", delay)
+			time.Sleep(delay)
+			continue
+		}
+		delay = 0
+
+		select {
+		case sem <- struct{}{}:
+		default:
+			// At capacity. Say so and hang up rather than holding the connection open.
+			// Logged too: a slot is held for as long as a command runs, and the slow
+			// command warning in serve says which ones are still running.
+			s.l.Warn("Local control socket is at capacity, refusing a connection", "max", s.maxConns)
+			s.reply(conn, &Response{Version: ProtocolVersion, Error: "local control socket is busy, try again"})
+			_ = conn.Close()
+			continue
+		}
+
+		go func() {
+			defer func() { <-sem }()
+			s.handle(conn)
+		}()
+	}
+}
+
+// handle serves one request. It recovers from panics in command callbacks: this
+// surface is present on every install and reachable by any local process running as
+// the daemon's user, so a malformed argument must cost a connection rather than the
+// overlay.
+func (s *Server) handle(conn net.Conn) {
+	defer conn.Close()
+
+	var args []string
+	defer func() {
+		if r := recover(); r != nil {
+			s.l.Error("Recovered from panic serving a local control command",
+				"panic", r,
+				"args", args,
+				"stack", string(debug.Stack()),
+			)
+			s.reply(conn, &Response{Version: ProtocolVersion, Error: "command failed"})
+		}
+	}()
+
+	// Where the platform can identify the caller, check it. Where it cannot, the
+	// endpoint is still protected by the socket's mode or the pipe's security
+	// descriptor, which listen() set before serving.
+	if peerCheckSupported {
+		uid, err := peerUID(conn)
+		if err != nil {
+			s.l.Warn("Refusing local control connection with unreadable credentials", "error", err)
+			return
+		}
+
+		// Access is restricted to the user who ran nebula. Root is allowed through
+		// because it can reach the daemon's memory regardless of what this check says.
+		if uid != s.euid && uid != 0 {
+			s.l.Warn("Refusing local control connection from another user", "peerUid", uid)
+			s.reply(conn, &Response{Version: ProtocolVersion, Error: "not permitted"})
+			return
+		}
+	}
+
+	_ = conn.SetReadDeadline(time.Now().Add(readTimeout))
+
+	req, err := readRequest(conn)
+	if err != nil {
+		s.reply(conn, &Response{Version: ProtocolVersion, Error: err.Error()})
+		return
+	}
+	args = req.Args
+
+	// No deadline while a command runs; the write deadline below is what bounds a client
+	// that stops reading.
+	_ = conn.SetReadDeadline(time.Time{})
+	_ = conn.SetWriteDeadline(time.Now().Add(writeTimeout))
+
+	s.reply(conn, s.serve(req))
+}
+
+// serve runs one request and describes the outcome.
+//
+// Nothing bounds a command's run; a context would have to reach every Run. What is
+// bounded is the silence: a command still running at slowAfter is named in the log, with
+// the slot it is holding, so a socket that has gone "busy" can be traced to the commands
+// that wedged it. The timer is stopped however the command ends, a panic included, so
+// the line is only ever written about a command that is still running.
+func (s *Server) serve(req *Request) *Response {
+	slow := time.AfterFunc(s.slowAfter, func() {
+		s.l.Warn("Local control command is still running and holding a connection slot",
+			"command", req.Command,
+			"args", req.Args,
+			"after", s.slowAfter,
+			"max", s.maxConns,
+		)
+	})
+	defer slow.Stop()
+
+	res := &Response{Version: ProtocolVersion}
+
+	if req.Describe {
+		schema, err := json.Marshal(s.registry().Schema())
+		if err != nil {
+			res.Error = err.Error()
+			return res
+		}
+
+		res.Result = schema
+		return res
+	}
+
+	if req.Command == "" {
+		res.Error = "no command given"
+		return res
+	}
+
+	params, err := stringParams(req.Params)
+	if err != nil {
+		res.Error = err.Error()
+		return res
+	}
+
+	cmd, err := s.registry().Resolve(commands.Name(req.Command))
+	if err != nil {
+		s.l.Debug("Local control command refused", "command", req.Command, "error", err)
+		res.Error = err.Error()
+		return res
+	}
+
+	out, errOut := &bytes.Buffer{}, &bytes.Buffer{}
+
+	// A command that failed may have explained itself on its error stream; if it did
+	// not, the returned error is the explanation.
+	if err := commands.Invoke(commands.Request{Command: cmd, Params: params, Args: req.Args}, commands.NewSplitStringWriter(out, errOut)); err != nil {
+		s.l.Debug("Local control command failed", "command", req.Command, "error", err)
+
+		// Whatever the command wrote is the better explanation; the returned error is the
+		// fallback for one that failed without saying so.
+		if errOut.Len() == 0 {
+			res.Error = err.Error()
+		}
+	}
+
+	if errOut.Len() > 0 {
+		res.Error = errOut.String()
+	}
+
+	// The command wrote bytes; carry them as a JSON value when they are one.
+	res.Result = jsonOrString(out.Bytes())
+	return res
+}
+
+// reply writes one response frame.
+func (s *Server) reply(conn net.Conn, res *Response) {
+	_ = conn.SetWriteDeadline(time.Now().Add(writeTimeout))
+	if err := writeFrame(conn, res); err != nil {
+		s.l.Debug("Could not send a local control response", "error", err)
+	}
+}
+
+// stringParams converts JSON's types into the strings flag.Value.Set expects, so a
+// caller writes true and 3 rather than "true" and "3".
+func stringParams(in map[string]any) (map[string]string, error) {
+	if len(in) == 0 {
+		return nil, nil
+	}
+
+	out := make(map[string]string, len(in))
+	for k, v := range in {
+		switch t := v.(type) {
+		case bool:
+			out[k] = strconv.FormatBool(t)
+		case string:
+			out[k] = t
+		case float64:
+			// encoding/json gives every number as a float64; render whole ones without
+			// a decimal point so an int flag accepts them.
+			if t != math.Trunc(t) {
+				out[k] = strconv.FormatFloat(t, 'f', -1, 64)
+				continue
+			}
+
+			// A whole float64 can be far outside int64, and Go's conversion of one that is
+			// gives an unspecified value rather than an error, so the bounds are checked
+			// first. MinInt64 is exactly representable and is in range. MaxInt64 is not:
+			// as a float64 it rounds up to 2^63, which is the first value out of range,
+			// hence the >= on that side.
+			if t < math.MinInt64 || t >= math.MaxInt64 {
+				return nil, fmt.Errorf("parameter %q is %s, outside the range of a 64-bit integer",
+					k, strconv.FormatFloat(t, 'g', -1, 64))
+			}
+			out[k] = strconv.FormatInt(int64(t), 10)
+		case nil:
+			return nil, fmt.Errorf("parameter %q has no value", k)
+		default:
+			return nil, fmt.Errorf("parameter %q is not a string, number or boolean", k)
+		}
+	}
+
+	return out, nil
+}
+
+func readRequest(conn net.Conn) (*Request, error) {
+	req := &Request{}
+	if err := readFrame(conn, MaxRequestBytes, "request", req); err != nil {
+		return nil, err
+	}
+
+	if req.Version != ProtocolVersion {
+		return nil, fmt.Errorf(
+			"this nebula speaks local control protocol v%d, the client sent v%d; the two binaries are different versions",
+			ProtocolVersion, req.Version,
+		)
+	}
+
+	return req, nil
+}

+ 726 - 0
localapi/server_test.go

@@ -0,0 +1,726 @@
+//go:build !windows
+
+package localapi
+
+import (
+	"bytes"
+	"context"
+	"encoding/json"
+	"errors"
+	"flag"
+	"fmt"
+	"math"
+	"net"
+	"os"
+	"path/filepath"
+	"sync"
+	"testing"
+	"time"
+
+	"github.com/slackhq/nebula/commands"
+	"github.com/slackhq/nebula/test"
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+	"golang.org/x/sys/unix"
+)
+
+// testSocket returns a short socket path whose directory does not exist yet and whose
+// parent is private: one level below the test's own directory, so that listen has a
+// directory of its own to create.
+func testSocket(t *testing.T) string {
+	t.Helper()
+	return filepath.Join(test.ShortTempDir(t), "n", "s.sock")
+}
+
+func serve(t *testing.T, reg *commands.Registry) (*Server, string) {
+	t.Helper()
+
+	path := testSocket(t)
+	s := NewServer(test.NewLogger(), reg)
+	require.NoError(t, s.Rebind(context.Background(), path))
+	t.Cleanup(s.Stop)
+
+	return s, path
+}
+
+// serveAt starts a server on an exact path, for tests that care where the socket is.
+func serveAt(t *testing.T, path string) (*Server, func()) {
+	t.Helper()
+
+	s := NewServer(test.NewLogger(), basicRegistry())
+	require.NoError(t, s.Rebind(context.Background(), path))
+	return s, s.Stop
+}
+
+// echoCommand reports its operands, for tests on either surface.
+func echoCommand() *commands.Command {
+	return &commands.Command{
+		Name:             "echo",
+		ShortDescription: "echoes its arguments",
+		Callback: func(fs any, a []string, w commands.StringWriter) error {
+			return w.Write(fmt.Sprintf("%v", a))
+		},
+	}
+}
+
+func basicRegistry() *commands.Registry {
+	reg := commands.New()
+	reg.Register(echoCommand())
+	reg.Register(&commands.Command{
+		Name:             "withheld",
+		ShortDescription: "a command this surface withholds",
+		ForProfiling:     true,
+		Callback: func(fs any, a []string, w commands.StringWriter) error {
+			return w.Write("mutated")
+		},
+	})
+	reg.Register(&commands.Command{
+		Name:             "boom",
+		ShortDescription: "panics",
+		Callback: func(fs any, a []string, w commands.StringWriter) error {
+			panic("this command is broken")
+		},
+	})
+	return reg
+}
+
+// run invokes one command and returns its output and its error text. An empty error is
+// success: the protocol has no separate status field.
+func run(t *testing.T, path, command string, args ...string) (string, string) {
+	t.Helper()
+
+	res, err := (&Client{Path: path}).Invoke(command, nil, args)
+	require.NoError(t, err)
+	return Text(res.Result), res.Error
+}
+
+func TestServeAllowedCommand(t *testing.T) {
+	_, path := serve(t, basicRegistry())
+
+	out, errOut := run(t, path, "echo", "a", "b")
+	assert.Equal(t, "[a b]", out)
+	assert.Empty(t, errOut, "a command that worked reports no error")
+}
+
+// TestWithheldCommandIsRefused checks the filter at the wire: the daemon refuses even
+// though the client named the command directly.
+func TestWithheldCommandIsRefused(t *testing.T) {
+	_, path := serve(t, basicRegistry())
+
+	out, errOut := run(t, path, "withheld")
+	assert.Empty(t, out)
+	assert.Contains(t, errOut, "withheld is not available on this surface")
+}
+
+// TestPanicInCommandDoesNotKillTheDaemon is ADR 003's requirement: a broken command
+// costs a connection, not the overlay.
+func TestPanicInCommandDoesNotKillTheDaemon(t *testing.T) {
+	_, path := serve(t, basicRegistry())
+
+	out, errOut := run(t, path, "boom")
+	assert.Empty(t, out)
+	assert.Contains(t, errOut, "command failed")
+
+	// The server is still serving, which is the point of the test.
+	out, errOut = run(t, path, "echo", "still", "here")
+	assert.Equal(t, "[still here]", out)
+	assert.Empty(t, errOut)
+}
+
+func TestSocketPermissions(t *testing.T) {
+	old := unix.Umask(0)
+	defer unix.Umask(old)
+
+	_, path := serve(t, basicRegistry())
+
+	fi, err := os.Stat(path)
+	require.NoError(t, err)
+	assert.Equal(t, os.FileMode(0600), fi.Mode().Perm(), "socket must not be reachable by other users")
+
+	di, err := os.Stat(filepath.Dir(path))
+	require.NoError(t, err)
+	assert.Equal(t, os.FileMode(0755), di.Mode().Perm(), "a directory we create ourselves is searchable, so ServedByRoot can see into it")
+}
+
+// TestSocketPermissionsUnderTightUmask: the modes are pinned, not left to the umask. A
+// 077 umask would make the directory 0700 and hide a root daemon's socket from an
+// unprivileged nebula ctl.
+func TestSocketPermissionsUnderTightUmask(t *testing.T) {
+	old := unix.Umask(0077)
+	defer unix.Umask(old)
+
+	_, path := serve(t, basicRegistry())
+
+	fi, err := os.Stat(path)
+	require.NoError(t, err)
+	assert.Equal(t, os.FileMode(0600), fi.Mode().Perm())
+
+	di, err := os.Stat(filepath.Dir(path))
+	require.NoError(t, err)
+	assert.Equal(t, os.FileMode(0755), di.Mode().Perm(), "the umask must not take the directory's searchability back")
+}
+
+func TestStaleSocketIsReplaced(t *testing.T) {
+	path := testSocket(t)
+	require.NoError(t, os.Mkdir(filepath.Dir(path), 0700))
+
+	// A hard kill leaves a socket file with nothing behind it.
+	ln, err := net.Listen("unix", path)
+	require.NoError(t, err)
+	require.NoError(t, ln.Close())
+	require.NoError(t, os.WriteFile(path, nil, 0600))
+	_ = os.Remove(path)
+	ln, err = net.Listen("unix", path)
+	require.NoError(t, err)
+	fl, ok := ln.(*net.UnixListener)
+	require.True(t, ok)
+	fl.SetUnlinkOnClose(false)
+	require.NoError(t, ln.Close())
+
+	_, err = os.Stat(path)
+	require.NoError(t, err, "test setup should leave a stale socket behind")
+
+	s := NewServer(test.NewLogger(), basicRegistry())
+	require.NoError(t, s.Rebind(context.Background(), path), "a stale socket must not block startup")
+	defer s.Stop()
+
+	out, _ := run(t, path, "echo", "ok")
+	assert.Equal(t, "[ok]", out)
+}
+
+func TestLiveSocketIsNotStolen(t *testing.T) {
+	first, path := serve(t, basicRegistry())
+	require.NotNil(t, first)
+
+	second := NewServer(test.NewLogger(), basicRegistry())
+	err := second.Rebind(context.Background(), path)
+	assert.ErrorContains(t, err, "already in use", "a live endpoint must not be unlinked by a second instance")
+}
+
+// TestLiveSocketIsNotStolenBeforeItsChmod: between an instance's bind and its chmod the
+// socket is at whatever mode the umask left. A second instance starting in that window
+// must still see it as live, not stale: only a refused connect proves nothing is behind
+// the file.
+func TestLiveSocketIsNotStolenBeforeItsChmod(t *testing.T) {
+	first, path := serve(t, basicRegistry())
+	require.NotNil(t, first)
+	require.NoError(t, os.Chmod(path, 0666))
+
+	second := NewServer(test.NewLogger(), basicRegistry())
+	err := second.Rebind(context.Background(), path)
+	require.ErrorContains(t, err, "already in use", "a live socket at any mode is not thereby dead")
+
+	_, err = os.Stat(path)
+	require.NoError(t, err, "the first instance's socket is still there")
+	require.NoError(t, os.Chmod(path, 0600))
+	out, _ := run(t, path, "echo", "ok")
+	assert.Equal(t, "[ok]", out, "and still served")
+}
+
+func TestRebindMovesTheSocket(t *testing.T) {
+	s, path := serve(t, basicRegistry())
+	next := filepath.Join(filepath.Dir(path), "n.sock")
+
+	require.NoError(t, s.Rebind(context.Background(), next))
+	assert.Equal(t, next, s.Path())
+
+	out, _ := run(t, next, "echo", "moved")
+	assert.Equal(t, "[moved]", out)
+
+	_, err := os.Stat(path)
+	assert.True(t, os.IsNotExist(err), "the old socket should be unlinked")
+}
+
+func TestRebindFailureKeepsTheOldSocket(t *testing.T) {
+	s, path := serve(t, basicRegistry())
+
+	err := s.Rebind(context.Background(), "/nonexistent-dir-for-tests/x.sock")
+	require.Error(t, err)
+	assert.Equal(t, path, s.Path(), "a bad path must leave the working endpoint alone")
+
+	out, _ := run(t, path, "echo", "ok")
+	assert.Equal(t, "[ok]", out)
+}
+
+// TestSemaphoreSurvivesRestart pins the rule that the connection limiter is made once. A
+// handler releases into the channel it acquired from, so if Rebind replaced it after a
+// Stop, a command still running across the restart would block forever on the new,
+// empty channel and leak its goroutine and connection.
+func TestSemaphoreSurvivesRestart(t *testing.T) {
+	s := NewServer(test.NewLogger(), basicRegistry())
+	path := testSocket(t)
+
+	require.NoError(t, s.Rebind(context.Background(), path))
+	first := s.sem
+	require.NotNil(t, first)
+	s.Stop()
+
+	require.NoError(t, s.Rebind(context.Background(), path))
+	defer s.Stop()
+	assert.Equal(t, first, s.sem, "Rebind after Stop must keep the same semaphore")
+
+	require.NoError(t, s.Rebind(context.Background(), testSocket(t)))
+	assert.Equal(t, first, s.sem, "Rebind must keep the same semaphore")
+}
+
+// TestRestartIsWatchedOnItsOwnContext: the ctx watcher is installed by the first bind of
+// a run and cleared by Stop, so a server stopped by one context's cancellation and
+// started again under another is stopped when that second context is cancelled, not
+// left serving until the process exits.
+func TestRestartIsWatchedOnItsOwnContext(t *testing.T) {
+	s := NewServer(test.NewLogger(), basicRegistry())
+	path := testSocket(t)
+
+	ctxA, cancelA := context.WithCancel(context.Background())
+	require.NoError(t, s.Rebind(ctxA, path))
+	cancelA()
+	require.Eventually(t, func() bool { return s.Path() == "" }, time.Second, 10*time.Millisecond, "the first context must stop the server")
+
+	ctxB, cancelB := context.WithCancel(context.Background())
+	require.NoError(t, s.Rebind(ctxB, path))
+	defer s.Stop()
+	assert.Equal(t, path, s.Path())
+
+	cancelB()
+	require.Eventually(t, func() bool { return s.Path() == "" }, time.Second, 10*time.Millisecond, "the second context must stop the server too")
+	_, err := os.Stat(path)
+	assert.True(t, os.IsNotExist(err), "the socket must be removed when the second context stops the server")
+}
+
+// TestStopDetachesTheWatcher: a server stopped by Stop and started again under another
+// context belongs to that context alone. The one that started the earlier run, ending
+// later, must not take the new run down with it.
+func TestStopDetachesTheWatcher(t *testing.T) {
+	s := NewServer(test.NewLogger(), basicRegistry())
+	path := testSocket(t)
+
+	ctxA, cancelA := context.WithCancel(context.Background())
+	t.Cleanup(cancelA)
+	require.NoError(t, s.Rebind(ctxA, path))
+	s.Stop()
+
+	ctxB, cancelB := context.WithCancel(context.Background())
+	t.Cleanup(cancelB)
+	require.NoError(t, s.Rebind(ctxB, path))
+	t.Cleanup(s.Stop)
+
+	cancelA()
+	time.Sleep(50 * time.Millisecond)
+	assert.Equal(t, path, s.Path(), "the first run's context has nothing to do with the second run")
+	_, err := (&Client{Path: path}).Describe()
+	require.NoError(t, err)
+
+	cancelB()
+	require.Eventually(t, func() bool { return s.Path() == "" }, time.Second, 10*time.Millisecond, "the second run's context stops it")
+}
+
+func TestVersionMismatchIsExplained(t *testing.T) {
+	_, path := serve(t, basicRegistry())
+
+	conn, err := net.Dial("unix", path)
+	require.NoError(t, err)
+	defer conn.Close()
+
+	body, _ := json.Marshal(Request{Version: ProtocolVersion + 1, Command: "echo"})
+	_, err = conn.Write(append(body, '\n'))
+	require.NoError(t, err)
+
+	res, err := readResponse(conn)
+	require.NoError(t, err)
+	assert.Contains(t, res.Error, "different versions")
+}
+
+func TestOversizedRequestIsRejected(t *testing.T) {
+	_, path := serve(t, basicRegistry())
+
+	conn, err := net.Dial("unix", path)
+	require.NoError(t, err)
+	defer conn.Close()
+
+	_, err = conn.Write(append(bytes.Repeat([]byte("a"), MaxRequestBytes+10), '\n'))
+	if err == nil {
+		res, rerr := readResponse(conn)
+		require.NoError(t, rerr)
+		assert.NotEmpty(t, res.Error)
+	}
+}
+
+// TestConcurrentClientsAreBounded checks that going over the connection cap is answered
+// rather than queued, so a burst of clients cannot pile up against the packet path.
+func TestConcurrentClientsAreBounded(t *testing.T) {
+	reg := commands.New()
+	release := make(chan struct{})
+	reg.Register(&commands.Command{
+		Name:             "block",
+		ShortDescription: "blocks until released",
+		Callback: func(fs any, a []string, w commands.StringWriter) error {
+			<-release
+			return w.Write("done")
+		},
+	})
+
+	s := NewServer(test.NewLogger(), reg)
+	s.maxConns = 2
+	path := testSocket(t)
+	require.NoError(t, s.Rebind(context.Background(), path))
+	defer s.Stop()
+
+	var wg sync.WaitGroup
+	for i := 0; i < 2; i++ {
+		wg.Add(1)
+		go func() {
+			defer wg.Done()
+			_, _ = (&Client{Path: path}).Invoke("block", nil, nil)
+		}()
+	}
+
+	// Give the two blockers time to occupy the slots.
+	time.Sleep(200 * time.Millisecond)
+
+	res, err := (&Client{Path: path}).Invoke("block", nil, nil)
+	require.NoError(t, err)
+	assert.Contains(t, res.Error, "busy")
+
+	close(release)
+	wg.Wait()
+}
+
+// flakyListener fails its first few Accepts the way a process that is out of file
+// descriptors does, then behaves.
+type flakyListener struct {
+	net.Listener
+	failures int
+}
+
+func (f *flakyListener) Accept() (net.Conn, error) {
+	if f.failures > 0 {
+		f.failures--
+		return nil, &net.OpError{Op: "accept", Net: "unix", Err: errors.New("too many open files")}
+	}
+	return f.Listener.Accept()
+}
+
+// TestAcceptSurvivesTransientErrors: an Accept that fails for a reason other than the
+// listener being closed is retried, so a moment at the descriptor limit does not take
+// the control socket down for the rest of the process. Closing the listener is still
+// what ends the loop.
+func TestAcceptSurvivesTransientErrors(t *testing.T) {
+	path := testSocket(t)
+	require.NoError(t, os.Mkdir(filepath.Dir(path), 0700))
+	ln, err := listen(path)
+	require.NoError(t, err)
+
+	s := NewServer(test.NewLogger(), basicRegistry())
+	done := make(chan struct{})
+	go func() {
+		defer close(done)
+		s.accept(&flakyListener{Listener: ln, failures: 3}, make(chan struct{}, 1))
+	}()
+
+	out, errText := run(t, path, "echo", "still", "here")
+	assert.Empty(t, errText)
+	assert.Equal(t, "[still here]", out)
+
+	require.NoError(t, ln.Close())
+	select {
+	case <-done:
+	case <-time.After(5 * time.Second):
+		t.Fatal("accept loop did not exit when the listener was closed")
+	}
+}
+
+func TestStopRemovesTheSocket(t *testing.T) {
+	s, path := serve(t, basicRegistry())
+	s.Stop()
+
+	_, err := os.Stat(path)
+	assert.True(t, os.IsNotExist(err))
+}
+
+func TestContextCancellationStops(t *testing.T) {
+	ctx, cancel := context.WithCancel(context.Background())
+	path := testSocket(t)
+
+	s := NewServer(test.NewLogger(), basicRegistry())
+	require.NoError(t, s.Rebind(ctx, path))
+
+	cancel()
+	assert.Eventually(t, func() bool {
+		_, err := os.Stat(path)
+		return os.IsNotExist(err)
+	}, 2*time.Second, 20*time.Millisecond)
+}
+
+// TestNamedParametersDriveFlags is the point of the protocol: a caller passes options by
+// name, with JSON's own types, and never assembles a command line.
+// TestStringParamsNumbers: JSON numbers arrive as float64. Whole ones render as integers
+// so int flags accept them, and a whole one outside int64 is refused rather than
+// converted, since Go's float-to-int conversion of an out-of-range value is unspecified
+// and would set a number the caller never sent.
+func TestStringParamsNumbers(t *testing.T) {
+	cases := map[string]struct {
+		in      float64
+		want    string
+		wantErr bool
+	}{
+		"whole":            {in: 3, want: "3"},
+		"negative whole":   {in: -1, want: "-1"},
+		"fraction":         {in: 1.5, want: "1.5"},
+		"min int64":        {in: math.MinInt64, want: "-9223372036854775808"},
+		"largest in range": {in: 9223372036854774784, want: "9223372036854774784"},
+		"2^63":             {in: 9223372036854775808, wantErr: true},
+		"1e19":             {in: 1e19, wantErr: true},
+		"below min int64":  {in: -9.3e18, wantErr: true},
+	}
+
+	for name, tc := range cases {
+		t.Run(name, func(t *testing.T) {
+			out, err := stringParams(map[string]any{"n": tc.in})
+			if tc.wantErr {
+				require.Error(t, err)
+				require.ErrorContains(t, err, `parameter "n" is `)
+				assert.ErrorContains(t, err, "outside the range of a 64-bit integer")
+				return
+			}
+			require.NoError(t, err)
+			assert.Equal(t, tc.want, out["n"])
+		})
+	}
+}
+
+func TestNamedParametersDriveFlags(t *testing.T) {
+	reg := commands.New()
+	reg.Register(&commands.Command{
+		Name:             "report",
+		ShortDescription: "reports its flags",
+		Flags: func() (*flag.FlagSet, any) {
+			fl := flag.NewFlagSet("", flag.ContinueOnError)
+			s := struct {
+				Verbose bool
+				Label   string
+			}{}
+			fl.BoolVar(&s.Verbose, "verbose", false, "say more")
+			fl.StringVar(&s.Label, "label", "none", "a label")
+			return fl, &s
+		},
+		Callback: func(fs any, a []string, w commands.StringWriter) error {
+			s := fs.(*struct {
+				Verbose bool
+				Label   string
+			})
+			return w.Write(fmt.Sprintf("verbose=%v label=%s args=%v", s.Verbose, s.Label, a))
+		},
+	})
+
+	_, path := serve(t, reg)
+
+	res, err := (&Client{Path: path}).Invoke("report",
+		map[string]string{"verbose": "true", "label": "here"},
+		[]string{"an-operand"},
+	)
+	require.NoError(t, err)
+
+	assert.Empty(t, res.Error)
+	assert.Equal(t, "verbose=true label=here args=[an-operand]", Text(res.Result))
+}
+
+// TestUnknownParameterIsReported: a misspelled parameter is an error rather than a
+// silently applied default, which is the difference between an API and a guess.
+func TestUnknownParameterIsReported(t *testing.T) {
+	reg := commands.New()
+	reg.Register(&commands.Command{
+		Name:             "report",
+		ShortDescription: "takes a flag",
+		Flags: func() (*flag.FlagSet, any) {
+			fl := flag.NewFlagSet("", flag.ContinueOnError)
+			fl.Bool("verbose", false, "say more")
+			return fl, nil
+		},
+		Callback: func(fs any, a []string, w commands.StringWriter) error { return nil },
+	})
+
+	_, path := serve(t, reg)
+
+	res, err := (&Client{Path: path}).Invoke("report", map[string]string{"verbos": "true"}, nil)
+	require.NoError(t, err)
+
+	assert.Contains(t, res.Error, `report has no parameter "verbos"`)
+}
+
+func TestParametersOnACommandThatTakesNoneAreReported(t *testing.T) {
+	_, path := serve(t, basicRegistry())
+
+	res, err := (&Client{Path: path}).Invoke("echo", map[string]string{"nope": "true"}, nil)
+	require.NoError(t, err)
+
+	assert.Contains(t, res.Error, "echo takes no parameters")
+}
+
+// TestJSONOutputIsAJSONValue: a command that emits JSON lands in the response as JSON,
+// not as a string a caller has to parse a second time.
+func TestJSONOutputIsAJSONValue(t *testing.T) {
+	reg := commands.New()
+	reg.Register(&commands.Command{
+		Name:             "emit",
+		ShortDescription: "emits json",
+		Callback: func(fs any, a []string, w commands.StringWriter) error {
+			return w.Write(`{"hosts":[1,2,3]}`)
+		},
+	})
+
+	_, path := serve(t, reg)
+
+	res, err := (&Client{Path: path}).Invoke("emit", nil, nil)
+	require.NoError(t, err)
+
+	var decoded struct {
+		Hosts []int `json:"hosts"`
+	}
+	require.NoError(t, json.Unmarshal(res.Result, &decoded))
+	assert.Equal(t, []int{1, 2, 3}, decoded.Hosts)
+}
+
+// TestTextOutputIsAJSONString: everything else round trips as a string, unchanged.
+func TestTextOutputIsAJSONString(t *testing.T) {
+	_, path := serve(t, basicRegistry())
+
+	res, err := (&Client{Path: path}).Invoke("echo", nil, []string{"hi"})
+	require.NoError(t, err)
+
+	var s string
+	require.NoError(t, json.Unmarshal(res.Result, &s))
+	assert.Equal(t, "[hi]", s)
+}
+
+func TestDescribeReportsFlagTypes(t *testing.T) {
+	_, path := serve(t, basicRegistry())
+
+	schema, err := (&Client{Path: path}).Describe()
+	require.NoError(t, err)
+
+	names := make([]string, 0, len(schema))
+	for _, c := range schema {
+		names = append(names, string(c.Name))
+	}
+
+	assert.Contains(t, names, "echo")
+	assert.NotContains(t, names, "withheld", "the schema advertises only what this surface serves")
+}
+
+// TestRebindAfterFailedBindStillStopsOnCancel covers a daemon whose initial listen
+// failed and which later Rebinds onto a good path from a reloaded config. The context
+// watcher has to be installed by that Rebind, or the socket outlives the daemon.
+func TestRebindAfterFailedBindStillStopsOnCancel(t *testing.T) {
+	ctx, cancel := context.WithCancel(context.Background())
+	defer cancel()
+
+	base := test.ShortTempDir(t)
+
+	// listen creates one level only, so a socket directory under a parent that is not
+	// there yet fails to bind, the shape of a runtime directory that does not exist.
+	s := NewServer(test.NewLogger(), basicRegistry())
+	require.Error(t, s.Rebind(ctx, filepath.Join(base, "missing", "n", "s.sock")))
+
+	good := filepath.Join(base, "n", "s.sock")
+	require.NoError(t, s.Rebind(ctx, good))
+	t.Cleanup(s.Stop)
+	_, err := (&Client{Path: good}).Describe()
+	require.NoError(t, err)
+
+	cancel()
+	assert.Eventually(t, func() bool {
+		_, err := os.Stat(good)
+		return os.IsNotExist(err)
+	}, 2*time.Second, 20*time.Millisecond)
+}
+
+// TestCommandRegisteredAfterStartIsServed: the server filters the registry per request,
+// so a command an embedder registers after wiring the socket is served, and described,
+// just as it would be over ssh.
+func TestCommandRegisteredAfterStartIsServed(t *testing.T) {
+	reg := basicRegistry()
+	_, path := serve(t, reg)
+
+	reg.Register(&commands.Command{
+		Name:             "late",
+		ShortDescription: "registered after the server started",
+		Callback: func(fs any, a []string, w commands.StringWriter) error {
+			return w.Write("late")
+		},
+	})
+
+	c := &Client{Path: path}
+
+	schema, err := c.Describe()
+	require.NoError(t, err)
+	names := make([]string, 0, len(schema))
+	for _, cs := range schema {
+		names = append(names, string(cs.Name))
+	}
+	assert.Contains(t, names, "late")
+
+	res, err := c.Invoke("late", nil, nil)
+	require.NoError(t, err)
+	require.Empty(t, res.Error)
+	assert.JSONEq(t, `"late"`, string(res.Result))
+}
+
+// TestRebindRefusesDuringShutdown: a reload that lands after the daemon's context is
+// cancelled must not bring the endpoint back. Stop is not terminal, so Rebind has to
+// take the answer from the context rather than from the running flag.
+func TestRebindRefusesDuringShutdown(t *testing.T) {
+	ctx, cancel := context.WithCancel(context.Background())
+	defer cancel()
+
+	// Built by hand rather than through serve: the context watcher is installed once,
+	// by the first bind, and this test needs it on a context it can cancel.
+	s := NewServer(test.NewLogger(), basicRegistry())
+	require.NoError(t, s.Rebind(ctx, testSocket(t)))
+	t.Cleanup(s.Stop)
+
+	cancel()
+	assert.Eventually(t, func() bool { return s.Path() == "" }, 2*time.Second, 20*time.Millisecond, "the watcher stops the server")
+
+	next := testSocket(t)
+	err := s.Rebind(ctx, next)
+	require.Error(t, err)
+	require.ErrorContains(t, err, "shutting down")
+	assert.Empty(t, s.Path(), "the server stays stopped")
+
+	_, statErr := os.Stat(next)
+	assert.True(t, os.IsNotExist(statErr), "and the refused socket is not left on disk")
+}
+
+// TestRebindAndStopSerialize hammers Rebind against Stop. Whichever runs last decides
+// the final state, so after the dust settles a Stop must leave nothing listening and no
+// socket file behind, on either path.
+func TestRebindAndStopSerialize(t *testing.T) {
+	a := testSocket(t)
+	b := testSocket(t)
+
+	for i := 0; i < 50; i++ {
+		s := NewServer(test.NewLogger(), basicRegistry())
+		require.NoError(t, s.Rebind(context.Background(), a))
+
+		var wg sync.WaitGroup
+		wg.Add(2)
+		go func() {
+			defer wg.Done()
+			_ = s.Rebind(context.Background(), b)
+		}()
+		go func() {
+			defer wg.Done()
+			s.Stop()
+		}()
+		wg.Wait()
+
+		// The Rebind may have run after the Stop, which restarts the server by design.
+		// A final Stop then has to take everything down.
+		s.Stop()
+		assert.Empty(t, s.Path())
+		for _, p := range []string{a, b} {
+			_, err := os.Stat(p)
+			assert.True(t, os.IsNotExist(err), "iteration %d: %s left behind", i, p)
+		}
+	}
+}

+ 122 - 0
localapi/wire.go

@@ -0,0 +1,122 @@
+package localapi
+
+import (
+	"context"
+	"errors"
+	"log/slog"
+
+	"github.com/slackhq/nebula/commands"
+	"github.com/slackhq/nebula/config"
+)
+
+// Wire brings up the local control socket for a running nebula and keeps it pointed at
+// the configured path across reloads.
+//
+// Every entry point that runs nebula as a daemon calls this, so the endpoint behaves 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 with no key to disable it,
+// so an embedder inside an app sandbox would have no way to decline 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 {
+	l = l.With("subsystem", "localapi")
+	srv := NewServer(l, reg)
+
+	// Registered before the first attempt, so that a path or bind that is bad at startup
+	// can be fixed with a config edit and a reload, the same as one that goes bad later,
+	// and so that a HUP landing in between is harmless: Rebind on a server already at
+	// the path is a no-op.
+	reconcile := followControlConfig(ctx, l, c, srv)
+	reconcile(c)
+	return srv
+}
+
+// followControlConfig registers the reload callback that keeps the socket where
+// local_api.control.socket says it is, moving it when the key changes and bringing one
+// up while there is none, and returns it for the first attempt to call.
+//
+// The key is reloadable because the client finds the socket by reading the config: if the
+// two could disagree, the file on disk would name a socket that is not there.
+//
+// Every reload is reconciled rather than gated on the key having changed. Rebind on a
+// server already at the path 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 fixed
+// outside the config too, so the reload that follows the fix is a plain one. That holds
+// for a move as much as for the first bind: a daemon left on its old path by a move that
+// 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) {
+		want, err := SocketPathFromConfig(c)
+		if err != nil {
+			if srv.Path() == "" {
+				l.Warn("Could not determine the local control socket path, control commands will not be available", "error", err)
+			} else {
+				l.Warn("Ignoring unusable local control socket path from reloaded config", "error", err)
+			}
+			return
+		}
+
+		reportRebind(l, srv.Path(), want, srv.Rebind(ctx, want))
+	}
+
+	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)
+			}
+		}()
+		reconcile(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
+// 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) {
+	switch {
+	case err == nil:
+		if old != "" && old != want {
+			l.Info("Moved the local control socket", "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)
+	default:
+		bindFailure(l, err)("Could not move the local control socket, keeping the current one",
+			"error", err, "current", old, "requested", want,
+		)
+	}
+}
+
+// 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
+// held by another administrator's process and an override outside the protected
+// namespace by any account's; on unix a path that is still being served is an earlier
+// nebula, which clearStale reports as in use.
+var errEndpointHeld = errors.New("the local control socket's name is held by another process")
+
+// bindFailure picks the level a failed bind is logged at. Nearly every failure is the
+// operator's environment, a missing runtime directory or a bad path, and a warning; a
+// name held by another process is logged as an error, since nothing else will say that
+// something is sitting where the daemon should be.
+func bindFailure(l *slog.Logger, err error) func(msg string, args ...any) {
+	if errors.Is(err, errEndpointHeld) {
+		return l.Error
+	}
+	return l.Warn
+}

+ 144 - 0
localapi/wire_test.go

@@ -0,0 +1,144 @@
+//go:build !windows
+
+package localapi
+
+import (
+	"context"
+	"fmt"
+	"os"
+	"path/filepath"
+	"strings"
+	"testing"
+
+	"github.com/slackhq/nebula/config"
+	"github.com/slackhq/nebula/test"
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+)
+
+// TestWireRecoversFromBadPathAtStartup covers a socket path that is unusable when nebula
+// starts. The operator's fix is a config edit and a reload, so the reload has to be able
+// to bring up a socket that never existed, not only move one that does.
+func TestWireRecoversFromBadPathAtStartup(t *testing.T) {
+	ctx, cancel := context.WithCancel(context.Background())
+	t.Cleanup(cancel)
+
+	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())
+	t.Cleanup(srv.Stop)
+	assert.Empty(t, srv.Path(), "an over-long path must not produce a socket")
+
+	good := testSocket(t)
+	require.NoError(t, c.ReloadConfigString(fmt.Sprintf("local_api:\n  control:\n    socket: %s\n", good)).Err)
+
+	assert.Equal(t, good, srv.Path())
+	out, errText := run(t, good, "echo", "hi")
+	assert.Empty(t, errText)
+	assert.Equal(t, "[hi]", out)
+}
+
+// TestRebindAfterReloadBoundTheSocket covers a HUP that lands inside Wire, after the
+// reload callback is registered and before the first attempt runs. The callback does the
+// first bind; the attempt must then find the socket it was about to create already up
+// and leave it, not fail or rebind it, which would have Wire warn that commands are
+// unavailable while they are.
+func TestRebindAfterReloadBoundTheSocket(t *testing.T) {
+	ctx, cancel := context.WithCancel(context.Background())
+	t.Cleanup(cancel)
+
+	path := testSocket(t)
+	cfg := fmt.Sprintf("local_api:\n  control:\n    socket: %s\n", path)
+	c := config.NewC(test.NewLogger())
+	require.NoError(t, c.LoadString(cfg))
+
+	srv := NewServer(test.NewLogger(), basicRegistry())
+	t.Cleanup(srv.Stop)
+
+	// Wire's first half, then the HUP.
+	followControlConfig(ctx, test.NewLogger(), c, srv)
+	require.NoError(t, c.ReloadConfigString(cfg).Err)
+	require.Equal(t, path, srv.Path(), "the reload callback does the first bind")
+
+	// Wire's second half.
+	require.NoError(t, srv.Rebind(ctx, path))
+	assert.Equal(t, path, srv.Path())
+	out, errText := run(t, path, "echo", "hi")
+	assert.Empty(t, errText)
+	assert.Equal(t, "[hi]", out)
+}
+
+// TestWireRetriesAFailedMoveOnPlainReload covers a move whose bind fails for a reason
+// outside the config: the key names a path under a runtime directory that is not there
+// yet. The daemon keeps the old socket, but the client reads the new path from the same
+// file and cannot reach it, so the operator creates the directory and reloads. The key
+// has not changed since the move was asked for, and the reload still has to make it.
+func TestWireRetriesAFailedMoveOnPlainReload(t *testing.T) {
+	ctx, cancel := context.WithCancel(context.Background())
+	t.Cleanup(cancel)
+
+	section := func(path string) string {
+		return fmt.Sprintf("local_api:\n  control:\n    socket: %s\n", path)
+	}
+	first := testSocket(t)
+	c := config.NewC(test.NewLogger())
+	require.NoError(t, c.LoadString(section(first)))
+
+	srv := Wire(ctx, test.NewLogger(), c, basicRegistry())
+	t.Cleanup(srv.Stop)
+	require.Equal(t, first, srv.Path())
+
+	runtimeDir := filepath.Join(privateBase(t), "run")
+	moved := filepath.Join(runtimeDir, DirName, "s.sock")
+	require.NoError(t, c.ReloadConfigString(section(moved)).Err)
+	assert.Equal(t, first, srv.Path(), "a move that cannot bind keeps the socket it had")
+
+	require.NoError(t, os.Mkdir(runtimeDir, 0700))
+	require.NoError(t, c.ReloadConfigString(section(moved)).Err)
+	require.False(t, c.HasChanged(CtlSocketKey), "the config is the same; the fix was on disk")
+
+	assert.Equal(t, moved, srv.Path())
+	_, err := os.Stat(first)
+	assert.True(t, os.IsNotExist(err), "the old socket is given up once the move binds")
+	out, errText := run(t, moved, "echo", "hi")
+	assert.Empty(t, errText)
+	assert.Equal(t, "[hi]", out)
+}
+
+// TestWireRecoversOnPlainReloadAfterBadBind covers a path that is fine but a bind that
+// is not: the runtime directory is missing when nebula starts. The fix is made outside
+// the config, so the config does not change; a plain reload still has to bring the
+// socket up.
+func TestWireRecoversOnPlainReloadAfterBadBind(t *testing.T) {
+	ctx, cancel := context.WithCancel(context.Background())
+	t.Cleanup(cancel)
+
+	base := privateBase(t)
+	runtimeDir := filepath.Join(base, "run")
+	path := filepath.Join(runtimeDir, DirName, "s.sock")
+	cfg := fmt.Sprintf("local_api:\n  control:\n    socket: %s\n", path)
+
+	c := config.NewC(test.NewLogger())
+	require.NoError(t, c.LoadString(cfg))
+
+	srv := Wire(ctx, test.NewLogger(), c, basicRegistry())
+	t.Cleanup(srv.Stop)
+	assert.Empty(t, srv.Path(), "listen creates one level only, so a missing runtime dir fails the bind")
+
+	require.NoError(t, os.Mkdir(runtimeDir, 0700))
+	require.NoError(t, c.ReloadConfigString(cfg).Err)
+	require.False(t, c.HasChanged(CtlSocketKey), "the config is the same; the fix was on disk")
+
+	assert.Equal(t, path, srv.Path())
+	out, errText := run(t, path, "echo", "hi")
+	assert.Empty(t, errText)
+	assert.Equal(t, "[hi]", out)
+
+	// And a reload with a socket up and no change in the key is still a no-op.
+	require.NoError(t, c.ReloadConfigString(cfg).Err)
+	assert.Equal(t, path, srv.Path())
+	out, errText = run(t, path, "echo", "still")
+	assert.Empty(t, errText)
+	assert.Equal(t, "[still]", out)
+}

+ 40 - 0
ssh_commands_test.go

@@ -10,6 +10,7 @@ import (
 	"github.com/slackhq/nebula/commands"
 	"github.com/slackhq/nebula/config"
 	"github.com/slackhq/nebula/test"
+	"github.com/stretchr/testify/assert"
 )
 
 // TestSSHCommandSurface is a characterization test for the ssh command surface.
@@ -55,3 +56,42 @@ func renderCommandSurface(cmds []*commands.Command) string {
 	// In the order All gives, so the golden pins display order too.
 	return strings.Join(lines, "")
 }
+
+// TestLocalSocketSurface pins which of nebula's commands the always-on local socket
+// serves. Commands are served by default, so this test is what turns "somebody forgot to
+// think about it" into a visible decision: adding a command fails here until the lists
+// below say where it belongs.
+func TestLocalSocketSurface(t *testing.T) {
+	reg := registeredCommands(t)
+
+	var served, withheld []commands.Name
+	for _, c := range reg.All() {
+		if c.ForProfiling {
+			withheld = append(withheld, c.Name)
+		} else {
+			served = append(served, c.Name)
+		}
+	}
+
+	// Nothing is withheld today: the profiling commands, which were, are gone.
+	assert.Empty(t, withheld)
+
+	assert.ElementsMatch(t, []commands.Name{
+		commands.Help,
+		commands.ListHostmap,
+		commands.ListPendingHostmap,
+		commands.ListLighthouseAddrmap,
+		commands.PrintCert,
+		commands.PrintTunnel,
+		commands.PrintRelays,
+		commands.DeviceInfo,
+		commands.Version,
+		commands.QueryLighthouse,
+		commands.Reload,
+		commands.LogLevel,
+		commands.LogFormat,
+		commands.ChangeRemote,
+		commands.CloseTunnel,
+		commands.CreateTunnel,
+	}, served)
+}

+ 1 - 0
ssh_reload_test.go

@@ -26,6 +26,7 @@ func (w *lineWriter) WriteLine(s string) error {
 func (w *lineWriter) Write(s string) error      { return w.WriteLine(s) }
 func (w *lineWriter) WriteBytes(b []byte) error { return w.WriteLine(string(b)) }
 func (w *lineWriter) GetWriter() io.Writer      { return io.Discard }
+func (w *lineWriter) Err() io.Writer            { return io.Discard }
 
 func TestReloadCommandReportsTheResult(t *testing.T) {
 	dir := t.TempDir()

+ 12 - 0
test/helpers.go

@@ -10,6 +10,18 @@ import (
 	"github.com/stretchr/testify/require"
 )
 
+// ShortTempDir returns a short private directory of the test's own, removed when the
+// test ends. t.TempDir embeds the test name, which on darwin routinely pushes a unix
+// socket path laid out under it past the 104 byte sun_path limit.
+func ShortTempDir(t *testing.T) string {
+	t.Helper()
+
+	dir, err := os.MkdirTemp("", "la")
+	require.NoError(t, err)
+	t.Cleanup(func() { _ = os.RemoveAll(dir) })
+	return dir
+}
+
 // Update is the -update flag: Golden rewrites its file instead of comparing against it.
 var Update = flag.Bool("update", false, "rewrite golden files instead of comparing against them")