Compare commits

...

51 Commits

Author SHA1 Message Date
riccardom
e91b538dfc Fixes Rosenpass left disable after MDM unlock 2026-06-09 19:11:36 +02:00
riccardom
316f8a84cc exclude MDM profile adminstrated keys data from debug bundle 2026-06-09 16:21:41 +02:00
riccardom
d5419355f6 nil,nil is no policy and no error. Allow it 2026-06-09 13:37:27 +02:00
riccardom
20fc908398 nil,nil is no policy and no error. Allow it 2026-06-09 13:29:37 +02:00
coderabbitai[bot]
963d8c8825 📝 Add docstrings to mdm_integration
Docstrings generation was requested by @riccardomanfrin.

* https://github.com/netbirdio/netbird/pull/6374#issuecomment-4658991606

The following files were modified:

* `client/cmd/debug.go`
* `client/cmd/root.go`
* `client/internal/profilemanager/config.go`
* `client/mdm/policy.go`
* `client/mdm/policy_darwin.go`
* `client/mdm/policy_mobile.go`
* `client/mdm/policy_other.go`
* `client/mdm/policy_windows.go`
* `client/mdm/ticker.go`
* `client/server/mdm.go`
* `client/server/server.go`
* `client/ui/client_ui.go`
* `docs/netbird-macos.sh`
2026-06-09 10:49:57 +00:00
riccardom
d7f00b3beb Linting 2026-06-09 12:45:50 +02:00
riccardom
d806f25b33 CI fixes
- Add docstrings to `mdm_integration`
- refactor for cognitive complexity
- mod tidy
2026-06-09 12:15:49 +02:00
riccardom
46602e4176 [PROVISIONING] ADMX/ADML/PS/bash scripts/templates 2026-06-09 09:21:03 +02:00
riccardom
b09a07023a Tests MDM config reload via ticker 2026-06-08 18:06:02 +02:00
riccardom
61b5b6b1a1 Introduces support for darwing plist loading 2026-06-08 18:06:02 +02:00
riccardom
b8818adc41 Adds dep for reading plist files 2026-06-08 18:06:02 +02:00
riccardom
83430c39e4 [UI] Fix for "hide" not working when propagating to parent with children 2026-06-08 18:06:02 +02:00
riccardom
47d438976e Pins feat profile retrieval to notif event 2026-06-08 18:06:02 +02:00
riccardom
3bef0fec5b [UI] Removes --disable-advanced-settings 2026-06-08 18:06:02 +02:00
riccardom
be3fe6edd6 [PROTO] Removes --disable-advanced-settings 2026-06-08 18:06:02 +02:00
riccardom
04998f6a1c Removes --disable-advanced-settings
It was a typo in our meetings. the actual thing is --disable-update-settings
2026-06-08 18:06:02 +02:00
riccardom
e55b64f2f7 Fixup: MDM wins. always 2026-06-08 18:06:02 +02:00
riccardom
d6780522ea [UI] Aligns to "enable/disable once on change only" 2026-06-08 18:06:02 +02:00
riccardom
01a4c245a7 Enforces disable networks 2026-06-08 18:06:02 +02:00
riccardom
6355a24deb [UI] Fixup for profile enable/disable toggle
We need to align the initial state to evaluate the delta in case.

The initial state has to be "true" since the profile starts visible.
Then we receive MDM and transition the cache bool value to the actual
MDM imposed state
2026-06-08 18:06:02 +02:00
riccardom
3d4240b979 [UI] Fixup for preshared key 2026-06-08 18:06:02 +02:00
riccardom
2be7ef4929 [UI] Fixup advanced Settings 2026-06-08 18:06:02 +02:00
riccardom
6509174400 Evaluate disable-update-settings errors only when there's an actual override 2026-06-08 18:06:02 +02:00
riccardom
b200f47e6d Adds Gate Login as well when --disable-update-settings=true is given to service
This commit tries to settle things with an old PR-4237 which had relaxed
the case where the SetConfig returned an `Unavailable` code error.

Under this circumnstance the PR allowed the upFunc to just emit a warning and
progress further with the login gRPC. Since the login call is consuming
the --management-url coming from the `up` command, it might be possible
to abuse the "Unavailable" code to inject a management URL that is different
from the configured one even though the --disable-update-settings is set
to true (?)
2026-06-08 18:06:02 +02:00
riccardom
1b477d39a8 Adds support for disabling of Profiles and UpdateSettings feature flags 2026-06-08 18:06:02 +02:00
riccardom
7570c5c911 Toggle gray in/out for Advanced Settings 2026-06-08 18:06:02 +02:00
riccardom
bf8c8b0ea3 Apply MDM locks 2026-06-08 18:06:02 +02:00
riccardom
362efb832c Fixup presharedkey 2026-06-08 18:06:02 +02:00
riccardom
1543d43a20 Advanced Settings locking 2026-06-08 18:06:02 +02:00
riccardom
cee17ebc36 Lock toggle Settngs 2026-06-08 18:06:02 +02:00
riccardom
aab1a3e150 UI behavior conflicts relaxation
UI sends full config snapshot with all values. It doesn't
make sense to block it if the values are aligned with the
values constrained by the MDM policy. It's just simplier
to allow values that are compliant. (this goes for the CLI
as well at this point)
2026-06-08 18:06:02 +02:00
riccardom
1100cea6a4 Add events to resync UI to actual config
This also provide fixup for UI no aligning to changed config when coming from cli up with config flags.
2026-06-08 18:06:02 +02:00
riccardom
029e69279c Publishes event for UI to sync upon MDM changes 2026-06-08 18:06:02 +02:00
riccardom
c759aed3a3 Adds also up/start after cancel 2026-06-08 18:06:02 +02:00
riccardom
c36bf3a171 Adds MDM 1m diff checker & reloader 2026-06-08 18:06:02 +02:00
riccardom
08966adf30 Adds debug config cobra command
This can be useful for troubleshooting and checking config
now that its resolution is not trivial

defaults > config > env cars > CLI/UI > MDM
2026-06-08 18:06:02 +02:00
riccardom
0db22debaf Prefix every log with MDM 2026-06-08 14:37:10 +02:00
riccardom
0594f76655 Adds some log 2026-06-08 14:37:10 +02:00
riccardom
5271df5962 Align split tunnel code 2026-06-08 14:37:10 +02:00
riccardom
2d7949adfe Cleanup setupKey to align to linear 2026-06-08 14:37:10 +02:00
riccardom
451fa5e142 Adds missing WGPort config 2026-06-08 14:37:10 +02:00
riccardom
22edfdd52b Implements windows loading of MDM policy 2026-06-08 14:37:10 +02:00
riccardom
df9e216370 MDM Name scoping for clarity 2026-06-08 14:37:10 +02:00
riccardom
293a93910e gRPC MDM changes 2026-06-08 14:37:10 +02:00
riccardom
66e807cc05 Adds initial 101 MDM policy business logic testing 2026-06-08 14:37:10 +02:00
riccardom
5255f5111b Add ManagedFields to returned config over GetConfig 2026-06-08 14:37:10 +02:00
riccardom
978a392453 Return error if trying to modify any config that is gated by MDM 2026-06-08 14:37:10 +02:00
riccardom
78fe7fc510 Helpers business logic 2026-06-08 14:37:10 +02:00
riccardom
3ad961c9a7 Unit tests 2026-06-08 14:37:10 +02:00
riccardom
607cb56515 Applies MDM override 2026-06-08 14:37:10 +02:00
riccardom
ce0750b73a Initial scaffolding 2026-06-08 14:37:10 +02:00
31 changed files with 3538 additions and 243 deletions

