Let an MDM policy set the groups the daemon sockets are restricted to

This commit is contained in:
Viktor Liu
2026-09-08 21:23:08 +02:00
parent b49b5494b4
commit 72bf5adad7
7 changed files with 157 additions and 19 deletions
+49 -17
View File
@@ -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
+64
View File
@@ -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
+8 -2
View File
@@ -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)
+1
View File
@@ -34,6 +34,7 @@ var allKeys = []string{
KeyLazyConnection,
KeyRemoteJobsAllowed,
KeyBundleUploadURL,
KeyAllowGroups,
}
// canonicalKey maps the lowercase form of a managed-config value name to
+14
View File
@@ -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
+8
View File
@@ -56,6 +56,8 @@
<string id="SplitTunnel_Name">Split tunnel</string>
<string id="SplitTunnel_Help">Restrict 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.</string>
<string id="AllowGroups_Name">Restrict the daemon sockets</string>
<string id="AllowGroups_Help">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.</string>
<string id="SplitTunnel_Allow">Allow only listed apps (everything else bypasses)</string>
<string id="SplitTunnel_Disallow">Disallow listed apps (everything else routes)</string>
@@ -101,6 +103,12 @@
<decimalTextBox refId="WireguardPort_Decimal" defaultValue="51820">WireGuard UDP port:</decimalTextBox>
</presentation>
<presentation id="AllowGroups_Pres">
<textBox refId="AllowGroups_Text">
<label>Allowed SIDs (comma-separated):</label>
</textBox>
</presentation>
<presentation id="SplitTunnel_Pres">
<dropdownList refId="SplitTunnel_Mode" defaultItem="0">Mode:</dropdownList>
<textBox refId="SplitTunnel_Apps">
+13
View File
@@ -204,6 +204,19 @@
</elements>
</policy>
<policy name="AllowGroups"
class="Machine"
displayName="$(string.AllowGroups_Name)"
explainText="$(string.AllowGroups_Help)"
key="Software\Policies\NetBird"
presentation="$(presentation.AllowGroups_Pres)">
<parentCategory ref="NetBird" />
<supportedOn ref="SUPPORTED_NetBird_All" />
<elements>
<text id="AllowGroups_Text" valueName="AllowGroups" required="false" />
</elements>
</policy>
<!-- ============================================================ -->
<!-- UI: visibility / UX kill switches -->
<!-- ============================================================ -->