| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417 |
- //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"))
- }
- // TestReleaseRemovesTheDirectoryItMade: a directory listen created goes away with the
- // last socket in it, and one that was already there is left alone, whatever it holds.
- func TestReleaseRemovesTheDirectoryItMade(t *testing.T) {
- base := privateBase(t)
- made := filepath.Join(base, DirName)
- first, err := listen(filepath.Join(made, "a.sock"), ownerOnly())
- require.NoError(t, err)
- second, err := listen(filepath.Join(made, "b.sock"), ownerOnly())
- require.NoError(t, err)
- release(first, filepath.Join(made, "a.sock"))
- assert.DirExists(t, made, "b.sock is still in it")
- release(second, filepath.Join(made, "b.sock"))
- assert.NoDirExists(t, made)
- given := filepath.Join(base, "given")
- require.NoError(t, os.Mkdir(given, 0755))
- ln, err := listen(filepath.Join(given, "s.sock"), ownerOnly())
- require.NoError(t, err)
- release(ln, filepath.Join(given, "s.sock"))
- assert.DirExists(t, given)
- assert.NoFileExists(t, filepath.Join(given, "s.sock"))
- }
- // 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")
- }
- // TestMadeDirStaysRememberedUntilItIsGone: a directory listen created that cannot be
- // removed right now is still ours to remove, so the record survives the failure and the
- // next release tries again. Only a directory that is gone is forgotten.
- func TestMadeDirStaysRememberedUntilItIsGone(t *testing.T) {
- if os.Geteuid() == 0 {
- t.Skip("root is not refused by directory permissions")
- }
- parent := t.TempDir()
- dir := filepath.Join(parent, "nebula")
- require.NoError(t, os.Mkdir(dir, 0o755))
- d := dirSet{made: map[string]bool{}}
- d.add(dir)
- // A parent without write permission refuses the rmdir.
- require.NoError(t, os.Chmod(parent, 0o500))
- t.Cleanup(func() { _ = os.Chmod(parent, 0o700) })
- d.remove(dir)
- assert.True(t, d.made[dir], "a directory that is still there must stay remembered")
- assert.DirExists(t, dir)
- require.NoError(t, os.Chmod(parent, 0o700))
- d.remove(dir)
- assert.False(t, d.made[dir])
- assert.NoDirExists(t, dir)
- // One that something else already removed is simply forgotten.
- gone := filepath.Join(parent, "gone")
- d.add(gone)
- d.remove(gone)
- assert.False(t, d.made[gone])
- }
- // 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")
- }
|