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