Files
nebula/localapi/platform_unix_test.go
T
Matt RichardsonandClaude Fable 5.1 0f767ce529 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>
2026-10-21 11:16:00 -04:00

359 lines
13 KiB
Go

//go:build !windows
package localapi
import (
"bufio"
"context"
"net"
"os"
"path/filepath"
"strings"
"syscall"
"testing"
"time"
"github.com/slackhq/nebula/test"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// privateBase returns a short directory of our own, 0700, for tests to lay sockets out
// under, short enough that a path under it fits sun_path.
func privateBase(t *testing.T) string {
t.Helper()
return test.ShortTempDir(t)
}
// openDir returns a world writable directory under base, the shape of /tmp.
func openDir(t *testing.T, base string) string {
t.Helper()
open := filepath.Join(base, "open")
require.NoError(t, os.Mkdir(open, 0777))
require.NoError(t, os.Chmod(open, 0777|os.ModeSticky))
return open
}
// TestListenLeavesAServiceManagersDirectoryAlone covers the directory a service manager
// pre-creates: systemd's RuntimeDirectory is 0755 by default. The socket is 0600, which
// is what refuses other users, so the directory is served from as it came and nothing
// is chmoded that the operator did not set.
func TestListenLeavesAServiceManagersDirectoryAlone(t *testing.T) {
base := privateBase(t)
dir := filepath.Join(base, DirName)
require.NoError(t, os.Mkdir(dir, 0755))
s := NewServer(test.NewLogger(), basicRegistry())
require.NoError(t, s.Rebind(context.Background(), filepath.Join(dir, "s.sock"), ownerOnly()))
t.Cleanup(s.Stop)
fi, err := os.Stat(dir)
require.NoError(t, err)
assert.Equal(t, os.FileMode(0755), fi.Mode().Perm(), "the directory is left exactly as it was")
fi, err = os.Stat(s.Path())
require.NoError(t, err)
assert.Equal(t, os.FileMode(0600), fi.Mode().Perm(), "the socket is what is private")
_, err = (&Client{Path: s.Path()}).Describe()
require.NoError(t, err)
}
// TestListenCreatesOneLevelOnly: the socket directory is created when it is missing,
// but nothing above it is. A runtime directory that does not exist is the operator's to
// provide, and the failure says which directory could not be created.
func TestListenCreatesOneLevelOnly(t *testing.T) {
base := privateBase(t)
s := NewServer(test.NewLogger(), basicRegistry())
require.NoError(t, s.Rebind(context.Background(), filepath.Join(base, DirName, "s.sock"), ownerOnly()))
s.Stop()
dir := filepath.Join(base, "missing", DirName)
err := s.Rebind(context.Background(), filepath.Join(dir, "s.sock"), ownerOnly())
require.Error(t, err)
require.ErrorContains(t, err, "could not create "+dir)
assert.NoDirExists(t, filepath.Join(base, "missing"))
}
// TestListenRestoresTheUmask: listen tightens the process umask around bind so the socket
// node is never created looser than 0600. That is process wide state, so it must come
// back exactly as it was, on success and on failure alike.
func TestListenRestoresTheUmask(t *testing.T) {
old := syscall.Umask(0022)
t.Cleanup(func() { syscall.Umask(old) })
base := privateBase(t)
ln, err := listen(filepath.Join(base, DirName, "s.sock"), ownerOnly())
require.NoError(t, err)
t.Cleanup(func() { ln.Close() })
assert.Equal(t, 0022, syscall.Umask(0022), "umask after a successful listen")
_, err = listen(filepath.Join(base, DirName, "s.sock"), ownerOnly())
require.Error(t, err, "the path is already bound")
assert.Equal(t, 0022, syscall.Umask(0022), "umask after a failed listen")
}
// TestListenServesFromASharedDirectory: an operator who points local_api.control.socket
// into a directory holding other files, /etc/nebula say, gets a socket there and keeps
// the config and certs readable by everyone who could read them before.
func TestListenServesFromASharedDirectory(t *testing.T) {
base := privateBase(t)
dir := filepath.Join(base, "etc-nebula")
require.NoError(t, os.Mkdir(dir, 0755))
cfg := filepath.Join(dir, "config.yml")
require.NoError(t, os.WriteFile(cfg, []byte("{}\n"), 0644))
s := NewServer(test.NewLogger(), basicRegistry())
require.NoError(t, s.Rebind(context.Background(), filepath.Join(dir, "ctl.sock"), ownerOnly()))
t.Cleanup(s.Stop)
fi, err := os.Stat(dir)
require.NoError(t, err)
assert.Equal(t, os.FileMode(0755), fi.Mode().Perm())
fi, err = os.Stat(cfg)
require.NoError(t, err)
assert.Equal(t, os.FileMode(0644), fi.Mode().Perm())
}
func TestOverlongPathIsDiagnosed(t *testing.T) {
long := filepath.Join(os.TempDir(), string(make([]byte, 0)))
for len(long) <= sunPathMax {
long = filepath.Join(long, "abcdefghij")
}
s := NewServer(test.NewLogger(), basicRegistry())
err := s.Rebind(context.Background(), filepath.Join(long, "s.sock"), ownerOnly())
require.Error(t, err)
assert.ErrorContains(t, err, "byte limit for a unix socket")
}
// TestPathLimitIsExact pins the boundary: sun_path has room for the NUL as well, and Go
// refuses a path that fills the whole buffer, so the last usable length is one short.
func TestPathLimitIsExact(t *testing.T) {
assert.False(t, pathTooLong(strings.Repeat("a", maxSocketPath)))
assert.True(t, pathTooLong(strings.Repeat("a", maxSocketPath+1)))
assert.True(t, pathTooLong(strings.Repeat("a", sunPathMax)))
}
// TestNamedPathIsUsedAsGiven: a path the operator named is served from, and dialed,
// wherever it is, a directory other users can write to included; the socket's own mode
// is what keeps them out.
func TestNamedPathIsUsedAsGiven(t *testing.T) {
base := privateBase(t)
path := filepath.Join(openDir(t, base), DirName, "s.sock")
s := NewServer(test.NewLogger(), basicRegistry())
require.NoError(t, s.Rebind(context.Background(), path, ownerOnly()))
t.Cleanup(s.Stop)
_, err := (&Client{Path: path}).Describe()
require.NoError(t, err)
}
// TestStaleSocketIsCleared: a dead socket in a good directory is the routine leftover of
// a hard kill, and the daemon's stale check must see it as free to replace.
func TestStaleSocketIsCleared(t *testing.T) {
path := testSocket(t)
require.NoError(t, os.Mkdir(filepath.Dir(path), 0700))
ln, err := net.Listen(network, path)
require.NoError(t, err)
require.NoError(t, os.Chmod(path, 0600))
// Close the listener but keep the file, the way a hard kill does.
ln.(*net.UnixListener).SetUnlinkOnClose(false)
require.NoError(t, ln.Close())
_, statErr := os.Stat(path)
require.NoError(t, statErr, "the socket file is still there")
require.NoError(t, clearStale(path), "a stale socket is cleared, not reported as in use")
_, statErr = os.Stat(path)
assert.True(t, os.IsNotExist(statErr))
}
// TestClientSaysWhenTheReplyCannotBeRead: something answered on the socket, but not
// with a response. That is reported as a reply that could not be read, which is what
// it is, and not as a failure to reach the daemon, whether the other side read the
// request first or hung up on it: a daemon that is not nebula may do either.
func TestClientSaysWhenTheReplyCannotBeRead(t *testing.T) {
for _, readsFirst := range []bool{true, false} {
path := testSocket(t)
require.NoError(t, os.Mkdir(filepath.Dir(path), 0700))
ln, err := net.Listen(network, path)
require.NoError(t, err)
require.NoError(t, os.Chmod(path, 0600))
go func() {
c, err := ln.Accept()
if err != nil {
return
}
if readsFirst {
_, _ = bufio.NewReader(c).ReadBytes('\n')
}
_, _ = c.Write([]byte("this is not a response\n"))
c.Close()
}()
_, err = (&Client{Path: path}).Describe()
var unread *ReplyError
require.ErrorAs(t, err, &unread, "reads first: %v", readsFirst)
assert.Contains(t, err.Error(), "the daemon's reply could not be read")
ln.Close()
}
}
// TestClientGivesUpOnASilentDaemon: a daemon that accepts and then never answers must
// not hang the client for good. A script wrapping nebula ctl has no other way out.
func TestClientGivesUpOnASilentDaemon(t *testing.T) {
path := testSocket(t)
require.NoError(t, os.Mkdir(filepath.Dir(path), 0700))
ln, err := net.Listen(network, path)
require.NoError(t, err)
defer ln.Close()
require.NoError(t, os.Chmod(path, 0600))
accepted := make(chan net.Conn, 1)
go func() {
c, err := ln.Accept()
if err == nil {
accepted <- c // held open, never written to
}
}()
start := time.Now()
_, err = (&Client{Path: path, Timeout: 200 * time.Millisecond}).Describe()
require.Error(t, err)
require.ErrorContains(t, err, "did not answer within")
assert.Less(t, time.Since(start), 5*time.Second)
select {
case c := <-accepted:
_ = c.Close()
default:
}
}
// asUID makes the owner checks run as uid for the rest of the test. The socket the test
// serves is still owned by the real user, so from uid's point of view it belongs to
// somebody else: the shape of a service account's daemon, and of an impostor's.
func asUID(t *testing.T, uid uint32) {
t.Helper()
real := currentUID
currentUID = func() uint32 { return uid }
t.Cleanup(func() { currentUID = real })
}
// TestPeerCheckRefusesAnotherUser: a server built as one user and serving as another
// refuses the real user at the peer check, and the refusal reaches the client as the
// daemon's answer rather than as a failure to reach it.
func TestPeerCheckRefusesAnotherUser(t *testing.T) {
if !peerCheckSupported {
t.Skip("no peer credentials on this platform")
}
me := currentUID()
asUID(t, me+1)
s := NewServer(test.NewLogger(), basicRegistry())
asUID(t, me)
path := testSocket(t)
require.NoError(t, s.Rebind(context.Background(), path, ownerOnly()))
defer s.Stop()
_, err := (&Client{Path: path}).Describe()
require.Error(t, err)
require.ErrorContains(t, err, "not permitted")
var refused *DaemonError
assert.ErrorAs(t, err, &refused, "the daemon answered; this is not a reachability failure")
}
// TestListenerDoesNotUnlinkOnClose: the server unlinks its socket node itself, before
// the listener closes, and the listener must not unlink again afterwards. By the time a
// close happens the path may belong to a newer daemon that found the node stale and
// bound its own, and a second unlink would take that daemon's socket from under it.
func TestListenerDoesNotUnlinkOnClose(t *testing.T) {
path := filepath.Join(privateBase(t), DirName, "s.sock")
old, err := listen(path, ownerOnly())
require.NoError(t, err)
// A newer daemon's start, with the old node gone and the path bound afresh.
require.NoError(t, os.Remove(path))
next, err := net.Listen(network, path)
require.NoError(t, err)
t.Cleanup(func() { next.Close() })
require.NoError(t, old.Close())
_, err = os.Stat(path)
assert.NoError(t, err, "closing the old listener left the newer daemon's socket in place")
}
// TestListenRefusesADirectoryOthersCanWrite: whoever can write the socket's directory
// can put a socket of their own at the path and answer the clients, who trust the path;
// so a directory writable by its group or by everyone is refused, and nothing is bound.
func TestListenRefusesADirectoryOthersCanWrite(t *testing.T) {
for _, mode := range []os.FileMode{0775, 0757, 0777, 01777} {
dir := filepath.Join(privateBase(t), "loose")
require.NoError(t, os.Mkdir(dir, mode))
require.NoError(t, os.Chmod(dir, mode))
_, err := listen(filepath.Join(dir, "s.sock"), ownerOnly())
require.ErrorContains(t, err, "writable by", "mode %04o", mode)
assert.NoFileExists(t, filepath.Join(dir, "s.sock"))
}
}
// TestListenRefusesADirectoryOwnedBySomebodyElse: a directory that is neither ours nor
// root's is refused, since its owner could replace the socket. The test's own directory
// stands in for somebody else's by the process pretending to be another user.
func TestListenRefusesADirectoryOwnedBySomebodyElse(t *testing.T) {
me := currentUID()
if me == 0 {
t.Skip("root's directories are accepted whoever is asking")
}
dir := filepath.Join(privateBase(t), "theirs")
require.NoError(t, os.Mkdir(dir, 0755))
asUID(t, me+1)
_, err := listen(filepath.Join(dir, "s.sock"), ownerOnly())
require.ErrorContains(t, err, "is owned by")
assert.NoFileExists(t, filepath.Join(dir, "s.sock"))
asUID(t, me)
ln, err := listen(filepath.Join(dir, "s.sock"), ownerOnly())
require.NoError(t, err, "the same directory, seen as our own, serves")
release(ln, filepath.Join(dir, "s.sock"))
}
// TestListenThroughASymlinkedDirectory: a link where the directory should be is judged
// by who owns it, since they decide where the path leads, and then by the directory it
// resolves to. Our own link to a proper directory serves, as /var/run on linux is root's
// link to /run; somebody else's link is refused, and so is a link of ours to a directory
// others can write.
func TestListenThroughASymlinkedDirectory(t *testing.T) {
me := currentUID()
if me == 0 {
t.Skip("root's links are accepted whoever is asking")
}
base := privateBase(t)
real := filepath.Join(base, "real")
require.NoError(t, os.Mkdir(real, 0755))
link := filepath.Join(base, "link")
require.NoError(t, os.Symlink(real, link))
path := filepath.Join(link, "s.sock")
ln, err := listen(path, ownerOnly())
require.NoError(t, err, "our link to our directory")
release(ln, path)
assert.NoFileExists(t, filepath.Join(real, "s.sock"))
asUID(t, me+1)
_, err = listen(path, ownerOnly())
require.ErrorContains(t, err, "is a symlink owned by")
asUID(t, me)
require.NoError(t, os.Chmod(real, 0777))
_, err = listen(path, ownerOnly())
require.ErrorContains(t, err, "writable by everyone")
}