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 // 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. // Otherwise the check succeeds only when the policy contains `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. // If p is nil the check always matches; otherwise the check requires the policy to contain the key and its integer value to 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 // check whose predicate returns false the corresponding key is included in the returned slice. 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 // key names that conflict with the values in msg. 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 // toggles). 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, // Rosenpass settings, lazy-connection or block-inbound), and false when the request 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 != "" || 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 // sentinel "**********" as an absent pre-shared key. 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; // if attaching details fails the base status error is returned. A warning is logged listing the rejected keys. 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() }