mirror of
https://github.com/slackhq/nebula.git
synced 2026-10-05 08:06:37 +02:00
Add nebula ctl, a local socket for the debug commands
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
This commit is contained in:
co-authored by
Claude Opus 5
parent
89178f45ba
commit
14a4d87faf
@@ -0,0 +1,131 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"log/slog"
|
||||
"os"
|
||||
"syscall"
|
||||
|
||||
"github.com/slackhq/nebula/config"
|
||||
"github.com/slackhq/nebula/diag"
|
||||
)
|
||||
|
||||
// ctlMain implements `nebula ctl <command> [args...]`, which runs a debug command against the
|
||||
// nebula already running on this host. Everything after the command name is forwarded to that
|
||||
// nebula verbatim and parsed there by the same flag sets the ssh console uses, so this side
|
||||
// deliberately understands as little as possible about it.
|
||||
//
|
||||
// Returns the process exit status.
|
||||
func ctlMain(argv []string) int {
|
||||
fl := flag.NewFlagSet("nebula ctl", flag.ContinueOnError)
|
||||
fl.Usage = func() {
|
||||
out := fl.Output()
|
||||
fmt.Fprintf(out, "Usage: nebula ctl [-config path] [-socket path] <command> [arguments]\n\n")
|
||||
fmt.Fprintf(out, "Runs a debug command against the running nebula on this host, over its local\n")
|
||||
fmt.Fprintf(out, "control socket. Run `nebula ctl` with no command for the list of commands.\n\n")
|
||||
fl.PrintDefaults()
|
||||
}
|
||||
|
||||
socket := fl.String("socket", "", "Path to the control socket. Overrides ctl.socket from the config")
|
||||
configPath := fl.String("config", "", "Path to the nebula config, read only to find ctl.socket")
|
||||
|
||||
// The flag package stops at the first non-flag argument, which is exactly the behaviour
|
||||
// wanted here: `nebula ctl -socket /x list-hostmap -json` consumes -socket, stops at
|
||||
// list-hostmap, and leaves the rest untouched for the daemon to parse.
|
||||
if err := fl.Parse(argv); err != nil {
|
||||
// -h is a request, not a failure.
|
||||
if errors.Is(err, flag.ErrHelp) {
|
||||
return diag.StatusOK
|
||||
}
|
||||
return diag.StatusUsage
|
||||
}
|
||||
|
||||
path := *socket
|
||||
if path == "" {
|
||||
path = ctlSocketPath(*configPath)
|
||||
}
|
||||
|
||||
if path == "" {
|
||||
fmt.Fprintln(os.Stderr, "nebula ctl: no control socket path is known for this platform, set ctl.socket in the config")
|
||||
return diag.StatusError
|
||||
}
|
||||
|
||||
client, err := diag.Dial(path)
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, ctlDialError(path, err))
|
||||
return diag.StatusError
|
||||
}
|
||||
defer client.Close()
|
||||
|
||||
args := fl.Args()
|
||||
status, err := client.Run(args, os.Stdout)
|
||||
if err != nil {
|
||||
if errors.Is(err, diag.ErrTruncated) {
|
||||
fmt.Fprintf(os.Stderr, "nebula ctl: nebula closed the connection before %s finished\n", ctlCommandName(args))
|
||||
return diag.StatusError
|
||||
}
|
||||
|
||||
fmt.Fprintf(os.Stderr, "nebula ctl: %s\n", err)
|
||||
if status == diag.StatusOK {
|
||||
return diag.StatusError
|
||||
}
|
||||
}
|
||||
|
||||
return status
|
||||
}
|
||||
|
||||
// ctlSocketPath finds the socket to talk to. The platform default is the primary mechanism;
|
||||
// reading the config is the refinement for someone who moved the socket. It is best effort by
|
||||
// design, because config.DefaultPath resolves next to the nebula binary and a packaged install
|
||||
// keeps its config somewhere else entirely, so a config we cannot find is the normal case
|
||||
// rather than a failure.
|
||||
func ctlSocketPath(configPath string) string {
|
||||
if configPath == "" {
|
||||
p, err := config.DefaultPath()
|
||||
if err != nil {
|
||||
return diag.DefaultSocketPath()
|
||||
}
|
||||
configPath = p
|
||||
}
|
||||
|
||||
c := config.NewC(slog.New(slog.DiscardHandler))
|
||||
if err := c.Load(configPath); err != nil {
|
||||
return diag.DefaultSocketPath()
|
||||
}
|
||||
|
||||
return c.GetString("ctl.socket", diag.DefaultSocketPath())
|
||||
}
|
||||
|
||||
// ctlDialError turns a connect failure into something an operator can act on. These messages
|
||||
// are the entire user experience when things are not working, so they name the path and say
|
||||
// what to check.
|
||||
func ctlDialError(path string, err error) string {
|
||||
switch {
|
||||
case errors.Is(err, diag.ErrNotSupported):
|
||||
return "nebula ctl is not supported on this platform yet"
|
||||
|
||||
case errors.Is(err, fs.ErrNotExist):
|
||||
return fmt.Sprintf("nebula ctl: no control socket at %s. Is nebula running? Is ctl.enabled set to false, or ctl.socket set to another path?", path)
|
||||
|
||||
case errors.Is(err, syscall.ECONNREFUSED):
|
||||
return fmt.Sprintf("nebula ctl: found a stale socket at %s, nebula is not listening on it", path)
|
||||
|
||||
case errors.Is(err, fs.ErrPermission):
|
||||
return fmt.Sprintf("nebula ctl: permission denied opening %s. nebula ctl must run as the user nebula runs as, usually root", path)
|
||||
|
||||
default:
|
||||
return fmt.Sprintf("nebula ctl: %s", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ctlCommandName names the command for an error message, for the case where there isn't one.
|
||||
func ctlCommandName(args []string) string {
|
||||
if len(args) == 0 {
|
||||
return "the command"
|
||||
}
|
||||
|
||||
return args[0]
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"syscall"
|
||||
"testing"
|
||||
|
||||
"github.com/slackhq/nebula/diag"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// The daemon parses the command's own flags, so this side must consume its own and forward
|
||||
// everything from the command name onwards untouched.
|
||||
func TestCtlSocketPath(t *testing.T) {
|
||||
t.Run("a config naming a socket is used", func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "config.yml")
|
||||
require.NoError(t, os.WriteFile(path, []byte("ctl:\n socket: /run/somewhere/ctl.sock\n"), 0600))
|
||||
|
||||
assert.Equal(t, "/run/somewhere/ctl.sock", ctlSocketPath(path))
|
||||
})
|
||||
|
||||
t.Run("a config without a ctl block falls back to the platform default", func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "config.yml")
|
||||
require.NoError(t, os.WriteFile(path, []byte("pki:\n ca: /dev/null\n"), 0600))
|
||||
|
||||
assert.Equal(t, diag.DefaultSocketPath(), ctlSocketPath(path))
|
||||
})
|
||||
|
||||
// A packaged install keeps its config somewhere config.DefaultPath will never look, so a
|
||||
// config we cannot read is the ordinary case and must not be fatal.
|
||||
t.Run("an unreadable config falls back to the platform default", func(t *testing.T) {
|
||||
assert.Equal(t, diag.DefaultSocketPath(), ctlSocketPath(filepath.Join(t.TempDir(), "nope.yml")))
|
||||
})
|
||||
}
|
||||
|
||||
func TestCtlDialError(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
err error
|
||||
wants string
|
||||
}{
|
||||
{"missing socket names the path and what to check", fs.ErrNotExist, "no control socket at /x/ctl.sock. Is nebula running?"},
|
||||
{"a stale socket is called stale", syscall.ECONNREFUSED, "found a stale socket at /x/ctl.sock"},
|
||||
{"permission denied suggests the right user", fs.ErrPermission, "must run as the user nebula runs as"},
|
||||
{"an unsupported platform says so", diag.ErrNotSupported, "not supported on this platform"},
|
||||
{"anything else is reported verbatim", errors.New("something else"), "something else"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
assert.Contains(t, ctlDialError("/x/ctl.sock", tt.err), tt.wants)
|
||||
})
|
||||
}
|
||||
|
||||
t.Run("a wrapped syscall error is still recognised", func(t *testing.T) {
|
||||
err := &os.SyscallError{Syscall: "connect", Err: syscall.ECONNREFUSED}
|
||||
assert.Contains(t, ctlDialError("/x/ctl.sock", err), "stale socket")
|
||||
})
|
||||
}
|
||||
|
||||
func TestCtlCommandName(t *testing.T) {
|
||||
assert.Equal(t, "print-cert", ctlCommandName([]string{"print-cert", "-json"}))
|
||||
assert.Equal(t, "the command", ctlCommandName(nil))
|
||||
}
|
||||
@@ -32,11 +32,26 @@ func init() {
|
||||
}
|
||||
|
||||
func main() {
|
||||
// Subcommands are dispatched before flag.Parse, because flag.Parse stops at the first
|
||||
// non-flag argument and everything after `ctl` has to reach the running nebula's own flag
|
||||
// parser untouched. Nothing here looks at -json or a vpn address.
|
||||
if len(os.Args) > 1 && os.Args[1] == "ctl" {
|
||||
os.Exit(ctlMain(os.Args[2:]))
|
||||
}
|
||||
|
||||
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")
|
||||
printUsage := flag.Bool("help", false, "Print command line usage")
|
||||
|
||||
flag.Usage = func() {
|
||||
out := flag.CommandLine.Output()
|
||||
fmt.Fprintf(out, "Usage of %s:\n", os.Args[0])
|
||||
flag.PrintDefaults()
|
||||
fmt.Fprintf(out, "\nCommands:\n")
|
||||
fmt.Fprintf(out, " ctl [command]\n\tRun a debug command against the running nebula on this host.\n\tRun `nebula ctl` on its own for the list of commands.\n")
|
||||
}
|
||||
|
||||
flag.Parse()
|
||||
|
||||
if *printVersion {
|
||||
|
||||
Reference in New Issue
Block a user