diff --git a/client/cmd/service_allow_group.go b/client/cmd/service_allow_group.go
index b4d2088da..5e18c47f6 100644
--- a/client/cmd/service_allow_group.go
+++ b/client/cmd/service_allow_group.go
@@ -6,6 +6,8 @@ import (
"fmt"
"slices"
"strings"
+
+ "github.com/netbirdio/netbird/client/mdm"
)
// Principal kinds an --allow-group value resolves to. The kind:value form is
@@ -17,27 +19,33 @@ const (
allowGroupKindSID = "sid"
)
-// resolveAllowGroups turns the values given on --allow-group into the typed
-// principals the daemon enforces on its sockets, dropping empty entries and
-// duplicates. Names are resolved through the platform's directory service, so
-// an entry that does not resolve is an error: a restriction the daemon cannot
-// evaluate must not become one that admits everybody. Values already in
-// kind:value form are validated and passed through, which is the form an
-// installed service hands to `service run`.
+// resolveAllowGroups turns configured group values into the typed principals
+// the daemon enforces on its sockets, dropping empty entries and duplicates.
+// Names are resolved through the platform's directory service, so an entry that
+// does not resolve is an error: a restriction the daemon cannot evaluate must
+// not become one that admits everybody. Values already in kind:value form are
+// validated and passed through, which is the form an installed service hands to
+// `service run`.
+//
+// Each value is split on commas, so one managed-configuration string listing
+// several principals behaves like the repeated flag. Neither a Unix group name
+// nor a Windows account name may contain a comma, so nothing is lost by it.
func resolveAllowGroups(values []string) ([]string, error) {
var resolved []string
for _, value := range values {
- value = strings.TrimSpace(value)
- if value == "" {
- continue
- }
+ for _, entry := range strings.Split(value, ",") {
+ entry = strings.TrimSpace(entry)
+ if entry == "" {
+ continue
+ }
- principal, err := resolveAllowGroup(value)
- if err != nil {
- return nil, fmt.Errorf("resolve --allow-group %q: %w", value, err)
- }
- if !slices.Contains(resolved, principal) {
- resolved = append(resolved, principal)
+ principal, err := resolveAllowGroup(entry)
+ if err != nil {
+ return nil, fmt.Errorf("resolve allowed group %q: %w", entry, err)
+ }
+ if !slices.Contains(resolved, principal) {
+ resolved = append(resolved, principal)
+ }
}
}
@@ -47,6 +55,30 @@ func resolveAllowGroups(values []string) ([]string, error) {
return resolved, nil
}
+// daemonSocketPrincipals returns the principals the daemon restricts its
+// sockets to, and the configuration that asked for them. An MDM policy
+// overrides the install-time --allow-group in both directions, as the other
+// MDM-overridable service flags do: a managed host can be restricted without a
+// reinstall, and a managed empty value lifts a restriction the install set.
+//
+// A configured value that cannot be resolved is an error rather than an
+// unrestricted socket. The daemon then does not serve at all, which is loud
+// enough for an administrator to find and correct, where a silently ignored
+// restriction would leave every local account reaching the daemon on a host
+// meant to be locked down.
+func daemonSocketPrincipals(policy *mdm.Policy) ([]string, string, error) {
+ values, source := allowGroups, "--allow-group"
+ if managed, ok := policy.GetStringSlice(mdm.KeyAllowGroups); ok {
+ values, source = managed, "MDM policy "+mdm.KeyAllowGroups
+ }
+
+ resolved, err := resolveAllowGroups(values)
+ if err != nil {
+ return nil, source, fmt.Errorf("%s: %w", source, err)
+ }
+ return resolved, source, nil
+}
+
// cutKind splits a value on the kind separator. ok is false when the value
// carries no kind, which is the case for a plain group or account name: neither
// a Unix group name nor a Windows account name may contain a colon, so the
diff --git a/client/cmd/service_allow_group_test.go b/client/cmd/service_allow_group_test.go
index 78a3c862a..c90d53704 100644
--- a/client/cmd/service_allow_group_test.go
+++ b/client/cmd/service_allow_group_test.go
@@ -7,6 +7,8 @@ import (
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
+
+ "github.com/netbirdio/netbird/client/mdm"
)
func TestResolveAllowGroups_NoValuesLeavesSocketOpen(t *testing.T) {
@@ -36,6 +38,68 @@ func TestResolveAllowGroups_UnresolvableIsAnError(t *testing.T) {
assert.Contains(t, err.Error(), "no-such-group-08b1f0c4")
}
+func TestResolveAllowGroups_SplitsCommaSeparatedEntries(t *testing.T) {
+ // One managed-configuration string listing several principals, as a
+ // Windows REG_SZ delivers them.
+ resolved, err := resolveAllowGroups([]string{testAllowGroupPrincipal + ", " + testAllowGroupPrincipal})
+ require.NoError(t, err)
+ assert.Equal(t, []string{testAllowGroupPrincipal}, resolved)
+}
+
+func TestDaemonSocketPrincipals(t *testing.T) {
+ original := allowGroups
+ t.Cleanup(func() { allowGroups = original })
+
+ t.Run("no configuration leaves the sockets open", func(t *testing.T) {
+ allowGroups = nil
+
+ resolved, _, err := daemonSocketPrincipals(mdm.NewPolicy(nil))
+ require.NoError(t, err)
+ assert.Empty(t, resolved)
+ })
+
+ t.Run("the install-time flag applies when nothing is managed", func(t *testing.T) {
+ allowGroups = []string{testAllowGroupPrincipal}
+
+ resolved, source, err := daemonSocketPrincipals(mdm.NewPolicy(nil))
+ require.NoError(t, err)
+ assert.Equal(t, []string{testAllowGroupPrincipal}, resolved)
+ assert.Contains(t, source, "--allow-group")
+ })
+
+ t.Run("an MDM policy overrides the install-time flag", func(t *testing.T) {
+ allowGroups = nil
+ policy := mdm.NewPolicy(map[string]any{mdm.KeyAllowGroups: testAllowGroupPrincipal})
+
+ resolved, source, err := daemonSocketPrincipals(policy)
+ require.NoError(t, err)
+ assert.Equal(t, []string{testAllowGroupPrincipal}, resolved)
+ assert.Contains(t, source, mdm.KeyAllowGroups)
+ })
+
+ t.Run("an empty MDM value lifts an install-time restriction", func(t *testing.T) {
+ allowGroups = []string{testAllowGroupPrincipal}
+ policy := mdm.NewPolicy(map[string]any{mdm.KeyAllowGroups: ""})
+
+ resolved, source, err := daemonSocketPrincipals(policy)
+ require.NoError(t, err)
+ assert.Empty(t, resolved)
+ assert.Contains(t, source, mdm.KeyAllowGroups)
+ })
+
+ // A managed value that cannot be resolved must not fall back to the
+ // install-time flag or to an open socket: the host was meant to be locked
+ // down, so the daemon refuses to serve instead.
+ t.Run("an unresolvable MDM value is an error", func(t *testing.T) {
+ allowGroups = []string{testAllowGroupPrincipal}
+ policy := mdm.NewPolicy(map[string]any{mdm.KeyAllowGroups: "no-such-group-08b1f0c4"})
+
+ _, _, err := daemonSocketPrincipals(policy)
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), mdm.KeyAllowGroups)
+ })
+}
+
func TestCutKind(t *testing.T) {
tests := []struct {
value string
diff --git a/client/cmd/service_controller.go b/client/cmd/service_controller.go
index f1f1c6f12..bd112494c 100644
--- a/client/cmd/service_controller.go
+++ b/client/cmd/service_controller.go
@@ -16,6 +16,7 @@ import (
"github.com/netbirdio/netbird/client/internal/daemonaddr"
"github.com/netbirdio/netbird/client/internal/ipcauth"
+ "github.com/netbirdio/netbird/client/mdm"
"github.com/netbirdio/netbird/client/proto"
"github.com/netbirdio/netbird/client/server"
"github.com/netbirdio/netbird/client/system"
@@ -89,12 +90,17 @@ func (p *program) Start(svc service.Service) error {
)
p.serv = grpc.NewServer(opts...)
- allowed, err := resolveAllowGroups(allowGroups)
+ allowed, source, err := daemonSocketPrincipals(mdm.LoadPolicy())
if err != nil {
+ // Logged as well as returned: the service manager is the only other
+ // place this surfaces, and it reports a service that will not start
+ // without saying why. Refusing to serve on a host whose lockdown
+ // cannot be applied is deliberate, so the reason has to be findable.
+ log.Errorf("failed to apply the daemon socket restriction, not serving: %v", err)
return err
}
if len(allowed) > 0 {
- log.Infof("daemon sockets are restricted to %v", allowed)
+ log.Infof("daemon sockets are restricted to %v by %s", allowed, source)
}
daemonListener, jsonListener, err := listenDaemonSockets(allowed)
diff --git a/client/mdm/canonical_loaders.go b/client/mdm/canonical_loaders.go
index 64a8093c3..a5e9f94ea 100644
--- a/client/mdm/canonical_loaders.go
+++ b/client/mdm/canonical_loaders.go
@@ -34,6 +34,7 @@ var allKeys = []string{
KeyLazyConnection,
KeyRemoteJobsAllowed,
KeyBundleUploadURL,
+ KeyAllowGroups,
}
// canonicalKey maps the lowercase form of a managed-config value name to
diff --git a/client/mdm/policy.go b/client/mdm/policy.go
index dac135ea6..a23b92e51 100644
--- a/client/mdm/policy.go
+++ b/client/mdm/policy.go
@@ -68,6 +68,20 @@ const (
// (which defaults to disabled). Stored on Config as RemoteJobsAllowed.
KeyRemoteJobsAllowed = "allowRemoteJobs"
+ // KeyAllowGroups restricts the daemon control socket and the JSON socket
+ // to the listed principals, overriding the install-time --allow-group in
+ // both directions: a managed host can be restricted without a reinstall,
+ // and an empty value lifts a restriction the install set. Absent = defer to
+ // the install-time flag.
+ //
+ // Read as a list of principals in kind:value form, "gid:1001" on Unix and
+ // "sid:S-1-5-21-..." on Windows, either as a real list or comma-separated.
+ // Resolved principals rather than group names because this is read on the
+ // daemon's boot path, where resolving a name can block on an unreachable
+ // LDAP or Active Directory backend. A name is still accepted, and still
+ // resolved, for a host where that is not a concern.
+ KeyAllowGroups = "allowGroups"
+
// KeyBundleUploadURL overrides the debug-bundle upload service URL for
// remote jobs, taking precedence over the management-supplied value. Read
// as a string; must be an https URL with a host. Absent = defer to the
diff --git a/docs/netbird.adml b/docs/netbird.adml
index 05cfb71ad..22a4f14cb 100644
--- a/docs/netbird.adml
+++ b/docs/netbird.adml
@@ -56,6 +56,8 @@
Split tunnelRestrict the NetBird tunnel to or from a chosen list of application package names. Choose either the allow mode (only the listed apps route through NetBird) or the disallow mode (the listed apps bypass NetBird; everything else routes through). The mode is mutually exclusive — only one can be active at a time. Android-only at the daemon level; Windows/macOS/iOS clients ignore this policy.
+ Restrict the daemon sockets
+ Restrict the NetBird daemon control pipe, and the JSON socket where it is enabled, to the listed principals. Accounts outside them cannot connect at all, so nothing the daemon exposes is reachable from them; LocalSystem and elevated administrators are never locked out. Enter security identifiers in sid:S-1-5-21-... form, comma-separated, because the daemon reads this while starting and must not wait on a domain controller to resolve a name. An empty value lifts a restriction that was set when the service was installed. Windows and macOS only; on macOS the entries are Unix group IDs in gid:1001 form.Allow only listed apps (everything else bypasses)Disallow listed apps (everything else routes)
@@ -101,6 +103,12 @@
WireGuard UDP port:
+
+
+
+
+
+
Mode:
diff --git a/docs/netbird.admx b/docs/netbird.admx
index 6a188cff1..f71b01709 100644
--- a/docs/netbird.admx
+++ b/docs/netbird.admx
@@ -204,6 +204,19 @@
+
+
+
+
+
+
+
+