View File

@@ -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,80 @@ 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
// debugConfigDump requests the daemon for the resolved effective configuration and prints it as indented JSON.
// It resolves the active profile and current OS user, calls DaemonService.GetConfig with those values, and
// marshals the response using protojson with default/zero-valued fields included.
// debugConfigDump prints the daemon's effective configuration for the active profile and current OS user as indented JSON.
// It requests the configuration from the daemon and writes the protobuf response with default fields emitted to stdout.
// Returns an error if active profile or user lookup fails, the daemon RPC fails, or the response cannot be marshaled.
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 and
// optional upload URL) to configure the bundle request. Returns an error if the RPC
// debugBundle requests creation of a debug bundle from the daemon and prints
// the local bundle file path and, if uploading was enabled, the uploaded file key.
// It returns an error if the RPC fails, if the daemon reports an upload failure
// reason, or if establishing the connection fails.
func debugBundle(cmd *cobra.Command, _ []string) error {
conn, err := getClient(cmd)
if err != nil {

View File

@@ -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,20 @@ func Execute() error {
return rootCmd.Execute()
}
// init initializes package-level defaults and the CLI command tree.
// init sets platform-specific default config and log directory paths and a default daemon address,
// registers persistent flags (daemon address, management/admin URLs, logging, setup key, preshared key,
// hostname, anonymize, config path), attaches top-level and nested subcommands to the root command,
// and configures `up` command specific flags (external IP maps, DNS resolver address, Rosenpass options,
// init initializes package-level defaults and configures the root Cobra command.
//
// It sets default configuration and log directory paths (including legacy Wiretrustee
// locations) based on the runtime OS, builds default config/log file paths, and selects
// a platform-appropriate default daemon address. It registers persistent CLI flags
// (including mutually exclusive setup-key and setup-key-file), attaches top-level
// commands and subcommands to the root command, and registers `up`-specific persistent
// flags for external IP mapping, custom DNS resolver address, Rosenpass options,
// auto-connect disabling, and lazy connection.
func init() {
defaultConfigPathDir = "/etc/netbird/"
defaultLogFileDir = "/var/log/netbird/"
@@ -168,6 +184,7 @@ func init() {
logCmd.AddCommand(logLevelCmd)
debugCmd.AddCommand(forCmd)
debugCmd.AddCommand(persistenceCmd)
debugCmd.AddCommand(debugConfigCmd)
// profile commands
profileCmd.AddCommand(profileListCmd)

View File

@@ -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")

View File

@@ -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"
@@ -174,6 +175,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 +630,96 @@ 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
// 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
// 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.
// It requires the URL to use the http or https scheme and, if no port is present,
// appends ":443" for https or ":80" for http. On success it returns the parsed
// ":80" is appended. The `serviceName` is used to contextualize error messages.
func parseURL(serviceName, serviceURL string) (*url.URL, error) {
parsedMgmtURL, err := url.ParseRequestURI(serviceURL)
if err != nil {

View File

@@ -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 }

View File

@@ -0,0 +1,25 @@
//go:build windows || darwin
package mdm
import "strings"
// 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
}()

278
client/mdm/policy.go Normal file
View File

@@ -0,0 +1,278 @@
// 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"
)
// AllKeys is the set of recognised MDM keys. Unknown keys in a managed
// configuration are ignored but logged.
var AllKeys = []string{
KeyManagementURL,
KeyDisableUpdateSettings,
KeyDisableProfiles,
KeyDisableNetworks,
KeyDisableClientRoutes,
KeyDisableServerRoutes,
KeyBlockInbound,
KeyDisableMetricsCollection,
KeyAllowServerSSH,
KeyDisableAutoConnect,
KeyPreSharedKey,
KeyRosenpassEnabled,
KeyRosenpassPermissive,
KeyWireguardPort,
KeySplitTunnelMode,
KeySplitTunnelApps,
}
// SecretKeys lists keys whose values must be redacted in logs.
var SecretKeys = map[string]struct{}{
KeyPreSharedKey: {},
}
// 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
// NewPolicy constructs a Policy backed by the provided key→value map.
// If values is nil it is replaced with an empty map so the returned *Policy
// NewPolicy constructs a non-nil *Policy that wraps the provided key/value map.
// If values is nil it is replaced with an empty map so the returned Policy always
// represents "no MDM enforcement" when its values are empty.
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)"
// LoadPolicy loads MDM-managed configuration from the platform and returns a Policy representing the managed settings.
// If the platform loader fails or returns nil, LoadPolicy returns a non-nil empty Policy.
// LoadPolicy loads platform-managed MDM key/value pairs and returns a non-nil Policy.
// If the platform loader returns an error or a nil map, an empty Policy is returned.
// On loader error a trace-level message is emitted. When a map is returned, an
// informational message is logged either indicating enrollment with no managed keys
// or the count and a stable, sorted list of managed key names.
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
}
// 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,
}
// 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
// sortedKeys returns the keys of m as a lexicographically sorted slice.
// The sorted order provides a deterministic key ordering for diagnostics and enumeration.
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
}

View File

@@ -0,0 +1,99 @@
//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. 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
//
// Value-type coercion mirrors the Windows loader: native plist types
// map naturally onto the Policy accessor expectations (GetString /
// GetBool / GetInt / GetStringSlice). Unknown top-level keys are
// logged and skipped so a stray entry in the payload does not block
// loadPlatformPolicy reads the managed-preferences plist at policyPlistPath and returns recognised MDM key/value pairs.
//
// If the plist file does not exist, it returns (nil, nil). It returns a wrapped error on open/stat/decode failures.
// The function refuses to read a world-writable plist and returns an error in that case.
// loadPlatformPolicy reads the device-level managed-preferences plist and returns its recognized keys.
//
// It looks for the plist at policyPlistPath and, if present, decodes it into a map[string]any.
// Top-level plist keys are canonicalized case-insensitively to the package's internal MDM key names;
// unknown keys are logged and ignored. If the plist file does not exist, it returns (nil, nil).
// The function refuses to read the file if it is world-writable and returns a wrapped error for
// failures to open, stat, or decode the plist.
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
}

View File

