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:
Matt RichardsonandClaude Fable 5.1 committed 2026-10-21 12:26:09 -04:00
1 parent 92c67a52b2
commit 613c7e3061
38 files changed
+2937 -332

No files matched your search

+81 -26
View File
@@ -43,21 +43,25 @@ func withPeerFD(conn net.Conn, f func(fd int) error) error {
// in for root, or for a stranger, without owning a second account on the host.
var currentUID = func() uint32 { return uint32(os.Geteuid()) }
// infoSupported reports that the info API can be served here: a unix socket takes the
// mode an operator configures, which is the whole of its access control.
const infoSupported = true
// listen binds the socket at path with the given access.
//
// Access control is the socket's own mode and group, which the node is created no
// looser than and which are set after bind, together with the peer check the server
// applies on accept unless a group shares the socket, in which case the kernel's check
// is the whole of it: connect(2) needs write permission on the socket file on every
// unix we build for.
// looser than and which are set after bind: connect(2) needs write permission on the
// socket file on every unix we build for. The control socket is 0600 and adds the peer
// check the server applies on accept, unless a group shares it, in which case it is
// 0660 with that group and the kernel's check is the whole of it; the info socket is
// whatever the operator configured, since opening it to others is its purpose.
//
// The directory is not part of that, and is never chmoded: a directory a service
// manager made for us is served from at whatever mode it came with (systemd's
// RuntimeDirectoryMode= is 0755 by default), and one an operator pointed us into keeps
// whatever else it holds readable. The one we make ourselves is 0755, searchable by
// everyone, so that a nebula ctl run as another user reaches the socket and gets the
// kernel's answer, which for a member of the configured group is a connection and for
// anyone else is permission denied.
// The directory is not part of that, and is never chmoded: a directory a service manager made for us is served from at whatever mode it
// came with (systemd's RuntimeDirectoryMode= is 0755 by default), and one an operator
// pointed us into keeps whatever else it holds readable. The one we make ourselves is
// 0755, searchable by everyone, so that a nebula ctl run as another user reaches the
// socket and gets the kernel's answer, which for a member of the configured group is
// a connection and for anyone else is permission denied.
func listen(path string, acc Access) (net.Listener, error) {
// The kernel silently truncates an over long sun_path and bind then fails with
// "invalid argument", which tells an operator nothing. Say what is actually wrong.
@@ -98,7 +102,8 @@ func listen(path string, acc Access) (net.Listener, error) {
// bind creates the socket node at the process umask, and a connect(2) between that
// and a later chmod would be queued for accept. Tighten the umask around the bind so
// the node is never looser than 0600, however briefly; see umaskMu.
// the node is never looser than 0600, however briefly, whatever mode it ends up at;
// see umaskMu.
umaskMu.Lock()
old = syscall.Umask(0077)
ln, err := net.Listen(network, path)
@@ -108,6 +113,10 @@ func listen(path string, acc Access) (net.Listener, error) {
return nil, err
}
// What was just bound, so that a failure below gives up that node and not whatever
// has been put at the path since.
id, _ := identifySocket(path)
// The node at path is given up by release, which unlinks it before the listener
// closes; see there for why the order matters. Go's listener would unlink on Close
// as well, and an unlink after the close could only be of whatever bound the path
@@ -119,26 +128,47 @@ func listen(path string, acc Access) (net.Listener, error) {
// The umask only promises no looser than 0600; this pins the mode and the group
// exactly, and is the one place a failure to secure the socket is reported.
if err := applyAccess(path, acc); err != nil {
release(ln, path)
release(ln, path, id)
return nil, fmt.Errorf("could not secure %s: %w", path, err)
}
return ln, nil
}
// applyAccess sets the owning group and the mode of the socket node at path. With no
// group configured the socket is handed back to the daemon's own group, so taking the
// key out of the config on a reload takes the access away as surely as a restart would.
// The chown is Lchown, so a link at the path is not followed to its target.
func applyAccess(path string, acc Access) error {
gid, err := gidFor(acc)
if err != nil {
return err
// checkOwnSocket refuses what fi found at path unless it is a socket owned by the user
// this process runs as. fi comes from a look that does not follow links, so a symlink is
// seen as one, and the mode is only ever applied to a socket of ours. checkSocketDir
// vouched for the directory at bind, but that was then: a directory loosened since, or a
// reload that moves the socket, is a chance for whoever can write it to replace the node
// with a link, or a hard link to a file of their choosing, and have a root daemon set the
// configured mode, 0660 or 0666 for an info socket, on that file. A link, another type
// of file, or somebody else's socket is reported and left as it is. applyAccess, per
// platform, is what makes the look and the change land on the same node.
func checkOwnSocket(path string, fi os.FileInfo) error {
if !isSocket(fi) {
return fmt.Errorf("%s is not a socket but %s, refusing to change its mode", path, describeNode(fi))
}
if err := os.Lchown(path, -1, gid); err != nil {
return err
if uid, ok := owner(fi); ok && uid != currentUID() {
return fmt.Errorf("%s is owned by %s, not by us, refusing to change its mode", path, describeUID(uid))
}
return nil
}
// describeNode names what kind of file fi describes, for a message about something found
// where a socket was expected.
func describeNode(fi os.FileInfo) string {
switch m := fi.Mode(); {
case m&os.ModeSymlink != 0:
return "a symlink"
case m.IsDir():
return "a directory"
case m.IsRegular():
return "a regular file"
default:
return "a " + m.Type().String() + " file"
}
return os.Chmod(path, acc.Mode.Perm())
}
// checkSocketDir refuses to serve from a directory that somebody other than the user
@@ -220,8 +250,12 @@ func describeUID(uid uint32) string {
// Liveness is a plain connect. Only a refused connect proves there is nothing behind
// the file. Anything else, a timeout or a permission error, is a socket we cannot vouch
// for, and it is left alone with the reason in the error.
//
// The look is an Lstat: a symlink at path is refused as not a socket, whether or not it
// resolves. Stat would report a dangling one as nothing there, and bind(2) on darwin
// and the BSDs follows a dangling link and creates the socket node at its target.
func clearStale(path string) error {
fi, err := os.Stat(path)
fi, err := os.Lstat(path)
if err != nil {
if os.IsNotExist(err) {
return nil
@@ -253,10 +287,31 @@ func clearStale(path string) error {
// removeSocket unlinks the socket node. Its directory stays, whoever made it; see
// listen.
func removeSocket(path string) {
//
// Only the node id names is removed. One that replaced it is another daemon's, bound
// after ours was taken from under us, and is left where it is. A zero id, a node that
// could not be identified when it was bound, is removed.
func removeSocket(path string, id socketID) {
if cur, ok := identifySocket(path); ok && id != (socketID{}) && cur != id {
return
}
_ = os.Remove(path)
}
// identifySocket returns the identity of the node at path, looked at without following
// links, and false when there is nothing there or it cannot be read.
func identifySocket(path string) (socketID, bool) {
fi, err := os.Lstat(path)
if err != nil {
return socketID{}, false
}
st, ok := fi.Sys().(*syscall.Stat_t)
if !ok {
return socketID{}, false
}
return socketID{dev: uint64(st.Dev), ino: uint64(st.Ino)}, true
}
func dial(path string, timeout time.Duration) (net.Conn, error) {
return net.DialTimeout(network, path, timeout)
}