Files
netbird/client/server/mdm.go
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

235 lines
8.4 KiB
Go

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
}
// 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 without tripping the gate.
func mdmManagedFieldConflicts(msg *proto.SetConfigRequest, policy *mdm.Policy) []string {
if msg == nil || policy.IsEmpty() {
return nil
}
var conflicts []string
mark := func(key string) { conflicts = append(conflicts, key) }
if msg.ManagementUrl != "" && policy.HasKey(mdm.KeyManagementURL) {
if want, ok := policy.GetString(mdm.KeyManagementURL); !ok || want != msg.ManagementUrl {
mark(mdm.KeyManagementURL)
}
}
if msg.OptionalPreSharedKey != nil && policy.HasKey(mdm.KeyPreSharedKey) {
// "**********" is the redacted echo from GetConfig — never a real
// override attempt regardless of what the policy holds.
if *msg.OptionalPreSharedKey != "**********" {
if want, ok := policy.GetString(mdm.KeyPreSharedKey); !ok || want != *msg.OptionalPreSharedKey {
mark(mdm.KeyPreSharedKey)
}
}
}
checkBool := func(key string, p *bool) {
if p == nil || !policy.HasKey(key) {
return
}
if want, ok := policy.GetBool(key); !ok || want != *p {
mark(key)
}
}
checkBool(mdm.KeyRosenpassEnabled, msg.RosenpassEnabled)
checkBool(mdm.KeyRosenpassPermissive, msg.RosenpassPermissive)
checkBool(mdm.KeyDisableAutoConnect, msg.DisableAutoConnect)
checkBool(mdm.KeyAllowServerSSH, msg.ServerSSHAllowed)
checkBool(mdm.KeyDisableClientRoutes, msg.DisableClientRoutes)
checkBool(mdm.KeyDisableServerRoutes, msg.DisableServerRoutes)
checkBool(mdm.KeyBlockInbound, msg.BlockInbound)
if msg.WireguardPort != nil && policy.HasKey(mdm.KeyWireguardPort) {
if want, ok := policy.GetInt(mdm.KeyWireguardPort); !ok || want != *msg.WireguardPort {
mark(mdm.KeyWireguardPort)
}
}
return conflicts
}
// 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
// partial apply).
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()
}