Files
netbird/client/server/update_settings_gate.go
T
riccardom ef88c4de5f [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.
2026-09-02 12:57:14 +02:00

60 lines
2.4 KiB
Go

package server
import (
log "github.com/sirupsen/logrus"
"github.com/netbirdio/netbird/client/internal/profilemanager"
"github.com/netbirdio/netbird/client/proto"
)
// configChangeRequested reports whether applying input would move the target
// profile away from the configuration it already persists. It is the decision
// procedure of the update-settings kill switch (--disable-update-settings /
// NB_DISABLE_UPDATE_SETTINGS / the MDM DisableUpdateSettings key): that switch
// forbids *changing* settings, so a request that restates the stored values is
// not a change and must not be refused.
//
// This has to be judged on values, not on field presence. `netbird up` rebuilds
// the whole config surface of SetConfigRequest and LoginRequest from its flags
// and environment on every invocation, so a service or container configured by
// environment restates its own configuration on every start. A presence-based
// gate refused those requests, and because Login carries the same fields it
// refused the login too — leaving such a client unable to come up at all.
//
// A dry run that cannot be evaluated fails closed: the request counts as a
// change, so a malformed field can never open the gate. The error itself is
// reported to the caller by the real update path.
func configChangeRequested(stored *profilemanager.Config, input profilemanager.ConfigInput) bool {
changed, err := stored.WouldChange(input)
if err != nil {
log.Warnf("cannot evaluate the requested config change, treating it as a change: %v", err)
return true
}
return changed
}
// loginOverridesInput builds the ConfigInput a login request persists. The
// management URL and the pre-shared key are the only config fields the daemon
// applies from a LoginRequest; everything else on that message is either pure
// auth or ignored. An empty pre-shared key is dropped rather than written, so
// a login cannot clear the stored key by omission.
//
// Both the write (persistLoginOverrides) and the update-settings gate go
// through this builder, so the gate can neither refuse a field the write
// ignores nor miss one it applies.
func loginOverridesInput(msg *proto.LoginRequest) profilemanager.ConfigInput {
if msg == nil {
return profilemanager.ConfigInput{}
}
preSharedKey := msg.OptionalPreSharedKey
if preSharedKey != nil && *preSharedKey == "" {
preSharedKey = nil
}
return profilemanager.ConfigInput{
ManagementURL: msg.ManagementUrl,
PreSharedKey: preSharedKey,
}
}