mirror of
https://github.com/slackhq/nebula.git
synced 2026-11-15 19:26: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,358 @@
|
||||
//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")
|
||||
}
|
||||
Reference in new issue
Block a user