diff --git a/client/mdm/ticker.go b/client/mdm/ticker.go new file mode 100644 index 000000000..6ea374b1e --- /dev/null +++ b/client/mdm/ticker.go @@ -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 +} diff --git a/client/server/mdm.go b/client/server/mdm.go index 3d4022f62..a6c710920 100644 --- a/client/server/mdm.go +++ b/client/server/mdm.go @@ -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. diff --git a/client/server/server.go b/client/server/server.go index 41052eadc..19fca03c3 100644 --- a/client/server/server.go +++ b/client/server/server.go @@ -24,6 +24,7 @@ import ( "github.com/netbirdio/netbird/client/internal/expose" "github.com/netbirdio/netbird/client/internal/profilemanager" sleephandler "github.com/netbirdio/netbird/client/internal/sleep/handler" + "github.com/netbirdio/netbird/client/mdm" "github.com/netbirdio/netbird/client/system" mgm "github.com/netbirdio/netbird/shared/management/client" "github.com/netbirdio/netbird/shared/management/domain" @@ -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.