Skip to content

[client] add MDM configuration profile support (Windows registry + macOS plist) - #6374

Merged
riccardomanfrin merged 68 commits into
mainfrom
mdm_integration
Jun 12, 2026
Merged

riccardomanfrin merged 68 commits into
mainfrom
mdm_integration

Conversation

@riccardomanfrin

@riccardomanfrin riccardomanfrin commented Jun 9, 2026 •

Copy link
Copy Markdown
Contributor

Describe your changes

First MDM integration milestone for NetBird. Adds a client/mdm/ package that
reads OS-native managed-configuration sources (Windows registry under
HKLM\Software\Policies\NetBird and macOS plist at
/Library/Managed Preferences/io.netbird.client.plist) and applies them to
the daemon's resolved configuration as the highest-priority layer. Scope of
this PR is Windows + macOS only; iOS and Android coverage is deferred to
follow-up PRs.

Highlights:

  1. New package client/mdm/ with per-OS loaders behind build tags. Shared
    Policy struct with typed accessors (GetString / GetBool / GetInt /
    GetStringSlice), case-insensitive canonicalKey lookup so admins can
    use either PascalCase ADMX-style names or camelCase plist-style names,
    1-minute reload ticker with reflect.DeepEqual diff + change callback,
    and a SecretKeys redaction set for log safety. 16 keys are surfaced:
    managementURL, disableUpdateSettings, disableProfiles,
    disableNetworks, disableClientRoutes, disableServerRoutes,
    blockInbound, disableMetricsCollection, allowServerSSH,
    disableAutoConnect, preSharedKey, rosenpassEnabled,
    rosenpassPermissive, wireguardPort, splitTunnelMode,
    splitTunnelApps.

  2. Daemon integration in client/internal/profilemanager/config.go:
    Config.apply() now runs applyMDMPolicy(LoadPolicy()) as the last
    layer (defaults → on-disk JSON → env → CLI/UI → MDM). MDM-derived
    values are never persisted back to default.json / <profile>.json,
    so un-enrollment takes effect on the next reload with no stale policy
    on disk.

  3. Server.Start arms the MDM ticker; any diff triggers
    restartEngineForMDM which re-resolves config + re-spawns
    connectWithRetryRuns + emits SystemEvent{type=config_changed, source=mdm} so the GUI tray / CLI subscribers can refresh without
    polling. Same event is now also emitted from Server.Start (source
    startup) and Server.Up (source up_rpc).

  4. Per-field MDM gate on SetConfig (client/server/server.go) and on
    Login (same file) — value-aware: a request that sets a field to the
    same value the policy already enforces is a no-op echo, not a
    conflict. Conflicts return codes.FailedPrecondition with the new
    MDMManagedFieldsViolation { repeated string fields } proto detail
    listing the offending key names. The Login gate is required because
    the CLI netbird up --flag=... path falls through to Login when
    SetConfig is rejected (legacy backward-compat behaviour); without
    the matching Login gate, MDM policy would be bypassable from the
    CLI.

  5. checkProfilesDisabled / checkUpdateSettingsDisabled /
    checkNetworksDisabled now resolve MDM-first: when the MDM key is
    present in the policy, that value wins (true OR false), and the CLI
    --disable-* flag is the fallback only when the MDM source is silent
    on the key. This honours the "MDM is authoritative" semantic: an
    admin pushing disableNetworks=false via MDM re-enables the feature
    even on a host installed with --disable-networks.

  6. gRPC surface: GetConfigResponse.mDMManagedFields[] lists the
    currently MDM-enforced key names so the UI can grey out the
    corresponding inputs. New MDMManagedFieldsViolation message used as
    gRPC error detail (see point 4).

  7. Legacy Fyne tray UI (client/ui/client_ui.go): submenu checkboxes
    (Allow SSH, Connect on Startup, Enable Quantum-Resistance,
    Block Inbound Connections) get a (MDM) title suffix and
    Disable() when MDM-managed; the value itself is sourced from the
    daemon-resolved config so the checkbox always reflects ground truth.
    Advanced Settings form: iMngURL and iInterfacePort get an inline
    (MDM) suffix in the text; password entry iPreSharedKey uses an
    empty Text + placeholder (MDM-managed / configured) so the eye
    toggle never leaks the redaction sentinel. mProfile.setEnabled(false)
    now disables parent + children (Manage Profiles / Deregister /
    per-profile sub-items) instead of Hide() so the user sees a
    greyed-out menu rather than a missing menu.

  8. UI 2-second GetFeatures poll replaced with event-driven refresh
    hooked to config_changed. One initial sync at boot; the periodic
    loop continues to handle status/connection updates (no event for
    those yet). Transition caches (profilesEnabled,
    networksMenuEnabled) avoid systray ClickedCh subscription drop
    from repeated Enable/Disable calls.

  9. New netbird debug config subcommand
    (client/cmd/debug.go) dumps the daemon's GetConfigResponse as
    JSON via protojson with EmitUnpopulated:true — used for verifying
    MDM application end-to-end (the mDMManagedFields array is the
    single source of truth for "which keys is the daemon actually
    enforcing right now").

Admin manifests + deployment helpers (docs/):

  • netbird.admx + netbird.adml — Group Policy / Intune ADMX
    ingestion. All 16 keys.
  • netbird-macos.mobileconfig — Apple Configuration Profile template
    with com.apple.ManagedClient.preferences payload targeting bundle
    io.netbird.client.
  • netbird-policy.reg — Windows .reg sample (full set of 16 keys)
    for reg import or sidecar attachment.
  • netbird-policy.reg.ps1 — JumpCloud Windows command companion that
    imports an attached netbird-policy.reg after wiping the existing
    policy key (single source of truth).
  • macos-netbird-mdm.sh — JumpCloud macOS command counterpart;
    inline-variable script that emits the plist.

Issue ticket number and link

Internal scoping task. Anchor for reviewers: the previous
community attempt at MDM support is at
#5986 — this PR is a from-scratch
rewrite with the gaps identified in that PR's review fixed
(daemon-side enforcement, gRPC mDMManagedFields surface, per-field
FailedPrecondition, no mdm.json intermediate file, 1-minute reload
ticker).

Stack

Checklist

  • Is it a bug fix
  • Is a typo/documentation fix
  • Is a feature enhancement
  • It is a refactor
  • Created tests that fail without the change (if possible)

By submitting this pull request, you confirm that you have read and agree to the terms of the Contributor License Agreement.

Documentation

Select exactly one:

  • I added/updated documentation for this change
  • Documentation is not needed for this change (explain why)

This PR ships the admin-facing manifests inline under docs/:

  • io.netbird.client.plist
  • netbird-macos.mobileconfig
  • netbird-macos.sh
  • netbird-policy.reg
  • netbird-policy.reg.ps1
  • netbird.adml
  • netbird.admx

Additionally the PR is paired by a netbirdio/docs PR for the end-user-facing admin
guide (linked below). The in-tree manifests target tooling that
expects them at known paths (ADMX/ADML in PolicyDefinitions,
.mobileconfig for profiles install); the netbirdio/docs PR
covers the narrative ("how do I push the NetBird policy from Intune /
Jamf / JumpCloud") that doesn't fit inside a manifest.

Docs PR URL (required if "docs added" is checked)

netbirdio/docs#783

Summary by CodeRabbit

  • New Features

    • Full MDM support: daemon reports managed fields, enforces policy on config changes (rejects conflicts with violation details), auto-applies policy updates with live restarts, and exposes MDM-driven feature flags and UI locks.
    • CLI: new debug subcommand to dump resolved client config as indented JSON.
  • UI

    • Tray and Settings reflect MDM locks and show PSK as configured/managed without leaking secrets.
  • Tests

    • Added comprehensive tests for MDM loading, enforcement, ticker behavior, and client/server flows.
  • Documentation

    • Added macOS/Windows MDM templates, deployment scripts, and policy guidance.

This can be useful for troubleshooting and checking config
now that its resolution is not trivial

defaults > config > env cars > CLI/UI > MDM
This also provide fixup for UI no aligning to changed config when coming from cli up with config flags.
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)
…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 (?)
…r whatever reason

We want to just signal this through the giveUpChan, we will manage the signal from
the waiter side and in case set it to false there. THis way we avoid locking,
which should allow the MDM down+wait_for_term_chan_signal_+up procedure

clientRunning is used to signal two different conditions here:

1. the initialization procedure is over (we have an engine)
2. the connection being up (or being attempted)

Probably these two functionalities should not alias, and the failure of the second condition
(because of any error) should just drive a reconnection (currently it's not happening,
and we silently go idle).
OR, mor probably, the two things are the SAME and there should not exist a case where
we did the "Up" initialization and connection attempt but we are not still attempting it.
Comment thread client/server/server.go
Comment thread client/server/mdm.go Outdated
Comment thread client/server/mdm.go Outdated
…her instance

We instead juts SKIP updating the policy, so
1. the MDM ticker will kick in 1 minute time,
2. find the policy misaligned,
3. enter the onMDMPolicyChange,
4. find the s.clientRunning == true
   (because it is set to false only in server cleanupConnection,
   and not by s.actCancel())
5. call s.actCancel() again if not nil
6. immediately return from <-s.clientGiveUpChan
7. finally call s.restartEngineForMDMLocked()
If the config was cancelled midflight, connect will abort later on
DisableAutoConnect should just avoid the connection attempts *when the service starts*.
If we are started and we are up and running, DisableAutoConnect should not kick in.

Another PR will follow about this topic
DisableAutoConnect should just avoid the connection attempts *when the service starts*.
If we are started and we are up and running, DisableAutoConnect should not kick in
Comment thread client/internal/profilemanager/config.go
Comment thread client/internal/profilemanager/config.go Outdated
Comment thread client/mdm/ticker.go Outdated
This was initially used to drive the UI but approach changed
to reload config/features upon notifications which makes this data redundant.
PoliciesEqual → policiesEqual
AllKeys → allKeys
@sonarqubecloud

Copy link
Copy Markdown

@riccardomanfrin
riccardomanfrin merged commit 2bcea9d into main Jun 12, 2026
46 of 47 checks passed
@riccardomanfrin
riccardomanfrin deleted the mdm_integration branch June 12, 2026 10:28
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants