mirror of
https://github.com/slackhq/nebula.git
synced 2026-09-30 03:16:38 +02:00
Every diagnostic command nebula has was reachable through exactly one door:
the built-in ssh debug server. That server is off by default, and turning it
on means generating a host key, writing an sshd block with authorized public
keys, and SIGHUPing the daemon. That is a lot of ceremony to answer "what
version is this node running".
Nebula now serves the same commands over a local unix socket, enabled by
default, and `nebula ctl <command>` runs them. The socket lives in a 0700
directory so filesystem permissions are the access control; no keys, nothing
on the network. Failing to create it is logged and never blocks startup.
The command registry was already transport neutral, so this is mostly new
transport rather than new commands:
- diag/ holds the registry, dispatch, writer and wire protocol, moved out
of sshd because none of it was ever about ssh. sshd and ctl.go dispatch
against one shared registry.
- commands.go holds every command implementation, moved out of ssh.go
(which was 85% not ssh) and renamed off the ssh prefix. Adding a command
there makes it available over both transports.
- ssh.go keeps only host keys, authorized users, and the listen address.
- ctl.go supervises the socket, following the statsServer lifecycle shape.
The wire protocol frames the response rather than terminating it, because
print-cert -raw and list-hostmap -json both emit arbitrary bytes that no
sentinel could safely delimit. argv travels as a list so quoting survives.
Exit statuses are real: 0, 2 for usage, 127 for an unknown command.
Two things fall out. The ssh console now reports a real exit status instead
of a hardcoded zero, so `ssh host list-hostmap` is scriptable too. And eight
command callbacks that silently returned nil on a flags type mismatch now
report it, which the exit status makes visible.
Windows is a stub returning a clear "not supported" until it gets a named
pipe with a security descriptor; iOS and Android are never enabled, having no
daemon for a CLI to attach to.
Breaking for embedders of the sshd package: NewSSHServer takes a
*diag.Registry, SSHServer.RegisterCommand is gone in favor of registering on
that registry, and the command types live in diag rather than sshd.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014fya5fTXGiwX72FUmoL9y3
126 lines
3.8 KiB
Go
126 lines
3.8 KiB
Go
package diag
|
|
|
|
import (
|
|
"fmt"
|
|
"sync"
|
|
|
|
"github.com/anmitsu/go-shlex"
|
|
"github.com/armon/go-radix"
|
|
)
|
|
|
|
// Registry is the set of commands nebula exposes for debugging and administration. It is
|
|
// transport neutral: the ssh console and the `nebula ctl` unix socket dispatch against the
|
|
// same registry, and neither knows the other exists.
|
|
//
|
|
// Registration is expected to happen once during startup, before any transport is serving,
|
|
// but the lock makes a late RegisterCommand safe rather than a data race waiting to happen.
|
|
type Registry struct {
|
|
mu sync.RWMutex
|
|
commands *radix.Tree
|
|
}
|
|
|
|
// NewRegistry returns a registry containing only `help`. Everything else is attached by
|
|
// the caller, see attachCommands in the nebula package.
|
|
func NewRegistry() *Registry {
|
|
r := &Registry{commands: radix.New()}
|
|
|
|
r.RegisterCommand(&Command{
|
|
Name: "help",
|
|
ShortDescription: "prints available commands or help <command> for specific usage info",
|
|
Callback: func(a any, args []string, w StringWriter) error {
|
|
return r.help(args, w)
|
|
},
|
|
})
|
|
|
|
return r
|
|
}
|
|
|
|
// RegisterCommand adds a command that a user can run.
|
|
func (r *Registry) RegisterCommand(c *Command) {
|
|
r.mu.Lock()
|
|
defer r.mu.Unlock()
|
|
r.commands.Insert(c.Name, c)
|
|
}
|
|
|
|
// Clone returns an independent copy sharing no tree with the original. The ssh session uses
|
|
// this so the `logout` command it adds for itself is invisible to every other session, and
|
|
// to `nebula ctl`.
|
|
func (r *Registry) Clone() *Registry {
|
|
r.mu.RLock()
|
|
defer r.mu.RUnlock()
|
|
return &Registry{commands: radix.NewFromMap(r.commands.ToMap())}
|
|
}
|
|
|
|
// Match returns every registered command name carrying the given prefix, for tab completion.
|
|
func (r *Registry) Match(prefix string) []string {
|
|
r.mu.RLock()
|
|
defer r.mu.RUnlock()
|
|
return matchCommand(r.commands, prefix)
|
|
}
|
|
|
|
// Dispatch splits line the way a shell would and runs the result. The ssh console uses this
|
|
// because a terminal only ever hands it a line; a transport that already has a real argv
|
|
// should call DispatchArgs instead rather than round tripping through a quoting parser.
|
|
func (r *Registry) Dispatch(line string, w StringWriter) error {
|
|
args, err := shlex.Split(line, true)
|
|
if err != nil {
|
|
if wErr := w.WriteLine(fmt.Sprintf("Unable to parse command: %s", err)); wErr != nil {
|
|
return wErr
|
|
}
|
|
return err
|
|
}
|
|
|
|
return r.DispatchArgs(args, w)
|
|
}
|
|
|
|
// DispatchArgs runs args[0] with args[1:] as its arguments, writing everything the command
|
|
// produces to w. An empty args dumps the command list, matching what an empty line does on
|
|
// the ssh console.
|
|
//
|
|
// Callbacks report user facing problems as prose on w and return nil by convention, so a
|
|
// non-nil error here means the command could not be run at all: ErrUnknownCommand, an
|
|
// ErrUsage wrapped flag failure, or an internal failure a callback chose to surface.
|
|
func (r *Registry) DispatchArgs(args []string, w StringWriter) error {
|
|
if len(args) == 0 {
|
|
r.mu.RLock()
|
|
defer r.mu.RUnlock()
|
|
dumpCommands(r.commands, w)
|
|
return nil
|
|
}
|
|
|
|
r.mu.RLock()
|
|
cmd, err := lookupCommand(r.commands, args[0])
|
|
r.mu.RUnlock()
|
|
if err != nil {
|
|
if wErr := w.WriteLine(fmt.Sprintf("Command lookup failed: %s", err)); wErr != nil {
|
|
return wErr
|
|
}
|
|
return err
|
|
}
|
|
|
|
if cmd == nil {
|
|
if wErr := w.WriteLine(fmt.Sprintf("Did not understand: %s", args[0])); wErr != nil {
|
|
return wErr
|
|
}
|
|
r.mu.RLock()
|
|
defer r.mu.RUnlock()
|
|
dumpCommands(r.commands, w)
|
|
return fmt.Errorf("%w: %s", ErrUnknownCommand, args[0])
|
|
}
|
|
|
|
// -h and -help anywhere in the arguments mean the user wants to know how the command
|
|
// works, not to run it.
|
|
if checkHelpArgs(args) {
|
|
return r.help([]string{cmd.Name}, w)
|
|
}
|
|
|
|
return execCommand(cmd, args[1:], w)
|
|
}
|
|
|
|
// help renders the command list, or one command's usage, onto w.
|
|
func (r *Registry) help(args []string, w StringWriter) error {
|
|
r.mu.RLock()
|
|
defer r.mu.RUnlock()
|
|
return helpCallback(r.commands, args, w)
|
|
}
|