mirror of
https://github.com/slackhq/nebula.git
synced 2026-11-15 11:06:52 +01:00
Add the identity command and serve it on an opt-in HTTP info API
Build slackhq/nebula#1821 ("who is this vpn address?" over a local HTTP+JSON socket) on the command registry, so the lookup is one command on every surface and the socket is one more local API. identity - `identity [-address <vpn addr>[:port]]`: no address reports this node; with one, this node or the peer behind an established tunnel - Certificate fields only: name, vpn addresses, networks, unsafe networks, groups, fingerprint, issuer, validity, cert version; a peer's fingerprint is the one nebula computed when it verified the cert - Port accepted and stripped, 4in6 unmapped, so a RemoteAddr passes straight through - No tunnel is a commands.NotFound; an empty -address is refused - Lighthouse TXT lookup shares the owner and peer lookups, keeps answering with the initiating certificate, and no longer panics without a default cert - Served on ssh and nebula ctl too Info API - Off until `local_api.info.enabled` with `socket` and optional quoted octal `socket_mode` (default "0600"); nothing derived - Serves commands declaring Command.Identity plus help; others 403 naming their surfaces. TestInfoAPISurface pins the set to identity - GET /v1 is the schema; GET /v1/<cmd>?<flag>=<value> runs one; body is the -json rendering via commands.Render; commands.PresentationFormat reads json/pretty from the query the way a line is read - 400 commands.ParamError, 403 commands.WithheldError (what Resolve now returns for a withheld name, so help and dispatch type-check it too), 404 NotFound or unknown, 500 Run error, refusal with no reason, or recovered panic with no detail; no redirects; every miss is JSON - listen takes a mode: bind under umask 0077 then chmod; "0660" plus a searchable dir opens it to a group - The chmod, at bind and on a mode-only reload, goes through chmodSocket, which never follows a link: a user who can write the socket's directory could otherwise swap the node for a link and have a root daemon put 0660 or 0666 on any file. Only a socket we own is chmoded. On linux the node is held open O_PATH|O_NOFOLLOW, checked by fstat, and chmoded through /proc/self/fd, so the check and the chmod land on one inode; fchmodat2 is the fallback without /proc. On darwin and the BSDs it is an Lstat check and fchmodat(AT_SYMLINK_NOFOLLOW). clearStale Lstats too, so a dangling link is refused rather than followed by bind on darwin - One endpoint type owns bind under mutex, bind-before-release move, context watch and stop/unlink; each server supplies what it serves - Reloadable through the same reconcile shape as the control socket: mode applied in place, unparsable section keeps what runs, control socket path refused by name - Wire returns Endpoints{Control, Info} with one Stop - Windows unsupported for now (a pipe has no mode); warned once Tests: identity lookups and shapes; API filter, status codes, panic recovery, socket mode, rebind, cancellation; config parsing incl. bare 0660; Wire transitions; e2e of two nebulas with the info API resolving the peer by address and port, through shared test.HTTPDo/HTTPGet helpers. Goldens gain identity. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
1 parent
92c67a52b2
commit
613c7e3061
38 files changed
+2937
-332
No files matched your search
+137
-21
@@ -9,23 +9,69 @@ import (
|
||||
"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.
|
||||
// Endpoints is every local API Wire brought up, with the one Stop the daemon's exit
|
||||
// path calls.
|
||||
type Endpoints struct {
|
||||
Control *Server
|
||||
Info *InfoServer
|
||||
}
|
||||
|
||||
// Stop closes both endpoints and removes their sockets.
|
||||
func (e *Endpoints) Stop() {
|
||||
e.Info.Stop()
|
||||
e.Control.Stop()
|
||||
}
|
||||
|
||||
// Wire brings up the local APIs for a running nebula and keeps them following the
|
||||
// config across reloads: the control socket unless the config turns it off, and the
|
||||
// info API when the config asks for it.
|
||||
//
|
||||
// Every entry point that runs nebula as a daemon calls this, so the endpoint behaves the
|
||||
// Every entry point that runs nebula as a daemon calls this, so the endpoints behave 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.
|
||||
// does not live in nebula.Main: the control socket 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 {
|
||||
// Call it after Control.Start, so the listeners only exist once the interface is up.
|
||||
func Wire(ctx context.Context, l *slog.Logger, c *config.C, reg *commands.Registry) *Endpoints {
|
||||
l = l.With("subsystem", "localapi")
|
||||
return &Endpoints{
|
||||
Control: wireControl(ctx, l, c, reg),
|
||||
Info: wireInfo(ctx, l, c, reg),
|
||||
}
|
||||
}
|
||||
|
||||
// sharedSocketPath reports the path the config gives to both the control socket and the
|
||||
// info API, when it gives them one path. The two speak different protocols, so neither
|
||||
// binds it: each side says so and leaves it, and the operator fixes the config. The
|
||||
// configured paths are compared, not the ones being served, so the case is caught on
|
||||
// whichever side reconciles first, when the other side's bind has failed, and when a
|
||||
// reload moves one socket onto the other's path.
|
||||
func sharedSocketPath(c *config.C) (string, bool) {
|
||||
if !ControlEnabled(c) {
|
||||
return "", false
|
||||
}
|
||||
|
||||
ctl, err := SocketPathFromConfig(c)
|
||||
if err != nil {
|
||||
return "", false
|
||||
}
|
||||
|
||||
info, err := infoConfigFrom(c)
|
||||
if err != nil || !info.Enabled || info.Path != ctl {
|
||||
return "", false
|
||||
}
|
||||
|
||||
return ctl, true
|
||||
}
|
||||
|
||||
// wireControl brings up the control socket and keeps it pointed at the configured path.
|
||||
func wireControl(ctx context.Context, l *slog.Logger, c *config.C, reg *commands.Registry) *Server {
|
||||
srv := NewServer(l, reg)
|
||||
|
||||
// Registered before the first attempt, so that a path or bind that is bad at startup
|
||||
@@ -55,10 +101,6 @@ func Wire(ctx context.Context, l *slog.Logger, c *config.C, reg *commands.Regist
|
||||
// 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) {
|
||||
@@ -81,20 +123,34 @@ func followControlConfig(ctx context.Context, l *slog.Logger, c *config.C, srv *
|
||||
return
|
||||
}
|
||||
|
||||
if p, shared := sharedSocketPath(c); shared {
|
||||
l.Warn("Local control socket is configured on the info API's socket path and will not bind it",
|
||||
"path", p, "current", srv.Path(), "fix", "set "+InfoSocketKey+" to another path")
|
||||
return
|
||||
}
|
||||
|
||||
old := srv.Path()
|
||||
err = srv.Rebind(ctx, want, acc)
|
||||
reportRebind(l, old, want, err, srv.Path())
|
||||
reportRebind(l, "local control socket", old, want, err, srv.Path())
|
||||
}
|
||||
|
||||
onReload(l, c, "local control socket", reconcile)
|
||||
return reconcile
|
||||
}
|
||||
|
||||
// onReload registers fn to run on every reload. 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 onReload(l *slog.Logger, c *config.C, what string, fn func(*config.C)) {
|
||||
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)
|
||||
l.Error("Recovered from panic reloading the "+what, "panic", r)
|
||||
}
|
||||
}()
|
||||
reconcile(c)
|
||||
fn(c)
|
||||
})
|
||||
return reconcile
|
||||
}
|
||||
|
||||
// warnUnusable logs a config the endpoint cannot follow: with nothing serving, the
|
||||
@@ -108,7 +164,7 @@ func warnUnusable(l *slog.Logger, what, current string, err error) {
|
||||
}
|
||||
|
||||
// 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
|
||||
// nothing for an endpoint 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
|
||||
@@ -117,7 +173,7 @@ func warnUnusable(l *slog.Logger, what, current string, err error) {
|
||||
// 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) {
|
||||
func reportRebind(l *slog.Logger, what, old, want string, err error, now string) {
|
||||
level := l.Warn
|
||||
if errors.Is(err, errEndpointHeld) {
|
||||
level = l.Error
|
||||
@@ -126,15 +182,75 @@ func reportRebind(l *slog.Logger, old, want string, err error, now string) {
|
||||
switch {
|
||||
case err == nil:
|
||||
if old != "" && old != want {
|
||||
l.Info("Moved the local control socket", "from", old, "to", want)
|
||||
l.Info("Moved the "+what, "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)
|
||||
level("Could not start the "+what+", it 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)
|
||||
level("Could not bind the "+what+" at the requested path", "error", err, "requested", want, "serving", now)
|
||||
}
|
||||
}
|
||||
|
||||
// wireInfo brings up the info API when the config asks for one and follows the config
|
||||
// across reloads: enabling it, disabling it, moving its socket and changing its mode.
|
||||
//
|
||||
// Every reload is reconciled, as the control socket's is; see followControlConfig. A
|
||||
// section that does not parse keeps whatever is running: the operator is told, and the
|
||||
// programs relying on the endpoint are not cut off by a typo.
|
||||
func wireInfo(ctx context.Context, l *slog.Logger, c *config.C, reg *commands.Registry) *InfoServer {
|
||||
srv := NewInfoServer(l, reg)
|
||||
|
||||
// Said once per process rather than on every reload: it is a platform limit, not
|
||||
// something the next reload could fix.
|
||||
saidUnsupported := false
|
||||
|
||||
reconcile := func(c *config.C) {
|
||||
cfg, err := infoConfigFrom(c)
|
||||
if err != nil {
|
||||
warnUnusable(l, "local info API", srv.Path(), err)
|
||||
return
|
||||
}
|
||||
|
||||
if !cfg.Enabled {
|
||||
if srv.Path() != "" {
|
||||
srv.Stop()
|
||||
l.Info("Stopped the local info API, it is disabled in the reloaded config")
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if !infoSupported {
|
||||
if !saidUnsupported {
|
||||
l.Warn("Local info API is enabled in the config but is not served on this platform yet", "key", InfoEnabledKey)
|
||||
saidUnsupported = true
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// The two endpoints speak different protocols; one socket cannot be both. Caught
|
||||
// here, from the configured paths, because listen's own answer, that the socket is
|
||||
// in use by another nebula, would send the operator looking for a second process.
|
||||
if p, shared := sharedSocketPath(c); shared {
|
||||
if cur := srv.Path(); cur != "" {
|
||||
l.Warn("Ignoring the local info API's new socket path, it is also the control socket's; keeping the current endpoint",
|
||||
"path", p, "current", cur, "fix", "set "+InfoSocketKey+" to another path")
|
||||
} else {
|
||||
l.Warn("Local info API is not available, its socket path is also the control socket's", "path", p, "fix", "set "+InfoSocketKey+" to another path")
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
old := srv.Path()
|
||||
err = srv.Rebind(ctx, cfg.Path, cfg.Access)
|
||||
reportRebind(l, "local info API", old, cfg.Path, err, srv.Path())
|
||||
}
|
||||
|
||||
// Registered before the first attempt, as the control socket's is; see wireControl.
|
||||
onReload(l, c, "local info API", reconcile)
|
||||
reconcile(c)
|
||||
return srv
|
||||
}
|
||||
|
||||
// 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
|
||||
|
||||
Reference in new issue
Block a user