Files
nebula/localapi/platform_windows_test.go
Matt RichardsonandClaude Fable 5.1 0f767ce529 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>
2026-10-21 11:16:00 -04:00

187 lines
6.4 KiB
Go

//go:build windows
package localapi
import (
"errors"
"net"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"golang.org/x/sys/windows"
)
// TestOwnerAccepted pins which accounts may answer a nebula ctl request: the client's
// own, and the accounts a daemon runs as that an ordinary user cannot create an object
// owned by. Any other user is refused, since any user can create any pipe name.
func TestOwnerAccepted(t *testing.T) {
me, err := windows.GetCurrentProcessToken().GetTokenUser()
require.NoError(t, err)
wellKnown := func(typ windows.WELL_KNOWN_SID_TYPE) *windows.SID {
sid, err := windows.CreateWellKnownSid(typ)
require.NoError(t, err)
return sid
}
parse := func(s string) *windows.SID {
sid, err := windows.StringToSid(s)
require.NoError(t, err)
return sid
}
accepted := map[string]*windows.SID{
"self": me.User.Sid,
"SYSTEM": wellKnown(windows.WinLocalSystemSid),
"Administrators": wellKnown(windows.WinBuiltinAdministratorsSid),
}
for name, sid := range accepted {
assert.True(t, ownerAccepted(sid, me.User.Sid), name)
}
refused := map[string]*windows.SID{
"Everyone": wellKnown(windows.WinWorldSid),
"Users": wellKnown(windows.WinBuiltinUsersSid),
"another user": parse("S-1-5-21-1-2-3-1001"),
"a service's own account": parse("S-1-5-80-1-2-3-4-5"),
"LocalService": wellKnown(windows.WinLocalServiceSid),
}
for name, sid := range refused {
assert.False(t, ownerAccepted(sid, me.User.Sid), name)
}
}
// TestDefaultPathIsProtected pins the default name to the namespace only administrators
// and SYSTEM can create under, which is what lets nebula ctl trust the default name
// from its spelling alone.
func TestDefaultPathIsProtected(t *testing.T) {
p := DefaultSocketPath
assert.True(t, isProtectedPath(p), p)
assert.Equal(t, `\\.\pipe\ProtectedPrefix\Administrators\nebula-ctl`, p)
}
// TestConfiguredPipeNameMustBeFull: a configured name is used as given, and only a full
// pipe name means anything here.
func TestConfiguredPipeNameMustBeFull(t *testing.T) {
p, err := SocketPathFromConfig(fakeWinConfig{CtlSocketKey: `\\.\pipe\nebula-ctl`})
require.NoError(t, err)
assert.Equal(t, `\\.\pipe\nebula-ctl`, p)
for _, bad := range []string{"nebula-ctl", `C:\nebula\ctl.sock`, `\\.\pipe\`} {
_, err := SocketPathFromConfig(fakeWinConfig{CtlSocketKey: bad})
require.Error(t, err, bad)
assert.ErrorContains(t, err, "full pipe name", bad)
}
}
type fakeWinConfig map[string]string
func (f fakeWinConfig) GetString(k, d string) string {
if v, ok := f[k]; ok {
return v
}
return d
}
func TestIsProtectedPath(t *testing.T) {
assert.True(t, isProtectedPath(`\\.\pipe\ProtectedPrefix\Administrators\nebula-1`))
assert.True(t, isProtectedPath(`\\.\pipe\protectedprefix\administrators\nebula-1`), "pipe names are case insensitive")
assert.False(t, isProtectedPath(`\\.\pipe\ProtectedPrefix\Administrators\`), "the prefix alone names nothing")
assert.False(t, isProtectedPath(`\\.\pipe\nebula-1`))
assert.False(t, isProtectedPath(`\\.\pipe\ProtectedPrefix\Users\nebula-1`))
}
// TestListenRefused pins how a failed create is reported. Access denied under the
// protected prefix is the namespace refusing a non-administrator, and says so; the same
// error for an administrator, or for any name outside the prefix, is a name some other
// process holds, and is errEndpointHeld so the daemon logs it as an error.
func TestListenRefused(t *testing.T) {
protected := `\\.\pipe\ProtectedPrefix\Administrators\nebula-1`
plain := `\\.\pipe\nebula-1`
err := listenRefused(protected, false, windows.ERROR_ACCESS_DENIED)
assert.NotErrorIs(t, err, errEndpointHeld)
assert.ErrorIs(t, err, windows.ERROR_ACCESS_DENIED)
assert.Contains(t, err.Error(), "requires an administrator")
assert.Contains(t, err.Error(), CtlSocketKey)
assert.ErrorIs(t, listenRefused(protected, true, windows.ERROR_ACCESS_DENIED), errEndpointHeld)
assert.ErrorIs(t, listenRefused(plain, false, windows.ERROR_ACCESS_DENIED), errEndpointHeld)
assert.ErrorIs(t, listenRefused(protected, false, windows.ERROR_ALREADY_EXISTS), errEndpointHeld)
assert.ErrorIs(t, listenRefused(plain, true, windows.ERROR_ALREADY_EXISTS), errEndpointHeld)
other := errors.New("something else")
assert.Same(t, other, listenRefused(protected, false, other))
}
// TestListenProtectedName creates and dials a name under the protected prefix. It needs
// an administrator, and otherwise checks only that the refusal is the one listen
// promises a non-administrator.
func TestListenProtectedName(t *testing.T) {
path := protectedPrefix + "nebula-test-" + t.Name()
ln, err := listen(path, ownerOnly())
if !isAdministrator() {
require.Error(t, err)
assert.Contains(t, err.Error(), "requires an administrator")
t.Skip("not an administrator; the protected namespace refused the create as it should")
}
require.NoError(t, err)
t.Cleanup(func() { ln.Close() })
accepted := make(chan error, 1)
go func() {
conn, err := ln.Accept()
if err == nil {
conn.Close()
}
accepted <- err
}()
conn, err := dial(path, time.Second)
require.NoError(t, err)
conn.Close()
require.NoError(t, <-accepted)
}
// TestDialAcceptsOwnPipe dials a pipe this process created outside the protected
// prefix, as an override might name, which the owner check must pass: the pipe's owner
// is this account, or Administrators when the test is elevated.
func TestDialAcceptsOwnPipe(t *testing.T) {
path := pipePrefix + "nebula-test-" + t.Name()
ln, err := listen(path, ownerOnly())
require.NoError(t, err)
t.Cleanup(func() { ln.Close() })
accepted := make(chan error, 1)
go func() {
conn, err := ln.Accept()
if err == nil {
conn.Close()
}
accepted <- err
}()
conn, err := dial(path, time.Second)
require.NoError(t, err)
conn.Close()
require.NoError(t, <-accepted)
}
// TestListenRefusesHeldName pins that a name another listener already holds is refused
// with errEndpointHeld rather than joined, which is what keeps an impostor from being
// served alongside, or in place of, the daemon.
func TestListenRefusesHeldName(t *testing.T) {
path := pipePrefix + "nebula-test-" + t.Name()
first, err := listen(path, ownerOnly())
require.NoError(t, err)
t.Cleanup(func() { first.Close() })
var second net.Listener
second, err = listen(path, ownerOnly())
if second != nil {
second.Close()
}
require.ErrorIs(t, err, errEndpointHeld)
}