Files
nebula/cmd/internal/ctl/ctl.go
Matt RichardsonandClaude Fable 5.1 0f767ce529 Add a local control socket and nebula ctl (#1975)
Add support for accessing the previously ssh-only debug commands via
`nebula ctl <command>.`

- unix socket (named pipe on windows) for communication
- sockets scoped to the user + an optional group
- in cmd/nebula and cmd/nebula-service, but not nebula.Main

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

421 lines
16 KiB
Go

package ctl
import (
"bytes"
"encoding/json"
"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{"-" + commands.FlagConfig, 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(commands.FlagConfig, "", "Path to the config of the running nebula, used to locate its control socket")
socketPath := fs.String(commands.FlagSocket, "", "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, or as a member of the group %s names.\n\n", localapi.CtlGroupKey)
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, 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, and what it said, or that it could not be
// read, is the whole message.
var refused *localapi.DaemonError
var unread *localapi.ReplyError
if errors.As(err, &refused) || errors.As(err, &unread) {
fmt.Fprintf(os.Stderr, "nebula: %s\n", err)
return 1
}
// Otherwise the operating system said why, and it is passed on as it came: a
// permission denied or a missing socket says more about what to do than a
// guess at the operator's setup would.
fmt.Fprintf(os.Stderr, "nebula: could not reach the control socket at %s: %s\n", path, err)
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, wireFormat(req.Format))
if err != nil {
fmt.Fprintf(os.Stderr, "nebula: %s\n", err)
return 1
}
out := output(res, req.Format)
if out != "" {
fmt.Fprint(os.Stdout, out)
if !strings.HasSuffix(out, "\n") {
fmt.Fprintln(os.Stdout)
}
}
// Error set is the one failure signal, whether the request never ran or the command
// ran and refused. A refusal asked for in JSON has already gone to stdout as the
// {ok, reason} object, and is not said again on stderr; the exit status carries it.
if res.Error == "" {
return 0
}
if len(res.Result) == 0 && len(res.Text) == 0 {
msg := res.Error
if !strings.HasSuffix(msg, "\n") {
msg += "\n"
}
fmt.Fprint(os.Stderr, msg)
}
return 1
}
// output renders what the daemon answered for the format the line asked for. A text
// request is answered in Text with the bytes the command wrote, printed as they are; a
// JSON request is answered in Result with the value itself, printed as it came, since a
// value that happens to be a string is still JSON to the script reading it. 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. The bytes are re-indented rather
// than decoded and encoded again, which would sort the keys and round the numbers.
func output(res *localapi.Response, format commands.Format) string {
switch format {
case commands.FormatJSON:
return string(res.Result)
case commands.FormatPrettyJSON:
var buf bytes.Buffer
if err := json.Indent(&buf, res.Result, "", " "); err != nil {
return string(res.Result)
}
return buf.String()
default:
return string(res.Text)
}
}
// 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, and its Format is
// FormatUnset when the line named none, which to nebula ctl means text: its default
// differs from the API's.
type ctlRequest struct {
name commands.Name
commands.Request
}
// wireFormat is what a request asks the daemon for. Pretty is the client's business,
// since whitespace does not survive a JSON envelope, so it travels as json. No
// preference is text, nebula ctl's default: a person gets the human rendering wherever
// one exists.
func wireFormat(f commands.Format) string {
switch f {
case commands.FormatJSON, commands.FormatPrettyJSON:
return "json"
default:
return "text"
}
}
// 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 ParseArgs, and ParseLine reads what was set into the Request to send, 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
}
// 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)
}
operands, err := commands.ParseArgs(fs, argv[1:])
if err != nil {
if errors.Is(err, flag.ErrHelp) {
// The same rendering the daemon uses for `help <command>`, so -h and help agree.
_ = commands.WriteCommandHelp(*found, os.Stdout)
return ctlRequest{}, true, 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, and the presentation flags are the
// client's to interpret, never sent.
//
// Where the command has no human form the daemon says so, and only once the command
// has run: a refusal ("No vpn address was provided") always has a human form, so
// telling the user to pass -json before finding out whether the command would even
// accept the line sends them to fix the wrong thing. That is why the schema does not
// say which commands have a human form: nothing should decide on it before running.
return ctlRequest{name: name, Request: commands.ParseLine(fs, operands)}, 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, 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 reserved in the commands package and no command can give
// one a value that would be lifted from 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 := commands.LookupArg(fs, a); f != nil && !hasValue && !commands.IsBoolFlag(f) && i+1 < len(args) {
i++
own = append(own, args[i])
}
continue
}
if len(a) >= 2 && a[0] == '-' {
// The same reading of the argument ParseArgs makes, so a word this lifts as a
// client flag's value is one the daemon's parser would have read the same way.
if f, hasValue := commands.LookupArg(fs, a); f != nil {
own = append(own, a)
if !hasValue && !commands.IsBoolFlag(f) && i+1 < len(args) {
i++
own = append(own, args[i])
}
continue
}
}
cmd = append(cmd, a)
}
return own, cmd
}
// 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.
func resolveSocket(socketFlag, configFlag string) (string, 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.
//
// 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. A guess that cannot be read is not an error, it is no
// guess: a member of the socket's group cannot read a root daemon's config, and is
// answered by the default path, where the daemon serves unless that config moved it.
// A config the operator named is theirs to make readable.
func pathFromConfig(configFlag string) (string, error) {
return socketFromConfigFile(configFlag, configFlag != "")
}
func socketFromConfigFile(configPath string, named bool) (string, error) {
if !named {
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 {
if !named {
return localapi.DefaultSocketPath, nil
}
// The operating system's error, as it came: permission denied on a root daemon's
// config says what to do.
return "", fmt.Errorf("nebula: could not read %s: %w", configPath, err)
}
// A daemon running this config serves no socket; dialing the path would only say
// that nothing is there.
if !localapi.ControlEnabled(c) {
return "", fmt.Errorf("nebula: the control socket is disabled in %s by %s", configPath, localapi.CtlEnabledKey)
}
// 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: %w", err)
}
return p, nil
}