diff --git a/client/cmd/debug.go b/client/cmd/debug.go
index 02a742b2..bc7b0e98 100644
--- a/client/cmd/debug.go
+++ b/client/cmd/debug.go
@@ -3,12 +3,14 @@ package cmd
import (
"context"
"fmt"
+ "os/user"
"strings"
"time"
log "github.com/sirupsen/logrus"
"github.com/spf13/cobra"
"google.golang.org/grpc/status"
+ "google.golang.org/protobuf/encoding/protojson"
"google.golang.org/protobuf/types/known/durationpb"
"github.com/netbirdio/netbird/client/internal"
@@ -85,6 +87,73 @@ var persistenceCmd = &cobra.Command{
RunE: setSyncResponsePersistence,
}
+var debugConfigCmd = &cobra.Command{
+ Use: "config",
+ Example: " netbird debug config",
+ Short: "Dump the effective configuration",
+ Long: "Prints the daemon's resolved configuration (after applying defaults, file, env, CLI input, and MDM policy overrides) as JSON. Includes the list of MDM-managed fields.",
+ RunE: debugConfigDump,
+}
+
+// debugConfigDump implements `netbird debug config`. It resolves the
+// active profile, queries the daemon for the effective configuration
+// via GetConfig, and prints the resulting GetConfigResponse as JSON
+// (via protojson with EmitUnpopulated=true so the output is stable
+// across runs and includes zero-valued fields).
+//
+// Useful for verifying MDM enforcement end-to-end: the response's
+// mDMManagedFields array is the single source of truth for "which
+// fields is the daemon currently enforcing from the MDM source", and
+// every config field side-by-side with that list confirms the merge
+// result. Secrets in the response (e.g. PreSharedKey) are already
+// redacted by the daemon-side handler.
+func debugConfigDump(cmd *cobra.Command, _ []string) error {
+ pm := profilemanager.NewProfileManager()
+ activeProf, err := pm.GetActiveProfile()
+ if err != nil {
+ return fmt.Errorf("get active profile: %v", err)
+ }
+ currUser, err := user.Current()
+ if err != nil {
+ return fmt.Errorf("get current user: %v", err)
+ }
+
+ conn, err := getClient(cmd)
+ if err != nil {
+ return err
+ }
+ defer func() {
+ if err := conn.Close(); err != nil {
+ log.Errorf(errCloseConnection, err)
+ }
+ }()
+
+ client := proto.NewDaemonServiceClient(conn)
+ resp, err := client.GetConfig(cmd.Context(), &proto.GetConfigRequest{
+ ProfileName: activeProf.Name,
+ Username: currUser.Username,
+ })
+ if err != nil {
+ return fmt.Errorf("failed to get config: %v", status.Convert(err).Message())
+ }
+
+ // Use protojson so well-known fields render correctly; emit defaults so
+ // the operator sees every field even when zero/empty.
+ m := protojson.MarshalOptions{Multiline: true, Indent: " ", EmitUnpopulated: true}
+ out, err := m.Marshal(resp)
+ if err != nil {
+ return fmt.Errorf("marshal config: %w", err)
+ }
+ cmd.Println(string(out))
+ return nil
+}
+
+// debugBundle requests the daemon to create a debug bundle and prints
+// the resulting local file path and, if uploaded, the uploaded file
+// key. It uses the package flags (anonymize, system info, log file
+// count, CLI version, optional upload URL) to configure the bundle
+// request. Returns an error if the RPC fails or if the daemon reports
+// an upload failure reason.
func debugBundle(cmd *cobra.Command, _ []string) error {
conn, err := getClient(cmd)
if err != nil {
diff --git a/client/cmd/root.go b/client/cmd/root.go
index 5c9e1ff8..b1d960be 100644
--- a/client/cmd/root.go
+++ b/client/cmd/root.go
@@ -95,7 +95,9 @@ var (
}
)
-// Execute executes the root command.
+// Execute runs the appropriate Cobra command for the CLI.
+// If the process is the update binary it delegates to updateCmd; otherwise it runs the root command.
+// It returns any error produced during command execution.
func Execute() error {
if isUpdateBinary() {
return updateCmd.Execute()
@@ -103,6 +105,16 @@ func Execute() error {
return rootCmd.Execute()
}
+// init initialises package-level defaults and configures the root
+// Cobra command tree. Sets platform-specific config / log directory
+// paths (including legacy Wiretrustee fallbacks) and a default daemon
+// address; registers persistent CLI flags (daemon address,
+// management / admin URLs, logging, setup key (file and inline,
+// mutually exclusive), preshared key, hostname, anonymise, config
+// path); attaches top-level and nested subcommands to the root
+// command; and registers `up`-specific persistent flags (external IP
+// maps, custom DNS resolver address, Rosenpass options, auto-connect
+// disabling, lazy connection).
func init() {
defaultConfigPathDir = "/etc/netbird/"
defaultLogFileDir = "/var/log/netbird/"
@@ -168,6 +180,7 @@ func init() {
logCmd.AddCommand(logLevelCmd)
debugCmd.AddCommand(forCmd)
debugCmd.AddCommand(persistenceCmd)
+ debugCmd.AddCommand(debugConfigCmd)
// kubernetes commands
rootCmd.AddCommand(kubernetesCmd)
diff --git a/client/internal/debug/debug.go b/client/internal/debug/debug.go
index 9ab18dd8..05501320 100644
--- a/client/internal/debug/debug.go
+++ b/client/internal/debug/debug.go
@@ -516,6 +516,14 @@ func (g *BundleGenerator) addConfig() error {
}
}
+ // Surface the set of MDM-enforced keys so a support engineer reading
+ // the bundle can tell which field values are user-set vs MDM-overridden.
+ // Same semantics as the mDMManagedFields list returned by the
+ // GetConfig RPC consumed by `netbird debug config`.
+ if managed := g.internalConfig.Policy().ManagedKeys(); len(managed) > 0 {
+ configContent.WriteString(fmt.Sprintf("MDMManagedFields: %v\n", managed))
+ }
+
configReader := strings.NewReader(configContent.String())
if err := g.addFileToZip(configReader, "config.txt"); err != nil {
return fmt.Errorf("add config file to zip: %w", err)
diff --git a/client/internal/debug/debug_test.go b/client/internal/debug/debug_test.go
index 39b97224..76df588a 100644
--- a/client/internal/debug/debug_test.go
+++ b/client/internal/debug/debug_test.go
@@ -843,6 +843,7 @@ func TestAddConfig_AllFieldsCovered(t *testing.T) {
"PreSharedKey": "sensitive: WireGuard pre-shared key",
"SSHKey": "sensitive: SSH private key",
"ClientCertKeyPair": "non-config: parsed cert pair, not serialized",
+ "policy": "non-config: in-memory MDM policy snapshot, surfaced via Config.Policy() / GetConfigResponse.MDMManagedFields",
}
mURL, _ := url.Parse("https://api.example.com:443")
diff --git a/client/internal/profilemanager/config.go b/client/internal/profilemanager/config.go
index cd5bc068..b0c7fd47 100644
--- a/client/internal/profilemanager/config.go
+++ b/client/internal/profilemanager/config.go
@@ -22,6 +22,7 @@ import (
"github.com/netbirdio/netbird/client/iface"
"github.com/netbirdio/netbird/client/internal/routemanager/dynamic"
+ "github.com/netbirdio/netbird/client/mdm"
"github.com/netbirdio/netbird/client/ssh"
mgm "github.com/netbirdio/netbird/shared/management/client"
"github.com/netbirdio/netbird/shared/management/domain"
@@ -57,6 +58,10 @@ var DefaultInterfaceBlacklist = []string{
"Tailscale", "tailscale", "docker", "veth", "br-", "lo",
}
+// loadMDMPolicy is the package-level indirection used by apply() to read the
+// active MDM policy. Tests override this to inject a fake policy.
+var loadMDMPolicy = mdm.LoadPolicy
+
// ConfigInput carries configuration changes to the client
type ConfigInput struct {
ManagementURL string
@@ -174,6 +179,23 @@ type Config struct {
LazyConnectionEnabled bool
MTU uint16
+
+ // policy is the MDM policy that produced the currently-set values for
+ // any MDM-enforced fields. Set by applyMDMPolicy at the tail of apply()
+ // and reset on every apply() invocation. Never persisted to disk.
+ // Callers query enforcement state via Policy() and the mdm.Policy API
+ // (HasKey, ManagedKeys, IsEmpty).
+ policy *mdm.Policy `json:"-"`
+}
+
+// Policy returns the MDM policy applied to this Config. Returns a non-nil
+// empty Policy when MDM enforcement is inactive; callers can always invoke
+// HasKey / ManagedKeys / IsEmpty without a nil check.
+func (config *Config) Policy() *mdm.Policy {
+ if config == nil || config.policy == nil {
+ return mdm.NewPolicy(nil)
+ }
+ return config.policy
}
var ConfigDirOverride string
@@ -612,10 +634,93 @@ func (config *Config) apply(input ConfigInput) (updated bool, err error) {
updated = true
}
+ // MDM is the last override layer: any key present in the policy
+ // supersedes defaults, on-disk config, env vars and CLI input.
+ config.applyMDMPolicy(loadMDMPolicy())
+
return updated, nil
}
-// parseURL parses and validates a service URL
+// applyMDMPolicy overlays MDM-supplied values on top of the resolved Config.
+// The provided Policy is also stored on the Config so callers can later query
+// which fields are enforced. Invalid values (e.g. malformed URLs) are logged
+// and skipped to avoid bricking the client; the field keeps its previous
+// resolved value but is still marked as managed (Policy.HasKey returns true
+// for the key, so per-field rejection of user writes still applies).
+func (config *Config) applyMDMPolicy(policy *mdm.Policy) {
+ config.policy = policy
+ if policy.IsEmpty() {
+ return
+ }
+
+ // Helper: log the application of a single MDM-managed key. Values for
+ // keys in mdm.SecretKeys are redacted.
+ logApplied := func(key string, displayValue any) {
+ if _, secret := mdm.SecretKeys[key]; secret {
+ log.Infof("MDM override %s = ********** (secret)", key)
+ return
+ }
+ log.Infof("MDM override %s = %v", key, displayValue)
+ }
+
+ if v, ok := policy.GetString(mdm.KeyManagementURL); ok {
+ if u, err := parseURL("Management URL", v); err != nil {
+ log.Warnf("MDM management URL %q invalid: %v; keeping previous value", v, err)
+ } else {
+ config.ManagementURL = u
+ logApplied(mdm.KeyManagementURL, u.String())
+ }
+ }
+
+ if v, ok := policy.GetString(mdm.KeyPreSharedKey); ok {
+ // Defensive: refuse the redaction mask in case it round-tripped
+ // through a manifest by mistake.
+ if !isPreSharedKeyHidden(&v) {
+ config.PreSharedKey = v
+ logApplied(mdm.KeyPreSharedKey, "")
+ }
+ }
+
+ // applyBool collapses the per-key "read + set + log" boilerplate
+ // for every plain bool MDM key into a single helper. Keeps the
+ // outer function's cognitive complexity below SonarCube's
+ // threshold; functional behaviour is identical to the inlined
+ // branches it replaces.
+ applyBool := func(key string, setter func(bool)) {
+ v, ok := policy.GetBool(key)
+ if !ok {
+ return
+ }
+ setter(v)
+ logApplied(key, v)
+ }
+
+ applyBool(mdm.KeyAllowServerSSH, func(v bool) { bv := v; config.ServerSSHAllowed = &bv })
+ applyBool(mdm.KeyDisableClientRoutes, func(v bool) { config.DisableClientRoutes = v })
+ applyBool(mdm.KeyDisableServerRoutes, func(v bool) { config.DisableServerRoutes = v })
+ applyBool(mdm.KeyBlockInbound, func(v bool) { config.BlockInbound = v })
+ applyBool(mdm.KeyDisableAutoConnect, func(v bool) { config.DisableAutoConnect = v })
+ applyBool(mdm.KeyRosenpassEnabled, func(v bool) { config.RosenpassEnabled = v })
+ applyBool(mdm.KeyRosenpassPermissive, func(v bool) { config.RosenpassPermissive = v })
+
+ if v, ok := policy.GetInt(mdm.KeyWireguardPort); ok {
+ // REG_DWORD is 32-bit; UDP port range is 1-65535. Clamp at the
+ // upper bound and reject obviously-invalid values to avoid the
+ // engine binding to an unusable port if the admin pushes garbage.
+ if v >= 1 && v <= 65535 {
+ config.WgPort = int(v)
+ logApplied(mdm.KeyWireguardPort, v)
+ } else {
+ log.Warnf("MDM wireguard port %d out of range [1,65535]; keeping previous value", v)
+ }
+ }
+}
+
+// parseURL parses and validates the URL for the named service. The URL
+// must use the http or https scheme; if no port is present, ":443" is
+// appended for https or ":80" for http. The serviceName parameter is
+// used to contextualise error messages. On success returns the parsed
+// *url.URL; on failure returns a non-nil error.
func parseURL(serviceName, serviceURL string) (*url.URL, error) {
parsedMgmtURL, err := url.ParseRequestURI(serviceURL)
if err != nil {
diff --git a/client/internal/profilemanager/config_mdm_test.go b/client/internal/profilemanager/config_mdm_test.go
new file mode 100644
index 00000000..6a201235
--- /dev/null
+++ b/client/internal/profilemanager/config_mdm_test.go
@@ -0,0 +1,152 @@
+package profilemanager
+
+import (
+ "path/filepath"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+
+ "github.com/netbirdio/netbird/client/mdm"
+)
+
+// withMDMPolicy temporarily overrides the package-level loadMDMPolicy hook so
+// apply() observes the supplied Policy. The original loader is restored at
+// test cleanup.
+func withMDMPolicy(t *testing.T, policy *mdm.Policy) {
+ t.Helper()
+ prev := loadMDMPolicy
+ loadMDMPolicy = func() *mdm.Policy { return policy }
+ t.Cleanup(func() { loadMDMPolicy = prev })
+}
+
+func TestApply_MDMEmpty_NoEnforcement(t *testing.T) {
+ withMDMPolicy(t, mdm.NewPolicy(nil))
+
+ cfg, err := UpdateOrCreateConfig(ConfigInput{
+ ConfigPath: filepath.Join(t.TempDir(), "config.json"),
+ })
+ require.NoError(t, err)
+ require.NotNil(t, cfg)
+
+ assert.True(t, cfg.Policy().IsEmpty(), "no MDM source ⇒ empty Policy")
+ assert.False(t, cfg.Policy().HasKey(mdm.KeyManagementURL))
+ assert.Empty(t, cfg.Policy().ManagedKeys())
+
+ // Default management URL still resolves.
+ assert.Equal(t, DefaultManagementURL, cfg.ManagementURL.String())
+}
+
+func TestApply_MDMOnly_OverridesDefaults(t *testing.T) {
+ const mdmURL = "https://corp.mdm.example.com:443"
+ withMDMPolicy(t, mdm.NewPolicy(map[string]any{
+ mdm.KeyManagementURL: mdmURL,
+ mdm.KeyDisableClientRoutes: true,
+ mdm.KeyBlockInbound: true,
+ }))
+
+ cfg, err := UpdateOrCreateConfig(ConfigInput{
+ ConfigPath: filepath.Join(t.TempDir(), "config.json"),
+ })
+ require.NoError(t, err)
+ require.NotNil(t, cfg)
+
+ assert.Equal(t, mdmURL, cfg.ManagementURL.String())
+ assert.True(t, cfg.DisableClientRoutes)
+ assert.True(t, cfg.BlockInbound)
+
+ assert.True(t, cfg.Policy().HasKey(mdm.KeyManagementURL))
+ assert.True(t, cfg.Policy().HasKey(mdm.KeyDisableClientRoutes))
+ assert.True(t, cfg.Policy().HasKey(mdm.KeyBlockInbound))
+ assert.False(t, cfg.Policy().HasKey(mdm.KeyAllowServerSSH))
+}
+
+func TestApply_MDMBeatsCLIInput(t *testing.T) {
+ const mdmURL = "https://mdm.example.com:443"
+ const cliURL = "https://cli.example.com:443"
+
+ withMDMPolicy(t, mdm.NewPolicy(map[string]any{
+ mdm.KeyManagementURL: mdmURL,
+ }))
+
+ cfg, err := UpdateOrCreateConfig(ConfigInput{
+ ConfigPath: filepath.Join(t.TempDir(), "config.json"),
+ ManagementURL: cliURL,
+ })
+ require.NoError(t, err)
+ require.NotNil(t, cfg)
+
+ // MDM wins over CLI-supplied management URL.
+ assert.Equal(t, mdmURL, cfg.ManagementURL.String())
+ assert.True(t, cfg.Policy().HasKey(mdm.KeyManagementURL))
+}
+
+func TestApply_MDMInvalidURL_KeepsPreviousValue(t *testing.T) {
+ withMDMPolicy(t, mdm.NewPolicy(map[string]any{
+ mdm.KeyManagementURL: "not-a-url",
+ }))
+
+ cfg, err := UpdateOrCreateConfig(ConfigInput{
+ ConfigPath: filepath.Join(t.TempDir(), "config.json"),
+ })
+ require.NoError(t, err)
+ require.NotNil(t, cfg)
+
+ // Invalid MDM URL is logged and skipped: default URL stays in place
+ // to keep the client functional.
+ assert.Equal(t, DefaultManagementURL, cfg.ManagementURL.String())
+
+ // But the key is still considered MDM-managed (admin intent is to
+ // enforce, daemon rejects user writes to this field — phase-1 scaffolding
+ // reflects this by keeping Policy.HasKey true even on parse failure).
+ assert.True(t, cfg.Policy().HasKey(mdm.KeyManagementURL))
+}
+
+func TestApply_MDMBoolKeysOverrideOnDiskValue(t *testing.T) {
+ tmp := filepath.Join(t.TempDir(), "config.json")
+
+ // Seed without MDM.
+ withMDMPolicy(t, mdm.NewPolicy(nil))
+ _, err := UpdateOrCreateConfig(ConfigInput{
+ ConfigPath: tmp,
+ DisableClientRoutes: boolPtr(false),
+ RosenpassEnabled: boolPtr(false),
+ })
+ require.NoError(t, err)
+
+ // Now enable MDM enforcement for these keys.
+ withMDMPolicy(t, mdm.NewPolicy(map[string]any{
+ mdm.KeyDisableClientRoutes: true,
+ mdm.KeyRosenpassEnabled: true,
+ }))
+
+ cfg, err := UpdateOrCreateConfig(ConfigInput{ConfigPath: tmp})
+ require.NoError(t, err)
+ require.NotNil(t, cfg)
+
+ assert.True(t, cfg.DisableClientRoutes, "MDM override should flip on-disk false to true")
+ assert.True(t, cfg.RosenpassEnabled)
+ assert.True(t, cfg.Policy().HasKey(mdm.KeyDisableClientRoutes))
+ assert.True(t, cfg.Policy().HasKey(mdm.KeyRosenpassEnabled))
+}
+
+func TestApply_MDMPreSharedKeyRedactionSentinelRejected(t *testing.T) {
+ const maskSentinel = "**********"
+
+ withMDMPolicy(t, mdm.NewPolicy(map[string]any{
+ mdm.KeyPreSharedKey: maskSentinel,
+ }))
+
+ cfg, err := UpdateOrCreateConfig(ConfigInput{
+ ConfigPath: filepath.Join(t.TempDir(), "config.json"),
+ })
+ require.NoError(t, err)
+ require.NotNil(t, cfg)
+
+ // Mask sentinel must not be persisted as the actual PSK.
+ assert.NotEqual(t, maskSentinel, cfg.PreSharedKey)
+ // Key still marked managed so user writes are still rejected.
+ assert.True(t, cfg.Policy().HasKey(mdm.KeyPreSharedKey))
+}
+
+func boolPtr(b bool) *bool { return &b }
diff --git a/client/mdm/canonical_loaders.go b/client/mdm/canonical_loaders.go
new file mode 100644
index 00000000..6e7ab19c
--- /dev/null
+++ b/client/mdm/canonical_loaders.go
@@ -0,0 +1,50 @@
+//go:build windows || darwin
+
+package mdm
+
+import "strings"
+
+// allKeys is the set of recognised MDM keys. Unknown keys in a managed
+// configuration are ignored but logged. Lives in this build-tagged file
+// (windows || darwin) because only desktop loaders need the
+// canonicalisation table that consumes it; including it unconditionally
+// would trigger the `unused` golangci-lint check on platforms that
+// don't import canonical_loaders.go.
+var allKeys = []string{
+ KeyManagementURL,
+ KeyDisableUpdateSettings,
+ KeyDisableProfiles,
+ KeyDisableNetworks,
+ KeyDisableClientRoutes,
+ KeyDisableServerRoutes,
+ KeyBlockInbound,
+ KeyDisableMetricsCollection,
+ KeyAllowServerSSH,
+ KeyDisableAutoConnect,
+ KeyPreSharedKey,
+ KeyRosenpassEnabled,
+ KeyRosenpassPermissive,
+ KeyWireguardPort,
+ KeySplitTunnelMode,
+ KeySplitTunnelApps,
+}
+
+// canonicalKey maps the lowercase form of a managed-config value name to
+// its canonical mdm.Key* form. Admins commonly write PascalCase value
+// names in ADMX / Group Policy ("ManagementURL"); the iOS/AppConfig and
+// macOS plist conventions are camelCase ("managementURL"); both must
+// resolve to the same Policy lookup.
+//
+// Lives in a desktop-loader-only file (build tag `windows || darwin`)
+// because no other build path consumes it. Linux / FreeBSD / mobile
+// builds don't ship a platform loader that reads arbitrary-case key
+// names, so they don't need the canonicalisation table — and including
+// the var unconditionally would trigger the `unused` golangci-lint
+// check on those platforms.
+var canonicalKey = func() map[string]string {
+ m := make(map[string]string, len(allKeys))
+ for _, k := range allKeys {
+ m[strings.ToLower(k)] = k
+ }
+ return m
+}()
diff --git a/client/mdm/policy.go b/client/mdm/policy.go
new file mode 100644
index 00000000..109fb322
--- /dev/null
+++ b/client/mdm/policy.go
@@ -0,0 +1,247 @@
+// Package mdm reads MDM-managed configuration from platform-native sources
+// (plist on macOS, registry on Windows, UserDefaults on iOS,
+// RestrictionsManager on Android). The returned Policy is consumed by
+// profilemanager.Config.apply() as the highest-priority override layer.
+//
+// An empty Policy (no source present, or source present with zero keys)
+// means no MDM enforcement is active and the client behaves as if the
+// feature did not exist.
+package mdm
+
+import (
+ "sort"
+ "strconv"
+
+ log "github.com/sirupsen/logrus"
+)
+
+// Well-known policy keys. Names mirror the corresponding ConfigInput Go field
+// names (lowerCamelCase) so the daemon can map a Policy key directly to a
+// configuration field.
+const (
+ KeyManagementURL = "managementURL"
+ KeyDisableUpdateSettings = "disableUpdateSettings"
+ KeyDisableProfiles = "disableProfiles"
+ KeyDisableNetworks = "disableNetworks"
+ KeyDisableClientRoutes = "disableClientRoutes"
+ KeyDisableServerRoutes = "disableServerRoutes"
+ KeyBlockInbound = "blockInbound"
+ KeyDisableMetricsCollection = "disableMetricsCollection"
+ KeyAllowServerSSH = "allowServerSSH"
+ KeyDisableAutoConnect = "disableAutoConnect"
+ KeyPreSharedKey = "preSharedKey"
+ KeyRosenpassEnabled = "rosenpassEnabled"
+ KeyRosenpassPermissive = "rosenpassPermissive"
+ KeyWireguardPort = "wireguardPort"
+
+ // Split tunnel is modeled as a single conceptual policy with two
+ // registry/plist values. KeySplitTunnelMode is the discriminator
+ // ("allow" or "disallow"); KeySplitTunnelApps is a comma-separated
+ // list of package names. The values are mutually exclusive by
+ // construction — only one mode can be set at a time.
+ KeySplitTunnelMode = "splitTunnelMode"
+ KeySplitTunnelApps = "splitTunnelApps"
+)
+
+// Split-tunnel mode literals (KeySplitTunnelMode values).
+const (
+ SplitTunnelModeAllow = "allow"
+ SplitTunnelModeDisallow = "disallow"
+)
+
+// SecretKeys lists keys whose values must be redacted in logs.
+var SecretKeys = map[string]struct{}{
+ KeyPreSharedKey: {},
+}
+
+// boolStringLiterals enumerates the textual boolean encodings the
+// platform loaders may produce (Windows REG_SZ "true", iOS / Android
+// managed-config booleans-as-strings, etc.). Lookup keeps GetBool flat
+// (no nested switch on the string case).
+var boolStringLiterals = map[string]bool{
+ "true": true,
+ "1": true,
+ "yes": true,
+ "false": false,
+ "0": false,
+ "no": false,
+}
+
+
+// Policy holds MDM-managed settings read from the platform source. A nil or
+// empty Policy means no enforcement is active.
+type Policy struct {
+ values map[string]any
+}
+
+// NewPolicy constructs a Policy from a key→value map. Pass nil or an
+// empty map to construct an empty (no-enforcement) Policy. The returned
+// *Policy is always non-nil.
+func NewPolicy(values map[string]any) *Policy {
+ if values == nil {
+ values = map[string]any{}
+ }
+ return &Policy{values: values}
+}
+
+// LoadPolicy reads the platform-native MDM configuration. Returns an
+// empty (but non-nil) Policy when no source is present, the source is
+// empty, or the platform is unsupported.
+//
+// Diagnostic logging differentiates the three states:
+// - source absent / unsupported platform: trace log only
+// - source present, zero keys: info "MDM enrolled (no managed keys)"
+// - source present, N keys: info "MDM enrolled with N managed keys: [...]"
+func LoadPolicy() *Policy {
+ values, err := loadPlatformPolicy()
+ if err != nil {
+ log.Tracef("MDM policy load: %v", err)
+ return &Policy{values: map[string]any{}}
+ }
+ if values == nil {
+ return &Policy{values: map[string]any{}}
+ }
+ if len(values) == 0 {
+ log.Info("MDM enrolled (no managed keys)")
+ } else {
+ log.Infof("MDM enrolled with %d managed key(s): %v", len(values), sortedKeys(values))
+ }
+ return &Policy{values: values}
+}
+
+// IsEmpty reports whether the Policy has no managed keys.
+func (p *Policy) IsEmpty() bool {
+ return p == nil || len(p.values) == 0
+}
+
+// HasKey reports whether the given key is MDM-managed.
+func (p *Policy) HasKey(key string) bool {
+ if p == nil {
+ return false
+ }
+ _, ok := p.values[key]
+ return ok
+}
+
+// ManagedKeys returns the sorted list of managed key names. Returns an empty
+// slice (not nil) on an empty Policy.
+func (p *Policy) ManagedKeys() []string {
+ if p == nil {
+ return []string{}
+ }
+ return sortedKeys(p.values)
+}
+
+// GetString returns the managed value for key coerced to string, and whether
+// the key was set. A non-string value returns ("", false).
+func (p *Policy) GetString(key string) (string, bool) {
+ if p == nil {
+ return "", false
+ }
+ v, ok := p.values[key]
+ if !ok {
+ return "", false
+ }
+ s, ok := v.(string)
+ if !ok || s == "" {
+ return "", false
+ }
+ return s, true
+}
+
+// GetBool returns the managed value for key coerced to bool, and whether the
+// key was set. Accepts native bool and string literals "true"/"false"/"1"/"0".
+func (p *Policy) GetBool(key string) (bool, bool) {
+ if p == nil {
+ return false, false
+ }
+ v, ok := p.values[key]
+ if !ok {
+ return false, false
+ }
+ switch t := v.(type) {
+ case bool:
+ return t, true
+ case string:
+ b, known := boolStringLiterals[t]
+ return b, known
+ case int:
+ return t != 0, true
+ case int64:
+ return t != 0, true
+ }
+ return false, false
+}
+
+// GetInt returns the managed value for key as int64, and whether the key
+// was set. Accepts native int / int64 (as produced by the Windows registry
+// loader for REG_DWORD/REG_QWORD) and numeric strings (decimal).
+func (p *Policy) GetInt(key string) (int64, bool) {
+ if p == nil {
+ return 0, false
+ }
+ v, ok := p.values[key]
+ if !ok {
+ return 0, false
+ }
+ switch t := v.(type) {
+ case int64:
+ return t, true
+ case int:
+ return int64(t), true
+ case int32:
+ return int64(t), true
+ case uint64:
+ return int64(t), true
+ case float64:
+ return int64(t), true
+ case string:
+ if n, err := strconv.ParseInt(t, 10, 64); err == nil {
+ return n, true
+ }
+ }
+ return 0, false
+}
+
+// GetStringSlice returns the managed value for key as []string, and whether
+// the key was set. Accepts []string, []any (of strings), and a single string
+// (treated as a one-element list).
+func (p *Policy) GetStringSlice(key string) ([]string, bool) {
+ if p == nil {
+ return nil, false
+ }
+ v, ok := p.values[key]
+ if !ok {
+ return nil, false
+ }
+ switch t := v.(type) {
+ case []string:
+ return append([]string(nil), t...), true
+ case []any:
+ out := make([]string, 0, len(t))
+ for _, item := range t {
+ s, ok := item.(string)
+ if !ok {
+ return nil, false
+ }
+ out = append(out, s)
+ }
+ return out, true
+ case string:
+ return []string{t}, true
+ }
+ return nil, false
+}
+
+// sortedKeys returns the keys of m as a deterministic, lexicographically
+// sorted slice. Used internally by Policy.ManagedKeys and LoadPolicy's
+// diagnostic log line so callers see a stable key order across runs
+// regardless of Go's randomised map iteration.
+func sortedKeys(m map[string]any) []string {
+ out := make([]string, 0, len(m))
+ for k := range m {
+ out = append(out, k)
+ }
+ sort.Strings(out)
+ return out
+}
diff --git a/client/mdm/policy_darwin.go b/client/mdm/policy_darwin.go
new file mode 100644
index 00000000..57aa1168
--- /dev/null
+++ b/client/mdm/policy_darwin.go
@@ -0,0 +1,90 @@
+//go:build darwin && !ios
+
+package mdm
+
+import (
+ "errors"
+ "fmt"
+ "io/fs"
+ "os"
+ "strings"
+
+ log "github.com/sirupsen/logrus"
+ "howett.net/plist"
+)
+
+// policyPlistPath is the well-known location where macOS writes the
+// device-level mandatory MDM payload for NetBird. The path is fixed by
+// Apple convention: when an MDM provider (Jamf / Kandji / Mosyle /
+// Intune for Mac / Workspace ONE) pushes a Configuration Profile that
+// contains a com.apple.ManagedClient.preferences payload targeting the
+// bundle id io.netbird.client, the OS materializes the payload here.
+//
+// Read-only — only the OS (root) is supposed to write this file. The
+// loader sanity-checks the file mode and refuses to honour a world-
+// writable plist, as a defense against tampered installs.
+const policyPlistPath = "/Library/Managed Preferences/io.netbird.client.plist"
+
+// loadPlatformPolicy reads the MDM-managed configuration from the macOS
+// managed-preferences plist at policyPlistPath. Returns:
+// - (nil, nil) when the plist is absent (device not MDM-enrolled for
+// NetBird, or admin has not yet pushed a payload)
+// - (map, nil) with N entries when N managed values are present
+// (N may be 0 — empty plist still signals enrollment to the caller)
+// - (nil, err) on permission / parse / safety errors (including
+// refusal to read a world-writable plist)
+//
+// Top-level plist keys are canonicalised case-insensitively to the
+// package's internal mdm.Key* names; unknown keys are logged and
+// skipped so a stray entry in the payload does not block startup.
+// Native plist value types map naturally onto the Policy accessor
+// expectations (GetString / GetBool / GetInt / GetStringSlice).
+func loadPlatformPolicy() (map[string]any, error) {
+ f, err := os.Open(policyPlistPath)
+ if err != nil {
+ if errors.Is(err, fs.ErrNotExist) {
+ // Not enrolled for NetBird. Caller treats nil as
+ // "no MDM source present".
+ //nolint:nilnil // (nil, nil) is the documented platform-absent sentinel; see LoadPolicy.
+ return nil, nil
+ }
+ return nil, fmt.Errorf("open %s: %w", policyPlistPath, err)
+ }
+ defer func() {
+ if closeErr := f.Close(); closeErr != nil {
+ log.Warnf("MDM close plist %s: %v", policyPlistPath, closeErr)
+ }
+ }()
+
+ info, err := f.Stat()
+ if err != nil {
+ return nil, fmt.Errorf("stat %s: %w", policyPlistPath, err)
+ }
+ // World-writable plist => tampered install. Refuse rather than
+ // honour potentially attacker-controlled policy values.
+ if info.Mode().Perm()&0o002 != 0 {
+ return nil, fmt.Errorf("refusing to read world-writable MDM source %s (mode %o)",
+ policyPlistPath, info.Mode().Perm())
+ }
+
+ raw := make(map[string]any)
+ if err := plist.NewDecoder(f).Decode(&raw); err != nil {
+ return nil, fmt.Errorf("decode plist %s: %w", policyPlistPath, err)
+ }
+
+ out := make(map[string]any, len(raw))
+ for name, val := range raw {
+ // macOS / AppConfig conventions both use camelCase for managed
+ // preferences keys; canonicalize to the mdm.Key* form so a key
+ // written as "ManagementURL" (PascalCase, rare on macOS but
+ // possible if the admin reused an ADMX-style name) still
+ // resolves.
+ canonical, known := canonicalKey[strings.ToLower(name)]
+ if !known {
+ log.Warnf("MDM ignoring unknown plist key %s: %s", policyPlistPath, name)
+ continue
+ }
+ out[canonical] = val
+ }
+ return out, nil
+}
diff --git a/client/mdm/policy_mobile.go b/client/mdm/policy_mobile.go
new file mode 100644
index 00000000..ec25d4bb
--- /dev/null
+++ b/client/mdm/policy_mobile.go
@@ -0,0 +1,14 @@
+//go:build ios || android
+
+package mdm
+
+// loadPlatformPolicy is unused on mobile: the native layer (Swift on iOS,
+// Kotlin/Java on Android) reads the OS managed-config store and pushes the
+// resulting dictionary in-process via a gomobile entry point that lands in
+// Phase 5 / Phase 6. The stub keeps the package compilable for mobile
+// builds and returns (nil, nil) — the platform-absent sentinel that
+// LoadPolicy in policy.go treats as "no MDM source present".
+func loadPlatformPolicy() (map[string]any, error) {
+ //nolint:nilnil // (nil, nil) is the documented platform-absent sentinel; see LoadPolicy.
+ return nil, nil
+}
diff --git a/client/mdm/policy_other.go b/client/mdm/policy_other.go
new file mode 100644
index 00000000..f4263afa
--- /dev/null
+++ b/client/mdm/policy_other.go
@@ -0,0 +1,14 @@
+//go:build !windows && !darwin && !ios && !android
+
+package mdm
+
+// loadPlatformPolicy returns no policy on platforms without an MDM channel
+// (Linux, FreeBSD). MDM enforcement is off and the client behaves as if
+// the feature did not exist. Returns (nil, nil) — the platform-absent
+// sentinel the caller (LoadPolicy in policy.go) treats as "no MDM
+// source present"; an error here would just translate to the same
+// outcome with an extra log line.
+func loadPlatformPolicy() (map[string]any, error) {
+ //nolint:nilnil // (nil, nil) is the documented platform-absent sentinel; see LoadPolicy.
+ return nil, nil
+}
diff --git a/client/mdm/policy_test.go b/client/mdm/policy_test.go
new file mode 100644
index 00000000..47a6ed2c
--- /dev/null
+++ b/client/mdm/policy_test.go
@@ -0,0 +1,160 @@
+package mdm
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestPolicy_NilSafe(t *testing.T) {
+ var p *Policy
+ assert.True(t, p.IsEmpty())
+ assert.False(t, p.HasKey(KeyManagementURL))
+ assert.Empty(t, p.ManagedKeys())
+
+ _, ok := p.GetString(KeyManagementURL)
+ assert.False(t, ok)
+ _, ok = p.GetBool(KeyDisableProfiles)
+ assert.False(t, ok)
+ _, ok = p.GetStringSlice(KeySplitTunnelApps)
+ assert.False(t, ok)
+}
+
+func TestPolicy_Empty(t *testing.T) {
+ p := NewPolicy(nil)
+ require.NotNil(t, p)
+ assert.True(t, p.IsEmpty())
+ assert.False(t, p.HasKey(KeyManagementURL))
+ assert.Empty(t, p.ManagedKeys())
+}
+
+func TestPolicy_HasKey(t *testing.T) {
+ p := NewPolicy(map[string]any{
+ KeyManagementURL: "https://corp.example.com",
+ KeyDisableProfiles: true,
+ })
+ assert.False(t, p.IsEmpty())
+ assert.True(t, p.HasKey(KeyManagementURL))
+ assert.True(t, p.HasKey(KeyDisableProfiles))
+ assert.False(t, p.HasKey(KeyPreSharedKey))
+}
+
+func TestPolicy_ManagedKeysSorted(t *testing.T) {
+ p := NewPolicy(map[string]any{
+ KeyDisableProfiles: true,
+ KeyManagementURL: "https://x",
+ KeyAllowServerSSH: false,
+ })
+ got := p.ManagedKeys()
+ assert.Equal(t, []string{KeyAllowServerSSH, KeyDisableProfiles, KeyManagementURL}, got)
+}
+
+func TestPolicy_GetString(t *testing.T) {
+ p := NewPolicy(map[string]any{
+ KeyManagementURL: "https://corp.example.com",
+ KeyDisableProfiles: true, // wrong type for GetString
+ KeyPreSharedKey: "", // empty rejected
+ })
+ v, ok := p.GetString(KeyManagementURL)
+ assert.True(t, ok)
+ assert.Equal(t, "https://corp.example.com", v)
+
+ _, ok = p.GetString(KeyDisableProfiles)
+ assert.False(t, ok, "non-string value must not be reported as string")
+
+ _, ok = p.GetString(KeyPreSharedKey)
+ assert.False(t, ok, "empty string treated as unset")
+
+ _, ok = p.GetString("nonexistent")
+ assert.False(t, ok)
+}
+
+func TestPolicy_GetBool(t *testing.T) {
+ cases := []struct {
+ name string
+ raw any
+ want bool
+ ok bool
+ }{
+ {"native true", true, true, true},
+ {"native false", false, false, true},
+ {"string true", "true", true, true},
+ {"string false", "false", false, true},
+ {"string 1", "1", true, true},
+ {"string 0", "0", false, true},
+ {"string yes", "yes", true, true},
+ {"string no", "no", false, true},
+ {"int nonzero", 1, true, true},
+ {"int zero", 0, false, true},
+ {"int64 nonzero", int64(2), true, true},
+ {"int64 zero", int64(0), false, true},
+ {"string garbage", "maybe", false, false},
+ {"float unsupported", 1.0, false, false},
+ }
+ for _, c := range cases {
+ t.Run(c.name, func(t *testing.T) {
+ p := NewPolicy(map[string]any{KeyDisableProfiles: c.raw})
+ got, ok := p.GetBool(KeyDisableProfiles)
+ assert.Equal(t, c.ok, ok)
+ if c.ok {
+ assert.Equal(t, c.want, got)
+ }
+ })
+ }
+
+ _, ok := NewPolicy(nil).GetBool(KeyDisableProfiles)
+ assert.False(t, ok)
+}
+
+func TestPolicy_GetStringSlice(t *testing.T) {
+ t.Run("native string slice", func(t *testing.T) {
+ p := NewPolicy(map[string]any{
+ KeySplitTunnelApps: []string{"com.a", "com.b"},
+ })
+ got, ok := p.GetStringSlice(KeySplitTunnelApps)
+ assert.True(t, ok)
+ assert.Equal(t, []string{"com.a", "com.b"}, got)
+ })
+
+ t.Run("any slice of strings", func(t *testing.T) {
+ p := NewPolicy(map[string]any{
+ KeySplitTunnelApps: []any{"com.a", "com.b"},
+ })
+ got, ok := p.GetStringSlice(KeySplitTunnelApps)
+ assert.True(t, ok)
+ assert.Equal(t, []string{"com.a", "com.b"}, got)
+ })
+
+ t.Run("single string lifts to one-element slice", func(t *testing.T) {
+ p := NewPolicy(map[string]any{
+ KeySplitTunnelApps: "com.a",
+ })
+ got, ok := p.GetStringSlice(KeySplitTunnelApps)
+ assert.True(t, ok)
+ assert.Equal(t, []string{"com.a"}, got)
+ })
+
+ t.Run("mixed any slice rejected", func(t *testing.T) {
+ p := NewPolicy(map[string]any{
+ KeySplitTunnelApps: []any{"com.a", 1},
+ })
+ _, ok := p.GetStringSlice(KeySplitTunnelApps)
+ assert.False(t, ok)
+ })
+
+ t.Run("missing key", func(t *testing.T) {
+ p := NewPolicy(nil)
+ _, ok := p.GetStringSlice(KeySplitTunnelApps)
+ assert.False(t, ok)
+ })
+}
+
+func TestLoadPolicy_PlatformStubReturnsEmpty(t *testing.T) {
+ // loadPlatformPolicy is a stub on every OS for Phase 1. LoadPolicy must
+ // degrade gracefully and never return nil.
+ p := LoadPolicy()
+ require.NotNil(t, p)
+ assert.True(t, p.IsEmpty())
+ assert.Empty(t, p.ManagedKeys())
+}
diff --git a/client/mdm/policy_windows.go b/client/mdm/policy_windows.go
new file mode 100644
index 00000000..0c2629f9
--- /dev/null
+++ b/client/mdm/policy_windows.go
@@ -0,0 +1,108 @@
+//go:build windows
+
+package mdm
+
+import (
+ "errors"
+ "fmt"
+ "strings"
+
+ log "github.com/sirupsen/logrus"
+ "golang.org/x/sys/windows/registry"
+)
+
+// policyRegistryPath is the well-known MDM policy registry key for NetBird.
+// Admins push values here through Group Policy, Intune ADMX ingestion, an
+// Intune custom Registry CSP profile, or `reg add` during MSI deployment.
+// Listed in the project's docs/mdm/netbird.admx schema.
+const policyRegistryPath = `Software\Policies\NetBird`
+
+// readRegistryValue reads a single value under policyRegistryPath and,
+// on success, stores the type-coerced result in out[canonical]. Type
+// coercion mirrors loadPlatformPolicy's documented mapping:
+// - REG_SZ / REG_EXPAND_SZ -> string (REG_EXPAND_SZ is expanded by the API)
+// - REG_DWORD / REG_QWORD -> int64
+// - REG_MULTI_SZ -> []string
+//
+// Unsupported value types and per-value read failures are logged at
+// warn level and skipped — one malformed value must not block the
+// surrounding loop. Extracted from loadPlatformPolicy to keep that
+// function's cognitive complexity in check.
+func readRegistryValue(k registry.Key, name, canonical string, out map[string]any) {
+ _, valType, err := k.GetValue(name, nil)
+ if err != nil {
+ log.Warnf("MDM stat %s\\%s: %v", policyRegistryPath, name, err)
+ return
+ }
+ switch valType {
+ case registry.SZ, registry.EXPAND_SZ:
+ if v, _, err := k.GetStringValue(name); err == nil {
+ out[canonical] = v
+ } else {
+ log.Warnf("MDM read string %s\\%s: %v", policyRegistryPath, name, err)
+ }
+ case registry.DWORD, registry.QWORD:
+ if v, _, err := k.GetIntegerValue(name); err == nil {
+ // uint64 from the registry API; Policy.GetBool / GetInt
+ // helpers consume int64, so narrow safely.
+ out[canonical] = int64(v)
+ } else {
+ log.Warnf("MDM read int %s\\%s: %v", policyRegistryPath, name, err)
+ }
+ case registry.MULTI_SZ:
+ if v, _, err := k.GetStringsValue(name); err == nil {
+ out[canonical] = v
+ } else {
+ log.Warnf("MDM read multi-string %s\\%s: %v", policyRegistryPath, name, err)
+ }
+ default:
+ log.Warnf("MDM ignoring unsupported registry value type %d at %s\\%s",
+ valType, policyRegistryPath, name)
+ }
+}
+
+// loadPlatformPolicy reads the MDM-managed configuration from the
+// Windows registry under HKLM\Software\Policies\NetBird. Returns:
+// - (nil, nil) when the key is absent (device not MDM-enrolled for NetBird)
+// - (map, nil) with N entries when N managed values are set (N may be 0)
+// - (nil, err) on open / enumerate registry errors
+//
+// Per-value type coercion + skip-on-error is delegated to
+// readRegistryValue. Unknown value names are logged and skipped so a
+// malformed deployment does not block startup.
+func loadPlatformPolicy() (map[string]any, error) {
+ k, err := registry.OpenKey(registry.LOCAL_MACHINE, policyRegistryPath, registry.QUERY_VALUE)
+ if err != nil {
+ if errors.Is(err, registry.ErrNotExist) {
+ // Not enrolled. Caller treats nil as "no MDM source present".
+ //nolint:nilnil // (nil, nil) is the documented platform-absent sentinel; see LoadPolicy.
+ return nil, nil
+ }
+ return nil, fmt.Errorf("open %s: %w", policyRegistryPath, err)
+ }
+ defer func() {
+ if closeErr := k.Close(); closeErr != nil {
+ log.Warnf("MDM close registry key %s: %v", policyRegistryPath, closeErr)
+ }
+ }()
+
+ names, err := k.ReadValueNames(-1)
+ if err != nil {
+ return nil, fmt.Errorf("enumerate values of %s: %w", policyRegistryPath, err)
+ }
+
+ out := make(map[string]any, len(names))
+ for _, name := range names {
+ // Canonicalize the registry value name against the known MDM key
+ // set so Policy.HasKey lookups (which use the canonical names)
+ // succeed regardless of the casing used by the admin's ADMX or
+ // `reg add` command.
+ canonical, known := canonicalKey[strings.ToLower(name)]
+ if !known {
+ log.Warnf("MDM ignoring unknown registry value %s\\%s", policyRegistryPath, name)
+ continue
+ }
+ readRegistryValue(k, name, canonical, out)
+ }
+ return out, nil
+}
diff --git a/client/mdm/ticker.go b/client/mdm/ticker.go
new file mode 100644
index 00000000..abd6ae23
--- /dev/null
+++ b/client/mdm/ticker.go
@@ -0,0 +1,129 @@
+package mdm
+
+import (
+ "context"
+ "reflect"
+ "sort"
+ "time"
+
+ log "github.com/sirupsen/logrus"
+)
+
+// DefaultReloadInterval is the production cadence at which the desktop daemon
+// re-reads the OS-native MDM policy. Picked to balance responsiveness against
+// registry/plist I/O overhead. Mobile builds use OS-side notifications
+// instead, hence anticipating the ticker mechanism entirely.
+const DefaultReloadInterval = 1 * time.Minute
+
+// policyLoader is the indirection through which the ticker reads the
+// OS-native policy, both for the initial observation and on every tick.
+// Production points it at LoadPolicy; tests in this package override it to
+// feed a scripted sequence of policies without touching the real OS store.
+var policyLoader = LoadPolicy
+
+// Ticker periodically re-reads the OS-native MDM policy via LoadPolicy and
+// invokes the onChange callback (supplied to Run) whenever the observed
+// Policy diverges from the last observation (added / removed / changed
+// keys). Launch with Run from a goroutine; cancel the supplied context
+// to stop.
+type Ticker struct {
+ interval time.Duration
+ prev *Policy
+}
+
+// NewTicker constructs a Ticker that will re-read the OS-native policy
+// every reloadInterval once Run is called.
+// The initial snapshot is populated by calling policyLoader at
+// construction time so the first tick only fires
+// onChange when the policy actually changed since boot — without
+// this baseline the first tick would report every currently-managed
+// key as "added" and trigger a spurious engine restart.
+func NewTicker(reloadInterval time.Duration) *Ticker {
+ return &Ticker{
+ interval: reloadInterval,
+ prev: policyLoader(),
+ }
+}
+
+// Run blocks until ctx is cancelled, polling the OS-native policy store at
+// the configured cadence and emitting log lines + onChange callback on
+// every observed diff. onChange must be non-nil.
+func (t *Ticker) Run(ctx context.Context, onChange func(prev, curr *Policy) error) {
+ tk := time.NewTicker(t.interval)
+ defer tk.Stop()
+ log.Infof("MDM policy reload ticker started (interval=%s)", t.interval)
+ for {
+ select {
+ case <-ctx.Done():
+ log.Info("MDM policy reload ticker stopped")
+ return
+ case <-tk.C:
+ curr := policyLoader()
+ if policiesEqual(t.prev, curr) {
+ continue
+ }
+ added, removed, changed := diffPolicies(t.prev, curr)
+ log.Infof("MDM policy changed: added=%v removed=%v changed=%v",
+ added, removed, changed)
+ prev := t.prev
+ if err := onChange(prev, curr); err != nil {
+ log.Errorf("MDM policy change handler failed (retrying in 1 minute): %v", err)
+ continue
+ }
+ t.prev = curr
+ }
+ }
+}
+
+// policiesEqual reports whether two Policy instances carry the same
+// managed key set with identical values. Nil and empty policies
+// compare equal; one-nil/one-non-empty compare not equal; otherwise
+// the underlying values maps are compared with reflect.DeepEqual.
+func policiesEqual(a, b *Policy) bool {
+ if a.IsEmpty() && b.IsEmpty() {
+ return true
+ }
+ if a == nil || b == nil {
+ return false
+ }
+ return reflect.DeepEqual(a.values, b.values)
+}
+
+// diffPolicies returns the keys added in curr, removed from prev, and
+// whose values changed between prev and curr. Each slice is sorted
+// lexicographically for stable log output; value differences are
+// determined with reflect.DeepEqual.
+func diffPolicies(prev, curr *Policy) (added, removed, changed []string) {
+ prevKVs := mapOf(prev)
+ currKVs := mapOf(curr)
+ for k := range currKVs {
+ if _, ok := prevKVs[k]; !ok {
+ added = append(added, k)
+ } else if !reflect.DeepEqual(prevKVs[k], currKVs[k]) {
+ changed = append(changed, k)
+ }
+ }
+ for k := range prevKVs {
+ if _, ok := currKVs[k]; !ok {
+ removed = append(removed, k)
+ }
+ }
+ sort.Strings(added)
+ sort.Strings(removed)
+ sort.Strings(changed)
+ return added, removed, changed
+}
+
+// mapOf returns a (possibly empty, never nil) copy of the underlying
+// values map of a Policy so callers outside this package can compare
+// keys/values across the type boundary. Returns an empty map on nil p.
+func mapOf(p *Policy) map[string]any {
+ if p == nil {
+ return map[string]any{}
+ }
+ out := make(map[string]any, len(p.values))
+ for k, v := range p.values {
+ out[k] = v
+ }
+ return out
+}
diff --git a/client/mdm/ticker_test.go b/client/mdm/ticker_test.go
new file mode 100644
index 00000000..17f3cfc2
--- /dev/null
+++ b/client/mdm/ticker_test.go
@@ -0,0 +1,100 @@
+package mdm
+
+import (
+ "context"
+ "sync"
+ "testing"
+ "time"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+// testReloadInterval for speeding up the ticker cadence under `go test`
+const testReloadInterval = 1 * time.Second
+
+// withPolicyLoader overrides the package-level policyLoader for the duration
+// of the test so the ticker observes a scripted policy instead of the real
+// OS-native store. The original loader is restored on cleanup.
+func withPolicyLoader(t *testing.T, fn func() *Policy) {
+ t.Helper()
+ prev := policyLoader
+ policyLoader = fn
+ t.Cleanup(func() { policyLoader = prev })
+}
+
+func TestTicker_FiresOnChangeWithDelta(t *testing.T) {
+ var mu sync.Mutex
+ current := NewPolicy(nil) // initial observation: empty (no enforcement)
+ withPolicyLoader(t, func() *Policy {
+ mu.Lock()
+ defer mu.Unlock()
+ return current
+ })
+
+ type change struct{ prev, curr *Policy }
+ changes := make(chan change, 1)
+ tk := NewTicker(testReloadInterval)
+ require.Equal(t, testReloadInterval, tk.interval)
+
+ ctx, cancel := context.WithCancel(context.Background())
+ done := make(chan struct{})
+ go func() {
+ tk.Run(ctx, func(prev, curr *Policy) error {
+ select {
+ case changes <- change{prev, curr}:
+ default:
+ }
+ return nil
+ })
+ close(done)
+ }()
+ // Stop Run and wait for it to exit before returning, so the policyLoader
+ // restore in t.Cleanup can't race the ticker goroutine still reading it.
+ defer func() { cancel(); <-done }()
+
+ // Flip the OS-observed policy from empty to one managed key. The next
+ // tick must detect the diff and invoke onChange.
+ mu.Lock()
+ current = NewPolicy(map[string]any{KeyManagementURL: "https://mdm.example.com:443"})
+ mu.Unlock()
+
+ select {
+ case c := <-changes:
+ assert.True(t, c.prev.IsEmpty(), "prev should be the initial empty policy")
+ assert.True(t, c.curr.HasKey(KeyManagementURL), "curr should carry the newly-pushed managed key")
+ case <-time.After(5 * time.Second):
+ t.Fatal("onChange not invoked within 5s; ticker should fire every 1s under test")
+ }
+}
+
+func TestTicker_NoCallbackWhenPolicyUnchanged(t *testing.T) {
+ withPolicyLoader(t, func() *Policy {
+ return NewPolicy(map[string]any{KeyBlockInbound: true})
+ })
+
+ fired := make(chan struct{}, 1)
+ tk := NewTicker(testReloadInterval)
+
+ ctx, cancel := context.WithCancel(context.Background())
+ done := make(chan struct{})
+ go func() {
+ tk.Run(ctx, func(_, _ *Policy) error {
+ select {
+ case fired <- struct{}{}:
+ default:
+ }
+ return nil
+ })
+ close(done)
+ }()
+ defer func() { cancel(); <-done }()
+
+ // Over ~2 ticks at the 1s test cadence the policy never changes, so the
+ // diff guard must suppress the callback entirely.
+ select {
+ case <-fired:
+ t.Fatal("onChange fired despite an unchanged policy")
+ case <-time.After(2500 * time.Millisecond):
+ }
+}
diff --git a/client/proto/daemon.pb.go b/client/proto/daemon.pb.go
index 79fa1418..70d9e821 100644
--- a/client/proto/daemon.pb.go
+++ b/client/proto/daemon.pb.go
@@ -1191,8 +1191,14 @@ type GetConfigResponse struct {
DisableSSHAuth bool `protobuf:"varint,25,opt,name=disableSSHAuth,proto3" json:"disableSSHAuth,omitempty"`
SshJWTCacheTTL int32 `protobuf:"varint,26,opt,name=sshJWTCacheTTL,proto3" json:"sshJWTCacheTTL,omitempty"`
DisableIpv6 bool `protobuf:"varint,27,opt,name=disable_ipv6,json=disableIpv6,proto3" json:"disable_ipv6,omitempty"`
- unknownFields protoimpl.UnknownFields
- sizeCache protoimpl.SizeCache
+ // mDMManagedFields lists the names of configuration keys whose value is
+ // currently enforced by an MDM policy. Names match mdm.Key* constants
+ // (e.g. "managementURL", "disableClientRoutes"). UI/CLI clients should
+ // render the corresponding inputs as read-only and display a "managed
+ // by MDM" indicator.
+ MDMManagedFields []string `protobuf:"bytes,28,rep,name=mDMManagedFields,proto3" json:"mDMManagedFields,omitempty"`
+ unknownFields protoimpl.UnknownFields
+ sizeCache protoimpl.SizeCache
}
func (x *GetConfigResponse) Reset() {
@@ -1414,6 +1420,13 @@ func (x *GetConfigResponse) GetDisableIpv6() bool {
return false
}
+func (x *GetConfigResponse) GetMDMManagedFields() []string {
+ if x != nil {
+ return x.MDMManagedFields
+ }
+ return nil
+}
+
// PeerState contains the latest state of a peer
type PeerState struct {
state protoimpl.MessageState `protogen:"open.v1"`
@@ -4961,6 +4974,55 @@ func (x *GetFeaturesResponse) GetDisableNetworks() bool {
return false
}
+// MDMManagedFieldsViolation is attached as a gRPC error detail on a
+// FailedPrecondition status returned from SetConfig (and similar mutating
+// RPCs) when the caller tries to modify one or more MDM-enforced fields.
+// The fields list contains the offending key names; the entire request is
+// rejected (no partial apply).
+type MDMManagedFieldsViolation struct {
+ state protoimpl.MessageState `protogen:"open.v1"`
+ Fields []string `protobuf:"bytes,1,rep,name=fields,proto3" json:"fields,omitempty"`
+ unknownFields protoimpl.UnknownFields
+ sizeCache protoimpl.SizeCache
+}
+
+func (x *MDMManagedFieldsViolation) Reset() {
+ *x = MDMManagedFieldsViolation{}
+ mi := &file_daemon_proto_msgTypes[71]
+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+ ms.StoreMessageInfo(mi)
+}
+
+func (x *MDMManagedFieldsViolation) String() string {
+ return protoimpl.X.MessageStringOf(x)
+}
+
+func (*MDMManagedFieldsViolation) ProtoMessage() {}
+
+func (x *MDMManagedFieldsViolation) ProtoReflect() protoreflect.Message {
+ mi := &file_daemon_proto_msgTypes[71]
+ if x != nil {
+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+ if ms.LoadMessageInfo() == nil {
+ ms.StoreMessageInfo(mi)
+ }
+ return ms
+ }
+ return mi.MessageOf(x)
+}
+
+// Deprecated: Use MDMManagedFieldsViolation.ProtoReflect.Descriptor instead.
+func (*MDMManagedFieldsViolation) Descriptor() ([]byte, []int) {
+ return file_daemon_proto_rawDescGZIP(), []int{71}
+}
+
+func (x *MDMManagedFieldsViolation) GetFields() []string {
+ if x != nil {
+ return x.Fields
+ }
+ return nil
+}
+
type TriggerUpdateRequest struct {
state protoimpl.MessageState `protogen:"open.v1"`
unknownFields protoimpl.UnknownFields
@@ -4969,7 +5031,7 @@ type TriggerUpdateRequest struct {
func (x *TriggerUpdateRequest) Reset() {
*x = TriggerUpdateRequest{}
- mi := &file_daemon_proto_msgTypes[71]
+ mi := &file_daemon_proto_msgTypes[72]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -4981,7 +5043,7 @@ func (x *TriggerUpdateRequest) String() string {
func (*TriggerUpdateRequest) ProtoMessage() {}
func (x *TriggerUpdateRequest) ProtoReflect() protoreflect.Message {
- mi := &file_daemon_proto_msgTypes[71]
+ mi := &file_daemon_proto_msgTypes[72]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -4994,7 +5056,7 @@ func (x *TriggerUpdateRequest) ProtoReflect() protoreflect.Message {
// Deprecated: Use TriggerUpdateRequest.ProtoReflect.Descriptor instead.
func (*TriggerUpdateRequest) Descriptor() ([]byte, []int) {
- return file_daemon_proto_rawDescGZIP(), []int{71}
+ return file_daemon_proto_rawDescGZIP(), []int{72}
}
type TriggerUpdateResponse struct {
@@ -5007,7 +5069,7 @@ type TriggerUpdateResponse struct {
func (x *TriggerUpdateResponse) Reset() {
*x = TriggerUpdateResponse{}
- mi := &file_daemon_proto_msgTypes[72]
+ mi := &file_daemon_proto_msgTypes[73]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -5019,7 +5081,7 @@ func (x *TriggerUpdateResponse) String() string {
func (*TriggerUpdateResponse) ProtoMessage() {}
func (x *TriggerUpdateResponse) ProtoReflect() protoreflect.Message {
- mi := &file_daemon_proto_msgTypes[72]
+ mi := &file_daemon_proto_msgTypes[73]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -5032,7 +5094,7 @@ func (x *TriggerUpdateResponse) ProtoReflect() protoreflect.Message {
// Deprecated: Use TriggerUpdateResponse.ProtoReflect.Descriptor instead.
func (*TriggerUpdateResponse) Descriptor() ([]byte, []int) {
- return file_daemon_proto_rawDescGZIP(), []int{72}
+ return file_daemon_proto_rawDescGZIP(), []int{73}
}
func (x *TriggerUpdateResponse) GetSuccess() bool {
@@ -5060,7 +5122,7 @@ type GetPeerSSHHostKeyRequest struct {
func (x *GetPeerSSHHostKeyRequest) Reset() {
*x = GetPeerSSHHostKeyRequest{}
- mi := &file_daemon_proto_msgTypes[73]
+ mi := &file_daemon_proto_msgTypes[74]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -5072,7 +5134,7 @@ func (x *GetPeerSSHHostKeyRequest) String() string {
func (*GetPeerSSHHostKeyRequest) ProtoMessage() {}
func (x *GetPeerSSHHostKeyRequest) ProtoReflect() protoreflect.Message {
- mi := &file_daemon_proto_msgTypes[73]
+ mi := &file_daemon_proto_msgTypes[74]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -5085,7 +5147,7 @@ func (x *GetPeerSSHHostKeyRequest) ProtoReflect() protoreflect.Message {
// Deprecated: Use GetPeerSSHHostKeyRequest.ProtoReflect.Descriptor instead.
func (*GetPeerSSHHostKeyRequest) Descriptor() ([]byte, []int) {
- return file_daemon_proto_rawDescGZIP(), []int{73}
+ return file_daemon_proto_rawDescGZIP(), []int{74}
}
func (x *GetPeerSSHHostKeyRequest) GetPeerAddress() string {
@@ -5112,7 +5174,7 @@ type GetPeerSSHHostKeyResponse struct {
func (x *GetPeerSSHHostKeyResponse) Reset() {
*x = GetPeerSSHHostKeyResponse{}
- mi := &file_daemon_proto_msgTypes[74]
+ mi := &file_daemon_proto_msgTypes[75]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -5124,7 +5186,7 @@ func (x *GetPeerSSHHostKeyResponse) String() string {
func (*GetPeerSSHHostKeyResponse) ProtoMessage() {}
func (x *GetPeerSSHHostKeyResponse) ProtoReflect() protoreflect.Message {
- mi := &file_daemon_proto_msgTypes[74]
+ mi := &file_daemon_proto_msgTypes[75]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -5137,7 +5199,7 @@ func (x *GetPeerSSHHostKeyResponse) ProtoReflect() protoreflect.Message {
// Deprecated: Use GetPeerSSHHostKeyResponse.ProtoReflect.Descriptor instead.
func (*GetPeerSSHHostKeyResponse) Descriptor() ([]byte, []int) {
- return file_daemon_proto_rawDescGZIP(), []int{74}
+ return file_daemon_proto_rawDescGZIP(), []int{75}
}
func (x *GetPeerSSHHostKeyResponse) GetSshHostKey() []byte {
@@ -5179,7 +5241,7 @@ type RequestJWTAuthRequest struct {
func (x *RequestJWTAuthRequest) Reset() {
*x = RequestJWTAuthRequest{}
- mi := &file_daemon_proto_msgTypes[75]
+ mi := &file_daemon_proto_msgTypes[76]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -5191,7 +5253,7 @@ func (x *RequestJWTAuthRequest) String() string {
func (*RequestJWTAuthRequest) ProtoMessage() {}
func (x *RequestJWTAuthRequest) ProtoReflect() protoreflect.Message {
- mi := &file_daemon_proto_msgTypes[75]
+ mi := &file_daemon_proto_msgTypes[76]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -5204,7 +5266,7 @@ func (x *RequestJWTAuthRequest) ProtoReflect() protoreflect.Message {
// Deprecated: Use RequestJWTAuthRequest.ProtoReflect.Descriptor instead.
func (*RequestJWTAuthRequest) Descriptor() ([]byte, []int) {
- return file_daemon_proto_rawDescGZIP(), []int{75}
+ return file_daemon_proto_rawDescGZIP(), []int{76}
}
func (x *RequestJWTAuthRequest) GetHint() string {
@@ -5237,7 +5299,7 @@ type RequestJWTAuthResponse struct {
func (x *RequestJWTAuthResponse) Reset() {
*x = RequestJWTAuthResponse{}
- mi := &file_daemon_proto_msgTypes[76]
+ mi := &file_daemon_proto_msgTypes[77]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -5249,7 +5311,7 @@ func (x *RequestJWTAuthResponse) String() string {
func (*RequestJWTAuthResponse) ProtoMessage() {}
func (x *RequestJWTAuthResponse) ProtoReflect() protoreflect.Message {
- mi := &file_daemon_proto_msgTypes[76]
+ mi := &file_daemon_proto_msgTypes[77]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -5262,7 +5324,7 @@ func (x *RequestJWTAuthResponse) ProtoReflect() protoreflect.Message {
// Deprecated: Use RequestJWTAuthResponse.ProtoReflect.Descriptor instead.
func (*RequestJWTAuthResponse) Descriptor() ([]byte, []int) {
- return file_daemon_proto_rawDescGZIP(), []int{76}
+ return file_daemon_proto_rawDescGZIP(), []int{77}
}
func (x *RequestJWTAuthResponse) GetVerificationURI() string {
@@ -5327,7 +5389,7 @@ type WaitJWTTokenRequest struct {
func (x *WaitJWTTokenRequest) Reset() {
*x = WaitJWTTokenRequest{}
- mi := &file_daemon_proto_msgTypes[77]
+ mi := &file_daemon_proto_msgTypes[78]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -5339,7 +5401,7 @@ func (x *WaitJWTTokenRequest) String() string {
func (*WaitJWTTokenRequest) ProtoMessage() {}
func (x *WaitJWTTokenRequest) ProtoReflect() protoreflect.Message {
- mi := &file_daemon_proto_msgTypes[77]
+ mi := &file_daemon_proto_msgTypes[78]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -5352,7 +5414,7 @@ func (x *WaitJWTTokenRequest) ProtoReflect() protoreflect.Message {
// Deprecated: Use WaitJWTTokenRequest.ProtoReflect.Descriptor instead.
func (*WaitJWTTokenRequest) Descriptor() ([]byte, []int) {
- return file_daemon_proto_rawDescGZIP(), []int{77}
+ return file_daemon_proto_rawDescGZIP(), []int{78}
}
func (x *WaitJWTTokenRequest) GetDeviceCode() string {
@@ -5384,7 +5446,7 @@ type WaitJWTTokenResponse struct {
func (x *WaitJWTTokenResponse) Reset() {
*x = WaitJWTTokenResponse{}
- mi := &file_daemon_proto_msgTypes[78]
+ mi := &file_daemon_proto_msgTypes[79]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -5396,7 +5458,7 @@ func (x *WaitJWTTokenResponse) String() string {
func (*WaitJWTTokenResponse) ProtoMessage() {}
func (x *WaitJWTTokenResponse) ProtoReflect() protoreflect.Message {
- mi := &file_daemon_proto_msgTypes[78]
+ mi := &file_daemon_proto_msgTypes[79]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -5409,7 +5471,7 @@ func (x *WaitJWTTokenResponse) ProtoReflect() protoreflect.Message {
// Deprecated: Use WaitJWTTokenResponse.ProtoReflect.Descriptor instead.
func (*WaitJWTTokenResponse) Descriptor() ([]byte, []int) {
- return file_daemon_proto_rawDescGZIP(), []int{78}
+ return file_daemon_proto_rawDescGZIP(), []int{79}
}
func (x *WaitJWTTokenResponse) GetToken() string {
@@ -5442,7 +5504,7 @@ type StartCPUProfileRequest struct {
func (x *StartCPUProfileRequest) Reset() {
*x = StartCPUProfileRequest{}
- mi := &file_daemon_proto_msgTypes[79]
+ mi := &file_daemon_proto_msgTypes[80]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -5454,7 +5516,7 @@ func (x *StartCPUProfileRequest) String() string {
func (*StartCPUProfileRequest) ProtoMessage() {}
func (x *StartCPUProfileRequest) ProtoReflect() protoreflect.Message {
- mi := &file_daemon_proto_msgTypes[79]
+ mi := &file_daemon_proto_msgTypes[80]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -5467,7 +5529,7 @@ func (x *StartCPUProfileRequest) ProtoReflect() protoreflect.Message {
// Deprecated: Use StartCPUProfileRequest.ProtoReflect.Descriptor instead.
func (*StartCPUProfileRequest) Descriptor() ([]byte, []int) {
- return file_daemon_proto_rawDescGZIP(), []int{79}
+ return file_daemon_proto_rawDescGZIP(), []int{80}
}
// StartCPUProfileResponse confirms CPU profiling has started
@@ -5479,7 +5541,7 @@ type StartCPUProfileResponse struct {
func (x *StartCPUProfileResponse) Reset() {
*x = StartCPUProfileResponse{}
- mi := &file_daemon_proto_msgTypes[80]
+ mi := &file_daemon_proto_msgTypes[81]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -5491,7 +5553,7 @@ func (x *StartCPUProfileResponse) String() string {
func (*StartCPUProfileResponse) ProtoMessage() {}
func (x *StartCPUProfileResponse) ProtoReflect() protoreflect.Message {
- mi := &file_daemon_proto_msgTypes[80]
+ mi := &file_daemon_proto_msgTypes[81]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -5504,7 +5566,7 @@ func (x *StartCPUProfileResponse) ProtoReflect() protoreflect.Message {
// Deprecated: Use StartCPUProfileResponse.ProtoReflect.Descriptor instead.
func (*StartCPUProfileResponse) Descriptor() ([]byte, []int) {
- return file_daemon_proto_rawDescGZIP(), []int{80}
+ return file_daemon_proto_rawDescGZIP(), []int{81}
}
// StopCPUProfileRequest for stopping CPU profiling
@@ -5516,7 +5578,7 @@ type StopCPUProfileRequest struct {
func (x *StopCPUProfileRequest) Reset() {
*x = StopCPUProfileRequest{}
- mi := &file_daemon_proto_msgTypes[81]
+ mi := &file_daemon_proto_msgTypes[82]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -5528,7 +5590,7 @@ func (x *StopCPUProfileRequest) String() string {
func (*StopCPUProfileRequest) ProtoMessage() {}
func (x *StopCPUProfileRequest) ProtoReflect() protoreflect.Message {
- mi := &file_daemon_proto_msgTypes[81]
+ mi := &file_daemon_proto_msgTypes[82]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -5541,7 +5603,7 @@ func (x *StopCPUProfileRequest) ProtoReflect() protoreflect.Message {
// Deprecated: Use StopCPUProfileRequest.ProtoReflect.Descriptor instead.
func (*StopCPUProfileRequest) Descriptor() ([]byte, []int) {
- return file_daemon_proto_rawDescGZIP(), []int{81}
+ return file_daemon_proto_rawDescGZIP(), []int{82}
}
// StopCPUProfileResponse confirms CPU profiling has stopped
@@ -5553,7 +5615,7 @@ type StopCPUProfileResponse struct {
func (x *StopCPUProfileResponse) Reset() {
*x = StopCPUProfileResponse{}
- mi := &file_daemon_proto_msgTypes[82]
+ mi := &file_daemon_proto_msgTypes[83]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -5565,7 +5627,7 @@ func (x *StopCPUProfileResponse) String() string {
func (*StopCPUProfileResponse) ProtoMessage() {}
func (x *StopCPUProfileResponse) ProtoReflect() protoreflect.Message {
- mi := &file_daemon_proto_msgTypes[82]
+ mi := &file_daemon_proto_msgTypes[83]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -5578,7 +5640,7 @@ func (x *StopCPUProfileResponse) ProtoReflect() protoreflect.Message {
// Deprecated: Use StopCPUProfileResponse.ProtoReflect.Descriptor instead.
func (*StopCPUProfileResponse) Descriptor() ([]byte, []int) {
- return file_daemon_proto_rawDescGZIP(), []int{82}
+ return file_daemon_proto_rawDescGZIP(), []int{83}
}
type InstallerResultRequest struct {
@@ -5589,7 +5651,7 @@ type InstallerResultRequest struct {
func (x *InstallerResultRequest) Reset() {
*x = InstallerResultRequest{}
- mi := &file_daemon_proto_msgTypes[83]
+ mi := &file_daemon_proto_msgTypes[84]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -5601,7 +5663,7 @@ func (x *InstallerResultRequest) String() string {
func (*InstallerResultRequest) ProtoMessage() {}
func (x *InstallerResultRequest) ProtoReflect() protoreflect.Message {
- mi := &file_daemon_proto_msgTypes[83]
+ mi := &file_daemon_proto_msgTypes[84]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -5614,7 +5676,7 @@ func (x *InstallerResultRequest) ProtoReflect() protoreflect.Message {
// Deprecated: Use InstallerResultRequest.ProtoReflect.Descriptor instead.
func (*InstallerResultRequest) Descriptor() ([]byte, []int) {
- return file_daemon_proto_rawDescGZIP(), []int{83}
+ return file_daemon_proto_rawDescGZIP(), []int{84}
}
type InstallerResultResponse struct {
@@ -5627,7 +5689,7 @@ type InstallerResultResponse struct {
func (x *InstallerResultResponse) Reset() {
*x = InstallerResultResponse{}
- mi := &file_daemon_proto_msgTypes[84]
+ mi := &file_daemon_proto_msgTypes[85]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -5639,7 +5701,7 @@ func (x *InstallerResultResponse) String() string {
func (*InstallerResultResponse) ProtoMessage() {}
func (x *InstallerResultResponse) ProtoReflect() protoreflect.Message {
- mi := &file_daemon_proto_msgTypes[84]
+ mi := &file_daemon_proto_msgTypes[85]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -5652,7 +5714,7 @@ func (x *InstallerResultResponse) ProtoReflect() protoreflect.Message {
// Deprecated: Use InstallerResultResponse.ProtoReflect.Descriptor instead.
func (*InstallerResultResponse) Descriptor() ([]byte, []int) {
- return file_daemon_proto_rawDescGZIP(), []int{84}
+ return file_daemon_proto_rawDescGZIP(), []int{85}
}
func (x *InstallerResultResponse) GetSuccess() bool {
@@ -5685,7 +5747,7 @@ type ExposeServiceRequest struct {
func (x *ExposeServiceRequest) Reset() {
*x = ExposeServiceRequest{}
- mi := &file_daemon_proto_msgTypes[85]
+ mi := &file_daemon_proto_msgTypes[86]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -5697,7 +5759,7 @@ func (x *ExposeServiceRequest) String() string {
func (*ExposeServiceRequest) ProtoMessage() {}
func (x *ExposeServiceRequest) ProtoReflect() protoreflect.Message {
- mi := &file_daemon_proto_msgTypes[85]
+ mi := &file_daemon_proto_msgTypes[86]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -5710,7 +5772,7 @@ func (x *ExposeServiceRequest) ProtoReflect() protoreflect.Message {
// Deprecated: Use ExposeServiceRequest.ProtoReflect.Descriptor instead.
func (*ExposeServiceRequest) Descriptor() ([]byte, []int) {
- return file_daemon_proto_rawDescGZIP(), []int{85}
+ return file_daemon_proto_rawDescGZIP(), []int{86}
}
func (x *ExposeServiceRequest) GetPort() uint32 {
@@ -5781,7 +5843,7 @@ type ExposeServiceEvent struct {
func (x *ExposeServiceEvent) Reset() {
*x = ExposeServiceEvent{}
- mi := &file_daemon_proto_msgTypes[86]
+ mi := &file_daemon_proto_msgTypes[87]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -5793,7 +5855,7 @@ func (x *ExposeServiceEvent) String() string {
func (*ExposeServiceEvent) ProtoMessage() {}
func (x *ExposeServiceEvent) ProtoReflect() protoreflect.Message {
- mi := &file_daemon_proto_msgTypes[86]
+ mi := &file_daemon_proto_msgTypes[87]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -5806,7 +5868,7 @@ func (x *ExposeServiceEvent) ProtoReflect() protoreflect.Message {
// Deprecated: Use ExposeServiceEvent.ProtoReflect.Descriptor instead.
func (*ExposeServiceEvent) Descriptor() ([]byte, []int) {
- return file_daemon_proto_rawDescGZIP(), []int{86}
+ return file_daemon_proto_rawDescGZIP(), []int{87}
}
func (x *ExposeServiceEvent) GetEvent() isExposeServiceEvent_Event {
@@ -5847,7 +5909,7 @@ type ExposeServiceReady struct {
func (x *ExposeServiceReady) Reset() {
*x = ExposeServiceReady{}
- mi := &file_daemon_proto_msgTypes[87]
+ mi := &file_daemon_proto_msgTypes[88]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -5859,7 +5921,7 @@ func (x *ExposeServiceReady) String() string {
func (*ExposeServiceReady) ProtoMessage() {}
func (x *ExposeServiceReady) ProtoReflect() protoreflect.Message {
- mi := &file_daemon_proto_msgTypes[87]
+ mi := &file_daemon_proto_msgTypes[88]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -5872,7 +5934,7 @@ func (x *ExposeServiceReady) ProtoReflect() protoreflect.Message {
// Deprecated: Use ExposeServiceReady.ProtoReflect.Descriptor instead.
func (*ExposeServiceReady) Descriptor() ([]byte, []int) {
- return file_daemon_proto_rawDescGZIP(), []int{87}
+ return file_daemon_proto_rawDescGZIP(), []int{88}
}
func (x *ExposeServiceReady) GetServiceName() string {
@@ -5917,7 +5979,7 @@ type StartCaptureRequest struct {
func (x *StartCaptureRequest) Reset() {
*x = StartCaptureRequest{}
- mi := &file_daemon_proto_msgTypes[88]
+ mi := &file_daemon_proto_msgTypes[89]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -5929,7 +5991,7 @@ func (x *StartCaptureRequest) String() string {
func (*StartCaptureRequest) ProtoMessage() {}
func (x *StartCaptureRequest) ProtoReflect() protoreflect.Message {
- mi := &file_daemon_proto_msgTypes[88]
+ mi := &file_daemon_proto_msgTypes[89]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -5942,7 +6004,7 @@ func (x *StartCaptureRequest) ProtoReflect() protoreflect.Message {
// Deprecated: Use StartCaptureRequest.ProtoReflect.Descriptor instead.
func (*StartCaptureRequest) Descriptor() ([]byte, []int) {
- return file_daemon_proto_rawDescGZIP(), []int{88}
+ return file_daemon_proto_rawDescGZIP(), []int{89}
}
func (x *StartCaptureRequest) GetTextOutput() bool {
@@ -5996,7 +6058,7 @@ type CapturePacket struct {
func (x *CapturePacket) Reset() {
*x = CapturePacket{}
- mi := &file_daemon_proto_msgTypes[89]
+ mi := &file_daemon_proto_msgTypes[90]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -6008,7 +6070,7 @@ func (x *CapturePacket) String() string {
func (*CapturePacket) ProtoMessage() {}
func (x *CapturePacket) ProtoReflect() protoreflect.Message {
- mi := &file_daemon_proto_msgTypes[89]
+ mi := &file_daemon_proto_msgTypes[90]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -6021,7 +6083,7 @@ func (x *CapturePacket) ProtoReflect() protoreflect.Message {
// Deprecated: Use CapturePacket.ProtoReflect.Descriptor instead.
func (*CapturePacket) Descriptor() ([]byte, []int) {
- return file_daemon_proto_rawDescGZIP(), []int{89}
+ return file_daemon_proto_rawDescGZIP(), []int{90}
}
func (x *CapturePacket) GetData() []byte {
@@ -6042,7 +6104,7 @@ type StartBundleCaptureRequest struct {
func (x *StartBundleCaptureRequest) Reset() {
*x = StartBundleCaptureRequest{}
- mi := &file_daemon_proto_msgTypes[90]
+ mi := &file_daemon_proto_msgTypes[91]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -6054,7 +6116,7 @@ func (x *StartBundleCaptureRequest) String() string {
func (*StartBundleCaptureRequest) ProtoMessage() {}
func (x *StartBundleCaptureRequest) ProtoReflect() protoreflect.Message {
- mi := &file_daemon_proto_msgTypes[90]
+ mi := &file_daemon_proto_msgTypes[91]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -6067,7 +6129,7 @@ func (x *StartBundleCaptureRequest) ProtoReflect() protoreflect.Message {
// Deprecated: Use StartBundleCaptureRequest.ProtoReflect.Descriptor instead.
func (*StartBundleCaptureRequest) Descriptor() ([]byte, []int) {
- return file_daemon_proto_rawDescGZIP(), []int{90}
+ return file_daemon_proto_rawDescGZIP(), []int{91}
}
func (x *StartBundleCaptureRequest) GetTimeout() *durationpb.Duration {
@@ -6085,7 +6147,7 @@ type StartBundleCaptureResponse struct {
func (x *StartBundleCaptureResponse) Reset() {
*x = StartBundleCaptureResponse{}
- mi := &file_daemon_proto_msgTypes[91]
+ mi := &file_daemon_proto_msgTypes[92]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -6097,7 +6159,7 @@ func (x *StartBundleCaptureResponse) String() string {
func (*StartBundleCaptureResponse) ProtoMessage() {}
func (x *StartBundleCaptureResponse) ProtoReflect() protoreflect.Message {
- mi := &file_daemon_proto_msgTypes[91]
+ mi := &file_daemon_proto_msgTypes[92]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -6110,7 +6172,7 @@ func (x *StartBundleCaptureResponse) ProtoReflect() protoreflect.Message {
// Deprecated: Use StartBundleCaptureResponse.ProtoReflect.Descriptor instead.
func (*StartBundleCaptureResponse) Descriptor() ([]byte, []int) {
- return file_daemon_proto_rawDescGZIP(), []int{91}
+ return file_daemon_proto_rawDescGZIP(), []int{92}
}
type StopBundleCaptureRequest struct {
@@ -6121,7 +6183,7 @@ type StopBundleCaptureRequest struct {
func (x *StopBundleCaptureRequest) Reset() {
*x = StopBundleCaptureRequest{}
- mi := &file_daemon_proto_msgTypes[92]
+ mi := &file_daemon_proto_msgTypes[93]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -6133,7 +6195,7 @@ func (x *StopBundleCaptureRequest) String() string {
func (*StopBundleCaptureRequest) ProtoMessage() {}
func (x *StopBundleCaptureRequest) ProtoReflect() protoreflect.Message {
- mi := &file_daemon_proto_msgTypes[92]
+ mi := &file_daemon_proto_msgTypes[93]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -6146,7 +6208,7 @@ func (x *StopBundleCaptureRequest) ProtoReflect() protoreflect.Message {
// Deprecated: Use StopBundleCaptureRequest.ProtoReflect.Descriptor instead.
func (*StopBundleCaptureRequest) Descriptor() ([]byte, []int) {
- return file_daemon_proto_rawDescGZIP(), []int{92}
+ return file_daemon_proto_rawDescGZIP(), []int{93}
}
type StopBundleCaptureResponse struct {
@@ -6157,7 +6219,7 @@ type StopBundleCaptureResponse struct {
func (x *StopBundleCaptureResponse) Reset() {
*x = StopBundleCaptureResponse{}
- mi := &file_daemon_proto_msgTypes[93]
+ mi := &file_daemon_proto_msgTypes[94]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -6169,7 +6231,7 @@ func (x *StopBundleCaptureResponse) String() string {
func (*StopBundleCaptureResponse) ProtoMessage() {}
func (x *StopBundleCaptureResponse) ProtoReflect() protoreflect.Message {
- mi := &file_daemon_proto_msgTypes[93]
+ mi := &file_daemon_proto_msgTypes[94]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -6182,7 +6244,7 @@ func (x *StopBundleCaptureResponse) ProtoReflect() protoreflect.Message {
// Deprecated: Use StopBundleCaptureResponse.ProtoReflect.Descriptor instead.
func (*StopBundleCaptureResponse) Descriptor() ([]byte, []int) {
- return file_daemon_proto_rawDescGZIP(), []int{93}
+ return file_daemon_proto_rawDescGZIP(), []int{94}
}
type PortInfo_Range struct {
@@ -6195,7 +6257,7 @@ type PortInfo_Range struct {
func (x *PortInfo_Range) Reset() {
*x = PortInfo_Range{}
- mi := &file_daemon_proto_msgTypes[95]
+ mi := &file_daemon_proto_msgTypes[96]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -6207,7 +6269,7 @@ func (x *PortInfo_Range) String() string {
func (*PortInfo_Range) ProtoMessage() {}
func (x *PortInfo_Range) ProtoReflect() protoreflect.Message {
- mi := &file_daemon_proto_msgTypes[95]
+ mi := &file_daemon_proto_msgTypes[96]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -6348,7 +6410,7 @@ const file_daemon_proto_rawDesc = "" +
"\fDownResponse\"P\n" +
"\x10GetConfigRequest\x12 \n" +
"\vprofileName\x18\x01 \x01(\tR\vprofileName\x12\x1a\n" +
- "\busername\x18\x02 \x01(\tR\busername\"\xfe\b\n" +
+ "\busername\x18\x02 \x01(\tR\busername\"\xaa\t\n" +
"\x11GetConfigResponse\x12$\n" +
"\rmanagementUrl\x18\x01 \x01(\tR\rmanagementUrl\x12\x1e\n" +
"\n" +
@@ -6380,7 +6442,8 @@ const file_daemon_proto_rawDesc = "" +
"\x1denableSSHRemotePortForwarding\x18\x17 \x01(\bR\x1denableSSHRemotePortForwarding\x12&\n" +
"\x0edisableSSHAuth\x18\x19 \x01(\bR\x0edisableSSHAuth\x12&\n" +
"\x0esshJWTCacheTTL\x18\x1a \x01(\x05R\x0esshJWTCacheTTL\x12!\n" +
- "\fdisable_ipv6\x18\x1b \x01(\bR\vdisableIpv6\"\x92\x06\n" +
+ "\fdisable_ipv6\x18\x1b \x01(\bR\vdisableIpv6\x12*\n" +
+ "\x10mDMManagedFields\x18\x1c \x03(\tR\x10mDMManagedFields\"\x92\x06\n" +
"\tPeerState\x12\x0e\n" +
"\x02IP\x18\x01 \x01(\tR\x02IP\x12\x16\n" +
"\x06pubKey\x18\x02 \x01(\tR\x06pubKey\x12\x1e\n" +
@@ -6695,7 +6758,9 @@ const file_daemon_proto_rawDesc = "" +
"\x13GetFeaturesResponse\x12)\n" +
"\x10disable_profiles\x18\x01 \x01(\bR\x0fdisableProfiles\x126\n" +
"\x17disable_update_settings\x18\x02 \x01(\bR\x15disableUpdateSettings\x12)\n" +
- "\x10disable_networks\x18\x03 \x01(\bR\x0fdisableNetworks\"\x16\n" +
+ "\x10disable_networks\x18\x03 \x01(\bR\x0fdisableNetworks\"3\n" +
+ "\x19MDMManagedFieldsViolation\x12\x16\n" +
+ "\x06fields\x18\x01 \x03(\tR\x06fields\"\x16\n" +
"\x14TriggerUpdateRequest\"M\n" +
"\x15TriggerUpdateResponse\x12\x18\n" +
"\asuccess\x18\x01 \x01(\bR\asuccess\x12\x1a\n" +
@@ -6851,7 +6916,7 @@ func file_daemon_proto_rawDescGZIP() []byte {
}
var file_daemon_proto_enumTypes = make([]protoimpl.EnumInfo, 4)
-var file_daemon_proto_msgTypes = make([]protoimpl.MessageInfo, 97)
+var file_daemon_proto_msgTypes = make([]protoimpl.MessageInfo, 98)
var file_daemon_proto_goTypes = []any{
(LogLevel)(0), // 0: daemon.LogLevel
(ExposeProtocol)(0), // 1: daemon.ExposeProtocol
@@ -6928,41 +6993,42 @@ var file_daemon_proto_goTypes = []any{
(*LogoutResponse)(nil), // 72: daemon.LogoutResponse
(*GetFeaturesRequest)(nil), // 73: daemon.GetFeaturesRequest
(*GetFeaturesResponse)(nil), // 74: daemon.GetFeaturesResponse
- (*TriggerUpdateRequest)(nil), // 75: daemon.TriggerUpdateRequest
- (*TriggerUpdateResponse)(nil), // 76: daemon.TriggerUpdateResponse
- (*GetPeerSSHHostKeyRequest)(nil), // 77: daemon.GetPeerSSHHostKeyRequest
- (*GetPeerSSHHostKeyResponse)(nil), // 78: daemon.GetPeerSSHHostKeyResponse
- (*RequestJWTAuthRequest)(nil), // 79: daemon.RequestJWTAuthRequest
- (*RequestJWTAuthResponse)(nil), // 80: daemon.RequestJWTAuthResponse
- (*WaitJWTTokenRequest)(nil), // 81: daemon.WaitJWTTokenRequest
- (*WaitJWTTokenResponse)(nil), // 82: daemon.WaitJWTTokenResponse
- (*StartCPUProfileRequest)(nil), // 83: daemon.StartCPUProfileRequest
- (*StartCPUProfileResponse)(nil), // 84: daemon.StartCPUProfileResponse
- (*StopCPUProfileRequest)(nil), // 85: daemon.StopCPUProfileRequest
- (*StopCPUProfileResponse)(nil), // 86: daemon.StopCPUProfileResponse
- (*InstallerResultRequest)(nil), // 87: daemon.InstallerResultRequest
- (*InstallerResultResponse)(nil), // 88: daemon.InstallerResultResponse
- (*ExposeServiceRequest)(nil), // 89: daemon.ExposeServiceRequest
- (*ExposeServiceEvent)(nil), // 90: daemon.ExposeServiceEvent
- (*ExposeServiceReady)(nil), // 91: daemon.ExposeServiceReady
- (*StartCaptureRequest)(nil), // 92: daemon.StartCaptureRequest
- (*CapturePacket)(nil), // 93: daemon.CapturePacket
- (*StartBundleCaptureRequest)(nil), // 94: daemon.StartBundleCaptureRequest
- (*StartBundleCaptureResponse)(nil), // 95: daemon.StartBundleCaptureResponse
- (*StopBundleCaptureRequest)(nil), // 96: daemon.StopBundleCaptureRequest
- (*StopBundleCaptureResponse)(nil), // 97: daemon.StopBundleCaptureResponse
- nil, // 98: daemon.Network.ResolvedIPsEntry
- (*PortInfo_Range)(nil), // 99: daemon.PortInfo.Range
- nil, // 100: daemon.SystemEvent.MetadataEntry
- (*durationpb.Duration)(nil), // 101: google.protobuf.Duration
- (*timestamppb.Timestamp)(nil), // 102: google.protobuf.Timestamp
+ (*MDMManagedFieldsViolation)(nil), // 75: daemon.MDMManagedFieldsViolation
+ (*TriggerUpdateRequest)(nil), // 76: daemon.TriggerUpdateRequest
+ (*TriggerUpdateResponse)(nil), // 77: daemon.TriggerUpdateResponse
+ (*GetPeerSSHHostKeyRequest)(nil), // 78: daemon.GetPeerSSHHostKeyRequest
+ (*GetPeerSSHHostKeyResponse)(nil), // 79: daemon.GetPeerSSHHostKeyResponse
+ (*RequestJWTAuthRequest)(nil), // 80: daemon.RequestJWTAuthRequest
+ (*RequestJWTAuthResponse)(nil), // 81: daemon.RequestJWTAuthResponse
+ (*WaitJWTTokenRequest)(nil), // 82: daemon.WaitJWTTokenRequest
+ (*WaitJWTTokenResponse)(nil), // 83: daemon.WaitJWTTokenResponse
+ (*StartCPUProfileRequest)(nil), // 84: daemon.StartCPUProfileRequest
+ (*StartCPUProfileResponse)(nil), // 85: daemon.StartCPUProfileResponse
+ (*StopCPUProfileRequest)(nil), // 86: daemon.StopCPUProfileRequest
+ (*StopCPUProfileResponse)(nil), // 87: daemon.StopCPUProfileResponse
+ (*InstallerResultRequest)(nil), // 88: daemon.InstallerResultRequest
+ (*InstallerResultResponse)(nil), // 89: daemon.InstallerResultResponse
+ (*ExposeServiceRequest)(nil), // 90: daemon.ExposeServiceRequest
+ (*ExposeServiceEvent)(nil), // 91: daemon.ExposeServiceEvent
+ (*ExposeServiceReady)(nil), // 92: daemon.ExposeServiceReady
+ (*StartCaptureRequest)(nil), // 93: daemon.StartCaptureRequest
+ (*CapturePacket)(nil), // 94: daemon.CapturePacket
+ (*StartBundleCaptureRequest)(nil), // 95: daemon.StartBundleCaptureRequest
+ (*StartBundleCaptureResponse)(nil), // 96: daemon.StartBundleCaptureResponse
+ (*StopBundleCaptureRequest)(nil), // 97: daemon.StopBundleCaptureRequest
+ (*StopBundleCaptureResponse)(nil), // 98: daemon.StopBundleCaptureResponse
+ nil, // 99: daemon.Network.ResolvedIPsEntry
+ (*PortInfo_Range)(nil), // 100: daemon.PortInfo.Range
+ nil, // 101: daemon.SystemEvent.MetadataEntry
+ (*durationpb.Duration)(nil), // 102: google.protobuf.Duration
+ (*timestamppb.Timestamp)(nil), // 103: google.protobuf.Timestamp
}
var file_daemon_proto_depIdxs = []int32{
- 101, // 0: daemon.LoginRequest.dnsRouteInterval:type_name -> google.protobuf.Duration
+ 102, // 0: daemon.LoginRequest.dnsRouteInterval:type_name -> google.protobuf.Duration
25, // 1: daemon.StatusResponse.fullStatus:type_name -> daemon.FullStatus
- 102, // 2: daemon.PeerState.connStatusUpdate:type_name -> google.protobuf.Timestamp
- 102, // 3: daemon.PeerState.lastWireguardHandshake:type_name -> google.protobuf.Timestamp
- 101, // 4: daemon.PeerState.latency:type_name -> google.protobuf.Duration
+ 103, // 2: daemon.PeerState.connStatusUpdate:type_name -> google.protobuf.Timestamp
+ 103, // 3: daemon.PeerState.lastWireguardHandshake:type_name -> google.protobuf.Timestamp
+ 102, // 4: daemon.PeerState.latency:type_name -> google.protobuf.Duration
23, // 5: daemon.SSHServerState.sessions:type_name -> daemon.SSHSessionInfo
20, // 6: daemon.FullStatus.managementState:type_name -> daemon.ManagementState
19, // 7: daemon.FullStatus.signalState:type_name -> daemon.SignalState
@@ -6973,8 +7039,8 @@ var file_daemon_proto_depIdxs = []int32{
55, // 12: daemon.FullStatus.events:type_name -> daemon.SystemEvent
24, // 13: daemon.FullStatus.sshServerState:type_name -> daemon.SSHServerState
31, // 14: daemon.ListNetworksResponse.routes:type_name -> daemon.Network
- 98, // 15: daemon.Network.resolvedIPs:type_name -> daemon.Network.ResolvedIPsEntry
- 99, // 16: daemon.PortInfo.range:type_name -> daemon.PortInfo.Range
+ 99, // 15: daemon.Network.resolvedIPs:type_name -> daemon.Network.ResolvedIPsEntry
+ 100, // 16: daemon.PortInfo.range:type_name -> daemon.PortInfo.Range
32, // 17: daemon.ForwardingRule.destinationPort:type_name -> daemon.PortInfo
32, // 18: daemon.ForwardingRule.translatedPort:type_name -> daemon.PortInfo
33, // 19: daemon.ForwardingRulesResponse.rules:type_name -> daemon.ForwardingRule
@@ -6985,15 +7051,15 @@ var file_daemon_proto_depIdxs = []int32{
52, // 24: daemon.TracePacketResponse.stages:type_name -> daemon.TraceStage
2, // 25: daemon.SystemEvent.severity:type_name -> daemon.SystemEvent.Severity
3, // 26: daemon.SystemEvent.category:type_name -> daemon.SystemEvent.Category
- 102, // 27: daemon.SystemEvent.timestamp:type_name -> google.protobuf.Timestamp
- 100, // 28: daemon.SystemEvent.metadata:type_name -> daemon.SystemEvent.MetadataEntry
+ 103, // 27: daemon.SystemEvent.timestamp:type_name -> google.protobuf.Timestamp
+ 101, // 28: daemon.SystemEvent.metadata:type_name -> daemon.SystemEvent.MetadataEntry
55, // 29: daemon.GetEventsResponse.events:type_name -> daemon.SystemEvent
- 101, // 30: daemon.SetConfigRequest.dnsRouteInterval:type_name -> google.protobuf.Duration
+ 102, // 30: daemon.SetConfigRequest.dnsRouteInterval:type_name -> google.protobuf.Duration
68, // 31: daemon.ListProfilesResponse.profiles:type_name -> daemon.Profile
1, // 32: daemon.ExposeServiceRequest.protocol:type_name -> daemon.ExposeProtocol
- 91, // 33: daemon.ExposeServiceEvent.ready:type_name -> daemon.ExposeServiceReady
- 101, // 34: daemon.StartCaptureRequest.duration:type_name -> google.protobuf.Duration
- 101, // 35: daemon.StartBundleCaptureRequest.timeout:type_name -> google.protobuf.Duration
+ 92, // 33: daemon.ExposeServiceEvent.ready:type_name -> daemon.ExposeServiceReady
+ 102, // 34: daemon.StartCaptureRequest.duration:type_name -> google.protobuf.Duration
+ 102, // 35: daemon.StartBundleCaptureRequest.timeout:type_name -> google.protobuf.Duration
30, // 36: daemon.Network.ResolvedIPsEntry.value:type_name -> daemon.IPList
5, // 37: daemon.DaemonService.Login:input_type -> daemon.LoginRequest
7, // 38: daemon.DaemonService.WaitSSOLogin:input_type -> daemon.WaitSSOLoginRequest
@@ -7013,9 +7079,9 @@ var file_daemon_proto_depIdxs = []int32{
46, // 52: daemon.DaemonService.DeleteState:input_type -> daemon.DeleteStateRequest
48, // 53: daemon.DaemonService.SetSyncResponsePersistence:input_type -> daemon.SetSyncResponsePersistenceRequest
51, // 54: daemon.DaemonService.TracePacket:input_type -> daemon.TracePacketRequest
- 92, // 55: daemon.DaemonService.StartCapture:input_type -> daemon.StartCaptureRequest
- 94, // 56: daemon.DaemonService.StartBundleCapture:input_type -> daemon.StartBundleCaptureRequest
- 96, // 57: daemon.DaemonService.StopBundleCapture:input_type -> daemon.StopBundleCaptureRequest
+ 93, // 55: daemon.DaemonService.StartCapture:input_type -> daemon.StartCaptureRequest
+ 95, // 56: daemon.DaemonService.StartBundleCapture:input_type -> daemon.StartBundleCaptureRequest
+ 97, // 57: daemon.DaemonService.StopBundleCapture:input_type -> daemon.StopBundleCaptureRequest
54, // 58: daemon.DaemonService.SubscribeEvents:input_type -> daemon.SubscribeRequest
56, // 59: daemon.DaemonService.GetEvents:input_type -> daemon.GetEventsRequest
58, // 60: daemon.DaemonService.SwitchProfile:input_type -> daemon.SwitchProfileRequest
@@ -7026,14 +7092,14 @@ var file_daemon_proto_depIdxs = []int32{
69, // 65: daemon.DaemonService.GetActiveProfile:input_type -> daemon.GetActiveProfileRequest
71, // 66: daemon.DaemonService.Logout:input_type -> daemon.LogoutRequest
73, // 67: daemon.DaemonService.GetFeatures:input_type -> daemon.GetFeaturesRequest
- 75, // 68: daemon.DaemonService.TriggerUpdate:input_type -> daemon.TriggerUpdateRequest
- 77, // 69: daemon.DaemonService.GetPeerSSHHostKey:input_type -> daemon.GetPeerSSHHostKeyRequest
- 79, // 70: daemon.DaemonService.RequestJWTAuth:input_type -> daemon.RequestJWTAuthRequest
- 81, // 71: daemon.DaemonService.WaitJWTToken:input_type -> daemon.WaitJWTTokenRequest
- 83, // 72: daemon.DaemonService.StartCPUProfile:input_type -> daemon.StartCPUProfileRequest
- 85, // 73: daemon.DaemonService.StopCPUProfile:input_type -> daemon.StopCPUProfileRequest
- 87, // 74: daemon.DaemonService.GetInstallerResult:input_type -> daemon.InstallerResultRequest
- 89, // 75: daemon.DaemonService.ExposeService:input_type -> daemon.ExposeServiceRequest
+ 76, // 68: daemon.DaemonService.TriggerUpdate:input_type -> daemon.TriggerUpdateRequest
+ 78, // 69: daemon.DaemonService.GetPeerSSHHostKey:input_type -> daemon.GetPeerSSHHostKeyRequest
+ 80, // 70: daemon.DaemonService.RequestJWTAuth:input_type -> daemon.RequestJWTAuthRequest
+ 82, // 71: daemon.DaemonService.WaitJWTToken:input_type -> daemon.WaitJWTTokenRequest
+ 84, // 72: daemon.DaemonService.StartCPUProfile:input_type -> daemon.StartCPUProfileRequest
+ 86, // 73: daemon.DaemonService.StopCPUProfile:input_type -> daemon.StopCPUProfileRequest
+ 88, // 74: daemon.DaemonService.GetInstallerResult:input_type -> daemon.InstallerResultRequest
+ 90, // 75: daemon.DaemonService.ExposeService:input_type -> daemon.ExposeServiceRequest
6, // 76: daemon.DaemonService.Login:output_type -> daemon.LoginResponse
8, // 77: daemon.DaemonService.WaitSSOLogin:output_type -> daemon.WaitSSOLoginResponse
10, // 78: daemon.DaemonService.Up:output_type -> daemon.UpResponse
@@ -7052,9 +7118,9 @@ var file_daemon_proto_depIdxs = []int32{
47, // 91: daemon.DaemonService.DeleteState:output_type -> daemon.DeleteStateResponse
49, // 92: daemon.DaemonService.SetSyncResponsePersistence:output_type -> daemon.SetSyncResponsePersistenceResponse
53, // 93: daemon.DaemonService.TracePacket:output_type -> daemon.TracePacketResponse
- 93, // 94: daemon.DaemonService.StartCapture:output_type -> daemon.CapturePacket
- 95, // 95: daemon.DaemonService.StartBundleCapture:output_type -> daemon.StartBundleCaptureResponse
- 97, // 96: daemon.DaemonService.StopBundleCapture:output_type -> daemon.StopBundleCaptureResponse
+ 94, // 94: daemon.DaemonService.StartCapture:output_type -> daemon.CapturePacket
+ 96, // 95: daemon.DaemonService.StartBundleCapture:output_type -> daemon.StartBundleCaptureResponse
+ 98, // 96: daemon.DaemonService.StopBundleCapture:output_type -> daemon.StopBundleCaptureResponse
55, // 97: daemon.DaemonService.SubscribeEvents:output_type -> daemon.SystemEvent
57, // 98: daemon.DaemonService.GetEvents:output_type -> daemon.GetEventsResponse
59, // 99: daemon.DaemonService.SwitchProfile:output_type -> daemon.SwitchProfileResponse
@@ -7065,14 +7131,14 @@ var file_daemon_proto_depIdxs = []int32{
70, // 104: daemon.DaemonService.GetActiveProfile:output_type -> daemon.GetActiveProfileResponse
72, // 105: daemon.DaemonService.Logout:output_type -> daemon.LogoutResponse
74, // 106: daemon.DaemonService.GetFeatures:output_type -> daemon.GetFeaturesResponse
- 76, // 107: daemon.DaemonService.TriggerUpdate:output_type -> daemon.TriggerUpdateResponse
- 78, // 108: daemon.DaemonService.GetPeerSSHHostKey:output_type -> daemon.GetPeerSSHHostKeyResponse
- 80, // 109: daemon.DaemonService.RequestJWTAuth:output_type -> daemon.RequestJWTAuthResponse
- 82, // 110: daemon.DaemonService.WaitJWTToken:output_type -> daemon.WaitJWTTokenResponse
- 84, // 111: daemon.DaemonService.StartCPUProfile:output_type -> daemon.StartCPUProfileResponse
- 86, // 112: daemon.DaemonService.StopCPUProfile:output_type -> daemon.StopCPUProfileResponse
- 88, // 113: daemon.DaemonService.GetInstallerResult:output_type -> daemon.InstallerResultResponse
- 90, // 114: daemon.DaemonService.ExposeService:output_type -> daemon.ExposeServiceEvent
+ 77, // 107: daemon.DaemonService.TriggerUpdate:output_type -> daemon.TriggerUpdateResponse
+ 79, // 108: daemon.DaemonService.GetPeerSSHHostKey:output_type -> daemon.GetPeerSSHHostKeyResponse
+ 81, // 109: daemon.DaemonService.RequestJWTAuth:output_type -> daemon.RequestJWTAuthResponse
+ 83, // 110: daemon.DaemonService.WaitJWTToken:output_type -> daemon.WaitJWTTokenResponse
+ 85, // 111: daemon.DaemonService.StartCPUProfile:output_type -> daemon.StartCPUProfileResponse
+ 87, // 112: daemon.DaemonService.StopCPUProfile:output_type -> daemon.StopCPUProfileResponse
+ 89, // 113: daemon.DaemonService.GetInstallerResult:output_type -> daemon.InstallerResultResponse
+ 91, // 114: daemon.DaemonService.ExposeService:output_type -> daemon.ExposeServiceEvent
76, // [76:115] is the sub-list for method output_type
37, // [37:76] is the sub-list for method input_type
37, // [37:37] is the sub-list for extension type_name
@@ -7097,8 +7163,8 @@ func file_daemon_proto_init() {
file_daemon_proto_msgTypes[54].OneofWrappers = []any{}
file_daemon_proto_msgTypes[56].OneofWrappers = []any{}
file_daemon_proto_msgTypes[67].OneofWrappers = []any{}
- file_daemon_proto_msgTypes[75].OneofWrappers = []any{}
- file_daemon_proto_msgTypes[86].OneofWrappers = []any{
+ file_daemon_proto_msgTypes[76].OneofWrappers = []any{}
+ file_daemon_proto_msgTypes[87].OneofWrappers = []any{
(*ExposeServiceEvent_Ready)(nil),
}
type x struct{}
@@ -7107,7 +7173,7 @@ func file_daemon_proto_init() {
GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
RawDescriptor: unsafe.Slice(unsafe.StringData(file_daemon_proto_rawDesc), len(file_daemon_proto_rawDesc)),
NumEnums: 4,
- NumMessages: 97,
+ NumMessages: 98,
NumExtensions: 0,
NumServices: 1,
},
diff --git a/client/proto/daemon.proto b/client/proto/daemon.proto
index 6982e4a1..265ab40b 100644
--- a/client/proto/daemon.proto
+++ b/client/proto/daemon.proto
@@ -314,6 +314,13 @@ message GetConfigResponse {
int32 sshJWTCacheTTL = 26;
bool disable_ipv6 = 27;
+
+ // mDMManagedFields lists the names of configuration keys whose value is
+ // currently enforced by an MDM policy. Names match mdm.Key* constants
+ // (e.g. "managementURL", "disableClientRoutes"). UI/CLI clients should
+ // render the corresponding inputs as read-only and display a "managed
+ // by MDM" indicator.
+ repeated string mDMManagedFields = 28;
}
// PeerState contains the latest state of a peer
@@ -733,6 +740,15 @@ message GetFeaturesResponse{
bool disable_networks = 3;
}
+// MDMManagedFieldsViolation is attached as a gRPC error detail on a
+// FailedPrecondition status returned from SetConfig (and similar mutating
+// RPCs) when the caller tries to modify one or more MDM-enforced fields.
+// The fields list contains the offending key names; the entire request is
+// rejected (no partial apply).
+message MDMManagedFieldsViolation {
+ repeated string fields = 1;
+}
+
message TriggerUpdateRequest {}
message TriggerUpdateResponse {
diff --git a/client/server/mdm.go b/client/server/mdm.go
new file mode 100644
index 00000000..0da0ec5d
--- /dev/null
+++ b/client/server/mdm.go
@@ -0,0 +1,419 @@
+package server
+
+import (
+ "context"
+ "fmt"
+ "time"
+
+ log "github.com/sirupsen/logrus"
+ "google.golang.org/grpc/codes"
+ gstatus "google.golang.org/grpc/status"
+
+ "github.com/netbirdio/netbird/client/mdm"
+ "github.com/netbirdio/netbird/client/proto"
+)
+
+// preSharedKeyRedactedSentinel is the value GetConfig returns in place
+// of an actual PSK, so a UI that round-trips the field back to the
+// daemon (via SetConfig / Login) can be distinguished from a deliberate
+// override. Any incoming PSK that equals this sentinel is treated as
+// a no-op echo, never as a conflict with the policy.
+const preSharedKeyRedactedSentinel = "**********"
+
+// loadMDMPolicy is the indirection used by server handlers to read the
+// active MDM policy. Tests override this to inject a fake policy.
+var loadMDMPolicy = mdm.LoadPolicy
+
+// conflictCheck is a value-aware comparison between a single field in
+// the incoming request and the corresponding MDM-enforced value. It
+// runs only when the field was actually set in the request (presence
+// already filtered upstream); ok=true reports the policy value, ok=false
+// means the policy is silent on the key — both are treated as conflicts
+// to be safe (an MDM key declared as managed must hold a value).
+type conflictCheck struct {
+ key string
+ check func(*mdm.Policy) (match bool)
+}
+
+// onMDMPolicyChange is invoked by the MDM reload ticker every time the
+// OS-native managed-config store reports a diff vs the last observation.
+//
+// Restart sequence:
+// 1. Cancel the active engine context (terminates connectWithRetryRuns).
+// 2. Wait briefly for that goroutine to exit (giveUpChan is closed on exit).
+// 3. Re-resolve Config from disk + MDM policy (Config.apply re-runs
+// applyMDMPolicy with the freshly loaded Policy).
+// 4. Spawn a fresh connectWithRetryRuns with the new context and config.
+// 5. Broadcast a SystemEvent so any GUI / CLI subscriber (SubscribeEvents
+// RPC) can refresh its cached config view without polling.
+//
+// The callback runs in the ticker's own goroutine. Ticker has already
+// logged the per-key diff before invoking this hook.
+func (s *Server) onMDMPolicyChange(_, _ *mdm.Policy) error {
+ log.Warn("MDM policy changed; restarting engine to apply new configuration")
+
+ // Hold s.mutex for the entire restart sequence (cancel + quiescence
+ // wait + re-spawn). Any concurrent Up/Down/Status arriving while
+ // MDM is restarting blocks on the Lock until we are done — they
+ // then observe the post-restart state coherently. This is safe
+ // because the connectWithRetryRuns goroutine no longer acquires
+ // s.mutex in its defer (intent vs. goroutine-alive concerns are
+ // fully separated; see the connectionGoroutineRunning helper).
+ s.mutex.Lock()
+ defer s.mutex.Unlock()
+
+ if !s.clientRunning {
+ // The client is not running, so there's no engine to restart.
+ return nil
+ }
+ if s.actCancel != nil {
+ s.actCancel()
+ }
+
+ // Wait for previous connectWithRetryRuns to exit so we don't end up
+ // with two goroutines fighting over the same status recorder + engine.
+ // The teardown engages a fan-out of engine goroutines (peer workers,
+ // signal handler, route manager, ...). close(clientGiveUpChan)
+ // happens in the function-scope defer of connectWithRetryRuns, on
+ // every exit path (ctx cancel, backoff exhausted, panic) — see the
+ // defer in server.go.
+ if s.clientGiveUpChan != nil {
+ select {
+ case <-s.clientGiveUpChan:
+ case <-time.After(10 * time.Second):
+ return fmt.Errorf("failed to restart the engine due to timeout")
+ }
+ }
+
+ if err := s.restartEngineForMDMLocked(); err != nil {
+ log.Errorf("MDM restart failed: %v", err)
+ return err
+ }
+
+ // publishConfigChangedEvent has already fired inside
+ // restartEngineForMDMLocked with source="mdm". Emit an MDM-specific
+ // user-visible toast so the operator knows their IT policy was
+ // applied (UserMessage != "" triggers the GUI notifier).
+ s.statusRecorder.PublishEvent(
+ proto.SystemEvent_INFO,
+ proto.SystemEvent_SYSTEM,
+ "MDM policy applied",
+ "NetBird configuration was updated by your IT policy.",
+ map[string]string{"source": "mdm", "type": "policy_applied"},
+ )
+ return nil
+}
+
+// publishConfigChangedEvent broadcasts a SystemEvent informing any active
+// SubscribeEvents subscriber (typically the GUI tray) that the daemon's
+// effective Config has been replaced and any cached client-side view
+// should be refreshed. Callers pass a stable `source` label so the GUI
+// can distinguish a startup spawn from a user-triggered Up or an
+// MDM-driven restart. Reusing the SYSTEM category keeps the proto enum
+// stable; metadata.type="config_changed" routes to the GUI's refresh
+// handler. UserMessage is left empty so the system tray does not toast
+// for every internal restart; the MDM path emits a separate
+// "policy_applied" event (with UserMessage) for that purpose.
+func (s *Server) publishConfigChangedEvent(source string) {
+ if s.statusRecorder == nil {
+ return
+ }
+ s.statusRecorder.PublishEvent(
+ proto.SystemEvent_INFO,
+ proto.SystemEvent_SYSTEM,
+ fmt.Sprintf("daemon config changed (source=%s)", source),
+ "",
+ map[string]string{
+ "source": source,
+ "type": "config_changed",
+ },
+ )
+}
+
+// restartEngineForMDMLocked re-resolves the active profile config
+// (re-running applyMDMPolicy via Config.apply) and re-spawns
+// connectWithRetryRuns. Mirrors the tail of Server.Start so a runtime
+// MDM change behaves identically to a fresh boot under the new policy.
+//
+// MUST be called with s.mutex held — onMDMPolicyChange holds the lock
+// for the entire restart sequence (cancel + quiescence wait + re-spawn)
+// so concurrent Up/Down/Status RPCs observe a coherent post-restart
+// state.
+func (s *Server) restartEngineForMDMLocked() error {
+ activeProf, err := s.profileManager.GetActiveProfileState()
+ if err != nil {
+ return fmt.Errorf("get active profile state: %w", err)
+ }
+ config, _, err := s.getConfig(activeProf)
+ if err != nil {
+ return fmt.Errorf("get active profile config: %w", err)
+ }
+
+ s.config = config
+ s.statusRecorder.UpdateManagementAddress(config.ManagementURL.String())
+ s.statusRecorder.UpdateRosenpass(config.RosenpassEnabled, config.RosenpassPermissive)
+ s.statusRecorder.UpdateLazyConnection(config.LazyConnectionEnabled)
+
+ ctx, cancel := context.WithCancel(s.rootCtx)
+ s.actCancel = cancel
+ s.clientRunning = true
+ s.clientRunningChan = make(chan struct{})
+ s.clientGiveUpChan = make(chan struct{})
+ log.Info("MDM restart: spawning connectWithRetryRuns with re-resolved config")
+ go s.connectWithRetryRuns(ctx, config, s.statusRecorder, s.clientRunningChan, s.clientGiveUpChan)
+ s.publishConfigChangedEvent("mdm")
+ return nil
+}
+
+// conflictBool builds a conflictCheck for a boolean MDM key. If p is nil
+// the field is treated as matching (no override requested); otherwise the
+// check returns true only when the policy contains the key and its
+// boolean value equals *p.
+func conflictBool(key string, p *bool) conflictCheck {
+ return conflictCheck{
+ key: key,
+ check: func(pol *mdm.Policy) bool {
+ if p == nil {
+ return true // absent → match by definition
+ }
+ want, ok := pol.GetBool(key)
+ return ok && want == *p
+ },
+ }
+}
+
+// conflictString builds a conflictCheck for a string MDM key. An empty
+// `got` is treated as "field not set" (no override requested); otherwise
+// the check returns true only when the policy contains the key and its
+// value equals got.
+func conflictString(key, got string) conflictCheck {
+ return conflictCheck{
+ key: key,
+ check: func(pol *mdm.Policy) bool {
+ if got == "" {
+ return true
+ }
+ want, ok := pol.GetString(key)
+ return ok && want == got
+ },
+ }
+}
+
+// conflictInt64 builds a conflictCheck for an integer MDM key. If p is
+// nil the field is treated as matching; otherwise the check returns
+// true only when the policy contains the key and its int value equals *p.
+func conflictInt64(key string, p *int64) conflictCheck {
+ return conflictCheck{
+ key: key,
+ check: func(pol *mdm.Policy) bool {
+ if p == nil {
+ return true
+ }
+ want, ok := pol.GetInt(key)
+ return ok && want == *p
+ },
+ }
+}
+
+// resolveConflicts walks the per-field checks against the active MDM
+// policy and returns the names of keys whose requested value diverges
+// from the policy-enforced value. Keys not present in the policy are
+// skipped silently (the gate fires only for keys the admin has
+// actually pushed). Returns nil for an empty policy.
+func resolveConflicts(policy *mdm.Policy, checks []conflictCheck) []string {
+ if policy.IsEmpty() {
+ return nil
+ }
+ var conflicts []string
+ for _, c := range checks {
+ if !policy.HasKey(c.key) {
+ continue
+ }
+ if !c.check(policy) {
+ conflicts = append(conflicts, c.key)
+ }
+ }
+ return conflicts
+}
+
+// mdmManagedFieldConflicts returns the names of MDM-managed keys whose
+// requested value in the SetConfigRequest differs from the MDM-enforced
+// value. A field set to the same value the policy already enforces is
+// treated as a no-op echo (the GUI tray sends a full Config snapshot on
+// every toggle, so most fields in a typical request match the policy
+// exactly and must NOT be flagged as conflicts). The redacted PSK
+// sentinel ("**********") returned by GetConfig is recognised and
+// treated as no-op so the UI can safely round-trip it.
+func mdmManagedFieldConflicts(msg *proto.SetConfigRequest, policy *mdm.Policy) []string {
+ if msg == nil {
+ return nil
+ }
+
+ // PSK round-trip echo: collapse the sentinel to empty so the
+ // shared check treats it as "field not set".
+ pskGot := ""
+ if msg.OptionalPreSharedKey != nil && *msg.OptionalPreSharedKey != preSharedKeyRedactedSentinel {
+ pskGot = *msg.OptionalPreSharedKey
+ }
+
+ return resolveConflicts(policy, []conflictCheck{
+ conflictString(mdm.KeyManagementURL, msg.ManagementUrl),
+ conflictString(mdm.KeyPreSharedKey, pskGot),
+ conflictBool(mdm.KeyRosenpassEnabled, msg.RosenpassEnabled),
+ conflictBool(mdm.KeyRosenpassPermissive, msg.RosenpassPermissive),
+ conflictBool(mdm.KeyDisableAutoConnect, msg.DisableAutoConnect),
+ conflictBool(mdm.KeyAllowServerSSH, msg.ServerSSHAllowed),
+ conflictBool(mdm.KeyDisableClientRoutes, msg.DisableClientRoutes),
+ conflictBool(mdm.KeyDisableServerRoutes, msg.DisableServerRoutes),
+ conflictBool(mdm.KeyBlockInbound, msg.BlockInbound),
+ conflictInt64(mdm.KeyWireguardPort, msg.WireguardPort),
+ })
+}
+
+// setConfigRequestHasConfigOverrides reports whether the SetConfigRequest
+// carries ANY field that would actually mutate the persisted config.
+// The CLI builds a SetConfigRequest unconditionally on every
+// `netbird up` (see setupSetConfigReq in cmd/up.go) — a plain
+// `netbird up` produces a request with every field at its zero value;
+// the gate must skip such no-op invocations or it would always fire
+// even when the user did not pass any --flag. Returns false on a nil
+// msg; true when any management/admin URL, PSK, DNS/NAT list+clean
+// flag, interface/port/MTU, or any optional bool/duration field is set.
+func setConfigRequestHasConfigOverrides(msg *proto.SetConfigRequest) bool {
+ if msg == nil {
+ return false
+ }
+ return msg.ManagementUrl != "" ||
+ msg.AdminURL != "" ||
+ msg.OptionalPreSharedKey != nil ||
+ len(msg.CustomDNSAddress) > 0 ||
+ len(msg.NatExternalIPs) > 0 || msg.CleanNATExternalIPs ||
+ len(msg.ExtraIFaceBlacklist) > 0 ||
+ len(msg.DnsLabels) > 0 || msg.CleanDNSLabels ||
+ msg.DnsRouteInterval != nil ||
+ msg.RosenpassEnabled != nil ||
+ msg.RosenpassPermissive != nil ||
+ msg.InterfaceName != nil ||
+ msg.WireguardPort != nil ||
+ msg.Mtu != nil ||
+ msg.DisableAutoConnect != nil ||
+ msg.ServerSSHAllowed != nil ||
+ msg.NetworkMonitor != nil ||
+ msg.DisableClientRoutes != nil ||
+ msg.DisableServerRoutes != nil ||
+ msg.DisableDns != nil ||
+ msg.DisableFirewall != nil ||
+ msg.BlockLanAccess != nil ||
+ msg.DisableNotifications != nil ||
+ msg.LazyConnectionEnabled != nil ||
+ msg.BlockInbound != nil ||
+ msg.DisableIpv6 != nil ||
+ msg.EnableSSHRoot != nil ||
+ msg.EnableSSHSFTP != nil ||
+ msg.EnableSSHLocalPortForwarding != nil ||
+ msg.EnableSSHRemotePortForwarding != nil ||
+ msg.DisableSSHAuth != nil ||
+ msg.SshJWTCacheTTL != nil
+}
+
+// loginRequestHasConfigOverrides reports whether the LoginRequest
+// carries ANY field that would mutate persisted daemon configuration
+// (as opposed to pure-auth fields like setupKey, hostname, hint,
+// profileName, username). Used by the Login handler to decide whether
+// the `--disable-update-settings` / MDM gates must run: a re-auth that
+// changes nothing about the configuration is always allowed.
+func loginRequestHasConfigOverrides(msg *proto.LoginRequest) bool {
+ if msg == nil {
+ return false
+ }
+ return msg.ManagementUrl != "" ||
+ msg.AdminURL != "" ||
+ msg.PreSharedKey != "" || //nolint:staticcheck // SA1019: legacy proto field still accepted by Login
+ msg.OptionalPreSharedKey != nil ||
+ len(msg.CustomDNSAddress) > 0 ||
+ len(msg.NatExternalIPs) > 0 || msg.CleanNATExternalIPs ||
+ msg.RosenpassEnabled != nil ||
+ msg.InterfaceName != nil ||
+ msg.WireguardPort != nil ||
+ msg.DisableAutoConnect != nil ||
+ msg.ServerSSHAllowed != nil ||
+ msg.RosenpassPermissive != nil ||
+ len(msg.ExtraIFaceBlacklist) > 0 ||
+ msg.NetworkMonitor != nil ||
+ msg.DnsRouteInterval != nil ||
+ msg.DisableClientRoutes != nil ||
+ msg.DisableServerRoutes != nil ||
+ msg.DisableDns != nil ||
+ msg.DisableFirewall != nil ||
+ msg.BlockLanAccess != nil ||
+ msg.DisableNotifications != nil ||
+ len(msg.DnsLabels) > 0 || msg.CleanDNSLabels ||
+ msg.LazyConnectionEnabled != nil ||
+ msg.BlockInbound != nil
+}
+
+// loginRequestMDMConflicts mirrors mdmManagedFieldConflicts but for the
+// LoginRequest surface. Same value-aware semantics: a field set to the
+// MDM-enforced value is a no-op echo, not a conflict; only a divergent
+// value is flagged. PSK has two proto fields — PreSharedKey (deprecated)
+// and OptionalPreSharedKey (current); either route trips the gate if it
+// diverges from the MDM-enforced PSK. OptionalPreSharedKey wins when
+// both are set; the redaction sentinel ("**********") is accepted as
+// a no-op echo.
+func loginRequestMDMConflicts(msg *proto.LoginRequest, policy *mdm.Policy) []string {
+ if msg == nil {
+ return nil
+ }
+
+ // Collapse the two PSK fields + the redaction sentinel down to a
+ // single "got" string the shared check can compare against the
+ // policy: OptionalPreSharedKey wins if set; PreSharedKey (deprecated)
+ // is the fallback; sentinel echo is treated as "field not set".
+ pskGot := ""
+ if msg.OptionalPreSharedKey != nil {
+ pskGot = *msg.OptionalPreSharedKey
+ } else if msg.PreSharedKey != "" { //nolint:staticcheck // SA1019: legacy proto field still accepted by Login
+ pskGot = msg.PreSharedKey //nolint:staticcheck // SA1019
+ }
+ if pskGot == preSharedKeyRedactedSentinel {
+ pskGot = ""
+ }
+
+ return resolveConflicts(policy, []conflictCheck{
+ conflictString(mdm.KeyManagementURL, msg.ManagementUrl),
+ conflictString(mdm.KeyPreSharedKey, pskGot),
+ conflictBool(mdm.KeyRosenpassEnabled, msg.RosenpassEnabled),
+ conflictBool(mdm.KeyRosenpassPermissive, msg.RosenpassPermissive),
+ conflictBool(mdm.KeyDisableAutoConnect, msg.DisableAutoConnect),
+ conflictBool(mdm.KeyAllowServerSSH, msg.ServerSSHAllowed),
+ conflictBool(mdm.KeyDisableClientRoutes, msg.DisableClientRoutes),
+ conflictBool(mdm.KeyDisableServerRoutes, msg.DisableServerRoutes),
+ conflictBool(mdm.KeyBlockInbound, msg.BlockInbound),
+ conflictInt64(mdm.KeyWireguardPort, msg.WireguardPort),
+ })
+}
+
+// rejectMDMManagedFieldConflicts returns a FailedPrecondition gRPC error
+// with an MDMManagedFieldsViolation detail when any of the requested
+// fields tries to change an MDM-enforced value to something else, and
+// nil otherwise. The whole request is rejected on any conflict; non-
+// conflicting fields in the same request are not applied either (no
+// partial apply).
+func rejectMDMManagedFieldConflicts(conflicts []string) error {
+ if len(conflicts) == 0 {
+ return nil
+ }
+ log.Warnf("MDM rejected request: tried to modify %d managed key(s): %v",
+ len(conflicts), conflicts)
+ st := gstatus.New(
+ codes.FailedPrecondition,
+ fmt.Sprintf("fields managed by MDM cannot be modified: %v", conflicts),
+ )
+ detailed, err := st.WithDetails(&proto.MDMManagedFieldsViolation{Fields: conflicts})
+ if err != nil {
+ // Detail attachment is best-effort; fall back to the plain status
+ // so the caller still gets a usable FailedPrecondition.
+ return st.Err()
+ }
+ return detailed.Err()
+}
diff --git a/client/server/network.go b/client/server/network.go
index 12cefbd9..7a3c08f2 100644
--- a/client/server/network.go
+++ b/client/server/network.go
@@ -30,7 +30,7 @@ func (s *Server) ListNetworks(context.Context, *proto.ListNetworksRequest) (*pro
s.mutex.Lock()
defer s.mutex.Unlock()
- if s.networksDisabled {
+ if s.checkNetworksDisabled() {
return nil, gstatus.Errorf(codes.Unavailable, errNetworksDisabled)
}
@@ -143,7 +143,7 @@ func (s *Server) SelectNetworks(_ context.Context, req *proto.SelectNetworksRequ
s.mutex.Lock()
defer s.mutex.Unlock()
- if s.networksDisabled {
+ if s.checkNetworksDisabled() {
return nil, gstatus.Errorf(codes.Unavailable, errNetworksDisabled)
}
@@ -195,7 +195,7 @@ func (s *Server) DeselectNetworks(_ context.Context, req *proto.SelectNetworksRe
s.mutex.Lock()
defer s.mutex.Unlock()
- if s.networksDisabled {
+ if s.checkNetworksDisabled() {
return nil, gstatus.Errorf(codes.Unavailable, errNetworksDisabled)
}
diff --git a/client/server/server.go b/client/server/server.go
index 397fb37e..32daf771 100644
--- a/client/server/server.go
+++ b/client/server/server.go
@@ -24,6 +24,7 @@ import (
"github.com/netbirdio/netbird/client/internal/expose"
"github.com/netbirdio/netbird/client/internal/profilemanager"
sleephandler "github.com/netbirdio/netbird/client/internal/sleep/handler"
+ "github.com/netbirdio/netbird/client/mdm"
"github.com/netbirdio/netbird/client/system"
mgm "github.com/netbirdio/netbird/shared/management/client"
"github.com/netbirdio/netbird/shared/management/domain"
@@ -71,7 +72,13 @@ type Server struct {
mutex sync.Mutex
config *profilemanager.Config
proto.UnimplementedDaemonServiceServer
- clientRunning bool // protected by mutex
+ // clientRunning tracks "the daemon wants to be connected" — set true by
+ // Start / Up, cleared by Down / Logout. Persists across retry
+ // loops, signal disconnects, and ErrResetConnection cycles. NOT
+ // changed by connectWithRetryRuns goroutine exit — for that
+ // (goroutine-still-alive) check, see connectionGoroutineRunning() which
+ // derives from clientGiveUpChan close state. Protected by s.mutex.
+ clientRunning bool
clientRunningChan chan struct{}
clientGiveUpChan chan struct{} // closed when connectWithRetryRuns goroutine exits
@@ -98,6 +105,11 @@ type Server struct {
sleepHandler *sleephandler.SleepHandler
+ // mdmTicker periodically re-reads the OS-native MDM policy and triggers
+ // an engine restart when the policy changes. Launched once by Start;
+ // stopped by the rootCtx cancellation.
+ mdmTicker *mdm.Ticker
+
updateManager *updater.Manager
jwtCache *jwtCache
@@ -155,6 +167,17 @@ func (s *Server) Start() error {
s.updateManager.CheckUpdateSuccess(s.rootCtx)
}
+ // MDM policy reload ticker: every minute the desktop daemon re-reads
+ // the OS-native managed-config store and, on diff vs the previous
+ // observation, cancels the active engine context so connectWithRetry-
+ // Runs re-resolves Config (re-running profilemanager.Config.apply which
+ // applies the freshly-read MDM policy as the last layer) and brings
+ // the engine back with the new values.
+ if s.mdmTicker == nil {
+ s.mdmTicker = mdm.NewTicker(mdm.DefaultReloadInterval)
+ go s.mdmTicker.Run(s.rootCtx, s.onMDMPolicyChange)
+ }
+
// if current state contains any error, return it
// in all other cases we can continue execution only if status is idle and up command was
// not in the progress or already successfully established connection.
@@ -213,17 +236,27 @@ func (s *Server) Start() error {
s.clientRunningChan = make(chan struct{})
s.clientGiveUpChan = make(chan struct{})
go s.connectWithRetryRuns(ctx, config, s.statusRecorder, s.clientRunningChan, s.clientGiveUpChan)
+ s.publishConfigChangedEvent("startup")
return nil
}
// connectWithRetryRuns runs the client connection with a backoff strategy where we retry the operation as additional
// mechanism to keep the client connected even when the connection is lost.
// we cancel retry if the client receive a stop or down command, or if disable auto connect is configured.
+//
+// The goroutine's exit is signalled to the daemon via close(giveUpChan)
+// — placed in the function-scope defer so every return path (panic,
+// DisableAutoConnect early-exit, backoff exhausted, ctx cancel) closes
+// it. Callers that need to observe "is the goroutine still alive?" use
+// Server.connectionGoroutineRunning() which non-blockingly checks the close state
+// of clientGiveUpChan. The defer does NOT touch s.mutex; the daemon's
+// "intent" (clientRunning) is maintained by the RPC handlers, not by this
+// goroutine.
func (s *Server) connectWithRetryRuns(ctx context.Context, profileConfig *profilemanager.Config, statusRecorder *peer.Status, runningChan chan struct{}, giveUpChan chan struct{}) {
defer func() {
- s.mutex.Lock()
- s.clientRunning = false
- s.mutex.Unlock()
+ if giveUpChan != nil {
+ close(giveUpChan)
+ }
}()
if s.config.DisableAutoConnect {
@@ -269,9 +302,26 @@ func (s *Server) connectWithRetryRuns(ctx context.Context, profileConfig *profil
if err := backoff.Retry(runOperation, backOff); err != nil {
log.Errorf("operation failed: %v", err)
}
+ // giveUpChan is closed by the function-scope defer.
+}
- if giveUpChan != nil {
- close(giveUpChan)
+// connectionGoroutineRunning reports whether the connectWithRetryRuns goroutine is
+// still running. Returns false when no goroutine has ever been started
+// AND when the most recent one has already closed clientGiveUpChan on
+// exit (whether due to ctx cancel, DisableAutoConnect single-shot
+// completion, or backoff retry exhaustion).
+//
+// MUST be called with s.mutex held — accesses s.clientGiveUpChan which
+// is written by Start/Up under the same lock.
+func (s *Server) connectionGoroutineRunning() bool {
+ if s.clientGiveUpChan == nil {
+ return false
+ }
+ select {
+ case <-s.clientGiveUpChan:
+ return false
+ default:
+ return true
}
}
@@ -304,54 +354,85 @@ func (s *Server) SetConfig(callerCtx context.Context, msg *proto.SetConfigReques
s.mutex.Lock()
defer s.mutex.Unlock()
- if s.checkUpdateSettingsDisabled() {
- return nil, gstatus.Errorf(codes.Unavailable, errUpdateSettingsDisabled)
+ // Skip the update-settings gate when the request carries no actual
+ // overrides: the CLI builds a SetConfigRequest unconditionally on
+ // every `netbird up` (setupSetConfigReq in cmd/up.go), so a plain
+ // `netbird up` would otherwise always trip the gate and surface a
+ // misleading "setConfig method is not available" warning, even when
+ // the user did not pass any config flag.
+ if setConfigRequestHasConfigOverrides(msg) {
+ if s.checkUpdateSettingsDisabled() {
+ return nil, gstatus.Errorf(codes.Unavailable, errUpdateSettingsDisabled)
+ }
}
+ // MDM gate: refuse the whole request if any of its fields is enforced
+ // by the active MDM policy. The error carries an MDMManagedFields-
+ // Violation detail listing the offending key names. Non-conflicting
+ // fields in the same request are not applied either.
+ policy := loadMDMPolicy()
+ if err := rejectMDMManagedFieldConflicts(mdmManagedFieldConflicts(msg, policy)); err != nil {
+ return nil, err
+ }
+
+ config, err := setConfigInputFromRequest(msg)
+ if err != nil {
+ return nil, err
+ }
+
+ if _, err := profilemanager.UpdateConfig(config); err != nil {
+ log.Errorf("failed to update profile config: %v", err)
+ return nil, fmt.Errorf("failed to update profile config: %w", err)
+ }
+
+ return &proto.SetConfigResponse{}, nil
+}
+
+// setConfigInputFromRequest translates a SetConfigRequest into the
+// profilemanager.ConfigInput that profilemanager.UpdateConfig consumes.
+// Pure mapping with no business logic beyond presence-aware copying of
+// optional fields and the "empty / clean" semantics for the two slice
+// fields (DNS labels, NAT external IPs). Extracted from SetConfig to
+// keep the handler's cognitive complexity below the SonarCube
+// threshold; the body is intentionally linear because each proto
+// field is its own optional case. Returns the resolved ConfigInput
+// and a non-nil error only when the active profile file path cannot
+// be determined.
+func setConfigInputFromRequest(msg *proto.SetConfigRequest) (profilemanager.ConfigInput, error) {
+ var config profilemanager.ConfigInput
+
profState := profilemanager.ActiveProfileState{
Name: msg.ProfileName,
Username: msg.Username,
}
-
profPath, err := profState.FilePath()
if err != nil {
log.Errorf("failed to get active profile file path: %v", err)
- return nil, fmt.Errorf("failed to get active profile file path: %w", err)
+ return config, fmt.Errorf("failed to get active profile file path: %w", err)
}
-
- var config profilemanager.ConfigInput
-
config.ConfigPath = profPath
if msg.ManagementUrl != "" {
config.ManagementURL = msg.ManagementUrl
}
-
if msg.AdminURL != "" {
config.AdminURL = msg.AdminURL
}
-
if msg.InterfaceName != nil {
config.InterfaceName = msg.InterfaceName
}
-
if msg.WireguardPort != nil {
wgPort := int(*msg.WireguardPort)
config.WireguardPort = &wgPort
}
-
- if msg.OptionalPreSharedKey != nil {
- if *msg.OptionalPreSharedKey != "" {
- config.PreSharedKey = msg.OptionalPreSharedKey
- }
+ if msg.OptionalPreSharedKey != nil && *msg.OptionalPreSharedKey != "" {
+ config.PreSharedKey = msg.OptionalPreSharedKey
}
if msg.CleanDNSLabels {
config.DNSLabels = domain.List{}
-
} else if msg.DnsLabels != nil {
- dnsLabels := domain.FromPunycodeList(msg.DnsLabels)
- config.DNSLabels = dnsLabels
+ config.DNSLabels = domain.FromPunycodeList(msg.DnsLabels)
}
if msg.CleanNATExternalIPs {
@@ -364,7 +445,6 @@ func (s *Server) SetConfig(callerCtx context.Context, msg *proto.SetConfigReques
if string(msg.CustomDNSAddress) == "empty" {
config.CustomDNSAddress = []byte{}
}
-
config.ExtraIFaceBlackList = msg.ExtraIFaceBlacklist
if msg.DnsRouteInterval != nil {
@@ -397,22 +477,31 @@ func (s *Server) SetConfig(callerCtx context.Context, msg *proto.SetConfigReques
ttl := int(*msg.SshJWTCacheTTL)
config.SSHJWTCacheTTL = &ttl
}
-
if msg.Mtu != nil {
mtu := uint16(*msg.Mtu)
config.MTU = &mtu
}
-
- if _, err := profilemanager.UpdateConfig(config); err != nil {
- log.Errorf("failed to update profile config: %v", err)
- return nil, fmt.Errorf("failed to update profile config: %w", err)
- }
-
- return &proto.SetConfigResponse{}, nil
+ return config, nil
}
// Login uses setup key to prepare configuration for the daemon.
func (s *Server) Login(callerCtx context.Context, msg *proto.LoginRequest) (*proto.LoginResponse, error) {
+ // Config-override gates. LoginRequest carries the same surface as
+ // SetConfigRequest (managementUrl, PSK, ssh/rosenpass/port toggles,
+ // ...), so the same protections must apply. Without these the CLI
+ // command `netbird up --management-url=X` (which falls through to
+ // Login when SetConfig is rejected — see cmd/up.go) would silently
+ // bypass `--disable-update-settings` and any MDM policy.
+ if loginRequestHasConfigOverrides(msg) {
+ if s.checkUpdateSettingsDisabled() {
+ return nil, gstatus.Errorf(codes.Unavailable, errUpdateSettingsDisabled)
+ }
+ policy := loadMDMPolicy()
+ if err := rejectMDMManagedFieldConflicts(loginRequestMDMConflicts(msg, policy)); err != nil {
+ return nil, err
+ }
+ }
+
s.mutex.Lock()
if s.actCancel != nil {
s.actCancel()
@@ -652,7 +741,13 @@ func (s *Server) WaitSSOLogin(callerCtx context.Context, msg *proto.WaitSSOLogin
// Up starts engine work in the daemon.
func (s *Server) Up(callerCtx context.Context, msg *proto.UpRequest) (*proto.UpResponse, error) {
s.mutex.Lock()
- if s.clientRunning {
+ // clientRunning is the daemon-intent flag (set by previous Up/Start, cleared
+ // by Down). connectionGoroutineRunning() reports whether the previous retry-loop
+ // goroutine is still trying. When intent is up AND goroutine is alive,
+ // the existing engine is on the job — just wait for it. When intent
+ // is up but the goroutine has given up (backoff exhausted) OR when
+ // intent is down, fall through to spawn a fresh retry loop.
+ if s.clientRunning && s.connectionGoroutineRunning() {
state := internal.CtxGetState(s.rootCtx)
status, err := state.Status()
if err != nil {
@@ -743,6 +838,7 @@ func (s *Server) Up(callerCtx context.Context, msg *proto.UpRequest) (*proto.UpR
s.clientGiveUpChan = make(chan struct{})
go s.connectWithRetryRuns(ctx, s.config, s.statusRecorder, s.clientRunningChan, s.clientGiveUpChan)
+ s.publishConfigChangedEvent("up_rpc")
s.mutex.Unlock()
return s.waitForUp(callerCtx)
@@ -871,6 +967,12 @@ func (s *Server) cleanupConnection() error {
return ErrServiceNotUp
}
+ // Daemon intent flips to "down" — all callers (Down RPC,
+ // Logout RPC handlers) tear down the connection because the user
+ // explicitly asked for it. MDM restart does NOT go through this
+ // path, so its clientRunning stays true.
+ s.clientRunning = false
+
// Capture the engine reference before cancelling the context.
// After actCancel(), the connectWithRetryRuns goroutine wakes up
// and sets connectClient.engine = nil, causing connectClient.Stop()
@@ -1074,10 +1176,14 @@ func (s *Server) Status(
msg *proto.StatusRequest,
) (*proto.StatusResponse, error) {
s.mutex.Lock()
- clientRunning := s.clientRunning
+ // Only wait if the retry-loop goroutine is alive and making
+ // progress. clientRunning=true with connectionGoroutineRunning=false means the
+ // backoff has given up — there is nothing to wait for; let the
+ // caller observe the failed status directly.
+ alive := s.connectionGoroutineRunning()
s.mutex.Unlock()
- if msg.WaitForReady != nil && *msg.WaitForReady && clientRunning {
+ if msg.WaitForReady != nil && *msg.WaitForReady && alive {
state := internal.CtxGetState(s.rootCtx)
status, err := state.Status()
if err != nil {
@@ -1548,6 +1654,7 @@ func (s *Server) GetConfig(ctx context.Context, req *proto.GetConfigRequest) (*p
EnableSSHRemotePortForwarding: enableSSHRemotePortForwarding,
DisableSSHAuth: disableSSHAuth,
SshJWTCacheTTL: sshJWTCacheTTL,
+ MDMManagedFields: cfg.Policy().ManagedKeys(),
}, nil
}
@@ -1646,7 +1753,7 @@ func (s *Server) GetFeatures(ctx context.Context, msg *proto.GetFeaturesRequest)
features := &proto.GetFeaturesResponse{
DisableProfiles: s.checkProfilesDisabled(),
DisableUpdateSettings: s.checkUpdateSettingsDisabled(),
- DisableNetworks: s.networksDisabled,
+ DisableNetworks: s.checkNetworksDisabled(),
}
return features, nil
@@ -1668,22 +1775,46 @@ func (s *Server) connect(ctx context.Context, config *profilemanager.Config, sta
return nil
}
+// MDM authority: when the platform-native MDM source sets a kill switch
+// key (regardless of true/false value), that value wins. The CLI flag
+// supplied at service install time is the fallback used only when the
+// MDM source is silent on the key. This honors the "MDM decides
+// everything" semantic agreed for NET-1214 — an admin pushing
+// disableX=false via MDM explicitly re-enables the feature even on a
+// box installed with --disable-X.
func (s *Server) checkProfilesDisabled() bool {
- // Check if the environment variable is set to disable profiles
- if s.profilesDisabled {
- return true
+ if s.config != nil {
+ if v, ok := s.config.Policy().GetBool(mdm.KeyDisableProfiles); ok {
+ return v
+ }
}
+ return s.profilesDisabled
+}
- return false
+// checkNetworksDisabled reports whether the networks/exit-node feature
+// is disabled on this daemon instance. Resolved MDM-first: when the
+// active policy declares mdm.KeyDisableNetworks the policy value wins
+// (regardless of true/false), so an admin can re-enable the feature
+// via MDM even on a host that was installed with --disable-networks.
+// Falls back to the s.networksDisabled CLI flag when the policy is
+// silent on the key. Mirrors checkProfilesDisabled and
+// checkUpdateSettingsDisabled.
+func (s *Server) checkNetworksDisabled() bool {
+ if s.config != nil {
+ if v, ok := s.config.Policy().GetBool(mdm.KeyDisableNetworks); ok {
+ return v
+ }
+ }
+ return s.networksDisabled
}
func (s *Server) checkUpdateSettingsDisabled() bool {
- // Check if the environment variable is set to disable profiles
- if s.updateSettingsDisabled {
- return true
+ if s.config != nil {
+ if v, ok := s.config.Policy().GetBool(mdm.KeyDisableUpdateSettings); ok {
+ return v
+ }
}
-
- return false
+ return s.updateSettingsDisabled
}
func (s *Server) startUpdateManagerForGUI() {
diff --git a/client/server/server_connect_test.go b/client/server/server_connect_test.go
index faea7da3..0c6e03a4 100644
--- a/client/server/server_connect_test.go
+++ b/client/server/server_connect_test.go
@@ -101,6 +101,7 @@ func TestCleanupConnection_ClearsConnectClient(t *testing.T) {
require.NoError(t, err)
assert.Nil(t, s.connectClient, "connectClient should be nil after cleanup")
+ assert.False(t, s.clientRunning, "clientRunning should be cleared after cleanup (intent = down)")
}
// TestCleanState_NilConnectClient validates that CleanState doesn't panic
@@ -144,17 +145,20 @@ func TestDownThenUp_StaleRunningChan(t *testing.T) {
_, cancel := context.WithCancel(context.Background())
s.actCancel = cancel
- // Simulate Down(): cleanupConnection sets connectClient = nil
+ // Simulate Down(): cleanupConnection sets connectClient = nil and
+ // flips clientRunning to false (intent = down). The connectionGoroutineRunning state
+ // remains independent of intent — derived from clientGiveUpChan.
s.mutex.Lock()
err := s.cleanupConnection()
s.mutex.Unlock()
require.NoError(t, err)
- // After cleanup: connectClient is nil, clientRunning still true
- // (goroutine hasn't exited yet)
+ // After cleanup: connectClient is nil, clientRunning is false (intent
+ // cleared by cleanupConnection), connectionGoroutineRunning may still be true
+ // (goroutine teardown is independent of the intent flag).
s.mutex.Lock()
assert.Nil(t, s.connectClient, "connectClient should be nil after cleanup")
- assert.True(t, s.clientRunning, "clientRunning still true until goroutine exits")
+ assert.False(t, s.clientRunning, "clientRunning should be cleared by cleanupConnection (intent = down)")
s.mutex.Unlock()
// waitForUp() returns immediately due to stale closed clientRunningChan
diff --git a/client/server/setconfig_mdm_test.go b/client/server/setconfig_mdm_test.go
new file mode 100644
index 00000000..53232c70
--- /dev/null
+++ b/client/server/setconfig_mdm_test.go
@@ -0,0 +1,198 @@
+package server
+
+import (
+ "context"
+ "os/user"
+ "path/filepath"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+ "google.golang.org/grpc/codes"
+ gstatus "google.golang.org/grpc/status"
+
+ "github.com/netbirdio/netbird/client/internal/profilemanager"
+ "github.com/netbirdio/netbird/client/mdm"
+ "github.com/netbirdio/netbird/client/proto"
+)
+
+// withMDMPolicy temporarily overrides the server-package loadMDMPolicy hook
+// so SetConfig observes the supplied Policy. Restores the original loader
+// at test cleanup.
+func withMDMPolicy(t *testing.T, policy *mdm.Policy) {
+ t.Helper()
+ prev := loadMDMPolicy
+ loadMDMPolicy = func() *mdm.Policy { return policy }
+ t.Cleanup(func() { loadMDMPolicy = prev })
+}
+
+// setupServerWithProfile mirrors the boilerplate of TestSetConfig_AllFieldsSaved:
+// overrides profilemanager paths to a temp dir, seeds a profile, sets it
+// active, and constructs a Server instance. Returns the constructed server
+// plus context + profile name + username + cfgPath for the seeded profile.
+func setupServerWithProfile(t *testing.T) (s *Server, ctx context.Context, profName, username, cfgPath string) {
+ t.Helper()
+ tempDir := t.TempDir()
+
+ origDefaultProfileDir := profilemanager.DefaultConfigPathDir
+ origDefaultConfigPath := profilemanager.DefaultConfigPath
+ origActiveProfileStatePath := profilemanager.ActiveProfileStatePath
+ profilemanager.ConfigDirOverride = tempDir
+ profilemanager.DefaultConfigPathDir = tempDir
+ profilemanager.ActiveProfileStatePath = tempDir + "/active_profile.json"
+ profilemanager.DefaultConfigPath = filepath.Join(tempDir, "default.json")
+ t.Cleanup(func() {
+ profilemanager.DefaultConfigPathDir = origDefaultProfileDir
+ profilemanager.ActiveProfileStatePath = origActiveProfileStatePath
+ profilemanager.DefaultConfigPath = origDefaultConfigPath
+ profilemanager.ConfigDirOverride = ""
+ })
+
+ currUser, err := user.Current()
+ require.NoError(t, err)
+
+ profName = "test-profile-mdm"
+ cfgPath = filepath.Join(tempDir, profName+".json")
+
+ _, err = profilemanager.UpdateOrCreateConfig(profilemanager.ConfigInput{
+ ConfigPath: cfgPath,
+ ManagementURL: "https://api.netbird.io:443",
+ })
+ require.NoError(t, err)
+
+ pm := profilemanager.ServiceManager{}
+ require.NoError(t, pm.SetActiveProfileState(&profilemanager.ActiveProfileState{
+ Name: profName,
+ Username: currUser.Username,
+ }))
+
+ ctx = context.Background()
+ s = New(ctx, "console", "", false, false, false, false)
+ return s, ctx, profName, currUser.Username, cfgPath
+}
+
+// extractViolation pulls the MDMManagedFieldsViolation detail from a
+// FailedPrecondition error. Fails the test if absent or malformed.
+func extractViolation(t *testing.T, err error) *proto.MDMManagedFieldsViolation {
+ t.Helper()
+ require.Error(t, err)
+ st, ok := gstatus.FromError(err)
+ require.True(t, ok, "error must be a gRPC status: %v", err)
+ require.Equal(t, codes.FailedPrecondition, st.Code(), "expected FailedPrecondition, got %s", st.Code())
+ for _, d := range st.Details() {
+ if v, ok := d.(*proto.MDMManagedFieldsViolation); ok {
+ return v
+ }
+ }
+ t.Fatalf("MDMManagedFieldsViolation detail not found on status; details: %v", st.Details())
+ return nil
+}
+
+func TestSetConfig_MDMReject_SingleField(t *testing.T) {
+ withMDMPolicy(t, mdm.NewPolicy(map[string]any{
+ mdm.KeyManagementURL: "https://mdm.example.com:443",
+ }))
+
+ s, ctx, profName, username, _ := setupServerWithProfile(t)
+
+ _, err := s.SetConfig(ctx, &proto.SetConfigRequest{
+ ProfileName: profName,
+ Username: username,
+ ManagementUrl: "https://user.tried.this.com:443",
+ })
+
+ v := extractViolation(t, err)
+ assert.Equal(t, []string{mdm.KeyManagementURL}, v.GetFields())
+}
+
+func TestSetConfig_MDMReject_MultipleFields(t *testing.T) {
+ withMDMPolicy(t, mdm.NewPolicy(map[string]any{
+ mdm.KeyManagementURL: "https://mdm.example.com:443",
+ mdm.KeyBlockInbound: true,
+ mdm.KeyRosenpassEnabled: true,
+ }))
+
+ s, ctx, profName, username, _ := setupServerWithProfile(t)
+
+ blockInbound := false
+ rosenpassEnabled := false
+ _, err := s.SetConfig(ctx, &proto.SetConfigRequest{
+ ProfileName: profName,
+ Username: username,
+ ManagementUrl: "https://user.tried.this.com:443",
+ BlockInbound: &blockInbound,
+ RosenpassEnabled: &rosenpassEnabled,
+ })
+
+ v := extractViolation(t, err)
+ assert.ElementsMatch(t, []string{
+ mdm.KeyManagementURL,
+ mdm.KeyBlockInbound,
+ mdm.KeyRosenpassEnabled,
+ }, v.GetFields())
+}
+
+func TestSetConfig_MDMReject_AllOrNothing(t *testing.T) {
+ // MDM enforces ManagementURL only; user request touches both the
+ // enforced field AND a non-enforced field (RosenpassEnabled).
+ // The whole request must be rejected — non-conflicting fields are not
+ // applied either.
+ withMDMPolicy(t, mdm.NewPolicy(map[string]any{
+ mdm.KeyManagementURL: "https://mdm.example.com:443",
+ }))
+
+ s, ctx, profName, username, cfgPath := setupServerWithProfile(t)
+
+ rosenpassEnabled := true
+ _, err := s.SetConfig(ctx, &proto.SetConfigRequest{
+ ProfileName: profName,
+ Username: username,
+ ManagementUrl: "https://user.tried.this.com:443",
+ RosenpassEnabled: &rosenpassEnabled,
+ })
+
+ v := extractViolation(t, err)
+ assert.Equal(t, []string{mdm.KeyManagementURL}, v.GetFields())
+
+ // Confirm RosenpassEnabled was NOT applied even though it was not
+ // in the conflict list: the request was rejected as a whole.
+ reloaded, err := profilemanager.GetConfig(cfgPath)
+ require.NoError(t, err)
+ assert.False(t, reloaded.RosenpassEnabled, "non-conflicting field must not be applied when request is rejected")
+}
+
+func TestSetConfig_MDMAllow_NonManagedFields(t *testing.T) {
+ // MDM enforces ManagementURL but the user only writes RosenpassEnabled.
+ // Request must succeed.
+ withMDMPolicy(t, mdm.NewPolicy(map[string]any{
+ mdm.KeyManagementURL: "https://mdm.example.com:443",
+ }))
+
+ s, ctx, profName, username, _ := setupServerWithProfile(t)
+
+ rosenpassEnabled := true
+ resp, err := s.SetConfig(ctx, &proto.SetConfigRequest{
+ ProfileName: profName,
+ Username: username,
+ RosenpassEnabled: &rosenpassEnabled,
+ })
+
+ require.NoError(t, err)
+ require.NotNil(t, resp)
+}
+
+func TestSetConfig_MDMEmpty_NoEnforcement(t *testing.T) {
+ // No MDM policy active: any field can be written.
+ withMDMPolicy(t, mdm.NewPolicy(nil))
+
+ s, ctx, profName, username, _ := setupServerWithProfile(t)
+
+ resp, err := s.SetConfig(ctx, &proto.SetConfigRequest{
+ ProfileName: profName,
+ Username: username,
+ ManagementUrl: "https://user.changed.url.com:443",
+ })
+
+ require.NoError(t, err)
+ require.NotNil(t, resp)
+}
diff --git a/client/ui/client_ui.go b/client/ui/client_ui.go
index c4b64435..5814ad9b 100644
--- a/client/ui/client_ui.go
+++ b/client/ui/client_ui.go
@@ -38,6 +38,7 @@ import (
"github.com/netbirdio/netbird/client/iface"
"github.com/netbirdio/netbird/client/internal"
"github.com/netbirdio/netbird/client/internal/profilemanager"
+ "github.com/netbirdio/netbird/client/mdm"
"github.com/netbirdio/netbird/client/proto"
"github.com/netbirdio/netbird/client/ui/desktop"
"github.com/netbirdio/netbird/client/ui/event"
@@ -56,8 +57,22 @@ const (
const (
censoredPreSharedKey = "**********"
maxSSHJWTCacheTTL = 86_400 // 24 hours in seconds
+ // mdmFieldSuffix is appended to plain-text Entry widgets in the
+ // advanced Settings window when the underlying field is enforced
+ // by MDM, so the user sees the lock indicator inline next to the
+ // value. Stripped before any read site that feeds the value back
+ // into a SetConfig request (saveSettings / parseNumericSettings).
+ mdmFieldSuffix = " (MDM)"
)
+// main is the entry point for the UI tray/client binary. Parses CLI
+// flags, initialises logging, builds the Fyne application and tray
+// icons, and constructs the service client (which may open a
+// requested UI window). When a window-mode flag is set the Fyne event
+// loop runs and main returns; otherwise main enforces single-instance
+// behaviour (signalling an existing instance to show its window when
+// present), sets up signal handling + default fonts, and runs the
+// system tray loop.
func main() {
flags := parseFlags()
@@ -315,9 +330,13 @@ type serviceClient struct {
isUpdateIconActive bool
isEnforcedUpdate bool
lastNotifiedVersion string
- settingsEnabled bool
profilesEnabled bool
networksEnabled bool
+ // networksMenuEnabled caches the last applied enabled-state of the
+ // mNetworks + mExitNode submenu items. Combines features.DisableNetworks
+ // AND s.connected — both must be true for the menus to be active.
+ // Zero value (false) matches the Disable() call at AddMenuItem time.
+ networksMenuEnabled bool
showNetworks bool
wNetworks fyne.Window
wProfiles fyne.Window
@@ -336,6 +355,13 @@ type serviceClient struct {
updateContextCancel context.CancelFunc
connectCancel context.CancelFunc
+
+ // mdmManagedFields caches the names of MDM-enforced policy keys
+ // surfaced by the daemon in GetConfigResponse. Each refresh of
+ // daemon config (loadSettings, getSrvConfig, config_changed event)
+ // updates this set and re-applies the lock/badge to the affected
+ // menu items and settings-form widgets.
+ mdmManagedFields map[string]bool
}
type menuHandler struct {
@@ -441,15 +467,12 @@ func (s *serviceClient) updateIcon() {
}
func (s *serviceClient) showSettingsUI() {
- // Check if update settings are disabled by daemon
- features, err := s.getFeatures()
- if err != nil {
- log.Errorf("failed to get features from daemon: %v", err)
- // Continue with default behavior if features can't be retrieved
- } else if features != nil && features.DisableUpdateSettings {
- log.Warn("Update settings are disabled by daemon")
- return
- }
+ // DisableUpdateSettings no longer gates the window from opening:
+ // the daemon blocks every actual mutation at SetConfig / Login,
+ // so the window is safe to show as a read-only view. The previous
+ // early-return also blocked Advanced Settings whenever update
+ // editing was off, which conflated two distinct kill switches
+ // (see comment in checkAndUpdateFeatures).
// add settings window UI elements.
s.wSettings = s.app.NewWindow("NetBird Settings")
@@ -532,7 +555,7 @@ func (s *serviceClient) saveSettings() {
return
}
- iMngURL := strings.TrimSpace(s.iMngURL.Text)
+ iMngURL := strings.TrimSpace(strings.TrimSuffix(s.iMngURL.Text, mdmFieldSuffix))
if s.hasSettingsChanged(iMngURL, port, mtu) {
if err := s.applySettingsChanges(iMngURL, port, mtu); err != nil {
@@ -554,7 +577,7 @@ func (s *serviceClient) validateSettings() error {
}
func (s *serviceClient) parseNumericSettings() (int64, int64, error) {
- port, err := strconv.ParseInt(s.iInterfacePort.Text, 10, 64)
+ port, err := strconv.ParseInt(strings.TrimSpace(strings.TrimSuffix(s.iInterfacePort.Text, mdmFieldSuffix)), 10, 64)
if err != nil {
return 0, 0, errors.New("invalid interface port")
}
@@ -663,7 +686,15 @@ func (s *serviceClient) buildSetConfigRequest(iMngURL string, port, mtu int64) (
req.SshJWTCacheTTL = &sshJWTCacheTTL32
}
- if s.iPreSharedKey.Text != censoredPreSharedKey {
+ // Only attach the PSK when the user actually typed something:
+ // - "" means the field was left untouched (we deliberately render
+ // an empty Text + placeholder hint to avoid leaking the daemon's
+ // "**********" redaction through the password reveal toggle);
+ // sending an empty pointer would tell the daemon to clear / overwrite
+ // the on-disk or MDM-enforced PSK, which then trips the MDM
+ // conflict gate when PSK is policy-managed.
+ // - "**********" is the redacted echo (legacy non-MDM path); also a no-op.
+ if s.iPreSharedKey.Text != "" && s.iPreSharedKey.Text != censoredPreSharedKey {
req.OptionalPreSharedKey = &s.iPreSharedKey.Text
}
@@ -1036,6 +1067,13 @@ func (s *serviceClient) onTrayReady() {
}
s.mProfile = newProfileMenu(*newProfileMenuArgs)
+ // Seed the transition cache to match the actual default menu
+ // state (visible / enabled). Without this, the first
+ // checkAndUpdateFeatures tick that observes DisableProfiles=true
+ // is a no-op (cache zero-value == desired-false) and the menu
+ // never gets hidden — symptom: MDM enforces the kill switch but
+ // the profile menu stays clickable.
+ s.profilesEnabled = true
systray.AddSeparator()
s.mUp = systray.AddMenuItem("Connect", "Connect")
@@ -1055,18 +1093,18 @@ func (s *serviceClient) onTrayReady() {
s.mCreateDebugBundle = s.mSettings.AddSubMenuItem("Create Debug Bundle", debugBundleMenuDescr)
s.loadSettings()
- // Disable settings menu if update settings are disabled by daemon
+ // Disable profile menu if profiles are disabled by daemon.
+ // DisableUpdateSettings is enforced at the daemon's SetConfig /
+ // Login gates, not by hiding the UI — so the Settings menu (and
+ // its Advanced Settings submenu, which has its own kill switch)
+ // stays visible and the user can still inspect current values.
features, err := s.getFeatures()
if err != nil {
log.Errorf("failed to get features from daemon: %v", err)
// Continue with default behavior if features can't be retrieved
- } else {
- if features != nil && features.DisableUpdateSettings {
- s.setSettingsEnabled(false)
- }
- if features != nil && features.DisableProfiles {
- s.mProfile.setEnabled(false)
- }
+ } else if features != nil && features.DisableProfiles {
+ s.mProfile.setEnabled(false)
+ s.profilesEnabled = false
}
s.exitNodeMu.Lock()
@@ -1100,13 +1138,20 @@ func (s *serviceClient) onTrayReady() {
// update exit node menu in case service is already connected
go s.updateExitNodes()
+ // Features (DisableProfiles, DisableUpdateSettings, DisableNetworks,
+ // ...) only change in two ways: at service install time (CLI flag,
+ // static) and at MDM ticker diff time. The daemon already publishes
+ // a SystemEvent{type=config_changed} on every MDM-driven engine
+ // restart, so the UI no longer needs to poll GetFeatures every 2 s.
+ // A single fetch at startup covers the static CLI-flag case; the
+ // event handler below covers MDM transitions. updateStatus stays in
+ // the 2 s loop because connection / peer state genuinely change
+ // continuously and have no event yet.
+ s.checkAndUpdateFeatures()
go func() {
s.getSrvConfig()
time.Sleep(100 * time.Millisecond) // To prevent race condition caused by systray not being fully initialized and ignoring setIcon
for {
- // Check features before status so menus respect disable flags before being enabled
- s.checkAndUpdateFeatures()
-
err := s.updateStatus()
if err != nil {
log.Errorf("error while updating status: %v", err)
@@ -1150,6 +1195,23 @@ func (s *serviceClient) onTrayReady() {
s.onUpdateAvailable(newVersion, enforced)
}
})
+ s.eventManager.AddHandler(func(event *proto.SystemEvent) {
+ // Daemon emits a config_changed event after every engine spawn
+ // (Server.Start, Server.Up, MDM ticker restart). Re-sync the
+ // tray submenu checkboxes from the fresh daemon-side config so
+ // the user does not have to restart the tray to see CLI- or
+ // MDM-driven changes.
+ if event.Category == proto.SystemEvent_SYSTEM && event.Metadata["type"] == "config_changed" {
+ log.Infof("config_changed event received (source=%s); refreshing settings + features", event.Metadata["source"])
+ s.loadSettings()
+ // MDM-driven feature kill switches (DisableProfiles /
+ // DisableUpdateSettings / DisableNetworks) ride the same
+ // config_changed signal because the daemon re-applies its
+ // MDM policy on every engine spawn. Pull them in here so
+ // the UI is up to date without a periodic GetFeatures poll.
+ s.checkAndUpdateFeatures()
+ }
+ })
go s.eventManager.Start(s.ctx)
go s.eventHandler.listen(s.ctx)
@@ -1213,18 +1275,6 @@ func (s *serviceClient) getSrvClient(timeout time.Duration) (proto.DaemonService
return s.conn, nil
}
-// setSettingsEnabled enables or disables the settings menu based on the provided state
-func (s *serviceClient) setSettingsEnabled(enabled bool) {
- if s.mSettings != nil {
- if enabled {
- s.mSettings.Enable()
- } else {
- s.mSettings.Hide()
- s.mSettings.SetTooltip("Settings are disabled by daemon")
- }
- }
-}
-
// checkAndUpdateFeatures checks the current features and updates the UI accordingly
func (s *serviceClient) checkAndUpdateFeatures() {
features, err := s.getFeatures()
@@ -1236,12 +1286,11 @@ func (s *serviceClient) checkAndUpdateFeatures() {
s.updateIndicationLock.Lock()
defer s.updateIndicationLock.Unlock()
- // Update settings menu based on current features
- settingsEnabled := features == nil || !features.DisableUpdateSettings
- if s.settingsEnabled != settingsEnabled {
- s.settingsEnabled = settingsEnabled
- s.setSettingsEnabled(settingsEnabled)
- }
+ // DisableUpdateSettings is enforced server-side by the daemon gates
+ // on SetConfig + Login: any attempt to mutate config from UI or
+ // CLI is rejected at that layer. The UI deliberately keeps the
+ // Settings menu visible so the user can still inspect current
+ // values — read-only by virtue of the daemon refusing edits.
// Update profile menu based on current features
if s.mProfile != nil {
@@ -1252,14 +1301,23 @@ func (s *serviceClient) checkAndUpdateFeatures() {
}
}
- // Update networks and exit node menus based on current features
+ // Update networks and exit node menus based on current features.
+ // `networksEnabled` is the bare feature flag (read elsewhere, e.g. at
+ // connection-status transitions). `networksMenuEnabled` is the
+ // transition-cached state actually applied to the menu items —
+ // it folds in the connection state so a Connected client with the
+ // kill switch off shows the menus active, and only flips on diff.
s.networksEnabled = features == nil || !features.DisableNetworks
- if s.networksEnabled && s.connected {
- s.mNetworks.Enable()
- s.mExitNode.Enable()
- } else {
- s.mNetworks.Disable()
- s.mExitNode.Disable()
+ desiredNetworksMenu := s.networksEnabled && s.connected
+ if desiredNetworksMenu != s.networksMenuEnabled {
+ s.networksMenuEnabled = desiredNetworksMenu
+ if desiredNetworksMenu {
+ s.mNetworks.Enable()
+ s.mExitNode.Enable()
+ } else {
+ s.mNetworks.Disable()
+ s.mExitNode.Disable()
+ }
}
}
@@ -1356,7 +1414,14 @@ func (s *serviceClient) getSrvConfig() {
if s.showAdvancedSettings {
s.iMngURL.SetText(s.managementURL)
- s.iPreSharedKey.SetText(cfg.PreSharedKey)
+ // PSK is rendered with an empty Text and a hint via the
+ // placeholder so the eye toggle never reveals literal asterisks
+ // (the daemon returns the "**********" sentinel — writing that
+ // into a PasswordEntry would surface the literal sentinel when
+ // the user unmasks the field). The placeholder communicates the
+ // configured / MDM-managed state without exposing any value.
+ s.iPreSharedKey.SetText("")
+ s.iPreSharedKey.SetPlaceHolder(preSharedKeyPlaceholder(srvCfg))
s.iInterfaceName.SetText(cfg.WgIface)
s.iInterfacePort.SetText(strconv.Itoa(cfg.WgPort))
if cfg.MTU != 0 {
@@ -1366,7 +1431,15 @@ func (s *serviceClient) getSrvConfig() {
s.iMTU.SetPlaceHolder(strconv.Itoa(int(iface.DefaultMTU)))
}
s.sRosenpassPermissive.SetChecked(cfg.RosenpassPermissive)
- if !cfg.RosenpassEnabled {
+ // Re-baseline the enabled state on every refresh: when Rosenpass
+ // is on the checkbox is editable, when it's off the field is
+ // inert. Without an explicit Enable() here the control stays
+ // stuck disabled after a previous refresh (or an MDM unlock) had
+ // turned it off — applyMDMLocksToSettingsForm below adds the
+ // MDM lock on top of this baseline.
+ if cfg.RosenpassEnabled {
+ s.sRosenpassPermissive.Enable()
+ } else {
s.sRosenpassPermissive.Disable()
}
s.sNetworkMonitor.SetChecked(*cfg.NetworkMonitor)
@@ -1395,6 +1468,13 @@ func (s *serviceClient) getSrvConfig() {
}
}
+ // MDM locks must run before the mNotifications-nil early return:
+ // the Settings window is rendered by a separate UI process launched
+ // with --settings (see handleAdvancedSettingsClick), and that child
+ // process does NOT run onReady — so its mNotifications is nil and
+ // the early return below skipped the lock pass entirely.
+ s.applyMDMLocks(srvCfg.MDMManagedFields)
+
if s.mNotifications == nil {
return
}
@@ -1579,6 +1659,129 @@ func (s *serviceClient) loadSettings() {
if s.eventManager != nil {
s.eventManager.SetNotificationsEnabled(s.mNotifications.Checked())
}
+ s.applyMDMLocks(cfg.MDMManagedFields)
+}
+
+// applyMDMLocks disables and badges any tray submenu item or settings-
+// form widget whose underlying field is enforced by the active MDM
+// policy. Called from loadSettings (submenu refresh) and from
+// getSrvConfig (settings-window refresh). Locked items keep their value
+// already set by the surrounding refresh code — this routine only
+// flips the enabled state and the title suffix, never the value.
+func (s *serviceClient) applyMDMLocks(managed []string) {
+ set := make(map[string]bool, len(managed))
+ for _, k := range managed {
+ set[k] = true
+ }
+ s.mdmManagedFields = set
+ if len(managed) > 0 {
+ log.Infof("MDM-managed UI fields: %v", managed)
+ }
+
+ type submenuTarget struct {
+ item *systray.MenuItem
+ title string
+ key string
+ }
+ for _, t := range []submenuTarget{
+ {s.mAllowSSH, "Allow SSH", mdm.KeyAllowServerSSH},
+ {s.mAutoConnect, "Connect on Startup", mdm.KeyDisableAutoConnect},
+ {s.mEnableRosenpass, "Enable Quantum-Resistance", mdm.KeyRosenpassEnabled},
+ {s.mBlockInbound, "Block Inbound Connections", mdm.KeyBlockInbound},
+ } {
+ if t.item == nil {
+ continue
+ }
+ if set[t.key] {
+ t.item.SetTitle(t.title + " (MDM)")
+ t.item.Disable()
+ } else {
+ t.item.SetTitle(t.title)
+ t.item.Enable()
+ }
+ }
+
+ s.applyMDMLocksToSettingsForm(set)
+}
+
+// preSharedKeyPlaceholder returns the hint string shown in the PSK
+// Entry's placeholder slot. The placeholder is the only signal the
+// user gets that a PSK is configured, because the entry's Text is
+// forced to empty to keep the password reveal toggle from leaking
+// the daemon-returned "**********" redaction sentinel. Returns "" if
+// no PSK is present, "MDM-managed" if the key is enforced by MDM,
+// and "configured" otherwise.
+func preSharedKeyPlaceholder(cfg *proto.GetConfigResponse) string {
+ if cfg == nil || cfg.PreSharedKey == "" {
+ return ""
+ }
+ for _, k := range cfg.MDMManagedFields {
+ if k == mdm.KeyPreSharedKey {
+ return "MDM-managed"
+ }
+ }
+ return "configured"
+}
+
+// applyMDMLocksToSettingsForm disables the per-field input widgets in
+// the advanced Settings window when the corresponding MDM key is set.
+// For plain-text entries (Management URL, Interface Port) the visible
+// value is suffixed with " (MDM)" so the user sees the lock indicator
+// inline; for the password entry the suffix is skipped (a password
+// widget renders every char as a dot and the indicator would not be
+// readable). The widgets are created lazily by showSettingsUI, so
+// guard each ref against nil.
+func (s *serviceClient) applyMDMLocksToSettingsForm(set map[string]bool) {
+ type entryTarget struct {
+ entry *widget.Entry
+ key string
+ inlineTag bool
+ }
+ for _, t := range []entryTarget{
+ {s.iMngURL, mdm.KeyManagementURL, true},
+ {s.iPreSharedKey, mdm.KeyPreSharedKey, false},
+ {s.iInterfacePort, mdm.KeyWireguardPort, true},
+ } {
+ if t.entry == nil {
+ continue
+ }
+ if set[t.key] {
+ if t.inlineTag && t.entry.Text != "" && !strings.HasSuffix(t.entry.Text, mdmFieldSuffix) {
+ t.entry.SetText(t.entry.Text + mdmFieldSuffix)
+ }
+ t.entry.Disable()
+ } else {
+ if t.inlineTag {
+ t.entry.SetText(strings.TrimSuffix(t.entry.Text, mdmFieldSuffix))
+ }
+ t.entry.Enable()
+ }
+ }
+ type checkTarget struct {
+ check *widget.Check
+ key string
+ }
+ for _, t := range []checkTarget{
+ {s.sDisableClientRoutes, mdm.KeyDisableClientRoutes},
+ {s.sDisableServerRoutes, mdm.KeyDisableServerRoutes},
+ } {
+ if t.check == nil {
+ continue
+ }
+ if set[t.key] {
+ t.check.Disable()
+ } else {
+ t.check.Enable()
+ }
+ }
+ if s.sRosenpassPermissive != nil && set[mdm.KeyRosenpassPermissive] {
+ // MDM lock layered on top of the Rosenpass-on/off baseline
+ // applied by getSrvConfig. No Enable() branch here: when the
+ // MDM key is removed, the next getSrvConfig refresh re-baselines
+ // the control on cfg.RosenpassEnabled and brings it back if
+ // Rosenpass is on.
+ s.sRosenpassPermissive.Disable()
+ }
}
// updateConfig updates the configuration parameters
diff --git a/client/ui/profile.go b/client/ui/profile.go
index 7ee89e63..d3db1785 100644
--- a/client/ui/profile.go
+++ b/client/ui/profile.go
@@ -666,16 +666,48 @@ func (p *profileMenu) clear(profiles []Profile) {
}
}
-// setEnabled enables or disables the profile menu based on the provided state
+// setEnabled greys out (Disable) the profile menu and every existing
+// sub-item when the daemon reports the kill switch active, so the user
+// sees the menu but cannot enter "Manage Profiles" or switch profile.
+// Previously this used Hide() on the parent, but Fyne's systray on
+// Windows does not propagate Hide() to a parent that already has
+// children — the submenu kept popping up and accepting clicks. Disable
+// is the reliable visual lock.
func (p *profileMenu) setEnabled(enabled bool) {
- if p.profileMenuItem != nil {
- if enabled {
- p.profileMenuItem.Enable()
- p.profileMenuItem.SetTooltip("")
- } else {
- p.profileMenuItem.Hide()
- p.profileMenuItem.SetTooltip("Profiles are disabled by daemon")
+ if p.profileMenuItem == nil {
+ return
+ }
+ p.mu.Lock()
+ defer p.mu.Unlock()
+
+ if enabled {
+ p.profileMenuItem.Enable()
+ p.profileMenuItem.SetTooltip("")
+ } else {
+ p.profileMenuItem.Disable()
+ p.profileMenuItem.SetTooltip("Profiles are disabled by daemon")
+ }
+
+ apply := func(item *systray.MenuItem) {
+ if item == nil {
+ return
}
+ if enabled {
+ item.Enable()
+ } else {
+ item.Disable()
+ }
+ }
+ for _, sub := range p.profileSubItems {
+ if sub != nil {
+ apply(sub.MenuItem)
+ }
+ }
+ if p.manageProfilesSubItem != nil {
+ apply(p.manageProfilesSubItem.MenuItem)
+ }
+ if p.logoutSubItem != nil {
+ apply(p.logoutSubItem.MenuItem)
}
}
diff --git a/docs/io.netbird.client.plist b/docs/io.netbird.client.plist
new file mode 100644
index 00000000..f42b6b3d
--- /dev/null
+++ b/docs/io.netbird.client.plist
@@ -0,0 +1,126 @@
+
+
+
+
+
+
+
+ managementURL
+ https://api.netbird.io:443
+
+
+
+
+
+
+ allowServerSSH
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/docs/netbird-macos.mobileconfig b/docs/netbird-macos.mobileconfig
new file mode 100644
index 00000000..53453db5
--- /dev/null
+++ b/docs/netbird-macos.mobileconfig
@@ -0,0 +1,159 @@
+
+
+
+
+
+
+ PayloadType
+ Configuration
+ PayloadVersion
+ 1
+ PayloadIdentifier
+ io.netbird.client.mdm
+ PayloadUUID
+ 11111111-1111-1111-1111-111111111111
+ PayloadDisplayName
+ NetBird MDM Policy
+ PayloadDescription
+ Enforces NetBird client configuration. Values written here override any local user / CLI / on-disk setting and are re-applied at every daemon boot and on every 1-minute MDM reload tick.
+ PayloadOrganization
+ NetBird
+ PayloadScope
+ System
+ PayloadRemovalDisallowed
+
+
+ PayloadContent
+
+
+
+ PayloadType
+ com.apple.ManagedClient.preferences
+ PayloadVersion
+ 1
+ PayloadIdentifier
+ io.netbird.client.mdm.preferences
+ PayloadUUID
+ 22222222-2222-2222-2222-222222222222
+ PayloadDisplayName
+ NetBird Managed Preferences
+ PayloadEnabled
+
+
+ PayloadContent
+
+ io.netbird.client
+
+ Forced
+
+
+ mcx_preference_settings
+
+
+
+ managementURL
+ https://api.netbird.io:443
+
+
+
+
+
+
+ allowServerSSH
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/docs/netbird-macos.sh b/docs/netbird-macos.sh
new file mode 100644
index 00000000..a2f5ff5e
--- /dev/null
+++ b/docs/netbird-macos.sh
@@ -0,0 +1,189 @@
+#!/bin/bash
+#
+# SYNOPSIS
+# Push the NetBird MDM policy to a macOS device via JumpCloud Commands.
+#
+# DESCRIPTION
+# This is the macOS counterpart of docs/netbird-policy.reg.ps1.
+# It writes the values declared in the "POLICY VALUES" block below to
+# the managed-preferences plist that the NetBird daemon's
+# client/mdm/policy_darwin.go loader reads on every 1-minute MDM
+# reload tick:
+#
+# /Library/Managed Preferences/io.netbird.client.plist
+#
+# Once the plist lands, the daemon picks up the new values without
+# restart (the ticker calls Config.apply() → applyMDMPolicy() and
+# restarts the engine on diff).
+#
+# DEPLOYMENT (JumpCloud)
+# 1. Admin Console -> Device Management -> Commands -> +.
+# 2. Type: Mac, Shell, Run as: root.
+# 3. Paste this file verbatim into the command body.
+# 4. Bind to the target system group, save, run.
+#
+# IMPORTANT: PERSISTENCE
+# macOS wipes /Library/Managed Preferences/ at every boot on devices
+# that are NOT MDM-enrolled. For a persistent fleet rollout, push the
+# companion docs/netbird-macos.mobileconfig as a Custom Configuration
+# Profile (Admin Console -> MDM -> Mac Custom Configuration Profiles)
+# instead of this script. Use this script when:
+# - the device is MDM-enrolled (file survives reboots), or
+# - you need a one-shot test push before reboot, or
+# - you orchestrate via JumpCloud Commands and want the same
+# variable-driven workflow as the Windows .ps1 sibling.
+#
+# IDEMPOTENCY: re-running with the same values is a no-op from the
+# daemon's point of view (the 1-minute reload ticker diff returns empty).
+#
+# SECURITY: PreSharedKey is redacted in this script's log output.
+
+set -euo pipefail
+
+### POLICY VALUES — EDIT THIS BLOCK ###########################################
+#
+# Set each variable below to the desired value. Set to empty string ""
+# or to NULL to omit a key entirely (the daemon treats an absent key
+# as "no enforcement" for that field). Booleans use "true"/"false"
+# (lowercase). Integers as decimal.
+#
+# Reference for key names + accepted values:
+# client/mdm/policy.go (Key* constants)
+# docs/netbird-macos.mobileconfig (sample profile)
+# docs/netbird.admx + .adml (Windows ADMX schema)
+#
+NULL='__UNSET__'
+managementURL='https://api.netbird.io:443'
+preSharedKey="$NULL" # secret; redacted in log
+allowServerSSH='true'
+blockInbound="$NULL"
+disableAutoConnect="$NULL"
+disableClientRoutes="$NULL"
+disableServerRoutes="$NULL"
+disableMetricsCollection="$NULL"
+disableUpdateSettings="$NULL"
+disableProfiles="$NULL"
+disableNetworks="$NULL"
+rosenpassEnabled="$NULL"
+rosenpassPermissive="$NULL"
+wireguardPort='51820'
+splitTunnelMode="$NULL" # "allow" or "disallow", Android-only at the daemon level
+splitTunnelApps="$NULL" # comma-separated app IDs, Android-only
+##############################################################################
+
+readonly PLIST_DIR='/Library/Managed Preferences'
+readonly PLIST_PATH="$PLIST_DIR/io.netbird.client.plist"
+readonly LOG_TAG='netbird-mdm'
+
+# log sends a message to the system logger using the configured tag and echoes the message to stdout prefixed by an ISO 8601 UTC timestamp and the tag.
+log() {
+ /usr/bin/logger -t "$LOG_TAG" "$*"
+ printf '%s [%s] %s\n' "$(date -u '+%Y-%m-%dT%H:%M:%SZ')" "$LOG_TAG" "$*"
+}
+
+# is_set returns success if the provided value is non-empty and is not equal to the special NULL marker.
+is_set() {
+ local value="$1"
+ [[ -n "$value" && "$value" != "$NULL" ]]
+}
+
+# start_plist creates the temporary plist file at "$PLIST_PATH.tmp" containing the XML plist header and opening `` for the policy plist.
+start_plist() {
+ cat > "$PLIST_PATH.tmp" <<'EOF'
+
+
+
+
+EOF
+}
+
+# end_plist appends the closing `` and `` tags to the temporary plist file.
+end_plist() {
+ cat >> "$PLIST_PATH.tmp" <<'EOF'
+
+
+EOF
+}
+
+# emit_string appends a plist ``/`` entry for the given key and value to "$PLIST_PATH.tmp", XML-escaping `&`, `<`, and `>`, and logs the assignment (masking the logged value as `********** (secret)` when the key is `preSharedKey`).
+emit_string() {
+ local key="$1" value="$2" log_value="$2"
+ # Escape XML entities in the value
+ local escaped
+ escaped="$(printf '%s' "$value" | sed -e 's/&/\&/g' -e 's/\</g' -e 's/>/\>/g')"
+ printf ' %s\n %s\n' "$key" "$escaped" >> "$PLIST_PATH.tmp"
+ if [[ "$key" == "preSharedKey" ]]; then
+ log_value='********** (secret)'
+ fi
+ log "set $key = $log_value"
+}
+
+# emit_bool writes a boolean plist entry for a given key into the temporary plist file.
+# emit_bool writes a boolean plist entry for a key when the provided value matches an accepted boolean token; logs an error and skips the key on invalid input.
+emit_bool() {
+ local key="$1" value="$2"
+ local xml_bool
+ case "$value" in
+ true|True|TRUE|1|yes) xml_bool='' ; value='true' ;;
+ false|False|FALSE|0|no) xml_bool='' ; value='false' ;;
+ *) log "invalid boolean for $key: $value (must be true/false); skipping"; return ;;
+ esac
+ printf ' %s\n %s\n' "$key" "$xml_bool" >> "$PLIST_PATH.tmp"
+ log "set $key = $value"
+}
+
+# emit_int validates that VALUE contains only decimal digits and, if valid, appends an `` plist entry for KEY to the temporary plist (`$PLIST_PATH.tmp`) and logs the assignment; on invalid input it logs a skip and does not emit the key.
+emit_int() {
+ local key="$1" value="$2"
+ if ! [[ "$value" =~ ^[0-9]+$ ]]; then
+ log "invalid integer for $key: $value (must be decimal); skipping"
+ return
+ fi
+ printf ' %s\n %s\n' "$key" "$value" >> "$PLIST_PATH.tmp"
+ log "set $key = $value"
+}
+
+# main builds the NetBird MDM plist from configured policy variables, validates and installs it to /Library/Managed Preferences/io.netbird.client.plist (root:wheel, 644) and optionally triggers the NetBird daemon to reload.
+main() {
+ log "applying NetBird MDM policy to $PLIST_PATH"
+ /bin/mkdir -p "$PLIST_DIR"
+ start_plist
+
+ is_set "$managementURL" && emit_string managementURL "$managementURL"
+ is_set "$preSharedKey" && emit_string preSharedKey "$preSharedKey"
+ is_set "$allowServerSSH" && emit_bool allowServerSSH "$allowServerSSH"
+ is_set "$blockInbound" && emit_bool blockInbound "$blockInbound"
+ is_set "$disableAutoConnect" && emit_bool disableAutoConnect "$disableAutoConnect"
+ is_set "$disableClientRoutes" && emit_bool disableClientRoutes "$disableClientRoutes"
+ is_set "$disableServerRoutes" && emit_bool disableServerRoutes "$disableServerRoutes"
+ is_set "$disableMetricsCollection" && emit_bool disableMetricsCollection "$disableMetricsCollection"
+ is_set "$disableUpdateSettings" && emit_bool disableUpdateSettings "$disableUpdateSettings"
+ is_set "$disableProfiles" && emit_bool disableProfiles "$disableProfiles"
+ is_set "$disableNetworks" && emit_bool disableNetworks "$disableNetworks"
+ is_set "$rosenpassEnabled" && emit_bool rosenpassEnabled "$rosenpassEnabled"
+ is_set "$rosenpassPermissive" && emit_bool rosenpassPermissive "$rosenpassPermissive"
+ is_set "$wireguardPort" && emit_int wireguardPort "$wireguardPort"
+ is_set "$splitTunnelMode" && emit_string splitTunnelMode "$splitTunnelMode"
+ is_set "$splitTunnelApps" && emit_string splitTunnelApps "$splitTunnelApps"
+
+ end_plist
+
+ if ! /usr/bin/plutil -lint "$PLIST_PATH.tmp" >/dev/null 2>&1; then
+ log "ERROR: generated plist failed plutil lint; not installing"
+ /usr/bin/plutil -lint "$PLIST_PATH.tmp" >&2 || true
+ /bin/rm -f "$PLIST_PATH.tmp"
+ exit 1
+ fi
+
+ /bin/mv -f "$PLIST_PATH.tmp" "$PLIST_PATH"
+ /usr/sbin/chown root:wheel "$PLIST_PATH"
+ /bin/chmod 644 "$PLIST_PATH"
+
+ log "policy installed; NetBird daemon will pick it up within the next 1-minute reload tick"
+
+ # Optional: kick the daemon for an immediate apply. Safe — does
+ # nothing on a host where NetBird is not yet installed.
+ /bin/launchctl kickstart -k system/io.netbird.client 2>/dev/null || true
+}
+
+main "$@"
diff --git a/docs/netbird-policy.reg b/docs/netbird-policy.reg
new file mode 100644
index 00000000..ba4402e5
Binary files /dev/null and b/docs/netbird-policy.reg differ
diff --git a/docs/netbird-policy.reg.ps1 b/docs/netbird-policy.reg.ps1
new file mode 100644
index 00000000..011d706d
--- /dev/null
+++ b/docs/netbird-policy.reg.ps1
@@ -0,0 +1,94 @@
+#requires -Version 5.1
+<#
+.SYNOPSIS
+ Push the NetBird MDM policy to a Windows device via JumpCloud Commands
+ by importing a sidecar netbird-policy.reg file.
+
+.DESCRIPTION
+ Windows counterpart of docs/netbird-macos.sh. Outcome:
+ HKLM\Software\Policies\NetBird populated from the attached
+ netbird-policy.reg file, daemon picks up the change via the
+ 1-minute MDM reload ticker.
+
+ Deployment:
+ 1. Admin Console -> Device Management -> Commands -> +.
+ 2. Type: Windows PowerShell. Run as: SYSTEM.
+ 3. Paste this file verbatim into the command body.
+ 4. In the same command, attach `netbird-policy.reg` as a file.
+ JumpCloud copies attached files into the command's working
+ directory before invoking the script, so `$PSScriptRoot` or
+ Get-Location resolves to where the .reg lives.
+ 5. Bind to the target system group, save, run.
+
+ Producing the .reg file:
+ On a reference machine, after configuring the policy values either
+ via gpedit (GPO) or manual `reg add`, export with:
+
+ reg export "HKLM\Software\Policies\NetBird" netbird-policy.reg /y
+
+ Then attach the resulting file to the JumpCloud command.
+
+ Semantics:
+ - The script nukes the existing HKLM\Software\Policies\NetBird key
+ before importing the .reg, so the .reg is the SINGLE SOURCE OF
+ TRUTH. Any value present in the registry but absent from the .reg
+ is removed. This is what an MDM admin almost always wants.
+ - Setting the .reg to an empty (header-only) file effectively unsets
+ the policy.
+
+ Idempotency: re-running the script with the same .reg is a no-op from
+ the daemon's perspective (values identical → 1-min ticker sees no
+ diff → engine not restarted).
+
+ Exit codes: 0 = success; 1 = .reg missing or reg.exe error.
+#>
+
+$ErrorActionPreference = "Stop"
+
+$RegFileName = "netbird-policy.reg"
+$RegKey = "HKLM\Software\Policies\NetBird"
+
+# Resolve the attached .reg file: JumpCloud copies command attachments
+# into C:\Windows\Temp\ before invoking the script. Cwd / $PSScriptRoot
+# fallbacks cover the local-dev case where you might dot-source this
+# from elsewhere.
+$candidates = @(
+ (Join-Path "$env:WINDIR\Temp" $RegFileName)
+ (Join-Path (Get-Location) $RegFileName)
+ (Join-Path $PSScriptRoot $RegFileName)
+) | Where-Object { Test-Path $_ }
+
+if ($candidates.Count -eq 0) {
+ Write-Error "[netbird-mdm] $RegFileName not found in working directory or `$PSScriptRoot. Attach the file to the JumpCloud command."
+ exit 1
+}
+$regFile = $candidates[0]
+Write-Host "[netbird-mdm] using $regFile"
+
+# Wipe the existing policy key so the .reg is authoritative.
+$existed = Test-Path "Registry::HKEY_LOCAL_MACHINE\Software\Policies\NetBird"
+if ($existed) {
+ & reg.exe delete $RegKey /f | Out-Null
+ if ($LASTEXITCODE -ne 0) {
+ Write-Error "[netbird-mdm] failed to clear $RegKey before import (exit $LASTEXITCODE)"
+ exit 1
+ }
+ Write-Host "[netbird-mdm] cleared previous values under $RegKey"
+}
+
+# Import. reg.exe writes both data and (re-)creates the key if needed.
+& reg.exe import $regFile
+if ($LASTEXITCODE -ne 0) {
+ Write-Error "[netbird-mdm] reg import failed (exit $LASTEXITCODE)"
+ exit 1
+}
+
+# Audit dump so the JumpCloud per-execution log captures the applied state.
+Write-Host "[netbird-mdm] final policy state under $RegKey :"
+& reg.exe query $RegKey /s
+
+# Daemon's 1-min reload ticker picks up the change automatically.
+# Uncomment to force immediate convergence (skips the ticker wait):
+# Restart-Service netbird -Force -ErrorAction SilentlyContinue
+
+exit 0
diff --git a/docs/netbird.adml b/docs/netbird.adml
new file mode 100644
index 00000000..d49b0502
--- /dev/null
+++ b/docs/netbird.adml
@@ -0,0 +1,95 @@
+
+
+ NetBird Client Policies
+ Group Policy template for NetBird client MDM-managed settings. Values are written under HKLM\Software\Policies\NetBird and consumed by the netbird daemon at startup and every 1-minute reload tick.
+
+
+
+
+ NetBird
+ NetBird Client 0.40+
+
+
+ Management URL
+ URL of the NetBird management server. Format: https://host[:port]. When set, users cannot override this value via UI or CLI.
+
+ Pre-shared key
+ WireGuard pre-shared key used as an additional symmetric secret on every peer-to-peer tunnel. Secret value.
+
+
+ Disable auto-connect
+ When enabled, the NetBird tunnel does not auto-connect at daemon startup. Equivalent to --disable-auto-connect.
+
+ Disable client routes
+ When enabled, this client will not consume routes advertised by routing peers. Equivalent to --disable-client-routes.
+
+ Disable server routes
+ When enabled, this client will not act as a routing peer for other clients. Equivalent to --disable-server-routes.
+
+ Block inbound
+ When enabled, the client firewall blocks all inbound peer traffic on the WireGuard interface. Equivalent to --block-inbound.
+
+
+
+
+ Enable Rosenpass
+ Enables Rosenpass post-quantum key exchange on WireGuard tunnels. Both peers must support it.
+
+ Rosenpass permissive
+ When enabled, the client falls back to plain WireGuard if a peer does not support Rosenpass; otherwise it refuses the connection.
+
+ WireGuard port
+ UDP port used by the local WireGuard interface. Allowed range: 1-65535.
+
+ Split tunnel
+ 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.
+ Allow only listed apps (everything else bypasses)
+ Disallow listed apps (everything else routes)
+
+
+ Disable update settings
+ When enabled, blocks every configuration change from the client UI and from the CLI (netbird up / login / setconfig). The Settings view stays viewable but read-only. Equivalent to --disable-update-settings.
+
+ Disable profiles
+ When enabled, the client UI/CLI cannot list, create, switch or remove NetBird connection profiles. Equivalent to --disable-profiles.
+
+ Disable networks
+ When enabled, the client UI/CLI cannot list, select or deselect NetBird networks (the corresponding daemon RPCs return Unavailable). Equivalent to --disable-networks.
+
+ Disable metrics collection
+ When enabled, the client does not collect or report local usage metrics.
+
+
+
+
+
+
+
+ https://api.netbird.io:443
+
+
+
+
+
+
+
+
+
+
+ WireGuard UDP port:
+
+
+
+ Mode:
+
+
+
+
+
+
+
+
diff --git a/docs/netbird.admx b/docs/netbird.admx
new file mode 100644
index 00000000..2f7645d6
--- /dev/null
+++ b/docs/netbird.admx
@@ -0,0 +1,223 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ - allow
+ - disallow
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/go.mod b/go.mod
index f42a3abe..0b9cc9f2 100644
--- a/go.mod
+++ b/go.mod
@@ -134,6 +134,7 @@ require (
gorm.io/driver/sqlite v1.5.7
gorm.io/gorm v1.25.12
gvisor.dev/gvisor v0.0.0-20260219192049-0f2374377e89
+ howett.net/plist v1.0.1
)
require (
diff --git a/go.sum b/go.sum
index e8ff034d..bc78d17e 100644
--- a/go.sum
+++ b/go.sum
@@ -380,6 +380,7 @@ github.com/jcmturner/rpc/v2 v2.0.3 h1:7FXXj8Ti1IaVFpSAziCZWNzbNuZmnvw/i6CqLNdWfZ
github.com/jcmturner/rpc/v2 v2.0.3/go.mod h1:VUJYCIDm3PVOEHw8sgt091/20OJjskO/YJki3ELg/Hc=
github.com/jeandeaual/go-locale v0.0.0-20250612000132-0ef82f21eade h1:FmusiCI1wHw+XQbvL9M+1r/C3SPqKrmBaIOYwVfQoDE=
github.com/jeandeaual/go-locale v0.0.0-20250612000132-0ef82f21eade/go.mod h1:ZDXo8KHryOWSIqnsb/CiDq7hQUYryCgdVnxbj8tDG7o=
+github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI=
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
@@ -946,6 +947,7 @@ gopkg.in/square/go-jose.v2 v2.6.0 h1:NGk74WTnPKBNUhNzQX7PYcTLUjoq7mzKk2OKbvwk2iI
gopkg.in/square/go-jose.v2 v2.6.0/go.mod h1:M9dMgbHiYLoDGQrXy7OpJDJWiKiU//h+vD76mk0e1AI=
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ=
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw=
+gopkg.in/yaml.v1 v1.0.0-20140924161607-9f9df34309c0/go.mod h1:WDnlLJ4WF5VGsH/HVa3CI79GS0ol3YnhVnKP89i0kNg=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
@@ -968,5 +970,7 @@ gotest.tools/v3 v3.5.1 h1:EENdUnS3pdur5nybKYIh2Vfgc8IUNBjxDPSjtiJcOzU=
gotest.tools/v3 v3.5.1/go.mod h1:isy3WKz7GK6uNw/sbHzfKBLvlvXwUyV06n6brMxxopU=
gvisor.dev/gvisor v0.0.0-20260219192049-0f2374377e89 h1:mGJaeA61P8dEHTqdvAgc70ZIV3QoUoJcXCRyyjO26OA=
gvisor.dev/gvisor v0.0.0-20260219192049-0f2374377e89/go.mod h1:QkHjoMIBaYtpVufgwv3keYAbln78mBoCuShZrPrer1Q=
+howett.net/plist v1.0.1 h1:37GdZ8tP09Q35o9ych3ehygcsL+HqKSwzctveSlarvM=
+howett.net/plist v1.0.1/go.mod h1:lqaXoTrLY4hg8tnEzNru53gicrbv7rrk+2xJA/7hw9g=
rsc.io/qr v0.2.0 h1:6vBLea5/NRMVTz8V66gipeLycZMl/+UlFmk8DvqQ6WY=
rsc.io/qr v0.2.0/go.mod h1:IF+uZjkb9fqyeF/4tlBoynqmQxUoPfWEKh921coOuXs=