mirror of
https://github.com/slackhq/nebula.git
synced 2026-11-15 06:46:52 +01:00
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>
177 lines
5.7 KiB
Go
177 lines
5.7 KiB
Go
//go:build !windows
|
|
|
|
package localapi
|
|
|
|
import (
|
|
"context"
|
|
"os"
|
|
"os/user"
|
|
"strconv"
|
|
"syscall"
|
|
"testing"
|
|
|
|
"github.com/slackhq/nebula/config"
|
|
"github.com/slackhq/nebula/test"
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
"golang.org/x/sys/unix"
|
|
)
|
|
|
|
// ownGroup returns a group this process may hand a socket to: its own effective group,
|
|
// by name and by gid, which is the one chown accepts from an unprivileged user.
|
|
func ownGroup(t *testing.T) (name, gid string) {
|
|
t.Helper()
|
|
gid = strconv.Itoa(os.Getegid())
|
|
g, err := user.LookupGroupId(gid)
|
|
require.NoError(t, err)
|
|
return g.Name, gid
|
|
}
|
|
|
|
func socketGroup(t *testing.T, path string) uint32 {
|
|
t.Helper()
|
|
fi, err := os.Lstat(path)
|
|
require.NoError(t, err)
|
|
st, ok := fi.Sys().(*syscall.Stat_t)
|
|
require.True(t, ok)
|
|
return uint32(st.Gid)
|
|
}
|
|
|
|
// TestLookupGroupID: a group is named as the host names it or by its gid; a gid the
|
|
// host does not list is accepted as chown would accept it, and a name it does not list
|
|
// is a mistake to report.
|
|
func TestLookupGroupID(t *testing.T) {
|
|
name, gid := ownGroup(t)
|
|
|
|
got, err := lookupGroupID(name)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, gid, got)
|
|
|
|
got, err = lookupGroupID(gid)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, gid, got)
|
|
|
|
got, err = lookupGroupID("4000000000")
|
|
require.NoError(t, err)
|
|
assert.Equal(t, "4000000000", got)
|
|
|
|
_, err = lookupGroupID("no-such-group-nebula-test")
|
|
require.ErrorContains(t, err, "does not exist")
|
|
}
|
|
|
|
// TestControlAccessFromConfig: no group is the owner-only socket; a group is 0660 with
|
|
// that group; a group the host does not know is a config error naming the key.
|
|
func TestControlAccessFromConfig(t *testing.T) {
|
|
name, gid := ownGroup(t)
|
|
load := func(body string) (Access, error) {
|
|
c := config.NewC(test.NewLogger())
|
|
require.NoError(t, c.LoadString(body))
|
|
return controlAccessFrom(c)
|
|
}
|
|
|
|
acc, err := load("local_api: {}\n")
|
|
require.NoError(t, err)
|
|
assert.Equal(t, ownerOnly(), acc)
|
|
|
|
acc, err = load("local_api:\n control:\n group: " + name + "\n")
|
|
require.NoError(t, err)
|
|
assert.Equal(t, os.FileMode(0660), acc.Mode)
|
|
assert.Equal(t, name, acc.Group.Name)
|
|
assert.Equal(t, gid, acc.Group.ID)
|
|
|
|
_, err = load("local_api:\n control:\n group: no-such-group-nebula-test\n")
|
|
require.ErrorContains(t, err, CtlGroupKey)
|
|
assert.ErrorContains(t, err, "does not exist")
|
|
}
|
|
|
|
// TestControlSocketIsHandedToTheGroup: the socket is created with the group's gid and
|
|
// 0660, whatever the umask, and a reload that takes the group away tightens the socket
|
|
// in place, without moving it.
|
|
func TestControlSocketIsHandedToTheGroup(t *testing.T) {
|
|
old := unix.Umask(0077)
|
|
defer unix.Umask(old)
|
|
|
|
name, gid := ownGroup(t)
|
|
want, err := strconv.ParseUint(gid, 10, 32)
|
|
require.NoError(t, err)
|
|
|
|
s := NewServer(test.NewLogger(), basicRegistry())
|
|
path := testSocket(t)
|
|
require.NoError(t, s.Rebind(context.Background(), path, Access{Mode: 0660, Group: Group{Name: name, ID: gid}}))
|
|
defer s.Stop()
|
|
|
|
fi, err := os.Lstat(path)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, os.FileMode(0660), fi.Mode().Perm())
|
|
assert.Equal(t, uint32(want), socketGroup(t, path))
|
|
|
|
require.NoError(t, s.Rebind(context.Background(), path, ownerOnly()))
|
|
fi, err = os.Lstat(path)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, os.FileMode(0600), fi.Mode().Perm())
|
|
assert.Equal(t, path, s.Path())
|
|
|
|
_, err = (&Client{Path: path}).Describe()
|
|
require.NoError(t, err, "the same listener is still serving")
|
|
}
|
|
|
|
// TestControlSocketWithAGroupTrustsTheKernel: with a group sharing the socket, the
|
|
// kernel's check on connect is the access control and the daemon's peer check stands
|
|
// aside, so a connection the peer check alone would refuse is served. Without a group
|
|
// the same connection is refused; see TestPeerCheckRefusesAnotherUser.
|
|
func TestControlSocketWithAGroupTrustsTheKernel(t *testing.T) {
|
|
if !peerCheckSupported {
|
|
t.Skip("no peer credentials on this platform")
|
|
}
|
|
name, gid := ownGroup(t)
|
|
|
|
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, Access{Mode: 0660, Group: Group{Name: name, ID: gid}}))
|
|
defer s.Stop()
|
|
|
|
_, err := (&Client{Path: path}).Describe()
|
|
require.NoError(t, err, "the socket's group admitted us; the peer check must not refuse")
|
|
|
|
// Taking the group away on a reload puts the peer check back.
|
|
require.NoError(t, s.Rebind(context.Background(), path, ownerOnly()))
|
|
_, err = (&Client{Path: path}).Describe()
|
|
require.ErrorContains(t, err, "not permitted")
|
|
}
|
|
|
|
// TestRemovingTheGroupHandsTheSocketBack: a reload that drops the group key leaves the
|
|
// mode the operator set but returns the socket to the daemon's own group, so the old
|
|
// group's members are no longer admitted. The socket is first handed to a group this
|
|
// process belongs to other than its own, which not every host offers.
|
|
func TestRemovingTheGroupHandsTheSocketBack(t *testing.T) {
|
|
own := os.Getegid()
|
|
groups, err := os.Getgroups()
|
|
require.NoError(t, err)
|
|
other := -1
|
|
for _, g := range groups {
|
|
if g != own {
|
|
other = g
|
|
break
|
|
}
|
|
}
|
|
if other < 0 {
|
|
t.Skip("this process belongs to no group but its own")
|
|
}
|
|
gid := strconv.Itoa(other)
|
|
|
|
s := NewServer(test.NewLogger(), basicRegistry())
|
|
path := testSocket(t)
|
|
require.NoError(t, s.Rebind(context.Background(), path, Access{Mode: 0660, Group: Group{Name: gid, ID: gid}}))
|
|
defer s.Stop()
|
|
assert.Equal(t, uint32(other), socketGroup(t, path))
|
|
|
|
require.NoError(t, s.Rebind(context.Background(), path, Access{Mode: 0660}))
|
|
assert.Equal(t, uint32(own), socketGroup(t, path), "the daemon's own group again")
|
|
fi, err := os.Lstat(path)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, os.FileMode(0660), fi.Mode().Perm(), "the mode is the operator's")
|
|
}
|