@@ -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
}

View File

@@ -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
}

160
client/mdm/policy_test.go Normal file
View File

@@ -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())
}

View File

@@ -0,0 +1,119 @@
//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`
// 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 any other registry error
//
// Type coercion of registry value types into the Policy map:
// - REG_SZ -> string
// - REG_EXPAND_SZ -> string (expanded by the registry API)
// - REG_DWORD -> int64 (caller's GetBool handles 0/!=0 coercion)
// - REG_QWORD -> int64
// - REG_MULTI_SZ -> []string
//
// Unsupported value types (REG_BINARY, REG_NONE, ...) are skipped with a
// loadPlatformPolicy reads managed NetBird policy values from HKLM\Software\Policies\NetBird.
// If the registry key does not exist it returns (nil, nil).
// It returns a map whose keys are canonical policy names and whose values are coerced from registry types:
// REG_SZ/REG_EXPAND_SZ -> string, REG_DWORD/REG_QWORD -> int64, REG_MULTI_SZ -> []string.
// readRegistryValue reads the registry value named by name from key k and, when the value is successfully read and its type is supported, stores the coerced Go value in out[canonical].
//
// REG_SZ and REG_EXPAND_SZ are stored as string, REG_DWORD and REG_QWORD are stored as int64, and REG_MULTI_SZ is stored as []string; unknown value names, unsupported value types, and per-value read errors are logged and skipped.
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 loads MDM-managed NetBird policy values from the Windows
// registry at HKLM\Software\Policies\NetBird.
//
// It returns a map that maps canonical policy names to coerced Go values:
// string for REG_SZ/REG_EXPAND_SZ, int64 for REG_DWORD/REG_QWORD, and []string
// for REG_MULTI_SZ. If the policy registry key does not exist, it returns
// (nil, nil). It returns an error when opening or enumerating the registry
// key fails. Individual values that are unknown, of unsupported types, or that
// fail to read are skipped and produce logged warnings; registry close failures
// are also logged.
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
}

159
client/mdm/ticker.go Normal file
View File

@@ -0,0 +1,159 @@
package mdm
import (
"context"
"reflect"
"sort"
"testing"
"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 and bypass this ticker entirely. Unexported on purpose: callers do
// not pass it — NewTicker owns the default (see reloadInterval).
const defaultReloadInterval = 1 * time.Minute
// testReloadInterval is the cadence used under `go test` (detected via
// testing.Testing()) so the reload path is exercised in seconds rather than
// minutes. It has no effect on production builds, where testing.Testing()
// always returns false.
const testReloadInterval = 1 * time.Second
// reloadInterval returns the production cadence, or the accelerated test
// cadence when running under `go test`. Centralising the choice here keeps
// reloadInterval selects the polling interval used to re-read the OS-native MDM policy.
// reloadInterval selects the polling interval used for policy reloads.
// It returns testReloadInterval when running under `go test` (testing.Testing() == true), and defaultReloadInterval otherwise.
func reloadInterval() time.Duration {
if testing.Testing() {
return testReloadInterval
}
return defaultReloadInterval
}
// 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 onChange 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
onChange func(prev, curr *Policy)
prev *Policy
}
// NewTicker constructs a Ticker that re-reads the OS-native policy every
// reloadInterval() and invokes onChange on any diff. The cadence is owned by
// reloadInterval (production default, accelerated under `go test`); callers
// NewTicker creates a Ticker that polls the OS-native MDM policy at the package reload interval and invokes onChange when a policy change is detected.
// If onChange is nil the ticker will only log detected changes.
// NewTicker creates a Ticker that polls for policy changes and invokes onChange when a difference is detected.
//
// The provided onChange callback, if non-nil, is called with the previous and current Policy snapshots when a
// change is observed. The returned Ticker's polling interval is set via reloadInterval and its initial snapshot
// is populated by calling policyLoader.
func NewTicker(onChange func(prev, curr *Policy)) *Ticker {
return &Ticker{
interval: reloadInterval(),
onChange: onChange,
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.
func (t *Ticker) Run(ctx context.Context) {
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
t.prev = curr
if t.onChange != nil {
t.onChange(prev, curr)
}
}
}
}
// PoliciesEqual reports whether two Policy instances carry the same managed
// PoliciesEqual reports whether two Policy instances represent the same policy.
// It returns true when both policies are empty, returns false if one pointer is nil
// while the other is not, and otherwise compares the policies' underlying value
// maps for deep equality.
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
// diffPolicies reports keys that were added, removed, or changed between two policies.
// The returned slices contain keys present only in `curr` (added), only in `prev` (removed),
// and present in both but whose values differ (changed). Each slice is sorted
// lexicographically for stable logging output; value differences are determined
// associated values differ by deep equality.
func diffPolicies(prev, curr *Policy) (added, removed, changed []string) {
prevKeys := mapOf(prev)
currKeys := mapOf(curr)
for k := range currKeys {
if _, ok := prevKeys[k]; !ok {
added = append(added, k)
} else if !reflect.DeepEqual(prevKeys[k], currKeys[k]) {
changed = append(changed, k)
}
}
for k := range prevKeys {
if _, ok := currKeys[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 across the
// mapOf returns a non-nil copy of the given Policy's key/value map.
// If p is nil, mapOf returns an empty map; otherwise it returns a newly
// mapOf returns a non-nil copy of a Policy's values map.
// If p is nil it returns an empty map; otherwise it returns a newly
// allocated map containing the same key/value pairs as p.values.
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
}

100
client/mdm/ticker_test.go Normal file
View File

@@ -0,0 +1,100 @@
package mdm
import (
"context"
"sync"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// 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_UsesTestCadenceUnderGoTest(t *testing.T) {
withPolicyLoader(t, func() *Policy { return NewPolicy(nil) })
// Under `go test`, testing.Testing() is true so reloadInterval() returns
// the accelerated 1s cadence instead of the minute-long production
// default — this is what makes the reload path observable without a real
// wall-clock wait.
assert.Equal(t, testReloadInterval, reloadInterval())
assert.Equal(t, testReloadInterval, NewTicker(nil).interval)
}
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(func(prev, curr *Policy) {
select {
case changes <- change{prev, curr}:
default:
}
})
require.Equal(t, testReloadInterval, tk.interval)
ctx, cancel := context.WithCancel(context.Background())
done := make(chan struct{})
go func() { tk.Run(ctx); 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(func(_, _ *Policy) {
select {
case fired <- struct{}{}:
default:
}
})
ctx, cancel := context.WithCancel(context.Background())
done := make(chan struct{})
go func() { tk.Run(ctx); 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):
}
}

View File

@@ -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,
},

View File

