Преглед изворни кода

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 дан
родитељ
комит
5d3e2fcecb
7 измењених фајлова са 83 додато и 7 уклоњено
  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")
+}