mirror of
https://github.com/netbirdio/netbird.git
synced 2026-09-12 17:59:06 +02:00
The update-settings kill switch (--disable-update-settings /
NB_DISABLE_UPDATE_SETTINGS / the MDM DisableUpdateSettings key) forbids
changing settings, but it decided what a "change" was by looking at
whether a field was present in the request. The CLI fills the whole
config surface of SetConfigRequest and LoginRequest from its flags and
environment on every `netbird up` (setupSetConfigReq in cmd/up.go), so a
client configured by environment restates its own configuration on every
start and tripped the gate every time.
SetConfig only warned about that, but Login carries the same fields and
was gated the same way, and Login runs inside the CLI's backoff loop: the
daemon answered every attempt with codes.Unavailable, `netbird up` never
completed, and a container with NB_DISABLE_UPDATE_SETTINGS plus any
config env var (NB_MANAGEMENT_URL, for one) could not come up at all.
Both gates now compare values. Config.WouldChange is the dry-run half of
UpdateConfig: it runs the very same diff logic (Config.apply) against a
copy of the stored config, so the gate cannot drift from what an actual
update would do, nor go stale when a field is added. A request that
restates what the profile already holds changes nothing and is allowed; a
request that diverges is refused exactly as before, and a dry run that
cannot be evaluated fails closed. A profile with no config on disk yet is
judged against the config the daemon would create for it.
For Login, the compared input comes from loginOverridesInput, which
persistLoginOverrides also uses to perform the write, so the gate judges
precisely the two fields a login can persist (management URL, pre-shared
key) and no field it ignores.
Two adjacent defects surfaced while making the comparison exact:
- Config.apply compared URLs as raw strings, so the same endpoint spelled
without its default port ("https://api.netbird.io" vs
"https://api.netbird.io:443") counted as a new value and rewrote the
config. It now compares the parsed forms.
- UpdateConfig did not collapse the redacted pre-shared key, unlike
UpdateOrCreateConfig and DirectUpdateConfig, so a UI round-trip of the
mask replaced the stored key with asterisks.
The CLI warning for a refused SetConfig said the method was not available
in the daemon, which sent people looking for a version mismatch that was
not there; it now reports the refusal.
396 lines
15 KiB
Go
396 lines
15 KiB
Go
package server
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"net/url"
|
|
"time"
|
|
|
|
log "github.com/sirupsen/logrus"
|
|
"google.golang.org/grpc/codes"
|
|
gstatus "google.golang.org/grpc/status"
|
|
|
|
"github.com/netbirdio/netbird/client/mdm"
|
|
"github.com/netbirdio/netbird/client/proto"
|
|
)
|
|
|
|
// 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 = "**********"
|
|
|
|
// 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
|
|
|
|
// 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)
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
// 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
|
|
},
|
|
}
|
|
}
|
|
|
|
func canonicalURL(s string) string {
|
|
u, err := url.ParseRequestURI(s)
|
|
if err != nil {
|
|
return s
|
|
}
|
|
if u.Port() == "" {
|
|
switch u.Scheme {
|
|
case "https":
|
|
u.Host += ":443"
|
|
case "http":
|
|
u.Host += ":80"
|
|
}
|
|
}
|
|
return u.String()
|
|
}
|
|
|
|
// conflictURL is conflictString for URL-typed keys: both sides are
|
|
// normalized via canonicalURL before comparison.
|
|
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)
|
|
return ok && canonicalURL(want) == canonicalURL(got)
|
|
},
|
|
}
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
// 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{
|
|
conflictURL(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.KeyRemoteJobsAllowed, msg.RemoteJobsAllowed),
|
|
conflictBool(mdm.KeyDisableClientRoutes, msg.DisableClientRoutes),
|
|
conflictBool(mdm.KeyDisableServerRoutes, msg.DisableServerRoutes),
|
|
conflictBool(mdm.KeyBlockInbound, msg.BlockInbound),
|
|
conflictInt64(mdm.KeyWireguardPort, msg.WireguardPort),
|
|
conflictBool(mdm.KeyEnableLocalMetrics, msg.EnableLocalMetrics),
|
|
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
|
|
}
|
|
|
|
// 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{
|
|
conflictURL(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.KeyRemoteJobsAllowed, msg.RemoteJobsAllowed),
|
|
conflictBool(mdm.KeyDisableClientRoutes, msg.DisableClientRoutes),
|
|
conflictBool(mdm.KeyDisableServerRoutes, msg.DisableServerRoutes),
|
|
conflictBool(mdm.KeyBlockInbound, msg.BlockInbound),
|
|
conflictInt64(mdm.KeyWireguardPort, msg.WireguardPort),
|
|
conflictBool(mdm.KeyEnableLocalMetrics, msg.EnableLocalMetrics),
|
|
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()
|
|
}
|