@@ -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 {

482
client/server/mdm.go Normal file
View File

@@ -0,0 +1,482 @@
package server
import (
"context"
"fmt"
"strings"
"time"
log "github.com/sirupsen/logrus"
"google.golang.org/grpc/codes"
gstatus "google.golang.org/grpc/status"
"github.com/netbirdio/netbird/client/internal"
"github.com/netbirdio/netbird/client/mdm"
"github.com/netbirdio/netbird/client/proto"
)
// 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
// 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(_, curr *mdm.Policy) {
log.Warn("MDM policy changed; restarting engine to apply new configuration")
s.mutex.Lock()
cancel := s.actCancel
giveUpChan := s.clientGiveUpChan
s.mutex.Unlock()
if cancel != nil {
cancel()
}
// Wait for previous connectWithRetryRuns to exit so we don't end up
// with two goroutines fighting over the same status recorder + engine.
if giveUpChan != nil {
select {
case <-giveUpChan:
case <-time.After(5 * time.Second):
log.Warn("MDM restart: timeout waiting for previous engine goroutine; proceeding anyway")
}
}
if err := s.restartEngineForMDM(); err != nil {
log.Errorf("MDM restart failed: %v", err)
return
}
// publishConfigChangedEvent has already fired inside
// restartEngineForMDM with source="mdm". Here we additionally emit an
// MDM-specific user-visible toast so the operator knows their IT
// policy was applied (UserMessage != "" triggers the GUI notifier).
_ = curr
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"},
)
}
// 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
}
var managed []string
if s.config != nil {
managed = s.config.Policy().ManagedKeys()
}
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",
"managed_fields": strings.Join(managed, ","),
},
)
}
// restartEngineForMDM 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.
func (s *Server) restartEngineForMDM() error {
activeProf, err := s.profileManager.GetActiveProfileState()
if err != nil {
return fmt.Errorf("get active profile state: %w", err)
}
config, existingConfig, err := s.getConfig(activeProf)
if err != nil {
return fmt.Errorf("get active profile config: %w", err)
}
s.mutex.Lock()
defer s.mutex.Unlock()
s.config = config
s.statusRecorder.UpdateManagementAddress(config.ManagementURL.String())
s.statusRecorder.UpdateRosenpass(config.RosenpassEnabled, config.RosenpassPermissive)
s.statusRecorder.UpdateLazyConnection(config.LazyConnectionEnabled)
state := internal.CtxGetState(s.rootCtx)
if config.DisableAutoConnect {
log.Info("MDM restart: DisableAutoConnect=true; staying idle")
state.Set(internal.StatusIdle)
s.actCancel = nil
return nil
}
if !existingConfig {
log.Warn("MDM restart: config absent; not reconnecting")
state.Set(internal.StatusNeedsLogin)
s.actCancel = nil
return nil
}
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
}
// 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 = "**********"
// 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)
}
// conflictBool builds a check for a *bool field on an arbitrary request
// conflictBool builds a conflictCheck for a boolean MDM key.
// If p is nil the returned check treats the field as matching; otherwise the
// check returns true only when the policy contains the key and its boolean
// conflictBool constructs a conflictCheck that verifies a boolean MDM policy key matches a desired value.
// If p is nil the produced check treats the field as matching by definition. Otherwise the check returns
// true only if the policy has 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 check for a string field. Empty string ("")
// conflictString returns a conflictCheck for the MDM string key identified by `key`.
// If `got` is empty the field is treated as unset and will not be considered a conflict.
// conflictString constructs a conflictCheck for a string policy key.
// The check treats an empty requested value as matching. Otherwise it
// succeeds 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 that verifies an *int64 field against the MDM policy key.
// conflictInt64 builds a conflictCheck that validates an int64 MDM policy key.
// If p is nil, the check always matches; otherwise the policy must contain the key and its integer value must equal *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 a list of 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 managed by MDM are
// skipped silently (the gate fires only for keys the admin has actually
// resolveConflicts identifies MDM-managed policy keys whose values differ from the provided checks.
// If the policy is empty, it returns nil. Only keys present in the policy are considered; for each
// resolveConflicts evaluates each conflictCheck against the provided MDM policy and returns
// a slice of policy keys whose checks report a mismatch. If the policy is empty, it returns nil.
// Checks whose key is not present in the policy are skipped.
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 PreSharedKey sentinel that GetConfig returns is
// recognised and treated as no-op so the UI can safely round-trip it
// mdmManagedFieldConflicts reports which MDM-managed policy keys would be violated by
// the provided SetConfigRequest.
//
// If msg is nil, it returns nil. The function treats the PSK redaction sentinel
// ("**********") as an intentional no-op (equivalent to field not set). Only keys
// present in the supplied policy are considered; returned slice contains the policy
// mdmManagedFieldConflicts reports MDM-managed policy keys that would conflict with a SetConfigRequest.
//
// If msg is nil, it returns nil. The pre-shared key redaction sentinel ("**********") is treated as unset
// so it does not produce a false conflict. The returned slice contains policy key names whose values in
// the request differ from the active policy; an empty or nil slice indicates no conflicts.
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 the request unconditionally on every `netbird up` (see
// setupSetConfigReq in cmd/up.go), so a plain `netbird up` results in a
// SetConfig call 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
// setConfigRequestHasConfigOverrides reports whether msg contains any fields that would mutate
// persisted daemon configuration rather than being purely authentication-only.
// It returns false if msg is nil; otherwise it returns true when any configuration-related
// field is present (for example: management/admin URLs, pre-shared key, DNS/NAT lists and
// cleaning flags, interface/port/MTU settings, auto-connect and routing toggles, DNS/firewall/IPv6
// controls, SSH-related flags, notification/lazy-connection options, or other persistent config
// setConfigRequestHasConfigOverrides reports whether msg contains any fields that would modify persisted daemon configuration.
// It returns false for a nil message. The check includes management/admin URLs, pre-shared key, DNS/NAT lists and cleanup flags,
// interface and WireGuard settings, MTU, auto-connect, routing, DNS/firewall/IPv6 controls, SSH-related flags, notification and
// lazy-connection options, and other persistent network/security fields.
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
// loginRequestHasConfigOverrides reports whether a LoginRequest includes any fields that would change persisted daemon configuration.
// It returns true when the request carries any configuration-related values (for example: management/admin URLs, pre-shared key,
// DNS or NAT lists/cleanup flags, interface or WireGuard port, connection and policy toggles, route/DNS/firewall/notification flags,
// loginRequestHasConfigOverrides reports whether the given LoginRequest contains any fields that would modify the daemon's persisted configuration.
// It returns true when the request sets any configuration-related fields (management/admin URLs, pre-shared key, DNS/NAT settings, Rosenpass options, interface/WireGuard settings, auto-connect, routing/SSH/firewall/DNS controls, notifications, lazy-connection, block-inbound, or similar persistent toggles); it returns false if msg is nil or contains only authentication/identity fields.
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. The redaction sentinel is treated
// loginRequestMDMConflicts reports MDM-managed keys that conflict between a LoginRequest and an active MDM policy.
//
// It returns a slice of policy keys that are managed by the given policy and whose values in the request
// differ from the policy. If msg is nil or the policy has no managed keys, it returns nil. The function
// prefers OptionalPreSharedKey over the legacy PreSharedKey when both are present and treats the redaction
// loginRequestMDMConflicts reports MDM-managed configuration keys that would
// conflict between a LoginRequest and the active MDM policy.
//
// It returns a slice of policy key names whose requested values differ from the
// policy. If msg is nil it returns nil. For pre-shared keys, OptionalPreSharedKey
// takes precedence over the deprecated PreSharedKey; a value equal to
// preSharedKeyRedactedSentinel ("**********") is treated as unset.
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
// rejectMDMManagedFieldConflicts returns a gRPC FailedPrecondition error when any MDM-managed fields conflict.
// If `conflicts` is empty this function returns nil. When conflicts exist it produces a FailedPrecondition status
// whose message lists the conflicting fields and attempts to attach a `proto.MDMManagedFieldsViolation` detail;
// rejectMDMManagedFieldConflicts rejects requests that attempt to modify fields managed by MDM.
// If `conflicts` is empty, it does nothing. Otherwise it logs a warning and returns a gRPC
// FailedPrecondition error whose message lists the conflicting keys and which carries a
// `proto.MDMManagedFieldsViolation` detail with the `Fields` set to `conflicts`. If attaching
// the detail fails, the base FailedPrecondition status is returned.
//
// Parameters:
// - policy: the active MDM policy (unused here, present for call-site symmetry).
// - conflicts: list of MDM-managed keys that the request attempted to modify.
//
// Returns:
// - a gRPC error indicating the request was rejected due to MDM-managed fields, or nil when
// there are no conflicts.
func rejectMDMManagedFieldConflicts(policy *mdm.Policy, conflicts []string) error {
if len(conflicts) == 0 {
return nil
}
_ = policy
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()
}

View File

@@ -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)
}

