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

+19 -1
View File
@@ -32,6 +32,11 @@ type C struct {
// Set by the first reload that needs a restart and never cleared. Guarded by reloadLock
needsRestart bool
// callbackLock guards callbacks. Registration is not confined to startup: CatchHUP is
// running from inside Main, and anything wired up after Main returns registers while
// a HUP could already be reloading, so append and iteration have to be serialized.
callbackLock sync.Mutex
}
func NewC(l *slog.Logger) *C {
@@ -78,9 +83,22 @@ func (c *C) LoadString(raw string) error {
// These functions should return quickly or spawn their own go routine if they will take a while.
// Use r to report anything that failed or needs a restart to apply.
func (c *C) RegisterReloadCallback(f func(c *C, r *Reload)) {
c.callbackLock.Lock()
defer c.callbackLock.Unlock()
c.callbacks = append(c.callbacks, f)
}
// reloadCallbacks returns a snapshot of the registered callbacks. Reloads iterate the
// snapshot rather than the live slice, so a registration from another goroutine cannot
// race the loop; a callback registered while a reload is in progress runs from the next
// reload on. callbackLock is not held during the calls themselves, so a callback that
// registers another callback does not deadlock.
func (c *C) reloadCallbacks() []func(*C, *Reload) {
c.callbackLock.Lock()
defer c.callbackLock.Unlock()
return append(make([]func(*C, *Reload), 0, len(c.callbacks)), c.callbacks...)
}
// InitialLoad returns true if this is the first load of the config, and ReloadConfig has not been called yet.
func (c *C) InitialLoad() bool {
return c.oldSettings == nil
@@ -204,7 +222,7 @@ func (c *C) ReloadConfigString(raw string) ReloadResult {
func (c *C) runCallbacks() ReloadResult {
r := &Reload{c: c}
for _, v := range c.callbacks {
for _, v := range c.reloadCallbacks() {
v(c, r)
}
+39
View File
@@ -3,6 +3,7 @@ package config
import (
"os"
"path/filepath"
"sync"
"testing"
"time"
@@ -220,3 +221,41 @@ firewall:
}
assert.Equal(t, expected, m)
}
// TestRegisterReloadCallbackDuringReload: registration is not confined to startup. The
// HUP handler runs from inside nebula.Main, and anything wired after Main returns
// registers while a reload may already be iterating the callbacks. Under -race this is
// the data race on the slice; without it, it checks a callback registered mid-reload is
// picked up by the next one.
func TestRegisterReloadCallbackDuringReload(t *testing.T) {
l := test.NewLogger()
c := NewC(l)
require.NoError(t, c.LoadString("a: 1"))
var wg sync.WaitGroup
wg.Add(2)
go func() {
defer wg.Done()
for i := 0; i < 200; i++ {
c.RegisterReloadCallback(func(*C, *Reload) {})
}
}()
go func() {
defer wg.Done()
for i := 0; i < 200; i++ {
assert.NoError(t, c.ReloadConfigString("a: 1").Err)
}
}()
wg.Wait()
// A callback that registers another callback must not deadlock, and the new one
// runs from the following reload on.
var ran int
c.RegisterReloadCallback(func(c *C, _ *Reload) {
c.RegisterReloadCallback(func(*C, *Reload) { ran++ })
})
require.NoError(t, c.ReloadConfigString("a: 1").Err)
assert.Equal(t, 0, ran, "registered during this reload, so not run by it")
require.NoError(t, c.ReloadConfigString("a: 1").Err)
assert.Equal(t, 1, ran, "and run by the next")
}