Ver código fonte

Let a group share the info API too, and serve it on windows

The info API is for other programs on the host, and on unix the only
setting between "the user nebula runs as" and "everyone" was a manual
chgrp that the next bind undid; on windows it was not served at all,
because which accounts a pipe admits had not been decided. The control
socket's `group` decides it, and the info API takes the same key.

- `local_api.info.group`: the socket is handed to the group at 0660
  unless `socket_mode` says otherwise, and a mode that gives a named
  group nothing is refused with the mode that would
- windows: served on a named pipe, the group an ACE on it as on the
  control pipe; the e2e test's helpers dial unix sockets, so it still
  skips there
- a changed group or mode on a reload is applied to the socket in place

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Matt Richardson 1 dia atrás
pai
commit
a400b393e3

+ 6 - 4
CHANGELOG.md

@@ -48,10 +48,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
   can be passed through as is; `identity` without an address is refused rather than answered with this node, so
   can be passed through as is; `identity` without an address is refused rather than answered with this node, so
   a program that failed to pass one cannot authorize against the daemon's own identity. Both are available over
   a program that failed to pass one cannot authorize against the daemon's own identity. Both are available over
   the ssh debug server and `nebula ctl`, and are what the new opt-in info API serves, and all it serves:
   the ssh debug server and `nebula ctl`, and are what the new opt-in info API serves, and all it serves:
-  `local_api.info` names a unix socket and a mode, and `GET /v1/identity?address=<addr>` and `GET /v1/whoami` on
-  it answer with the same JSON, 404 when no tunnel to the address is up, so a program on the host can resolve the
-  source of a connection arriving over the overlay to an identity without a client library. The socket's mode is
-  the access control; the section is reloadable and the API is not yet served on Windows.
+  `local_api.info` names a unix socket (a named pipe on windows), and `GET /v1/identity?address=<addr>` and
+  `GET /v1/whoami` on it answer with the same JSON, 404 when no tunnel to the address is up, so a program on the
+  host can resolve the source of a connection arriving over the overlay to an identity without a client library.
+  Who may use it is `local_api.info.group`, a group the socket is handed to at 0660 on unix and granted access to
+  the pipe on windows, with `socket_mode` to widen it further on unix; without a group only the user nebula runs
+  as can. The section is reloadable.
 
 
 ### Removed
 ### Removed
 
 

+ 1 - 1
e2e/info_api_test.go