View File

@@ -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"
@@ -98,6 +99,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 +161,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(s.onMDMPolicyChange)
go s.mdmTicker.Run(s.rootCtx)
}
// 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,6 +230,7 @@ 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
}
@@ -304,54 +322,92 @@ 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(policy, 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 of this function is intentionally linear and
// setConfigInputFromRequest builds a profilemanager.ConfigInput from a SetConfigRequest proto.
//
// It translates each provided proto field into the corresponding ConfigInput field,
// preserving the request's semantics for "clean" vs explicit-empty slices/bytes and
// converting optional numeric fields into typed pointers where applicable.
//
// msg: the incoming SetConfigRequest whose fields are mapped into the returned ConfigInput.
//
// Returns the constructed ConfigInput and a non-nil error if 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 +420,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 +452,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(policy, loginRequestMDMConflicts(msg, policy)); err != nil {
return nil, err
}
}
s.mutex.Lock()
if s.actCancel != nil {
s.actCancel()
@@ -743,6 +807,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)
@@ -1548,6 +1613,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 +1712,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 +1734,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() {

View File

@@ -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)
}

View File

@@ -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.
//
// It parses CLI flags, initializes logging, creates the Fyne application and tray icons,
// and constructs the service client (which may open a requested UI window). If a window-mode
// flag is set the Fyne event loop runs and main returns; otherwise it ensures only one
// main is the program entry point that initializes logging and the Fyne UI, enforces single-instance behavior, creates the service client, and starts the system tray.
//
// It parses CLI flags, configures logging, constructs the Fyne application and service client (optionally showing a requested UI window), monitors theme/settings changes, ensures only a single instance runs (signaling an existing instance to show its window when present), sets up signal handling and default fonts, and finally 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,128 @@ 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
// preSharedKeyPlaceholder returns the placeholder text for the pre-shared key entry based on the daemon config.
// It returns an empty string if no pre-shared key 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

View File

@@ -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)
}
}

View File

@@ -0,0 +1,126 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<!--
NetBird MDM preferences (macOS) — bare plist for MDM platforms that
accept a managed-preferences plist tied to a bundle identifier
(e.g. JumpCloud "Mac Application Custom Settings", Mosyle "Custom
Settings", Jamf "Application & Custom Settings" → External
Application).
Bundle identifier (preference domain): io.netbird.client
The MDM provider will wrap this plist into a Configuration Profile
payload of type com.apple.ManagedClient.preferences and push it to
target devices via the Apple MDM protocol. The OS materializes the
final file at:
/Library/Managed Preferences/io.netbird.client.plist
which is what the NetBird daemon's client/mdm/policy_darwin.go
loader reads on every 1-minute MDM reload tick.
For MDM platforms that expect a full Configuration Profile instead
of a bare plist (Custom Configuration Profile / .mobileconfig upload),
use docs/netbird-macos.mobileconfig — same keys, additional Payload*
envelope.
Editing this file:
- Remove or comment out any key you do NOT want to enforce. The
daemon treats an absent key as "no enforcement" for that field.
- Keep the document well-formed XML. Validate locally with:
plutil -lint docs/io.netbird.client.plist
- Keys are camelCase; values are typed (<string>, <true/>, <false/>,
<integer>). See docs/src/pages/client/mdm-integration.mdx (the
public docs page) for the full reference.
Persistence caveat:
macOS wipes /Library/Managed Preferences/ at every boot on
devices that are NOT MDM-enrolled. This plist only sticks across
reboots when delivered through a real MDM channel. For local
testing on an un-enrolled host, write the file manually as root
and accept it will not survive the next boot.
-->
<plist version="1.0">
<dict>
<!-- ===== Identity / auth ===== -->
<key>managementURL</key>
<string>https://api.netbird.io:443</string>
<!--
Pre-shared key: secret. Remove the entry entirely when not used;
do NOT leave an empty <string></string>, which the daemon would
otherwise treat as a deliberate empty-PSK enforcement.
-->
<!--
<key>preSharedKey</key>
<string>REPLACE_ME</string>
-->
<!-- ===== Engine / runtime behavior =====
Each key is optional. Remove or comment out to leave the
field unmanaged on the client. -->
<key>allowServerSSH</key>
<true/>
<!--
<key>disableAutoConnect</key>
<false/>
<key>disableClientRoutes</key>
<false/>
<key>disableServerRoutes</key>
<false/>
<key>blockInbound</key>
<false/>
<key>rosenpassEnabled</key>
<true/>
<key>rosenpassPermissive</key>
<false/>
-->
<!-- ===== WireGuard UDP port =====
Range 1-65535. Omit to keep the daemon default. -->
<!--
<key>wireguardPort</key>
<integer>51820</integer>
-->
<!-- ===== UI / lockdown kill switches =====
disableUpdateSettings : block every config change from UI and CLI
on this device (Settings view stays
readable but read-only).
disableProfiles : hide the profile menu, reject profile CRUD.
disableNetworks : hide the Networks / Exit Node menus,
reject the related RPCs.
disableMetricsCollection: opt out of anonymous usage telemetry. -->
<!--
<key>disableUpdateSettings</key>
<true/>
<key>disableProfiles</key>
<true/>
<key>disableNetworks</key>
<true/>
<key>disableMetricsCollection</key>
<false/>
-->
<!-- ===== Split tunnel =====
Android-only at the client level. Safe to ship on macOS for
mixed-platform fleets; the macOS daemon parses and ignores. -->
<!--
<key>splitTunnelMode</key>
<string>allow</string>
<key>splitTunnelApps</key>
<string>com.acme.app1,com.acme.app2</string>
-->
</dict>
</plist>

