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:
Matt RichardsonandClaude Fable 5.1 authored and GitHub committed 2026-10-21 11:16:00 -04:00
1 parent 9a5bdb508e
commit 0f767ce529
54 files changed
+7581 -925

No files matched your search

+176
View File
@@ -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")
}