[client] Gate settings updates on value, not on field presence

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.
This commit is contained in:
riccardom
2026-09-02 12:57:14 +02:00
parent c3cf7c0c37
commit ef88c4de5f
8 changed files with 526 additions and 154 deletions
+59 -51
View File
@@ -468,16 +468,27 @@ func (s *Server) SetConfig(callerCtx context.Context, msg *proto.SetConfigReques
s.mutex.Lock()
defer s.mutex.Unlock()
// Skip the update-settings gate when the request carries no actual
// overrides: the CLI builds a SetConfigRequest unconditionally on
// every `netbird up` (setupSetConfigReq in cmd/up.go), so a plain
// `netbird up` would otherwise always trip the gate and surface a
// misleading "setConfig method is not available" warning, even when
// the user did not pass any config flag.
if setConfigRequestHasConfigOverrides(msg) {
if s.checkUpdateSettingsDisabled() {
return nil, gstatus.Errorf(codes.Unavailable, errUpdateSettingsDisabled)
}
stored, err := s.storedProfileConfig(msg.ProfileName, msg.Username)
if err != nil {
return nil, err
}
config, err := s.setConfigInputFromRequest(msg)
if err != nil {
return nil, err
}
// Update-settings gate: refuse the request only when it would actually
// change a persisted setting. The CLI builds a SetConfigRequest
// unconditionally on every `netbird up` (setupSetConfigReq in
// cmd/up.go) and fills it from its flags and environment, so a service
// or container that restates the configuration it already runs with
// must pass the gate. Deciding this on field presence alone refused
// those callers, and — through the identical gate in Login — refused
// their login too, which left a client configured by environment
// (NB_MANAGEMENT_URL and friends) unable to come up at all.
if s.checkUpdateSettingsDisabled() && configChangeRequested(stored, config) {
return nil, gstatus.Errorf(codes.Unavailable, errUpdateSettingsDisabled)
}
// MDM gate: refuse the whole request if any of its fields is enforced
@@ -489,19 +500,10 @@ func (s *Server) SetConfig(callerCtx context.Context, msg *proto.SetConfigReques
return nil, err
}
stored, err := s.storedProfileConfig(msg.ProfileName, msg.Username)
if err != nil {
return nil, err
}
if err := requirePrivilegeForConfigChange(callerCtx, stored, privilegedChangeFromSetConfig(msg)); err != nil {
return nil, err
}
config, err := s.setConfigInputFromRequest(msg)
if err != nil {
return nil, err
}
updatedConf, err := profilemanager.UpdateConfig(config)
if err != nil {
log.Errorf("failed to update profile config: %v", err)
@@ -617,37 +619,46 @@ func (s *Server) setConfigInputFromRequest(msg *proto.SetConfigRequest) (profile
// Login uses setup key to prepare configuration for the daemon.
func (s *Server) Login(callerCtx context.Context, msg *proto.LoginRequest) (*proto.LoginResponse, error) {
// Config-override gates. LoginRequest carries the same surface as
// SetConfigRequest (managementUrl, PSK, ssh/rosenpass/port toggles,
// ...), so the same protections must apply. Without these the CLI
// command `netbird up --management-url=X` (which falls through to
// Login when SetConfig is rejected — see cmd/up.go) would silently
// bypass `--disable-update-settings` and any MDM policy.
if loginRequestHasConfigOverrides(msg) {
if s.checkUpdateSettingsDisabled() {
return nil, gstatus.Errorf(codes.Unavailable, errUpdateSettingsDisabled)
}
policy := loadMDMPolicy()
if err := rejectMDMManagedFieldConflicts(loginRequestMDMConflicts(msg, policy)); err != nil {
return nil, err
}
}
activeProf, err := s.profileManager.GetActiveProfileState()
if err != nil {
log.Errorf("failed to get active profile state: %v", err)
return nil, fmt.Errorf("failed to get active profile state: %w", err)
}
// Privilege gate: same restrictions as SetConfig, since LoginRequest can carry
// the same fields. It runs before anything here changes daemon state, so a
// refused login neither switches the profile nor cancels a login already in
// progress, and it reads the profile the request targets, which is the one the
// switch below would activate.
// The stored config of the profile this request targets backs all three
// gates below. It is read before anything changes daemon state, so a
// refused login neither switches the profile nor cancels a login already
// in progress, and it is the profile the switch further down would
// activate.
stored, err := s.storedLoginConfig(activeProf, msg)
if err != nil {
return nil, err
}
// Config-override gates. LoginRequest carries the same surface as
// SetConfigRequest (managementUrl, PSK, ssh/rosenpass/port toggles,
// ...), so the same protections must apply. Without these the CLI
// command `netbird up --management-url=X` (which falls through to
// Login when SetConfig is rejected — see cmd/up.go) would silently
// bypass `--disable-update-settings` and any MDM policy.
//
// The update-settings gate is value-aware, as in SetConfig: it looks at
// what a login would actually persist (loginOverridesInput) and refuses
// only a real divergence from the stored config. A login that restates
// the values already on disk changes nothing, so it must go through —
// that is what keeps a re-login, or a container restart carrying
// NB_MANAGEMENT_URL, working with the kill switch on.
if s.checkUpdateSettingsDisabled() && configChangeRequested(stored, loginOverridesInput(msg)) {
return nil, gstatus.Errorf(codes.Unavailable, errUpdateSettingsDisabled)
}
policy := loadMDMPolicy()
if err := rejectMDMManagedFieldConflicts(loginRequestMDMConflicts(msg, policy)); err != nil {
return nil, err
}
// Privilege gate: same restrictions as SetConfig, since LoginRequest can carry
// the same fields.
if err := requirePrivilegeForConfigChange(callerCtx, stored, privilegedChangeFromLogin(msg)); err != nil {
return nil, err
}
@@ -2644,18 +2655,19 @@ func (s *Server) authorizeAndPrepareLogin(callerCtx context.Context, msg *proto.
return nil, nil, fmt.Errorf("active profile state: %w", err)
}
if err := persistLoginOverrides(activeProf, msg.ManagementUrl, msg.OptionalPreSharedKey); err != nil {
if err := persistLoginOverrides(activeProf, msg); err != nil {
return nil, nil, fmt.Errorf("persist login overrides: %w", err)
}
return ctx, activeProf, nil
}
func persistLoginOverrides(activeProf *profilemanager.ActiveProfileState, managementURL string, preSharedKey *string) error {
if preSharedKey != nil && *preSharedKey == "" {
preSharedKey = nil
}
if managementURL == "" && preSharedKey == nil {
// persistLoginOverrides writes the config fields a login request is allowed to
// carry into the active profile. It shares its input builder with the
// update-settings gate, so the gate judges exactly the fields this writes.
func persistLoginOverrides(activeProf *profilemanager.ActiveProfileState, msg *proto.LoginRequest) error {
input := loginOverridesInput(msg)
if input.ManagementURL == "" && input.PreSharedKey == nil {
return nil
}
@@ -2664,11 +2676,7 @@ func persistLoginOverrides(activeProf *profilemanager.ActiveProfileState, manage
return fmt.Errorf("active profile file path: %w", err)
}
input := profilemanager.ConfigInput{
ConfigPath: cfgPath,
ManagementURL: managementURL,
PreSharedKey: preSharedKey,
}
input.ConfigPath = cfgPath
if _, err := profilemanager.UpdateOrCreateConfig(input); err != nil {
return fmt.Errorf("update config: %w", err)
}