mirror of
https://github.com/netbirdio/netbird.git
synced 2026-09-16 03:39:07 +02:00
# Conflicts: # client/ios/NetBirdSDK/client.go # client/server/mdm.go # client/server/server.go
364 lines
14 KiB
Go
364 lines
14 KiB
Go
package server
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"time"
|
|
|
|
log "github.com/sirupsen/logrus"
|
|
"google.golang.org/grpc/codes"
|
|
gstatus "google.golang.org/grpc/status"
|
|
|
|
"github.com/netbirdio/netbird/client/internal/profilemanager"
|
|
"github.com/netbirdio/netbird/client/mdm"
|
|
"github.com/netbirdio/netbird/client/proto"
|
|
)
|
|
|
|
// 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(_, _ *mdm.Policy) error {
|
|
log.Warn("MDM policy changed; restarting engine to apply new configuration")
|
|
|
|
// Hold s.mutex for the entire restart sequence (cancel + quiescence
|
|
// wait + re-spawn). Any concurrent Up/Down/Status arriving while
|
|
// MDM is restarting blocks on the Lock until we are done — they
|
|
// then observe the post-restart state coherently. This is safe
|
|
// because the connectWithRetryRuns goroutine no longer acquires
|
|
// s.mutex in its defer (intent vs. goroutine-alive concerns are
|
|
// fully separated; see the connectionGoroutineRunning helper).
|
|
s.mutex.Lock()
|
|
defer s.mutex.Unlock()
|
|
|
|
if !s.clientRunning {
|
|
// The client is not running, so there's no engine to restart.
|
|
return nil
|
|
}
|
|
if s.actCancel != nil {
|
|
s.actCancel()
|
|
}
|
|
|
|
// Wait for previous connectWithRetryRuns to exit so we don't end up
|
|
// with two goroutines fighting over the same status recorder + engine.
|
|
// The teardown engages a fan-out of engine goroutines (peer workers,
|
|
// signal handler, route manager, ...). close(clientGiveUpChan)
|
|
// happens in the function-scope defer of connectWithRetryRuns, on
|
|
// every exit path (ctx cancel, backoff exhausted, panic) — see the
|
|
// defer in server.go.
|
|
if s.clientGiveUpChan != nil {
|
|
select {
|
|
case <-s.clientGiveUpChan:
|
|
case <-time.After(10 * time.Second):
|
|
return fmt.Errorf("failed to restart the engine due to timeout")
|
|
}
|
|
}
|
|
|
|
if err := s.restartEngineForMDMLocked(); err != nil {
|
|
log.Errorf("MDM restart failed: %v", err)
|
|
return err
|
|
}
|
|
|
|
// publishConfigChangedEvent has already fired inside
|
|
// restartEngineForMDMLocked with source="mdm". Emit an MDM-specific
|
|
// user-visible toast so the operator knows their IT policy was
|
|
// applied (UserMessage != "" triggers the GUI notifier).
|
|
s.statusRecorder.PublishEvent(
|
|
proto.SystemEvent_INFO,
|
|
proto.SystemEvent_SYSTEM,
|
|
"MDM policy applied",
|
|
"NetBird configuration was updated by your IT policy.",
|
|
map[string]string{
|
|
proto.MetadataSourceKey: proto.MetadataSourceMDM,
|
|
proto.MetadataTypeKey: proto.MetadataTypePolicyApplied,
|
|
},
|
|
)
|
|
return nil
|
|
}
|
|
|
|
// 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
|
|
}
|
|
s.statusRecorder.PublishEvent(
|
|
proto.SystemEvent_INFO,
|
|
proto.SystemEvent_SYSTEM,
|
|
fmt.Sprintf("daemon config changed (source=%s)", source),
|
|
"",
|
|
map[string]string{
|
|
proto.MetadataSourceKey: source,
|
|
proto.MetadataTypeKey: proto.MetadataTypeConfigChanged,
|
|
},
|
|
)
|
|
}
|
|
|
|
// restartEngineForMDMLocked 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.
|
|
//
|
|
// MUST be called with s.mutex held — onMDMPolicyChange holds the lock
|
|
// for the entire restart sequence (cancel + quiescence wait + re-spawn)
|
|
// so concurrent Up/Down/Status RPCs observe a coherent post-restart
|
|
// state.
|
|
func (s *Server) restartEngineForMDMLocked() error {
|
|
activeProf, err := s.profileManager.GetActiveProfileState()
|
|
if err != nil {
|
|
return fmt.Errorf("get active profile state: %w", err)
|
|
}
|
|
config, _, err := s.getConfig(activeProf)
|
|
if err != nil {
|
|
return fmt.Errorf("get active profile config: %w", err)
|
|
}
|
|
|
|
s.config = config
|
|
s.statusRecorder.UpdateManagementAddress(config.ManagementURL.String())
|
|
s.statusRecorder.UpdateRosenpass(config.RosenpassEnabled, config.RosenpassPermissive)
|
|
|
|
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(proto.MetadataSourceMDM)
|
|
return nil
|
|
}
|
|
|
|
<<<<<<< HEAD
|
|
// conflictBool builds a conflictCheck for a boolean MDM key. If p is nil
|
|
// the field is treated as matching (no override requested); 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
|
|
},
|
|
}
|
|
}
|
|
|
|
// conflictURL is conflictString for URL-typed keys: both sides are compared as
|
|
// endpoints (profilemanager.SameServiceURL), so an implicit default port, a
|
|
// trailing slash or a different host case is not read as a divergence from the
|
|
// policy. A value that does not parse as a URL falls back to string equality,
|
|
// which is the strictest thing left to do with it.
|
|
func conflictURL(key, got string) conflictCheck {
|
|
return conflictCheck{
|
|
key: key,
|
|
check: func(pol *mdm.Policy) bool {
|
|
if got == "" {
|
|
return true
|
|
}
|
|
want, ok := pol.GetString(key)
|
|
if !ok {
|
|
return false
|
|
}
|
|
wantURL, wantErr := profilemanager.ParseServiceURL(key, want)
|
|
gotURL, gotErr := profilemanager.ParseServiceURL(key, got)
|
|
if wantErr != nil || gotErr != nil {
|
|
return want == got
|
|
}
|
|
return profilemanager.SameServiceURL(wantURL, gotURL)
|
|
},
|
|
}
|
|
}
|
|
|
|
// conflictString builds a conflictCheck for a string MDM key. An empty
|
|
// `got` is treated as "field not set" (no override requested); otherwise
|
|
// the check returns true only when the policy contains the 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
|
|
},
|
|
}
|
|
}
|
|
|
|
// conflictStringPtr is conflictString for optional proto fields, where an
|
|
// explicit empty value is still a request to change the setting. If p is
|
|
// nil the field is treated as matching (no override requested); otherwise
|
|
// the check returns true only when the policy contains the key and its
|
|
// value equals *p.
|
|
func conflictStringPtr(key string, p *string) conflictCheck {
|
|
return conflictCheck{
|
|
key: key,
|
|
check: func(pol *mdm.Policy) bool {
|
|
if p == nil {
|
|
return true
|
|
}
|
|
want, ok := pol.GetString(key)
|
|
return ok && want == *p
|
|
},
|
|
}
|
|
}
|
|
|
|
// conflictInt64 builds a conflictCheck for an integer MDM key. If p is
|
|
// nil the field is treated as matching; otherwise the check returns
|
|
// true only when the policy contains the key and its int value equals *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 the 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 present in the policy are
|
|
// skipped silently (the gate fires only for keys the admin has
|
|
// actually pushed). Returns nil for an empty policy.
|
|
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
|
|
}
|
|
|
|
=======
|
|
>>>>>>> origin/main
|
|
// 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 PSK
|
|
// sentinel ("**********") returned by GetConfig is recognised and
|
|
// treated as no-op so the UI can safely round-trip it.
|
|
func mdmManagedFieldConflicts(msg *proto.SetConfigRequest, policy *mdm.Policy) []string {
|
|
if msg == nil {
|
|
return nil
|
|
}
|
|
|
|
pskGot := msg.OptionalPreSharedKey
|
|
if pskGot != nil && *pskGot == mdm.PreSharedKeyRedactedSentinel {
|
|
pskGot = nil
|
|
}
|
|
|
|
return mdm.ResolveConflicts(policy, []mdm.ConflictCheck{
|
|
mdm.ConflictURL(mdm.KeyManagementURL, msg.ManagementUrl),
|
|
mdm.ConflictStringPtr(mdm.KeyPreSharedKey, pskGot),
|
|
mdm.ConflictBool(mdm.KeyRosenpassEnabled, msg.RosenpassEnabled),
|
|
mdm.ConflictBool(mdm.KeyRosenpassPermissive, msg.RosenpassPermissive),
|
|
mdm.ConflictBool(mdm.KeyDisableAutoConnect, msg.DisableAutoConnect),
|
|
mdm.ConflictBool(mdm.KeyAllowServerSSH, msg.ServerSSHAllowed),
|
|
mdm.ConflictBool(mdm.KeyRemoteJobsAllowed, msg.RemoteJobsAllowed),
|
|
mdm.ConflictBool(mdm.KeyDisableClientRoutes, msg.DisableClientRoutes),
|
|
mdm.ConflictBool(mdm.KeyDisableServerRoutes, msg.DisableServerRoutes),
|
|
mdm.ConflictBool(mdm.KeyBlockInbound, msg.BlockInbound),
|
|
mdm.ConflictInt64(mdm.KeyWireguardPort, msg.WireguardPort),
|
|
mdm.ConflictBool(mdm.KeyEnableLocalMetrics, msg.EnableLocalMetrics),
|
|
mdm.ConflictStringPtr(mdm.KeyLocalMetricsAddress, msg.LocalMetricsAddress),
|
|
})
|
|
}
|
|
|
|
// 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. OptionalPreSharedKey wins when
|
|
// both are set; the redaction sentinel ("**********") is accepted as
|
|
// a no-op echo.
|
|
func loginRequestMDMConflicts(msg *proto.LoginRequest, policy *mdm.Policy) []string {
|
|
if msg == nil {
|
|
return nil
|
|
}
|
|
|
|
pskGot := msg.OptionalPreSharedKey
|
|
if pskGot == nil && msg.PreSharedKey != "" { //nolint:staticcheck // SA1019: legacy proto field still accepted by Login
|
|
pskGot = &msg.PreSharedKey //nolint:staticcheck // SA1019
|
|
}
|
|
if pskGot != nil && *pskGot == mdm.PreSharedKeyRedactedSentinel {
|
|
pskGot = nil
|
|
}
|
|
|
|
return mdm.ResolveConflicts(policy, []mdm.ConflictCheck{
|
|
mdm.ConflictURL(mdm.KeyManagementURL, msg.ManagementUrl),
|
|
mdm.ConflictStringPtr(mdm.KeyPreSharedKey, pskGot),
|
|
mdm.ConflictBool(mdm.KeyRosenpassEnabled, msg.RosenpassEnabled),
|
|
mdm.ConflictBool(mdm.KeyRosenpassPermissive, msg.RosenpassPermissive),
|
|
mdm.ConflictBool(mdm.KeyDisableAutoConnect, msg.DisableAutoConnect),
|
|
mdm.ConflictBool(mdm.KeyAllowServerSSH, msg.ServerSSHAllowed),
|
|
mdm.ConflictBool(mdm.KeyRemoteJobsAllowed, msg.RemoteJobsAllowed),
|
|
mdm.ConflictBool(mdm.KeyDisableClientRoutes, msg.DisableClientRoutes),
|
|
mdm.ConflictBool(mdm.KeyDisableServerRoutes, msg.DisableServerRoutes),
|
|
mdm.ConflictBool(mdm.KeyBlockInbound, msg.BlockInbound),
|
|
mdm.ConflictInt64(mdm.KeyWireguardPort, msg.WireguardPort),
|
|
mdm.ConflictBool(mdm.KeyEnableLocalMetrics, msg.EnableLocalMetrics),
|
|
mdm.ConflictStringPtr(mdm.KeyLocalMetricsAddress, msg.LocalMetricsAddress),
|
|
})
|
|
}
|
|
|
|
// 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(conflicts []string) error {
|
|
if len(conflicts) == 0 {
|
|
return nil
|
|
}
|
|
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()
|
|
}
|