View File

@@ -0,0 +1,159 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<!--
NetBird MDM configuration profile (macOS).
Wraps a `com.apple.ManagedClient.preferences` payload that pushes the
NetBird MDM policy into:
/Library/Managed Preferences/io.netbird.client.plist
Read at runtime by the netbird daemon's macOS loader
(client/mdm/policy_darwin.go — Phase 2). Key names match the canonical
lowerCamelCase form used in docs/netbird.admx and the mdm.Key*
constants in client/mdm/policy.go.
Bundle identifier: io.netbird.client
(confirm against the signed pkg before fleet roll-out)
Distribution:
- sign with `productsign --sign "Developer ID Installer: ..." ...`
before fleet roll-out (Apple-Configurator-2 won't install an
unsigned profile on Sonoma+ without user override).
- For local dev install: `sudo profiles install -path netbird-macos.mobileconfig`.
- For MDM (Jamf/Kandji/Mosyle/Intune): upload as a Custom Profile.
Editing:
- Replace UUID placeholders below with fresh UUIDs (`uuidgen` on
macOS) when forking this template for a real fleet — each
deployment should have unique UUIDs so the OS treats it as a
distinct profile.
- Tune the PayloadContent values to the policy you want to enforce.
- Remove any key you do NOT want to enforce (the daemon treats an
absent key as "no enforcement" for that field).
iOS note:
This file is macOS-specific. iOS uses managed app config via
UserDefaults[com.apple.configuration.managed] under a different
payload type (com.apple.app.configuration.managed); the wrapper
structure is the same but the inner payload dictionary differs.
See docs/netbird-ios.mobileconfig (Phase 5) when shipped.
-->
<plist version="1.0">
<dict>
<!-- Outer profile envelope -->
<key>PayloadType</key>
<string>Configuration</string>
<key>PayloadVersion</key>
<integer>1</integer>
<key>PayloadIdentifier</key>
<string>io.netbird.client.mdm</string>
<key>PayloadUUID</key>
<string>11111111-1111-1111-1111-111111111111</string>
<key>PayloadDisplayName</key>
<string>NetBird MDM Policy</string>
<key>PayloadDescription</key>
<string>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.</string>
<key>PayloadOrganization</key>
<string>NetBird</string>
<key>PayloadScope</key>
<string>System</string>
<key>PayloadRemovalDisallowed</key>
<false/>
<key>PayloadContent</key>
<array>
<dict>
<!-- Managed preferences payload: writes /Library/Managed Preferences/io.netbird.client.plist -->
<key>PayloadType</key>
<string>com.apple.ManagedClient.preferences</string>
<key>PayloadVersion</key>
<integer>1</integer>
<key>PayloadIdentifier</key>
<string>io.netbird.client.mdm.preferences</string>
<key>PayloadUUID</key>
<string>22222222-2222-2222-2222-222222222222</string>
<key>PayloadDisplayName</key>
<string>NetBird Managed Preferences</string>
<key>PayloadEnabled</key>
<true/>
<key>PayloadContent</key>
<dict>
<key>io.netbird.client</key>
<dict>
<key>Forced</key>
<array>
<dict>
<key>mcx_preference_settings</key>
<dict>
<!-- ===== Identity / auth (strings) ===== -->
<key>managementURL</key>
<string>https://api.netbird.io:443</string>
<!-- Pre-shared key: secret. Remove the entry entirely
when not used; do NOT leave an empty string. -->
<!--
<key>preSharedKey</key>
<string>REPLACE_ME</string>
-->
<!-- ===== Engine / runtime behavior (bool) =====
Remove any key to leave the field unmanaged. -->
<!--
<key>disableAutoConnect</key>
<false/>
<key>disableClientRoutes</key>
<false/>
<key>disableServerRoutes</key>
<false/>
<key>blockInbound</key>
<false/>
-->
<key>allowServerSSH</key>
<true/>
<!--
<key>rosenpassEnabled</key>
<true/>
<key>rosenpassPermissive</key>
<false/>
-->
<!-- ===== WireGuard UDP port (int) =====
Range 1-65535. Omit to keep the default. -->
<!--
<key>wireguardPort</key>
<integer>51820</integer>
-->
<!-- ===== Split tunnel (Android-only at the daemon level)
Pushed harmlessly on macOS for fleets with mixed
desktop+mobile devices; the macOS daemon ignores it. -->
<!--
<key>splitTunnelMode</key>
<string>allow</string>
<key>splitTunnelApps</key>
<string>com.acme.app1,com.acme.app2</string>
-->
<!-- ===== UI / kill switches (bool) ===== -->
<!--
<key>disableUpdateSettings</key>
<true/>
<key>disableProfiles</key>
<true/>
<key>disableNetworks</key>
<true/>
<key>disableMetricsCollection</key>
<false/>
-->
</dict>
</dict>
</array>
</dict>
</dict>
</dict>
</array>
</dict>
</plist>

189
docs/netbird-macos.sh Normal file
View File

@@ -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 `<dict>` for the policy plist.
start_plist() {
cat > "$PLIST_PATH.tmp" <<'EOF'
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
EOF
}
# end_plist appends the closing `</dict>` and `</plist>` tags to the temporary plist file.
end_plist() {
cat >> "$PLIST_PATH.tmp" <<'EOF'
</dict>
</plist>
EOF
}
# emit_string appends a plist `<key>`/`<string>` 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/&/\&amp;/g' -e 's/</\&lt;/g' -e 's/>/\&gt;/g')"
printf ' <key>%s</key>\n <string>%s</string>\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='<true/>' ; value='true' ;;
false|False|FALSE|0|no) xml_bool='<false/>' ; value='false' ;;
*) log "invalid boolean for $key: $value (must be true/false); skipping"; return ;;
esac
printf ' <key>%s</key>\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 `<integer>` 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 ' <key>%s</key>\n <integer>%s</integer>\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 "$@"

BIN
docs/netbird-policy.reg Normal file

Binary file not shown.

View File

@@ -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

95
docs/netbird.adml Normal file
View File

