mirror of
https://github.com/slackhq/nebula.git
synced 2026-09-30 10:26:39 +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
235 lines
6.9 KiB
Go
235 lines
6.9 KiB
Go
package nebula
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"log/slog"
|
|
"net"
|
|
"path/filepath"
|
|
"sync"
|
|
|
|
"github.com/slackhq/nebula/config"
|
|
"github.com/slackhq/nebula/diag"
|
|
"github.com/slackhq/nebula/util"
|
|
)
|
|
|
|
// ctlConfig is the parsed form of the `ctl` config block. It is comparable so that a reload
|
|
// can tell "nothing changed" from "the socket moved" with ==.
|
|
type ctlConfig struct {
|
|
enabled bool
|
|
socket string
|
|
|
|
// explicit records that the operator named a socket path rather than taking the platform
|
|
// default. It only affects how loudly a failure to listen is reported: an unprivileged
|
|
// nebula that cannot create /run/nebula is a normal deployment, not a problem to shout
|
|
// about on every upgrade, but a path someone chose deliberately failing to bind is.
|
|
explicit bool
|
|
}
|
|
|
|
// ctlServer owns the unix socket `nebula ctl` connects to. It exposes the same command
|
|
// registry the ssh console does, minus the ceremony of running an ssh server: the socket is
|
|
// local only and guarded by filesystem permissions, so it needs no keys.
|
|
//
|
|
// The lifecycle mirrors statsServer: the constructor wires the reload callback, reload
|
|
// records config and reconciles a running listener, Start builds and serves the runtime, and
|
|
// Stop tears it down.
|
|
type ctlServer struct {
|
|
l *slog.Logger
|
|
ctx context.Context
|
|
srv *diag.Server
|
|
|
|
runMu sync.Mutex
|
|
runCfg *ctlConfig
|
|
run *ctlRuntime
|
|
}
|
|
|
|
// ctlRuntime is the live state owned by a single Start invocation.
|
|
type ctlRuntime struct {
|
|
cancel context.CancelFunc
|
|
listener net.Listener
|
|
}
|
|
|
|
// newCtlServerFromConfig builds a ctlServer, parses the config, and registers a reload
|
|
// callback. It deliberately does not start listening: there is no interface yet, and
|
|
// Control.Start is what launches the first runtime. The callback is registered before the
|
|
// config is parsed so a SIGHUP can fix a bad block even if the first parse failed.
|
|
//
|
|
// reg is only held, never read, until Start runs. That is what lets this be constructed
|
|
// before attachCommands has populated the registry.
|
|
func newCtlServerFromConfig(ctx context.Context, l *slog.Logger, c *config.C, reg *diag.Registry) (*ctlServer, error) {
|
|
s := &ctlServer{
|
|
l: l,
|
|
ctx: ctx,
|
|
srv: diag.NewServer(l, reg),
|
|
}
|
|
|
|
c.RegisterReloadCallback(func(c *config.C) {
|
|
if err := s.reload(c, false); err != nil {
|
|
s.l.Error("Failed to reload ctl from config", "error", err)
|
|
}
|
|
})
|
|
|
|
if err := s.reload(c, true); err != nil {
|
|
return s, err
|
|
}
|
|
|
|
return s, nil
|
|
}
|
|
|
|
// loadCtlConfig parses and validates the `ctl` block. An empty socket path while enabled is
|
|
// not an error: it means the platform has no default and the operator did not name one, so
|
|
// there is simply nothing to listen on.
|
|
func loadCtlConfig(c *config.C) (ctlConfig, error) {
|
|
cfg := ctlConfig{
|
|
enabled: c.GetBool("ctl.enabled", true),
|
|
socket: c.GetString("ctl.socket", diag.DefaultSocketPath()),
|
|
explicit: c.IsSet("ctl.socket"),
|
|
}
|
|
|
|
if cfg.enabled && cfg.socket != "" && !filepath.IsAbs(cfg.socket) {
|
|
return cfg, util.NewContextualError("ctl.socket must be an absolute path", m{"path": cfg.socket}, nil)
|
|
}
|
|
|
|
return cfg, nil
|
|
}
|
|
|
|
// reload parses the config and records it, then reconciles the running listener against it:
|
|
//
|
|
// - newly enabled -> spawn Start
|
|
// - newly disabled -> Stop the runtime
|
|
// - socket moved (still enabled) -> Stop the old, Start the new
|
|
// - no change -> no-op
|
|
//
|
|
// On the initial call it only records configuration; Control.Start is what launches the first
|
|
// runtime via ctlStart. There is no interface to serve yet at that point.
|
|
func (s *ctlServer) reload(c *config.C, initial bool) error {
|
|
newCfg, err := loadCtlConfig(c)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
s.runMu.Lock()
|
|
sameCfg := s.runCfg != nil && *s.runCfg == newCfg
|
|
s.runCfg = &newCfg
|
|
running := s.run != nil
|
|
s.runMu.Unlock()
|
|
|
|
if initial || sameCfg {
|
|
return nil
|
|
}
|
|
|
|
if running {
|
|
s.Stop()
|
|
}
|
|
|
|
if newCfg.enabled && newCfg.socket != "" {
|
|
go s.Start()
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// Start binds the socket and serves until Stop is called or ctx fires. Safe to call when ctl
|
|
// is disabled or already running: both no-op.
|
|
func (s *ctlServer) Start() {
|
|
s.runMu.Lock()
|
|
if s.ctx.Err() != nil || s.run != nil || s.runCfg == nil {
|
|
s.runMu.Unlock()
|
|
return
|
|
}
|
|
cfg := *s.runCfg
|
|
s.runMu.Unlock()
|
|
|
|
if !cfg.enabled || cfg.socket == "" {
|
|
if cfg.enabled {
|
|
s.l.Info("ctl has no socket path on this platform, `nebula ctl` will not be available",
|
|
"hint", "set ctl.socket to enable it",
|
|
)
|
|
}
|
|
return
|
|
}
|
|
|
|
listener, err := diag.Listen(cfg.socket)
|
|
if err != nil {
|
|
// A default path nebula cannot create is an ordinary state for an unprivileged
|
|
// install; a path the operator chose failing to bind is something they want to know
|
|
// about. Either way ctl is optional and nebula carries on without it.
|
|
if cfg.explicit {
|
|
s.l.Error("Failed to listen on the ctl socket", "ctlSocket", cfg.socket, "error", err)
|
|
} else {
|
|
s.l.Info("Not serving the ctl socket, `nebula ctl` will not be available",
|
|
"ctlSocket", cfg.socket,
|
|
"error", err,
|
|
"hint", "set ctl.socket to a path nebula can write, or ctl.enabled to false",
|
|
)
|
|
}
|
|
|
|
// Drop the cached config so a SIGHUP retries once the underlying problem is fixed,
|
|
// even when the config itself is unchanged.
|
|
s.runMu.Lock()
|
|
if s.runCfg != nil && *s.runCfg == cfg {
|
|
s.runCfg = nil
|
|
}
|
|
s.runMu.Unlock()
|
|
return
|
|
}
|
|
|
|
runCtx, cancel := context.WithCancel(s.ctx)
|
|
rt := &ctlRuntime{cancel: cancel, listener: listener}
|
|
|
|
s.runMu.Lock()
|
|
// Losing the race against a Stop or a competing Start means this listener is already
|
|
// obsolete. Close it rather than serving a socket nobody will tear down.
|
|
if s.ctx.Err() != nil || s.run != nil {
|
|
s.runMu.Unlock()
|
|
cancel()
|
|
_ = listener.Close()
|
|
return
|
|
}
|
|
s.run = rt
|
|
s.runMu.Unlock()
|
|
|
|
s.l.Info("ctl socket is listening", "ctlSocket", cfg.socket)
|
|
|
|
err = s.srv.Serve(runCtx, listener)
|
|
if err != nil {
|
|
s.l.Error("The ctl listener stopped", "ctlSocket", cfg.socket, "error", err)
|
|
}
|
|
|
|
// Clear our runtime only if nothing has replaced it.
|
|
s.runMu.Lock()
|
|
if s.run == rt {
|
|
rt.cancel()
|
|
s.run = nil
|
|
if err != nil {
|
|
// An unclean exit leaves runCfg cached as if it were applied, so drop it and let a
|
|
// SIGHUP retry.
|
|
s.runCfg = nil
|
|
}
|
|
}
|
|
s.runMu.Unlock()
|
|
}
|
|
|
|
// Stop closes the listener and unlinks the socket. It deliberately does not touch connections
|
|
// that are already being served: `nebula ctl reload` runs every reload callback inline on its
|
|
// own connection, including this one, and hanging up on it would truncate the response to a
|
|
// reload that actually succeeded.
|
|
//
|
|
// The socket file is removed by net.UnixListener's unlink-on-close, so there is no os.Remove
|
|
// here; doing it by hand would delete a successor's socket after a fast reload.
|
|
func (s *ctlServer) Stop() {
|
|
s.runMu.Lock()
|
|
rt := s.run
|
|
s.run = nil
|
|
s.runMu.Unlock()
|
|
|
|
if rt == nil {
|
|
return
|
|
}
|
|
|
|
rt.cancel()
|
|
if err := rt.listener.Close(); err != nil && !errors.Is(err, net.ErrClosed) {
|
|
s.l.Warn("Failed to close the ctl listener", "error", err)
|
|
}
|
|
}
|