@@ -27,7 +27,7 @@ import (
 // established with.
 // established with.
 func TestIdentityOverTheInfoAPI(t *testing.T) {
 func TestIdentityOverTheInfoAPI(t *testing.T) {
 	if runtime.GOOS == "windows" {
 	if runtime.GOOS == "windows" {
-		t.Skip("the info API is not served on windows")
+		t.Skip("the test's HTTP helpers dial unix sockets; on windows the info API is a named pipe")
 	}
 	}
 
 
 	ca, _, caKey, _ := cert_test.NewTestCaCert(cert.Version2, cert.Curve_CURVE25519, time.Now(), time.Now().Add(10*time.Minute), nil, nil, []string{})
 	ca, _, caKey, _ := cert_test.NewTestCaCert(cert.Version2, cert.Curve_CURVE25519, time.Now(), time.Now().Add(10*time.Minute), nil, nil, []string{})

+ 8 - 4
examples/config.yml

@@ -255,7 +255,7 @@ punchy:
     #group: nebula
     #group: nebula
 
 
   # The info socket is not made available by default. If you enable it, it makes some
   # The info socket is not made available by default. If you enable it, it makes some
-  # internal state available over HTTP. It's not available on Windows.
+  # internal state available over HTTP.
   #
   #
   # The currently supported HTTP requests are:
   # The currently supported HTTP requests are:
   #   GET /v1/identity?address=<vpn addr>[:port]  the host owning the address: a peer with
   #   GET /v1/identity?address=<vpn addr>[:port]  the host owning the address: a peer with
@@ -267,10 +267,14 @@ punchy:
   #
   #
   #info:
   #info:
     #enabled: false
     #enabled: false
-    # The absolute path to listen on.
+    # The absolute path to listen on, or a full pipe name on Windows.
     #socket: /var/run/nebula/info.sock
     #socket: /var/run/nebula/info.sock
-    # Octal mode for the socket, as a quoted string. Defaults to "0600".
-    #socket_mode: "0600"
+    # A group (or account/group/SID on Windows) that should be allowed to access the
+    # socket.
+    #group: nebula-info
+    # Octal mode for the socket, as a quoted string. Defaults to "0600" unless a
+    # group is set, in which case it defaults to "0660".
+    #socket_mode: "0660"
 
 
 # EXPERIMENTAL: relay support for networks that can't establish direct connections.
 # EXPERIMENTAL: relay support for networks that can't establish direct connections.
 relay:
 relay:

+ 61 - 0
localapi/access_unix_test.go

@@ -4,6 +4,7 @@ package localapi
 
 
 import (
 import (
 	"context"
 	"context"
+	"net/http"
 	"os"
 	"os"
 	"os/user"
 	"os/user"
 	"strconv"
 	"strconv"
@@ -139,3 +140,63 @@ func TestControlSocketWithAGroupTrustsTheKernel(t *testing.T) {
 	_, err = (&Client{Path: path}).Describe()
 	_, err = (&Client{Path: path}).Describe()
 	require.ErrorContains(t, err, "not permitted")
 	require.ErrorContains(t, err, "not permitted")
 }
 }
+
+// TestInfoAccessFromConfig: a group with no mode is 0660; a mode that gives the group
+// nothing is refused, with the mode that would; an explicit mode with group bits is
+// kept as given.
+func TestInfoAccessFromConfig(t *testing.T) {
+	name, gid := ownGroup(t)
+	load := func(body string) (infoConfig, error) {
+		c := config.NewC(test.NewLogger())
+		require.NoError(t, c.LoadString(body))
+		return infoConfigFrom(c)
+	}
+	section := func(extra string) string {
+		return "local_api:\n  info:\n    enabled: true\n    socket: /run/nebula/info.sock\n" + extra
+	}
+
+	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"))
+	require.NoError(t, err)
+	assert.Equal(t, os.FileMode(0664), cfg.Access.Mode)
+
+	_, err = load(section("    group: " + name + "\n    socket_mode: \"0600\"\n"))
+	require.ErrorContains(t, err, "gives group "+name+" no access")
+	require.ErrorContains(t, err, `"0660"`)
+
+	cfg, err = load(section(""))
+	require.NoError(t, err)
+	assert.Equal(t, os.FileMode(0600), cfg.Access.Mode)
+	assert.Nil(t, cfg.Access.Group)
+}
+
+// TestSocketIsHandedToTheGroup: the socket is created with the group's gid and the
+// mode, and a changed group or mode on a reload is applied to the socket in place.
+func TestSocketIsHandedToTheGroup(t *testing.T) {
+	name, gid := ownGroup(t)
+	group := &Group{Name: name, ID: gid}
+	want, err := strconv.ParseUint(gid, 10, 32)
+	require.NoError(t, err)
+
+	s, path := serveInfo(t, infoRegistry(), 0600)
+	require.NoError(t, s.Rebind(context.Background(), path, Access{Mode: 0660, Group: group}))
+	fi, err := os.Lstat(path)
+	require.NoError(t, err)
+	assert.Equal(t, os.FileMode(0660), fi.Mode().Perm())
+	assert.Equal(t, uint32(want), socketGroup(t, path))
+
+	status, _ := test.HTTPGet(t, path, "/v1/whoami")
+	assert.Equal(t, http.StatusOK, status, "the same listener is still serving")
+
+	// The mode is the operator's to widen further.
+	require.NoError(t, s.Rebind(context.Background(), path, Access{Mode: 0664, Group: group}))
+	fi, err = os.Lstat(path)
+	require.NoError(t, err)
+	assert.Equal(t, os.FileMode(0664), fi.Mode().Perm())
+	assert.Equal(t, path, s.Path())
+}

+ 24 - 8
localapi/info.go

@@ -31,12 +31,13 @@ const (
 	InfoEnabledKey = "local_api.info.enabled"
 	InfoEnabledKey = "local_api.info.enabled"
 	InfoSocketKey  = "local_api.info.socket"
 	InfoSocketKey  = "local_api.info.socket"
 	InfoModeKey    = "local_api.info.socket_mode"
 	InfoModeKey    = "local_api.info.socket_mode"
+	InfoGroupKey   = "local_api.info.group"
 )
 )
 
 
 const (
 const (
-	// defaultInfoMode is the socket's mode when the config names none: the daemon's
-	// user alone, so that enabling the API does not open it to anyone until the operator
-	// says who.
+	// 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
 	defaultInfoMode os.FileMode = 0600
 
 
 	// infoMaxConns bounds the connections held open at once. Beyond it the kernel
 	// infoMaxConns bounds the connections held open at once. Beyond it the kernel
@@ -364,7 +365,10 @@ type infoConfig struct {
 }
 }
 
 
 // infoConfigFrom reads the info API section. A disabled section needs nothing else to be
 // infoConfigFrom reads the info API section. A disabled section needs nothing else to be
-// right; an enabled one needs a socket path and, if it names a mode, one that parses.
+// right; an enabled one needs a socket path, a group that exists if it names one, and
+// a mode that parses if it names one. A group with no mode means groupSocketMode, and a
+// mode that gives a named group nothing is refused: it would be a group that cannot
+// connect, which is not what naming one asks for.
 func infoConfigFrom(c *config.C) (infoConfig, error) {
 func infoConfigFrom(c *config.C) (infoConfig, error) {
 	cfg := infoConfig{Enabled: c.GetBool(InfoEnabledKey, false)}
 	cfg := infoConfig{Enabled: c.GetBool(InfoEnabledKey, false)}
 	if !cfg.Enabled {
 	if !cfg.Enabled {
@@ -381,11 +385,23 @@ func infoConfigFrom(c *config.C) (infoConfig, error) {
 	}
 	}
 	cfg.Path = path
 	cfg.Path = path
 
 
-	mode, err := parseSocketMode(c.Get(InfoModeKey))
+	group, err := groupFromConfig(c, InfoGroupKey)
+	if err != nil {
+		return cfg, err
+	}
+	def := defaultInfoMode
+	if group != nil {
+		def = groupSocketMode
+	}
+
+	mode, err := parseSocketMode(c.Get(InfoModeKey), def)
 	if err != nil {
 	if err != nil {
 		return cfg, fmt.Errorf("%s: %w", InfoModeKey, err)
 		return cfg, fmt.Errorf("%s: %w", InfoModeKey, err)
 	}
 	}
-	cfg.Access = Access{Mode: mode}
+	if group != nil && 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}
 
 
 	return cfg, nil
 	return cfg, nil
 }
 }
@@ -394,10 +410,10 @@ func infoConfigFrom(c *config.C) (infoConfig, error) {
 // integer, and which integer depends on the parser (octal 432 under YAML 1.1, decimal
 // integer, and which integer depends on the parser (octal 432 under YAML 1.1, decimal
 // 660 under 1.2), so a number is refused with the fix rather than quietly applied as
 // 660 under 1.2), so a number is refused with the fix rather than quietly applied as
 // whichever mode its digits happen to spell in octal.
 // whichever mode its digits happen to spell in octal.
-func parseSocketMode(raw any) (os.FileMode, error) {
+func parseSocketMode(raw any, def os.FileMode) (os.FileMode, error) {
 	switch v := raw.(type) {
 	switch v := raw.(type) {
 	case nil:
 	case nil:
-		return defaultInfoMode, nil
+		return def, nil
 	case string:
 	case string:
 		mode, err := strconv.ParseUint(v, 8, 32)
 		mode, err := strconv.ParseUint(v, 8, 32)
 		if err != nil || mode&^0777 != 0 {
 		if err != nil || mode&^0777 != 0 {

+ 39 - 7
localapi/info_windows_test.go

@@ -4,20 +4,52 @@ package localapi
 
 
 import (
 import (
 	"context"
 	"context"
+	"io"
+	"net"
+	"net/http"
 	"testing"
 	"testing"
+	"time"
 
 
+	"github.com/Microsoft/go-winio"
 	"github.com/slackhq/nebula/commands"
 	"github.com/slackhq/nebula/commands"
 	"github.com/slackhq/nebula/test"
 	"github.com/slackhq/nebula/test"
 	"github.com/stretchr/testify/assert"
 	"github.com/stretchr/testify/assert"
 	"github.com/stretchr/testify/require"
 	"github.com/stretchr/testify/require"
+	"golang.org/x/sys/windows"
 )
 )
 
 
-// TestInfoIsNotServedOnWindows: a pipe has no mode to open to other accounts, so Rebind
-// says so and leaves nothing listening, whatever the config asked for.
-func TestInfoIsNotServedOnWindows(t *testing.T) {
+// TestInfoIsServedOnAPipe: the info API serves HTTP on a named pipe, whose security
+// descriptor is its access control the way a socket's mode is on unix; a group named
+// in the config is admitted by it.
+func TestInfoIsServedOnAPipe(t *testing.T) {
+	users, err := windows.CreateWellKnownSid(windows.WinBuiltinUsersSid)
+	require.NoError(t, err)
+	group := &Group{Name: "Users", ID: users.String()}
+
 	s := NewInfoServer(test.NewLogger(), commands.New())
 	s := NewInfoServer(test.NewLogger(), commands.New())
-	err := s.Rebind(context.Background(), pipePrefix+"nebula-info-test", Access{Mode: 0600})
-	require.ErrorIs(t, err, errInfoUnsupported)
-	assert.Empty(t, s.Path())
-	s.Stop()
+	path := pipePrefix + "nebula-info-test"
+	require.NoError(t, s.Rebind(context.Background(), path, Access{Mode: groupSocketMode, Group: group}))
+	t.Cleanup(s.Stop)
+	assert.Equal(t, path, s.Path())
+
+	client := &http.Client{Transport: &http.Transport{
+		DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) {
+			timeout := 2 * time.Second
+			return winio.DialPipe(path, &timeout)
+		},
+	}}
+	res, err := client.Get("http://nebula/v1")
+	require.NoError(t, err)
+	defer res.Body.Close()
+	body, err := io.ReadAll(res.Body)
+	require.NoError(t, err)
+	assert.Equal(t, http.StatusOK, res.StatusCode)
+	assert.Contains(t, string(body), `"help"`)
+
+	// A change of group means a new pipe under the same name, still serving.
+	require.NoError(t, s.Rebind(context.Background(), path, Access{Mode: groupSocketMode}))
+	res, err = client.Get("http://nebula/v1")
+	require.NoError(t, err)
+	res.Body.Close()
+	assert.Equal(t, http.StatusOK, res.StatusCode)
 }
 }

+ 4 - 5
localapi/platform_windows.go

@@ -45,11 +45,10 @@ func logListening(l *slog.Logger, path string, acc Access) {
 	)
 	)
 }
 }
 
 
-// infoSupported is false here. A pipe has no mode to open to other accounts, and the
-// security descriptor listen builds admits the daemon's account alone, which is the
-// opposite of what the info API is for. Serving it on windows means designing which
-// accounts a pipe admits, and that has not been done.
-const infoSupported = false
+// infoSupported is true here: the pipe's security descriptor is what a mode is on unix,
+// and the group an operator names in the config is the way to open it to other
+// accounts, as it is for the control pipe. The mode itself means nothing to a pipe.
+const infoSupported = true
 
 
 // listen creates the named pipe at path with a security descriptor that admits the user
 // 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
 // nebula runs as, SYSTEM and Administrators, and the group acc names, if any, for