mirror of
https://github.com/slackhq/nebula.git
synced 2026-11-15 17:56:52 +01:00
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>
This commit is contained in:
1 parent
9a5bdb508e
commit
0f767ce529
54 files changed
+7581
-925
No files matched your search
@@ -0,0 +1,144 @@
|
||||
package localapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"log/slog"
|
||||
|
||||
"github.com/slackhq/nebula/commands"
|
||||
"github.com/slackhq/nebula/config"
|
||||
)
|
||||
|
||||
// Wire brings up the local control socket for a running nebula, unless the config turns
|
||||
// it off, and keeps it following the config across reloads.
|
||||
//
|
||||
// Every entry point that runs nebula as a daemon calls this, so the endpoint behaves the
|
||||
// same whether nebula was started from a shell or by a service manager. It deliberately
|
||||
// does not live in nebula.Main: the endpoint is on by default, so an embedder inside an
|
||||
// app sandbox would have to know to turn off a socket it cannot use. Wiring it at the
|
||||
// daemon entry points means embedders get the command registry and no listener.
|
||||
//
|
||||
// Every failure below is a warning, bar one. Nebula is expected to keep forwarding
|
||||
// packets whether or not anyone can reach it; but a name that another process holds is
|
||||
// not a misconfiguration, it is a process that will be answering nebula ctl in the
|
||||
// daemon's place, and that is logged as an error.
|
||||
//
|
||||
// Call it after Control.Start, so the listener only exists once the interface is up.
|
||||
func Wire(ctx context.Context, l *slog.Logger, c *config.C, reg *commands.Registry) *Server {
|
||||
l = l.With("subsystem", "localapi")
|
||||
srv := NewServer(l, reg)
|
||||
|
||||
// Registered before the first attempt, so that a path or bind that is bad at startup
|
||||
// can be fixed with a config edit and a reload, the same as one that goes bad later,
|
||||
// and so that a HUP landing in between is harmless: Rebind on a server already at
|
||||
// the path is a no-op.
|
||||
reconcile := followControlConfig(ctx, l, c, srv)
|
||||
reconcile(c)
|
||||
if !ControlEnabled(c) {
|
||||
l.Info("Local control socket is disabled", "key", CtlEnabledKey)
|
||||
}
|
||||
return srv
|
||||
}
|
||||
|
||||
// followControlConfig registers the reload callback that keeps the socket where
|
||||
// local_api.control.socket says it is, moving it when the key changes and bringing one
|
||||
// up while there is none, and returns it for the first attempt to call.
|
||||
//
|
||||
// The key is reloadable because the client finds the socket by reading the config: if the
|
||||
// two could disagree, the file on disk would name a socket that is not there.
|
||||
//
|
||||
// Every reload is reconciled rather than gated on the key having changed. Rebind on a
|
||||
// server already at the path is a no-op, so that costs nothing; and a bind that failed
|
||||
// for a reason outside the config, a runtime directory that was not there yet, is fixed
|
||||
// outside the config too, so the reload that follows the fix is a plain one. That holds
|
||||
// for a move as much as for the first bind: a daemon left on its old path by a move that
|
||||
// could not bind is one the client, reading the new path from the same file, cannot
|
||||
// reach, and the operator's fix is the directory and a reload, not an edit to an
|
||||
// unrelated line to make the key read as changed.
|
||||
//
|
||||
// The callback recovers from its own panics rather than relying on the config package,
|
||||
// whose callback loop has no isolation of its own. A panic there would take the daemon
|
||||
// down and skip every callback queued behind this one.
|
||||
func followControlConfig(ctx context.Context, l *slog.Logger, c *config.C, srv *Server) func(*config.C) {
|
||||
reconcile := func(c *config.C) {
|
||||
if !ControlEnabled(c) {
|
||||
if srv.Path() != "" {
|
||||
srv.Stop()
|
||||
l.Info("Stopped the local control socket, it is disabled in the reloaded config")
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
want, err := SocketPathFromConfig(c)
|
||||
if err != nil {
|
||||
warnUnusable(l, "local control socket", srv.Path(), err)
|
||||
return
|
||||
}
|
||||
|
||||
acc, err := controlAccessFrom(c)
|
||||
if err != nil {
|
||||
warnUnusable(l, "local control socket", srv.Path(), err)
|
||||
return
|
||||
}
|
||||
|
||||
old := srv.Path()
|
||||
err = srv.Rebind(ctx, want, acc)
|
||||
reportRebind(l, old, want, err, srv.Path())
|
||||
}
|
||||
|
||||
c.RegisterReloadCallback(func(c *config.C, _ *config.Reload) {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
l.Error("Recovered from panic reloading the local control socket", "panic", r)
|
||||
}
|
||||
}()
|
||||
reconcile(c)
|
||||
})
|
||||
return reconcile
|
||||
}
|
||||
|
||||
// warnUnusable logs a config the endpoint cannot follow: with nothing serving, the
|
||||
// endpoint is not available; with something serving, it is kept as it is.
|
||||
func warnUnusable(l *slog.Logger, what, current string, err error) {
|
||||
if current == "" {
|
||||
l.Warn("The "+what+" is not available, its config is unusable", "error", err)
|
||||
return
|
||||
}
|
||||
l.Warn("Ignoring unusable "+what+" config from the reload, keeping the current endpoint", "error", err, "current", current)
|
||||
}
|
||||
|
||||
// reportRebind logs what a Rebind did beyond the listening line the bind itself writes:
|
||||
// nothing for a server left where it was, a move with both paths, and a failure with
|
||||
// what the operator is left with. old is the path served before the call, "" for none,
|
||||
// want the one asked for, and now the path served after it: on unix a failed move keeps
|
||||
// the old socket, and now is old; on windows a change of access closes the pipe before
|
||||
// creating it again, and a failure there leaves nothing serving, so now is "".
|
||||
//
|
||||
// Nearly every failure is the operator's environment, a missing runtime directory or a
|
||||
// bad path, and a warning; a name held by another process is an error, since nothing
|
||||
// else will say that something is sitting where the daemon should be.
|
||||
func reportRebind(l *slog.Logger, old, want string, err error, now string) {
|
||||
level := l.Warn
|
||||
if errors.Is(err, errEndpointHeld) {
|
||||
level = l.Error
|
||||
}
|
||||
|
||||
switch {
|
||||
case err == nil:
|
||||
if old != "" && old != want {
|
||||
l.Info("Moved the local control socket", "from", old, "to", want)
|
||||
}
|
||||
case old == "":
|
||||
level("Could not start the local control socket, control commands will not be available until a reload binds it", "error", err, "path", want)
|
||||
default:
|
||||
level("Could not bind the local control socket at the requested path", "error", err, "requested", want, "serving", now)
|
||||
}
|
||||
}
|
||||
|
||||
// errEndpointHeld is returned by listen when the endpoint's name is already held by
|
||||
// some other process, which nebula will not serve alongside: whoever holds it would be
|
||||
// answering nebula ctl. It is only raised on windows, where the default name can be
|
||||
// held by another administrator's process and an override outside the protected
|
||||
// namespace by any account's; on unix a path that is still being served is an earlier
|
||||
// nebula, which clearStale reports as in use.
|
||||
var errEndpointHeld = errors.New("the local control socket's name is held by another process")
|
||||
Reference in new issue
Block a user