Jelajahi Sumber

Let the config turn the control socket off

The control socket was on for every daemon with no way to decline it. An
operator who does not want a local control endpoint, or a host whose
policy forbids one, now sets local_api.control.enabled to false.

- local_api.control.enabled, true unless set; reloadable both ways, and turning it off removes the socket
- the daemon logs at startup that the socket is disabled
- nebula ctl reading such a config says the socket is disabled instead of dialing; -socket still wins
- example config, CHANGELOG and tests for the wiring and the client

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Matt Richardson 1 hari lalu
induk
melakukan
5d3e2fcecb
7 mengubah file dengan 83 tambahan dan 7 penghapusan
  1. 2 1
      CHANGELOG.md
  2. 6 0
      cmd/internal/ctl/ctl.go
  3. 14 0
      cmd/internal/ctl/ctl_test.go
  4. 2 0
      examples/config.yml
  5. 12 0
      localapi/path.go
  6. 16 6
      localapi/wire.go
  7. 31 0
      localapi/wire_test.go

+ 2 - 1
CHANGELOG.md

@@ -31,7 +31,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
   holds and logs that as an error. `local_api.control.group` names a group that shares the socket, the way
   `dockerd -G` does: the socket is created 0660 and owned by the group on unix, and the group is granted
   access to the pipe on windows, so its members run `nebula ctl` as themselves; the daemon then leaves the
-  access check to the kernel.
+  access check to the kernel. `local_api.control.enabled: false` turns the socket off, and a reload that
+  changes it removes the socket or brings it back.
 - Every command on both the ssh debug server and `nebula ctl` now takes `-json` and `-pretty`, rendered by one
   shared implementation per command. `nebula ctl` prints human readable output by default; `print-tunnel`,
   `print-relays` and `query-lighthouse` have no human form yet and ask for `-json` or `-pretty` rather than

+ 6 - 0
cmd/internal/ctl/ctl.go

@@ -409,6 +409,12 @@ func pathFromConfig(configFlag string) (path, configPath, overrideIn string, err
 		return "", "", "", fmt.Errorf("nebula: could not read %s: %s", configPath, err)
 	}
 
+	// A daemon running this config serves no socket; dialing the path would only say
+	// that nothing is there.
+	if !localapi.ControlEnabled(c) {
+		return "", "", "", fmt.Errorf("nebula: the control socket is disabled in %s by %s", configPath, localapi.CtlEnabledKey)
+	}
+
 	// The same call the daemon makes, so the two agree on the path and on what makes a
 	// path unusable.
 	p, err := localapi.SocketPathFromConfig(c)

+ 14 - 0
cmd/internal/ctl/ctl_test.go

@@ -44,6 +44,20 @@ func TestResolveSocketTrustsANamedConfig(t *testing.T) {
 	assert.Equal(t, nowhereSocket, p)
 }
 
+// TestResolveSocketSaysTheSocketIsDisabled: a config that turns the control socket off
+// is answered with that, not with a dial to a path nothing serves. -socket still wins.
+func TestResolveSocketSaysTheSocketIsDisabled(t *testing.T) {
+	cfg := filepath.Join(t.TempDir(), "config.yml")
+	require.NoError(t, os.WriteFile(cfg, []byte("local_api:\n  control:\n    enabled: false\n"), 0600))
+
+	_, _, _, err := resolveSocket("", cfg)
+	require.ErrorContains(t, err, "the control socket is disabled in "+cfg+" by local_api.control.enabled")
+
+	p, _, _, err := resolveSocket("/explicit.sock", cfg)
+	require.NoError(t, err)
+	assert.Equal(t, "/explicit.sock", p)
+}
+
 // TestResolveSocketUsesTheDefaultWithoutAnOverride: a config that does not name the
 // socket means the default path, the same one the daemon serves with that config, and
 // no config at all means the default too, since that is where a daemon started without

+ 2 - 0
examples/config.yml

@@ -244,6 +244,8 @@ punchy:
   # `nebula ctl <command>`. It defaults to /var/run/nebula/ctl.sock on Linux, macOS and
   # the BSDs; on Windows it is a named pipe under \\.\pipe\ProtectedPrefix\Administrators.
   #control:
+    # Set to false to turn the control socket off. Reloadable.
+    #enabled: true
     # The absolute path where the control socket should live, or a full pipe name on
     # Windows. The last directory is created if missing, and must be owned by root or by
     # the user running nebula. It must not be writable by anyone else.

+ 12 - 0
localapi/path.go

@@ -3,6 +3,18 @@ package localapi
 // DirName is the directory under /var/run that holds nebula's sockets by default.
 const DirName = "nebula"
 
