mirror of
https://github.com/slackhq/nebula.git
synced 2026-09-30 03:56:37 +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
138 lines
4.2 KiB
Go
138 lines
4.2 KiB
Go
package diag
|
|
|
|
import (
|
|
"bufio"
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"log/slog"
|
|
"net"
|
|
"time"
|
|
)
|
|
|
|
// Exit statuses the client reports. They follow shell convention closely enough that a
|
|
// script can tell "you asked for something that does not exist" from "it ran and failed".
|
|
const (
|
|
// StatusOK means the command ran. Note that commands report their own user facing
|
|
// problems as prose and still exit 0, matching the ssh console.
|
|
StatusOK = 0
|
|
// StatusError means the command could not be completed.
|
|
StatusError = 1
|
|
// StatusUsage means the arguments were not valid for that command.
|
|
StatusUsage = 2
|
|
// StatusUnknownCommand means there is no such command.
|
|
StatusUnknownCommand = 127
|
|
)
|
|
|
|
// requestTimeout bounds how long a connected client may take to send its request line. There
|
|
// is deliberately no timeout on the response: `reload` runs every reload callback inline
|
|
// before it returns, and a slow one is not a reason to hang up on the operator.
|
|
const requestTimeout = 5 * time.Second
|
|
|
|
// Server serves a Registry over a stream listener. It knows nothing about unix sockets, so
|
|
// tests can drive it over a net.Pipe.
|
|
type Server struct {
|
|
l *slog.Logger
|
|
reg *Registry
|
|
}
|
|
|
|
func NewServer(l *slog.Logger, reg *Registry) *Server {
|
|
return &Server{l: l, reg: reg}
|
|
}
|
|
|
|
// Serve accepts connections until ln is closed. Cancelling ctx closes ln, which is what ends
|
|
// the accept loop; a listener closed underneath us is a normal shutdown, not an error.
|
|
func (s *Server) Serve(ctx context.Context, ln net.Listener) error {
|
|
go func() {
|
|
<-ctx.Done()
|
|
if err := ln.Close(); err != nil && !errors.Is(err, net.ErrClosed) {
|
|
s.l.Warn("Failed to close the ctl listener", "error", err)
|
|
}
|
|
}()
|
|
|
|
for {
|
|
conn, err := ln.Accept()
|
|
if err != nil {
|
|
if errors.Is(err, net.ErrClosed) || ctx.Err() != nil {
|
|
return nil
|
|
}
|
|
return err
|
|
}
|
|
|
|
go s.ServeConn(ctx, conn)
|
|
}
|
|
}
|
|
|
|
// ServeConn handles one request and closes c.
|
|
func (s *Server) ServeConn(ctx context.Context, c net.Conn) {
|
|
defer func() {
|
|
if err := c.Close(); err != nil && !errors.Is(err, net.ErrClosed) {
|
|
s.l.Debug("Failed to close a ctl connection", "error", err)
|
|
}
|
|
}()
|
|
|
|
if err := c.SetReadDeadline(time.Now().Add(requestTimeout)); err != nil {
|
|
s.l.Debug("Failed to set a ctl read deadline", "error", err)
|
|
}
|
|
|
|
req, err := readRequest(bufio.NewReaderSize(c, maxRequest))
|
|
if err != nil {
|
|
s.l.Debug("Rejected a ctl request", "error", err)
|
|
// Best effort: the client may already be gone, and there is nowhere else to report it.
|
|
_ = writeEnd(c, StatusError, err.Error())
|
|
return
|
|
}
|
|
|
|
// The request is in hand, so the command owns the rest of the connection's lifetime.
|
|
if err := c.SetReadDeadline(time.Time{}); err != nil {
|
|
s.l.Debug("Failed to clear the ctl read deadline", "error", err)
|
|
}
|
|
|
|
s.l.Debug("Running a ctl command", "args", req.Args)
|
|
|
|
buf := bufio.NewWriterSize(&frameWriter{w: c}, outputBuffer)
|
|
dispatchErr := s.reg.DispatchArgs(req.Args, NewWriter(buf))
|
|
|
|
if err := buf.Flush(); err != nil {
|
|
s.l.Debug("Failed to flush ctl output", "error", err)
|
|
return
|
|
}
|
|
|
|
status, msg := statusFor(dispatchErr)
|
|
if err := writeEnd(c, status, msg); err != nil {
|
|
s.l.Debug("Failed to write the ctl end frame", "error", err)
|
|
}
|
|
}
|
|
|
|
// StatusFor maps a dispatch error onto an exit status, for a transport that has somewhere to
|
|
// put one.
|
|
func StatusFor(err error) int {
|
|
status, _ := statusFor(err)
|
|
return status
|
|
}
|
|
|
|
// statusFor maps a dispatch error onto an exit status and, when the failure is ours to
|
|
// explain rather than one the command already wrote as prose, a message to go with it.
|
|
func statusFor(err error) (int, string) {
|
|
switch {
|
|
case err == nil:
|
|
return StatusOK, ""
|
|
case errors.Is(err, ErrUnknownCommand):
|
|
return StatusUnknownCommand, ""
|
|
case errors.Is(err, ErrUsage):
|
|
return StatusUsage, ""
|
|
default:
|
|
return StatusError, fmt.Sprintf("%s", err)
|
|
}
|
|
}
|
|
|
|
// ErrNotSupported means this platform has no ctl transport. Windows is waiting on a named
|
|
// pipe implementation; mobile has no daemon for a CLI to attach to in the first place.
|
|
var ErrNotSupported = errors.New("nebula ctl is not supported on this platform")
|
|
|
|
// Listen creates the ctl listener at path. It is the platform boundary: everything above it
|
|
// in this package is portable.
|
|
func Listen(path string) (net.Listener, error) {
|
|
return listenSocket(path)
|
|
}
|