@@ -0,0 +1,95 @@
<?xml version="1.0" encoding="utf-8"?>
<policyDefinitionResources xmlns:xsd="http://www.w3.org/2001/XMLSchema"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
revision="1.0"
schemaVersion="1.0"
xmlns="http://schemas.microsoft.com/GroupPolicy/2006/07/PolicyDefinitions">
<displayName>NetBird Client Policies</displayName>
<description>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.</description>
<resources>
<stringTable>
<!-- Categories -->
<string id="NetBird_Category">NetBird</string>
<string id="SUPPORTED_NetBird_All">NetBird Client 0.40+</string>
<!-- Identity / auth -->
<string id="ManagementURL_Name">Management URL</string>
<string id="ManagementURL_Help">URL of the NetBird management server. Format: https://host[:port]. When set, users cannot override this value via UI or CLI.</string>
<string id="PreSharedKey_Name">Pre-shared key</string>
<string id="PreSharedKey_Help">WireGuard pre-shared key used as an additional symmetric secret on every peer-to-peer tunnel. Secret value.</string>
<!-- Settings: engine / runtime behavior -->
<string id="DisableAutoConnect_Name">Disable auto-connect</string>
<string id="DisableAutoConnect_Help">When enabled, the NetBird tunnel does not auto-connect at daemon startup. Equivalent to --disable-auto-connect.</string>
<string id="DisableClientRoutes_Name">Disable client routes</string>
<string id="DisableClientRoutes_Help">When enabled, this client will not consume routes advertised by routing peers. Equivalent to --disable-client-routes.</string>
<string id="DisableServerRoutes_Name">Disable server routes</string>
<string id="DisableServerRoutes_Help">When enabled, this client will not act as a routing peer for other clients. Equivalent to --disable-server-routes.</string>
<string id="BlockInbound_Name">Block inbound</string>
<string id="BlockInbound_Help">When enabled, the client firewall blocks all inbound peer traffic on the WireGuard interface. Equivalent to --block-inbound.</string>
<string id="AllowServerSSH_Name">Allow server SSH</string>
<string id="AllowServerSSH_Help">When enabled, this client accepts incoming SSH sessions via NetBird SSH. Equivalent to --allow-server-ssh.</string>
<string id="RosenpassEnabled_Name">Enable Rosenpass</string>
<string id="RosenpassEnabled_Help">Enables Rosenpass post-quantum key exchange on WireGuard tunnels. Both peers must support it.</string>
<string id="RosenpassPermissive_Name">Rosenpass permissive</string>
<string id="RosenpassPermissive_Help">When enabled, the client falls back to plain WireGuard if a peer does not support Rosenpass; otherwise it refuses the connection.</string>
<string id="WireguardPort_Name">WireGuard port</string>
<string id="WireguardPort_Help">UDP port used by the local WireGuard interface. Allowed range: 1-65535.</string>
<string id="SplitTunnel_Name">Split tunnel</string>
<string id="SplitTunnel_Help">Restrict the NetBird tunnel to or from a chosen list of application package names. Choose either the allow mode (only the listed apps route through NetBird) or the disallow mode (the listed apps bypass NetBird; everything else routes through). The mode is mutually exclusive — only one can be active at a time. Android-only at the daemon level; Windows/macOS/iOS clients ignore this policy.</string>
<string id="SplitTunnel_Allow">Allow only listed apps (everything else bypasses)</string>
<string id="SplitTunnel_Disallow">Disallow listed apps (everything else routes)</string>
<!-- UI -->
<string id="DisableUpdateSettings_Name">Disable update settings</string>
<string id="DisableUpdateSettings_Help">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.</string>
<string id="DisableProfiles_Name">Disable profiles</string>
<string id="DisableProfiles_Help">When enabled, the client UI/CLI cannot list, create, switch or remove NetBird connection profiles. Equivalent to --disable-profiles.</string>
<string id="DisableNetworks_Name">Disable networks</string>
<string id="DisableNetworks_Help">When enabled, the client UI/CLI cannot list, select or deselect NetBird networks (the corresponding daemon RPCs return Unavailable). Equivalent to --disable-networks.</string>
<string id="DisableMetricsCollection_Name">Disable metrics collection</string>
<string id="DisableMetricsCollection_Help">When enabled, the client does not collect or report local usage metrics.</string>
</stringTable>
<presentationTable>
<presentation id="ManagementURL_Pres">
<textBox refId="ManagementURL_Text">
<label>Management URL:</label>
<defaultValue>https://api.netbird.io:443</defaultValue>
</textBox>
</presentation>
<presentation id="PreSharedKey_Pres">
<textBox refId="PreSharedKey_Text">
<label>Pre-shared key:</label>
</textBox>
</presentation>
<presentation id="WireguardPort_Pres">
<decimalTextBox refId="WireguardPort_Decimal" defaultValue="51820">WireGuard UDP port:</decimalTextBox>
</presentation>
<presentation id="SplitTunnel_Pres">
<dropdownList refId="SplitTunnel_Mode" defaultItem="0">Mode:</dropdownList>
<textBox refId="SplitTunnel_Apps">
<label>Package names (comma-separated):</label>
</textBox>
</presentation>
</presentationTable>
</resources>
</policyDefinitionResources>

223
docs/netbird.admx Normal file
View File