+// CtlEnabledKey is the optional config key that turns the control socket off. It is on
+// unless the config says false, and reloadable: a reload that turns it off removes the
+// socket, and one that turns it back on brings it up again.
+const CtlEnabledKey = "local_api.control.enabled"
+
+// ControlEnabled reports whether a config leaves the control socket on. nebula ctl reads
+// it too, so a client pointed at a config that turned the socket off says so rather than
+// failing to dial.
+func ControlEnabled(c interface{ GetBool(string, bool) bool }) bool {
+	return c.GetBool(CtlEnabledKey, true)
+}
+
 // CtlSocketKey is the optional config key naming the control socket. Unset, the socket
 // is DefaultSocketPath, one fixed path that suits a daemon running as root, which is
 // what the tun device needs anyway. A daemon run as another user, or a second daemon on

+ 16 - 6
localapi/wire.go

@@ -9,15 +9,14 @@ import (
 	"github.com/slackhq/nebula/config"
 )
 
-// Wire brings up the local control socket for a running nebula and keeps it pointed at
-// the configured path across reloads.
+// Wire brings up the local control socket for a running nebula, unless the config turns
+// it off, and keeps it following the config across reloads.
 //
 // Every entry point that runs nebula as a daemon calls this, so the endpoint behaves the
 // same whether nebula was started from a shell or by a service manager. It deliberately
-// does not live in nebula.Main: the endpoint is on by default with no key to disable it,
-// so an embedder inside an app sandbox would have no way to decline a socket it cannot
-// use. Wiring it at the daemon entry points means embedders get the command registry and
-// no listener.
+// does not live in nebula.Main: the endpoint is on by default, so an embedder inside an
+// app sandbox would have to know to turn off a socket it cannot use. Wiring it at the
+// daemon entry points means embedders get the command registry and no listener.
 //
 // Every failure below is a warning, bar one. Nebula is expected to keep forwarding
 // packets whether or not anyone can reach it; but a name that another process holds is
@@ -35,6 +34,9 @@ func Wire(ctx context.Context, l *slog.Logger, c *config.C, reg *commands.Regist
 	// the path is a no-op.
 	reconcile := followControlConfig(ctx, l, c, srv)
 	reconcile(c)
+	if !ControlEnabled(c) {
+		l.Info("Local control socket is disabled", "key", CtlEnabledKey)
+	}
 	return srv
 }
 
@@ -59,6 +61,14 @@ func Wire(ctx context.Context, l *slog.Logger, c *config.C, reg *commands.Regist
 // down and skip every callback queued behind this one.
 func followControlConfig(ctx context.Context, l *slog.Logger, c *config.C, srv *Server) func(*config.C) {
 	reconcile := func(c *config.C) {
+		if !ControlEnabled(c) {
+			if srv.Path() != "" {
+				srv.Stop()
+				l.Info("Stopped the local control socket, it is disabled in the reloaded config")
+			}
+			return
+		}
+
 		want, err := SocketPathFromConfig(c)
 		if err != nil {
 			if srv.Path() == "" {

+ 31 - 0
localapi/wire_test.go

@@ -142,3 +142,34 @@ func TestWireRecoversOnPlainReloadAfterBadBind(t *testing.T) {
 	assert.Empty(t, errText)
 	assert.Equal(t, "[still]", out)
 }
+
+// TestWireControlCanBeDisabled: local_api.control.enabled false serves no socket, and
+// the key follows reloads both ways.
+func TestWireControlCanBeDisabled(t *testing.T) {
+	ctx, cancel := context.WithCancel(context.Background())
+	t.Cleanup(cancel)
+
+	path := testSocket(t)
+	section := func(enabled bool) string {
+		return fmt.Sprintf("local_api:\n  control:\n    enabled: %t\n    socket: %s\n", enabled, path)
+	}
+
+	c := config.NewC(test.NewLogger())
+	require.NoError(t, c.LoadString(section(false)))
+	srv := Wire(ctx, test.NewLogger(), c, basicRegistry())
+	t.Cleanup(srv.Stop)
+	assert.Empty(t, srv.Path())
+	_, err := os.Lstat(path)
+	assert.True(t, os.IsNotExist(err), "no socket is bound")
+
+	require.NoError(t, c.ReloadConfigString(section(true)).Err)
+	assert.Equal(t, path, srv.Path())
+	out, errText := run(t, path, "echo", "on")
+	assert.Empty(t, errText)
+	assert.Equal(t, "[on]", out)
+
+	require.NoError(t, c.ReloadConfigString(section(false)).Err)
+	assert.Empty(t, srv.Path())
+	_, err = os.Lstat(path)
+	assert.True(t, os.IsNotExist(err), "the socket is removed")
+}