Adds MDM 1m diff checker & reloader

This commit is contained in:
riccardom
2026-06-01 18:38:08 +02:00
parent 08966adf30
commit c36bf3a171
3 changed files with 158 additions and 0 deletions

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

@@ -0,0 +1,118 @@
package mdm
import (
"context"
"reflect"
"sort"
"time"
log "github.com/sirupsen/logrus"
)
// DefaultReloadInterval is the 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.
const DefaultReloadInterval = 1 * time.Minute
// 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 calls LoadPolicy every `interval` and
// invokes `onChange` on any diff. Pass a zero interval to fall back to
// DefaultReloadInterval. onChange may be nil for a log-only ticker.
func NewTicker(interval time.Duration, onChange func(prev, curr *Policy)) *Ticker {
if interval <= 0 {
interval = DefaultReloadInterval
}
return &Ticker{
interval: interval,
onChange: onChange,
prev: LoadPolicy(),
}
}
// 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 := LoadPolicy()
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
// key set with identical values. Nil and empty policies compare equal.
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
// value changed. Returned slices are sorted for stable log output.
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
// public Policy boundary without touching unexported state.
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
}

View File

@@ -15,6 +15,29 @@ import (
// 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.
// The handler cancels the active engine context (if any) so the
// connectWithRetryRuns goroutine re-resolves the profile config —
// re-running profilemanager.Config.apply() picks up the freshly-loaded
// policy as the highest-priority override layer, and the engine restarts
// with the new values.
//
// The callback runs in the ticker's own goroutine; it must not block on
// long-running work nor take s.mutex for longer than the cancel call.
// Ticker has already logged the per-key diff before invoking this hook.
func (s *Server) onMDMPolicyChange(_, _ *mdm.Policy) {
log.Warn("MDM policy changed; cancelling engine context so connectWithRetryRuns re-resolves config")
s.mutex.Lock()
cancel := s.actCancel
s.mutex.Unlock()
if cancel != nil {
cancel()
}
}
// requestedMDMManagedKeys returns the names of MDM-managed keys whose
// corresponding field is set in the SetConfigRequest. Only keys with an
// MDM mapping are considered; other fields are ignored.

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(mdm.DefaultReloadInterval, 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.