Kaynağa Gözat

Fold the one-caller wrappers and duplicate constants in localapi

Several helpers had one caller or wrapped a constant, and Group was a
pointer only to say "none", which cost an equal method and nil checks.

- bindFailure inlined into reportRebind; one warnUnusable for the three unusable-config pairs
- logListening goes on both platforms; the control socket logs its line where the info API does
- one ownerOnlyMode for the two 0600 constants
- configuredSocketPath goes, its callers call checkSocketPath; DefaultSocketPath is the exported const
- Group is a value, the zero Group is none, so Access compares with == and Access.equal goes

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Matt Richardson 1 gün önce
ebeveyn
işleme
6428643339

+ 1 - 1
cmd/internal/ctl/ctl.go

@@ -378,7 +378,7 @@ func pathFromConfig(configFlag string) (string, error) {
 	if configPath == "" {
 		p, err := config.DefaultPath()
 		if err != nil {
-			return localapi.DefaultSocketPath(), nil
+			return localapi.DefaultSocketPath, nil
 		}
 		configPath = p
 	}

+ 2 - 2
cmd/internal/ctl/ctl_test.go

@@ -68,12 +68,12 @@ func TestResolveSocketUsesTheDefaultWithoutAnOverride(t *testing.T) {
 
 	p, err := resolveSocket("", cfg)
 	require.NoError(t, err)
-	assert.Equal(t, localapi.DefaultSocketPath(), p)
+	assert.Equal(t, localapi.DefaultSocketPath, p)
 
 	// The test binary has no config next to it.
 	p, err = resolveSocket("", "")
 	require.NoError(t, err)
-	assert.Equal(t, localapi.DefaultSocketPath(), p)
+	assert.Equal(t, localapi.DefaultSocketPath, p)
 }
 
 func TestResolveSocketFlagWins(t *testing.T) {

+ 27 - 23
localapi/access.go

@@ -15,10 +15,11 @@ import (
 // entry in the pipe's security descriptor and the mode means nothing to a pipe.
 type Access struct {
 	Mode  os.FileMode
-	Group *Group
+	Group Group
 }
 
-// Group is a group named in the config, resolved to what the platform checks.
+// Group is a group named in the config, resolved to what the platform checks. The zero
+// Group is none.
 type Group struct {
 	// Name is what the config said.
 	Name string
@@ -27,47 +28,50 @@ type Group struct {
 	ID string
 }
 
-// groupSocketMode is the mode a socket takes when a group shares it and the config
-// names no other: the account nebula runs as and the group, and nobody else.
-const groupSocketMode os.FileMode = 0660
+const (
+	// ownerOnlyMode is a socket's mode when no group shares it: the account nebula runs
+	// as, and nobody else.
+	ownerOnlyMode os.FileMode = 0600
+
+	// groupSocketMode is the mode a socket takes when a group shares it and the config
+	// names no other: the account nebula runs as and the group, and nobody else.
+	groupSocketMode os.FileMode = 0660
+)
 
 // errReopenForAccess is returned by applyAccess on a platform whose access control is
 // fixed when the endpoint is created, so a change means creating it again.
 var errReopenForAccess = errors.New("access is fixed at listen time")
 
-// ownerOnly is the control socket's access when no group is configured: the account
-// nebula runs as, and nobody else.
-func ownerOnly() Access { return Access{Mode: ctlSocketMode} }
+// ownerOnly is the control socket's access when no group is configured. The mode
+// itself is not configurable there; what an operator can choose is a group,
+// CtlGroupKey, which makes it groupSocketMode.
+func ownerOnly() Access { return Access{Mode: ownerOnlyMode} }
+
+// shared reports whether a group shares the socket.
+func (a Access) shared() bool { return a.Group.ID != "" }
 
 // String renders the access for a log line.
 func (a Access) String() string {
-	if a.Group == nil {
+	if !a.shared() {
 		return modeString(a.Mode)
 	}
 	return fmt.Sprintf("%s group %s (%s)", modeString(a.Mode), a.Group.Name, a.Group.ID)
 }
 
-func (a Access) equal(b Access) bool {
-	if a.Mode != b.Mode || (a.Group == nil) != (b.Group == nil) {
-		return false
-	}
-	return a.Group == nil || a.Group.ID == b.Group.ID
-}
-
-// groupFromConfig reads the group a key names, or nil when it names none. The name is
-// resolved here, when the config is read, so a group that does not exist is a config
-// error reported with the key rather than a bind that fails later.
-func groupFromConfig(c interface{ GetString(string, string) string }, key string) (*Group, error) {
+// groupFromConfig reads the group a key names, the zero Group when it names none. The
+// name is resolved here, when the config is read, so a group that does not exist is a
+// config error reported with the key rather than a bind that fails later.
+func groupFromConfig(c interface{ GetString(string, string) string }, key string) (Group, error) {
 	name := c.GetString(key, "")
 	if name == "" {
-		return nil, nil
+		return Group{}, nil
 	}
 
 	id, err := lookupGroupID(name)
 	if err != nil {
-		return nil, fmt.Errorf("%s: %w", key, err)
+		return Group{}, fmt.Errorf("%s: %w", key, err)
 	}
-	return &Group{Name: name, ID: id}, nil
+	return Group{Name: name, ID: id}, nil
 }
 
 func modeString(m os.FileMode) string {

+ 4 - 8
localapi/access_unix_test.go

@@ -71,12 +71,10 @@ func TestControlAccessFromConfig(t *testing.T) {
 	acc, err := load("local_api: {}\n")
 	require.NoError(t, err)
 	assert.Equal(t, ownerOnly(), acc)
-	assert.Nil(t, acc.Group)
 
 	acc, err = load("local_api:\n  control:\n    group: " + name + "\n")
 	require.NoError(t, err)
 	assert.Equal(t, os.FileMode(0660), acc.Mode)
-	require.NotNil(t, acc.Group)
 	assert.Equal(t, name, acc.Group.Name)
 	assert.Equal(t, gid, acc.Group.ID)
 
@@ -98,7 +96,7 @@ func TestControlSocketIsHandedToTheGroup(t *testing.T) {
 
 	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}}))
+	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)
@@ -132,7 +130,7 @@ func TestControlSocketWithAGroupTrustsTheKernel(t *testing.T) {
 	asUID(t, me)
 
 	path := testSocket(t)
-	require.NoError(t, s.Rebind(context.Background(), path, Access{Mode: 0660, Group: &Group{Name: name, ID: gid}}))
+	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()
@@ -161,7 +159,6 @@ func TestInfoAccessFromConfig(t *testing.T) {
 	cfg, err := load(section("    group: " + name + "\n"))
 	require.NoError(t, err)
 	assert.Equal(t, os.FileMode(0660), cfg.Access.Mode)
-	require.NotNil(t, cfg.Access.Group)
 	assert.Equal(t, gid, cfg.Access.Group.ID)
 
 	cfg, err = load(section("    group: " + name + "\n    socket_mode: \"0664\"\n"))
@@ -174,8 +171,7 @@ func TestInfoAccessFromConfig(t *testing.T) {
 
 	cfg, err = load(section(""))
 	require.NoError(t, err)
-	assert.Equal(t, os.FileMode(0600), cfg.Access.Mode)
-	assert.Nil(t, cfg.Access.Group)
+	assert.Equal(t, ownerOnly(), cfg.Access)
 }
 
 // TestRemovingTheGroupHandsTheSocketBack: a reload that drops the group key leaves the
@@ -199,7 +195,7 @@ func TestRemovingTheGroupHandsTheSocketBack(t *testing.T) {
 	gid := strconv.Itoa(other)
 
 	s, path := serveInfo(t, infoRegistry(), 0600)
-	require.NoError(t, s.Rebind(context.Background(), path, Access{Mode: 0660, Group: &Group{Name: gid, ID: gid}}))
+	require.NoError(t, s.Rebind(context.Background(), path, Access{Mode: 0660, Group: Group{Name: gid, ID: gid}}))
 	assert.Equal(t, uint32(other), socketGroup(t, path))
 
 	require.NoError(t, s.Rebind(context.Background(), path, Access{Mode: 0660}))

+ 1 - 1
localapi/access_windows_test.go

@@ -35,7 +35,7 @@ func TestSDDLAdmitsTheGroup(t *testing.T) {
 	require.NoError(t, err)
 	assert.NotContains(t, sddl, "GRGW")
 
-	sddl, err = sddlFor(Access{Mode: groupSocketMode, Group: &Group{Name: "Users", ID: "S-1-5-32-545"}})
+	sddl, err = sddlFor(Access{Mode: groupSocketMode, Group: Group{Name: "Users", ID: "S-1-5-32-545"}})
 	require.NoError(t, err)
 	assert.Contains(t, sddl, "(A;;GA;;;SY)(A;;GA;;;BA)(A;;0x12019b;;;S-1-5-32-545)")
 }

+ 1 - 1
localapi/endpoint.go

@@ -72,7 +72,7 @@ func (e *endpoint) rebindLocked(ctx context.Context, path string, acc Access, se
 	reopening := false
 	if e.closer != nil && e.path == path {
 		if cur, ok := identifySocket(path); ok && cur == e.id {
-			if e.access.equal(acc) {
+			if e.access == acc {
 				return nil
 			}
 			err := applyAccess(path, acc)

+ 1 - 1
localapi/group_unix.go

@@ -28,7 +28,7 @@ func lookupGroupID(name string) (string, error) {
 // gidFor returns the gid the socket is owned by under acc: the configured group's, or
 // the daemon's own effective group when none is configured.
 func gidFor(acc Access) (int, error) {
-	if acc.Group == nil {
+	if !acc.shared() {
 		return os.Getegid(), nil
 	}
 	gid, err := strconv.ParseUint(acc.Group.ID, 10, 32)

+ 7 - 11
localapi/info.go

@@ -35,11 +35,6 @@ const (
 )
 
 const (
-	// defaultInfoMode is the socket's mode when the config names neither a mode nor a
-	// group: the daemon's user alone, so that enabling the API does not open it to
-	// anyone until the operator says who. Naming a group makes it groupSocketMode.
-	defaultInfoMode os.FileMode = 0600
-
 	// infoMaxConns bounds the connections held open at once. Beyond it the kernel
 	// queues, which is what an HTTP client expects of a busy server; the control socket
 	// refuses instead because its clients are people at a prompt.
@@ -368,18 +363,19 @@ func infoConfigFrom(c *config.C) (infoConfig, error) {
 	if p == "" {
 		return cfg, fmt.Errorf("%s is true but %s is not set; the info API needs a socket path", InfoEnabledKey, InfoSocketKey)
 	}
-	path, err := configuredSocketPath(InfoSocketKey, p)
-	if err != nil {
+	if err := checkSocketPath(InfoSocketKey, p); err != nil {
 		return cfg, err
 	}
-	cfg.Path = path
+	cfg.Path = p
 
 	group, err := groupFromConfig(c, InfoGroupKey)
 	if err != nil {
 		return cfg, err
 	}
-	def := defaultInfoMode
-	if group != nil {
+	// With neither a mode nor a group named, the daemon's user alone, so that enabling
+	// the API does not open it to anyone until the operator says who.
+	def := ownerOnlyMode
+	if group.ID != "" {
 		def = groupSocketMode
 	}
 
@@ -387,7 +383,7 @@ func infoConfigFrom(c *config.C) (infoConfig, error) {
 	if err != nil {
 		return cfg, fmt.Errorf("%s: %w", InfoModeKey, err)
 	}
-	if group != nil && mode&0070 == 0 {
+	if group.ID != "" && mode&0070 == 0 {
 		return cfg, fmt.Errorf("%s %s gives group %s no access to the socket; use \"%04o\" or leave the mode unset", InfoModeKey, modeString(mode), group.Name, mode|0060)
 	}
 	cfg.Access = Access{Mode: mode, Group: group}

+ 1 - 1
localapi/info_windows_test.go

@@ -24,7 +24,7 @@ import (
 func TestInfoIsServedOnAPipe(t *testing.T) {
 	users, err := windows.CreateWellKnownSid(windows.WinBuiltinUsersSid)
 	require.NoError(t, err)
-	group := &Group{Name: "Users", ID: users.String()}
+	group := Group{Name: "Users", ID: users.String()}
 
 	s := NewInfoServer(test.NewLogger(), commands.New())
 	path := pipePrefix + "nebula-info-test"

+ 12 - 22
localapi/path.go

@@ -16,8 +16,9 @@ func ControlEnabled(c interface{ GetBool(string, bool) bool }) bool {
 }
 
 // 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
+// is DefaultSocketPath, one fixed path per platform that both the daemon and nebula ctl
+// know, which is what lets the endpoint work with no configuration on either side. It
+// 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
 // one host, names its own. The CLI reads the same key, so when both sides see the same
 // config file they agree on the socket with nothing more said.
 const CtlSocketKey = "local_api.control.socket"
@@ -37,34 +38,23 @@ func controlAccessFrom(c interface{ GetString(string, string) string }) (Access,
 	if err != nil {
 		return Access{}, err
 	}
-	if group == nil {
+	if group.ID == "" {
 		return ownerOnly(), nil
 	}
 	return Access{Mode: groupSocketMode, Group: group}, nil
 }
 
-// DefaultSocketPath is the control socket when the config names none: one fixed path per
-// platform, see defaultSocketPath. Both the daemon and nebula ctl use it, which is what
-// lets the endpoint work with no configuration on either side.
-func DefaultSocketPath() string { return defaultSocketPath }
-
 // SocketPathFromConfig returns the control socket path a config implies: CtlSocketKey
-// when it is set, else DefaultSocketPath.
+// when it is set, else DefaultSocketPath. A configured path must be absolute, so that
+// the daemon and nebula ctl, started from wherever the operator happens to be, name the
+// same socket from one config, and one this platform can bind; see checkSocketPath.
 func SocketPathFromConfig(c interface{ GetString(string, string) string }) (string, error) {
-	if override := c.GetString(CtlSocketKey, ""); override != "" {
-		return configuredSocketPath(CtlSocketKey, override)
+	override := c.GetString(CtlSocketKey, "")
+	if override == "" {
+		return DefaultSocketPath, nil
 	}
-
-	return DefaultSocketPath(), nil
-}
-
-// configuredSocketPath checks the value of a socket path key and returns it as given. It
-// must be absolute, so that the daemon and nebula ctl, started from wherever the
-// operator happens to be, name the same socket from one config, and it must be a path
-// this platform can bind. key names the setting in the error.
-func configuredSocketPath(key, value string) (string, error) {
-	if err := checkSocketPath(key, value); err != nil {
+	if err := checkSocketPath(CtlSocketKey, override); err != nil {
 		return "", err
 	}
-	return value, nil
+	return override, nil
 }

+ 1 - 1
localapi/path_test.go

@@ -25,7 +25,7 @@ func TestSocketPathFromConfigUsesTheDefault(t *testing.T) {
 	p, err := SocketPathFromConfig(fakeConfig{})
 	require.NoError(t, err)
 	assert.Equal(t, "/var/run/nebula/ctl.sock", p)
-	assert.Equal(t, DefaultSocketPath(), p)
+	assert.Equal(t, DefaultSocketPath, p)
 }
 
 func TestSocketPathFromConfigHonoursOverride(t *testing.T) {

+ 2 - 14
localapi/platform_unix.go

@@ -5,7 +5,6 @@ package localapi
 import (
 	"errors"
 	"fmt"
-	"log/slog"
 	"net"
 	"os"
 	"os/user"
@@ -322,10 +321,10 @@ const maxSocketPath = sunPathMax - 1
 
 func pathTooLong(path string) bool { return len(path) > maxSocketPath }
 
-// defaultSocketPath is the control socket when the config names none. It is root's to
+// DefaultSocketPath is the control socket when the config names none. It is root's to
 // create, as the tun device is root's to open; a daemon run as anyone else names a path
 // of its own with CtlSocketKey.
-const defaultSocketPath = "/var/run/" + DirName + "/ctl.sock"
+const DefaultSocketPath = "/var/run/" + DirName + "/ctl.sock"
 
 // checkSocketPath refuses a configured socket path that is relative, or too long to
 // bind. A relative path would mean a different socket to the daemon and to nebula ctl,
@@ -340,15 +339,4 @@ func checkSocketPath(key, value string) error {
 	return nil
 }
 
-// logListening is the line written when the socket is up. It says what the access
-// control is: the socket's mode plus the peer check on each connection, or, when a group
-// shares the socket, the mode and group alone.
-func logListening(l *slog.Logger, path string, acc Access) {
-	if acc.Group != nil {
-		l.Info("Local control socket listening, access decided by the socket's group", "path", path, "access", acc.String())
-		return
-	}
-	l.Info("Local control socket listening", "path", path, "access", acc.String())
-}
-
 func isSocket(fi os.FileInfo) bool { return fi.Mode()&os.ModeSocket != 0 }

+ 1 - 1
localapi/platform_unix_test.go

@@ -241,7 +241,7 @@ func TestPeerCheckRefusesAnotherUser(t *testing.T) {
 // bound its own, and a second unlink would take that daemon's socket from under it.
 func TestListenerDoesNotUnlinkOnClose(t *testing.T) {
 	path := filepath.Join(privateBase(t), DirName, "s.sock")
-	old, err := listen(path, Access{Mode: ctlSocketMode})
+	old, err := listen(path, ownerOnly())
 	require.NoError(t, err)
 
 	// A newer daemon's start, with the old node gone and the path bound afresh.

+ 3 - 16
localapi/platform_windows.go

@@ -5,7 +5,6 @@ package localapi
 import (
 	"errors"
 	"fmt"
-	"log/slog"
 	"net"
 	"strings"
 	"time"
@@ -33,18 +32,6 @@ const network = "pipe"
 // exists so a Server is built the same way on every platform.
 var currentUID = func() uint32 { return 0 }
 
-// logListening is the line written when the pipe is up. It says which layer is doing
-// the access control, so nobody assumes a per-connection check that is not running:
-// there is none here, the pipe's security descriptor admits only the accounts listed in
-// ownerOnlySDDL, and the kernel enforces that at connect time.
-func logListening(l *slog.Logger, path string, acc Access) {
-	l.Info("Local control socket listening, access restricted by the pipe's security descriptor",
-		"path", path,
-		"access", acc.String(),
-		"reason", "the security descriptor built at listen time is the access check; the connecting process is not identified separately",
-	)
-}
-
 // listen creates the named pipe at path with a security descriptor that admits the user
 // nebula runs as, SYSTEM and Administrators, and the group acc names, if any, for
 // reading and writing, which is what connecting to a pipe takes. acc's mode means
@@ -140,7 +127,7 @@ func sddlFor(acc Access) (string, error) {
 	}
 
 	sddl := fmt.Sprintf("D:P(A;;GA;;;%s)(A;;GA;;;SY)(A;;GA;;;BA)", user.User.Sid.String())
-	if acc.Group != nil {
+	if acc.shared() {
 		sddl += fmt.Sprintf("(A;;%s;;;%s)", groupPipeMask, acc.Group.ID)
 	}
 	return sddl, nil
@@ -263,10 +250,10 @@ func describeSID(sid *windows.SID) string {
 	return domain + `\` + account
 }
 
-// defaultSocketPath is the control pipe when the config names none. It is under
+// DefaultSocketPath is the control pipe when the config names none. It is under
 // protectedPrefix, so whatever answers it was created by an administrator or SYSTEM; see
 // there.
-const defaultSocketPath = protectedPrefix + "nebula-ctl"
+const DefaultSocketPath = protectedPrefix + "nebula-ctl"
 
 // checkSocketPath refuses a configured name that is not a full pipe name. A pipe lives
 // under pipePrefix and nowhere else, so a bare name has no meaning here, and one under

+ 5 - 5
localapi/platform_windows_test.go

@@ -59,7 +59,7 @@ func TestOwnerAccepted(t *testing.T) {
 // 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()
+	p := DefaultSocketPath
 	assert.True(t, isProtectedPath(p), p)
 	assert.Equal(t, `\\.\pipe\ProtectedPrefix\Administrators\nebula-ctl`, p)
 }
@@ -123,7 +123,7 @@ func TestListenRefused(t *testing.T) {
 // promises a non-administrator.
 func TestListenProtectedName(t *testing.T) {
 	path := protectedPrefix + "nebula-test-" + t.Name()
-	ln, err := listen(path, Access{Mode: ctlSocketMode})
+	ln, err := listen(path, ownerOnly())
 	if !isAdministrator() {
 		require.Error(t, err)
 		assert.Contains(t, err.Error(), "requires an administrator")
@@ -152,7 +152,7 @@ func TestListenProtectedName(t *testing.T) {
 // 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, Access{Mode: ctlSocketMode})
+	ln, err := listen(path, ownerOnly())
 	require.NoError(t, err)
 	t.Cleanup(func() { ln.Close() })
 
@@ -176,12 +176,12 @@ func TestDialAcceptsOwnPipe(t *testing.T) {
 // served alongside, or in place of, the daemon.
 func TestListenRefusesHeldName(t *testing.T) {
 	path := pipePrefix + "nebula-test-" + t.Name()
-	first, err := listen(path, Access{Mode: ctlSocketMode})
+	first, err := listen(path, ownerOnly())
 	require.NoError(t, err)
 	t.Cleanup(func() { first.Close() })
 
 	var second net.Listener
-	second, err = listen(path, Access{Mode: ctlSocketMode})
+	second, err = listen(path, ownerOnly())
 	if second != nil {
 		second.Close()
 	}

+ 2 - 8
localapi/server.go

@@ -10,7 +10,6 @@ import (
 	"log/slog"
 	"math"
 	"net"
-	"os"
 	"runtime/debug"
 	"strconv"
 	"sync/atomic"
@@ -38,11 +37,6 @@ const (
 	defaultSlowAfter = 30 * time.Second
 )
 
-// ctlSocketMode is the control socket's mode when no group shares it: the user who ran
-// nebula, and nobody else. The mode itself is not configurable; what an operator can
-// choose is a group, CtlGroupKey, which makes it groupSocketMode.
-const ctlSocketMode os.FileMode = 0600
-
 // Server serves a command registry over a local socket to the user who ran nebula.
 type Server struct {
 	endpoint
@@ -87,9 +81,9 @@ func (s *Server) Rebind(ctx context.Context, path string, acc Access) error {
 	// admits is checked against the old setting. Early is safe either way: a member
 	// arriving before a group's mode is set is refused by the kernel, and with a group
 	// being taken away the peer check applies a moment before the mode tightens.
-	prev := s.trustSocket.Swap(acc.Group != nil)
+	prev := s.trustSocket.Swap(acc.shared())
 	err := s.rebindLocked(ctx, path, acc, func(ln net.Listener) io.Closer {
-		logListening(s.l, path, acc)
+		s.l.Info("Local control socket listening", "path", path, "access", acc.String())
 		// Closing the listener, when it is given up, ends the accept loop on the
 		// closed-listener error; in flight commands keep their connections, which stay
 		// usable until they finish.

+ 1 - 1
localapi/server_test.go

@@ -422,7 +422,7 @@ func (f *flakyListener) Accept() (net.Conn, error) {
 func TestAcceptSurvivesTransientErrors(t *testing.T) {
 	path := testSocket(t)
 	require.NoError(t, os.Mkdir(filepath.Dir(path), 0700))
-	ln, err := listen(path, Access{Mode: ctlSocketMode})
+	ln, err := listen(path, ownerOnly())
 	require.NoError(t, err)
 
 	s := NewServer(test.NewLogger(), basicRegistry())

+ 24 - 30
localapi/wire.go

@@ -113,21 +113,13 @@ func followControlConfig(ctx context.Context, l *slog.Logger, c *config.C, srv *
 
 		want, err := SocketPathFromConfig(c)
 		if err != nil {
-			if srv.Path() == "" {
-				l.Warn("Could not determine the local control socket path, control commands will not be available", "error", err)
-			} else {
-				l.Warn("Ignoring unusable local control socket path from reloaded config", "error", err)
-			}
+			warnUnusable(l, "local control socket", srv.Path(), err)
 			return
 		}
 
 		acc, err := controlAccessFrom(c)
 		if err != nil {
-			if srv.Path() == "" {
-				l.Warn("Local control socket is not available, its group is unusable", "error", err)
-			} else {
-				l.Warn("Ignoring unusable local control socket group from reloaded config, keeping the current socket", "error", err)
-			}
+			warnUnusable(l, "local control socket", srv.Path(), err)
 			return
 		}
 
@@ -159,22 +151,39 @@ func onReload(l *slog.Logger, c *config.C, what string, fn func(*config.C)) {
 	})
 }
 
+// warnUnusable logs a config the endpoint cannot follow: with nothing serving, the
+// endpoint is not available; with something serving, it is kept as it is.
+func warnUnusable(l *slog.Logger, what, current string, err error) {
+	if current == "" {
+		l.Warn("The "+what+" is not available, its config is unusable", "error", err)
+		return
+	}
+	l.Warn("Ignoring unusable "+what+" config from the reload, keeping the current endpoint", "error", err, "current", current)
+}
+
 // reportRebind logs what a Rebind did beyond the listening line the bind itself writes:
 // nothing for an endpoint left where it was, a move with both paths, and a failure with
 // what the operator is left with. old is the path served before the call, "" for none,
 // and want the one asked for.
+//
+// Nearly every failure is the operator's environment, a missing runtime directory or a
+// bad path, and a warning; a name held by another process is an error, since nothing
+// else will say that something is sitting where the daemon should be.
 func reportRebind(l *slog.Logger, what, old, want string, err error) {
+	level := l.Warn
+	if errors.Is(err, errEndpointHeld) {
+		level = l.Error
+	}
+
 	switch {
 	case err == nil:
 		if old != "" && old != want {
 			l.Info("Moved the "+what, "from", old, "to", want)
 		}
 	case old == "":
-		bindFailure(l, err)("Could not start the "+what+", it will not be available until a reload binds it", "error", err, "path", want)
+		level("Could not start the "+what+", it will not be available until a reload binds it", "error", err, "path", want)
 	default:
-		bindFailure(l, err)("Could not move the "+what+", keeping the current one",
-			"error", err, "current", old, "requested", want,
-		)
+		level("Could not move the "+what+", keeping the current one", "error", err, "current", old, "requested", want)
 	}
 }
 
@@ -193,11 +202,7 @@ func wireInfo(ctx context.Context, l *slog.Logger, c *config.C, reg *commands.Re
 	reconcile := func(c *config.C) {
 		cfg, err := infoConfigFrom(c)
 		if err != nil {
-			if srv.Path() == "" {
-				l.Warn("Local info API is not available, its config is unusable", "error", err)
-			} else {
-				l.Warn("Ignoring unusable local info API config, keeping the current endpoint", "error", err)
-			}
+			warnUnusable(l, "local info API", srv.Path(), err)
 			return
 		}
 
@@ -239,14 +244,3 @@ func wireInfo(ctx context.Context, l *slog.Logger, c *config.C, reg *commands.Re
 // namespace by any account's; on unix a path that is still being served is an earlier
 // nebula, which clearStale reports as in use.
 var errEndpointHeld = errors.New("the local control socket's name is held by another process")
-
-// bindFailure picks the level a failed bind is logged at. Nearly every failure is the
-// operator's environment, a missing runtime directory or a bad path, and a warning; a
-// name held by another process is logged as an error, since nothing else will say that
-// something is sitting where the daemon should be.
-func bindFailure(l *slog.Logger, err error) func(msg string, args ...any) {
-	if errors.Is(err, errEndpointHeld) {
-		return l.Error
-	}
-	return l.Warn
-}