@@ -0,0 +1,223 @@
<?xml version="1.0" encoding="utf-8"?>
<policyDefinitions xmlns:xsd="http://www.w3.org/2001/XMLSchema"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
revision="1.0"
schemaVersion="1.0"
xmlns="http://schemas.microsoft.com/GroupPolicy/2006/07/PolicyDefinitions">
<policyNamespaces>
<target prefix="netbird" namespace="NetBird.Policies.Client" />
</policyNamespaces>
<resources minRequiredRevision="1.0" />
<supportedOn>
<definitions>
<definition name="SUPPORTED_NetBird_All" displayName="$(string.SUPPORTED_NetBird_All)" />
</definitions>
</supportedOn>
<categories>
<category name="NetBird" displayName="$(string.NetBird_Category)" />
</categories>
<policies>
<!-- ============================================================ -->
<!-- TOP-LEVEL: foundational identity / authentication -->
<!-- ============================================================ -->
<policy name="ManagementURL"
class="Machine"
displayName="$(string.ManagementURL_Name)"
explainText="$(string.ManagementURL_Help)"
key="Software\Policies\NetBird"
presentation="$(presentation.ManagementURL_Pres)">
<parentCategory ref="NetBird" />
<supportedOn ref="SUPPORTED_NetBird_All" />
<elements>
<text id="ManagementURL_Text" valueName="ManagementURL" required="true" />
</elements>
</policy>
<policy name="PreSharedKey"
class="Machine"
displayName="$(string.PreSharedKey_Name)"
explainText="$(string.PreSharedKey_Help)"
key="Software\Policies\NetBird"
presentation="$(presentation.PreSharedKey_Pres)">
<parentCategory ref="NetBird" />
<supportedOn ref="SUPPORTED_NetBird_All" />
<elements>
<text id="PreSharedKey_Text" valueName="PreSharedKey" />
</elements>
</policy>
<!-- ============================================================ -->
<!-- SETTINGS: engine / runtime / connection behavior -->
<!-- ============================================================ -->
<policy name="DisableAutoConnect"
class="Machine"
displayName="$(string.DisableAutoConnect_Name)"
explainText="$(string.DisableAutoConnect_Help)"
key="Software\Policies\NetBird"
valueName="DisableAutoConnect">
<parentCategory ref="NetBird" />
<supportedOn ref="SUPPORTED_NetBird_All" />
<enabledValue><decimal value="1" /></enabledValue>
<disabledValue><decimal value="0" /></disabledValue>
</policy>
<policy name="DisableClientRoutes"
class="Machine"
displayName="$(string.DisableClientRoutes_Name)"
explainText="$(string.DisableClientRoutes_Help)"
key="Software\Policies\NetBird"
valueName="DisableClientRoutes">
<parentCategory ref="NetBird" />
<supportedOn ref="SUPPORTED_NetBird_All" />
<enabledValue><decimal value="1" /></enabledValue>
<disabledValue><decimal value="0" /></disabledValue>
</policy>
<policy name="DisableServerRoutes"
class="Machine"
displayName="$(string.DisableServerRoutes_Name)"
explainText="$(string.DisableServerRoutes_Help)"
key="Software\Policies\NetBird"
valueName="DisableServerRoutes">
<parentCategory ref="NetBird" />
<supportedOn ref="SUPPORTED_NetBird_All" />
<enabledValue><decimal value="1" /></enabledValue>
<disabledValue><decimal value="0" /></disabledValue>
</policy>
<policy name="BlockInbound"
class="Machine"
displayName="$(string.BlockInbound_Name)"
explainText="$(string.BlockInbound_Help)"
key="Software\Policies\NetBird"
valueName="BlockInbound">
<parentCategory ref="NetBird" />
<supportedOn ref="SUPPORTED_NetBird_All" />
<enabledValue><decimal value="1" /></enabledValue>
<disabledValue><decimal value="0" /></disabledValue>
</policy>
<policy name="AllowServerSSH"
class="Machine"
displayName="$(string.AllowServerSSH_Name)"
explainText="$(string.AllowServerSSH_Help)"
key="Software\Policies\NetBird"
valueName="AllowServerSSH">
<parentCategory ref="NetBird" />
<supportedOn ref="SUPPORTED_NetBird_All" />
<enabledValue><decimal value="1" /></enabledValue>
<disabledValue><decimal value="0" /></disabledValue>
</policy>
<policy name="RosenpassEnabled"
class="Machine"
displayName="$(string.RosenpassEnabled_Name)"
explainText="$(string.RosenpassEnabled_Help)"
key="Software\Policies\NetBird"
valueName="RosenpassEnabled">
<parentCategory ref="NetBird" />
<supportedOn ref="SUPPORTED_NetBird_All" />
<enabledValue><decimal value="1" /></enabledValue>
<disabledValue><decimal value="0" /></disabledValue>
</policy>
<policy name="RosenpassPermissive"
class="Machine"
displayName="$(string.RosenpassPermissive_Name)"
explainText="$(string.RosenpassPermissive_Help)"
key="Software\Policies\NetBird"
valueName="RosenpassPermissive">
<parentCategory ref="NetBird" />
<supportedOn ref="SUPPORTED_NetBird_All" />
<enabledValue><decimal value="1" /></enabledValue>
<disabledValue><decimal value="0" /></disabledValue>
</policy>
<policy name="WireguardPort"
class="Machine"
displayName="$(string.WireguardPort_Name)"
explainText="$(string.WireguardPort_Help)"
key="Software\Policies\NetBird"
presentation="$(presentation.WireguardPort_Pres)">
<parentCategory ref="NetBird" />
<supportedOn ref="SUPPORTED_NetBird_All" />
<elements>
<decimal id="WireguardPort_Decimal" valueName="WireguardPort"
minValue="1" maxValue="65535" required="true" />
</elements>
</policy>
<policy name="SplitTunnel"
class="Machine"
displayName="$(string.SplitTunnel_Name)"
explainText="$(string.SplitTunnel_Help)"
key="Software\Policies\NetBird"
presentation="$(presentation.SplitTunnel_Pres)">
<parentCategory ref="NetBird" />
<supportedOn ref="SUPPORTED_NetBird_All" />
<elements>
<enum id="SplitTunnel_Mode" valueName="SplitTunnelMode" required="true">
<item displayName="$(string.SplitTunnel_Allow)"><value><string>allow</string></value></item>
<item displayName="$(string.SplitTunnel_Disallow)"><value><string>disallow</string></value></item>
</enum>
<text id="SplitTunnel_Apps" valueName="SplitTunnelApps" required="true" />
</elements>
</policy>
<!-- ============================================================ -->
<!-- UI: visibility / UX kill switches -->
<!-- ============================================================ -->
<policy name="DisableUpdateSettings"
class="Machine"
displayName="$(string.DisableUpdateSettings_Name)"
explainText="$(string.DisableUpdateSettings_Help)"
key="Software\Policies\NetBird"
valueName="DisableUpdateSettings">
<parentCategory ref="NetBird" />
<supportedOn ref="SUPPORTED_NetBird_All" />
<enabledValue><decimal value="1" /></enabledValue>
<disabledValue><decimal value="0" /></disabledValue>
</policy>
<policy name="DisableProfiles"
class="Machine"
displayName="$(string.DisableProfiles_Name)"
explainText="$(string.DisableProfiles_Help)"
key="Software\Policies\NetBird"
valueName="DisableProfiles">
<parentCategory ref="NetBird" />
<supportedOn ref="SUPPORTED_NetBird_All" />
<enabledValue><decimal value="1" /></enabledValue>
<disabledValue><decimal value="0" /></disabledValue>
</policy>
<policy name="DisableNetworks"
class="Machine"
displayName="$(string.DisableNetworks_Name)"
explainText="$(string.DisableNetworks_Help)"
key="Software\Policies\NetBird"
valueName="DisableNetworks">
<parentCategory ref="NetBird" />
<supportedOn ref="SUPPORTED_NetBird_All" />
<enabledValue><decimal value="1" /></enabledValue>
<disabledValue><decimal value="0" /></disabledValue>
</policy>
<policy name="DisableMetricsCollection"
class="Machine"
displayName="$(string.DisableMetricsCollection_Name)"
explainText="$(string.DisableMetricsCollection_Help)"
key="Software\Policies\NetBird"
valueName="DisableMetricsCollection">
<parentCategory ref="NetBird" />
<supportedOn ref="SUPPORTED_NetBird_All" />
<enabledValue><decimal value="1" /></enabledValue>
<disabledValue><decimal value="0" /></disabledValue>
</policy>
</policies>
</policyDefinitions>

1
go.mod
View File

@@ -131,6 +131,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 (

4
go.sum
View File

@@ -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=