mirror of
https://github.com/netbirdio/netbird.git
synced 2026-09-21 06:09:07 +02:00
The mobile bridges only carried the policy fetcher, leaving every enforcement decision to the native apps: the desktop derived its UI restrictions in the Wails service layer, the daemon kept the conflict machinery in the server package, and both mobile bridges duplicated the JSON fetch adapter. Anything the native side had to reimplement was a place for iOS and Android to drift apart. Enforcement now lives in client/mdm and is consumed identically by all three platforms: - conflicts.go holds the value-aware conflict checks lifted out of the daemon, so the same normalization (canonical URLs, PSK sentinel echo) applies wherever a config change is validated. - restrictions.go derives the UI enforcement snapshot from a policy and renders it in the JSON shape the desktop frontend already consumes. The service-layer types become aliases, keeping one source of truth. - jsonloader.go replaces the adapter that was copy-pasted into both bridges. - changedetector.go moves change detection off the native side: the caller forwards the OS notification and asks whether the managed configuration actually changed, instead of diffing dictionaries itself. The mobile bridges gain the enforcement the daemon already had. The Preferences getters resolve managed keys from the policy, so a naive UI shows the enforced value; Commit rejects a staged change that diverges from a managed key; NewAuth resolves the managed management URL before persisting the config and overlays the policy on it, so a login can no longer run against a URL the policy forbids. Android's profile mutations fail closed when disableProfiles is set. NewAuth takes the fetcher as a required argument rather than keeping a policy-blind overload: the apps consume this code as a submodule, so a compile error at the bump is the point. The mobile PSK getter is replaced by a presence check — the key has no reason to cross the bridge, and not returning it means the native side needs no redaction sentinel of its own.
327 lines
13 KiB
Go
327 lines
13 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/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
|
|
}
|
|
|
|
// 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 != mdm.PreSharedKeyRedactedSentinel {
|
|
pskGot = *msg.OptionalPreSharedKey
|
|
}
|
|
|
|
return mdm.ResolveConflicts(policy, []mdm.ConflictCheck{
|
|
mdm.ConflictURL(mdm.KeyManagementURL, msg.ManagementUrl),
|
|
mdm.ConflictString(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.KeyDisableClientRoutes, msg.DisableClientRoutes),
|
|
mdm.ConflictBool(mdm.KeyDisableServerRoutes, msg.DisableServerRoutes),
|
|
mdm.ConflictBool(mdm.KeyBlockInbound, msg.BlockInbound),
|
|
mdm.ConflictInt64(mdm.KeyWireguardPort, msg.WireguardPort),
|
|
})
|
|
}
|
|
|
|
// setConfigRequestHasConfigOverrides reports whether the SetConfigRequest
|
|
// carries ANY field that would actually mutate the persisted config.
|
|
// The CLI builds a SetConfigRequest unconditionally on every
|
|
// `netbird up` (see setupSetConfigReq in cmd/up.go) — a plain
|
|
// `netbird up` produces a request with every field at its zero value;
|
|
// the gate must skip such no-op invocations or it would always fire
|
|
// even when the user did not pass any --flag. Returns false on a nil
|
|
// msg; true when any management/admin URL, PSK, DNS/NAT list+clean
|
|
// flag, interface/port/MTU, or any optional bool/duration field is set.
|
|
func setConfigRequestHasConfigOverrides(msg *proto.SetConfigRequest) bool {
|
|
if msg == nil {
|
|
return false
|
|
}
|
|
return msg.ManagementUrl != "" ||
|
|
msg.AdminURL != "" ||
|
|
msg.OptionalPreSharedKey != nil ||
|
|
len(msg.CustomDNSAddress) > 0 ||
|
|
len(msg.NatExternalIPs) > 0 || msg.CleanNATExternalIPs ||
|
|
len(msg.ExtraIFaceBlacklist) > 0 ||
|
|
len(msg.DnsLabels) > 0 || msg.CleanDNSLabels ||
|
|
msg.DnsRouteInterval != nil ||
|
|
msg.RosenpassEnabled != nil ||
|
|
msg.RosenpassPermissive != nil ||
|
|
msg.InterfaceName != nil ||
|
|
msg.WireguardPort != nil ||
|
|
msg.Mtu != nil ||
|
|
msg.DisableAutoConnect != nil ||
|
|
msg.ServerSSHAllowed != nil ||
|
|
msg.NetworkMonitor != nil ||
|
|
msg.DisableClientRoutes != nil ||
|
|
msg.DisableServerRoutes != nil ||
|
|
msg.DisableDns != nil ||
|
|
msg.DisableFirewall != nil ||
|
|
msg.BlockLanAccess != nil ||
|
|
msg.DisableNotifications != nil ||
|
|
msg.BlockInbound != nil ||
|
|
msg.DisableIpv6 != nil ||
|
|
msg.EnableSSHRoot != nil ||
|
|
msg.EnableSSHSFTP != nil ||
|
|
msg.EnableSSHLocalPortForwarding != nil ||
|
|
msg.EnableSSHRemotePortForwarding != nil ||
|
|
msg.DisableSSHAuth != nil ||
|
|
msg.SshJWTCacheTTL != nil
|
|
}
|
|
|
|
// loginRequestHasConfigOverrides reports whether the LoginRequest
|
|
// carries ANY field that would mutate persisted daemon configuration
|
|
// (as opposed to pure-auth fields like setupKey, hostname, hint,
|
|
// profileName, username). Used by the Login handler to decide whether
|
|
// the `--disable-update-settings` / MDM gates must run: a re-auth that
|
|
// changes nothing about the configuration is always allowed.
|
|
func loginRequestHasConfigOverrides(msg *proto.LoginRequest) bool {
|
|
if msg == nil {
|
|
return false
|
|
}
|
|
return msg.ManagementUrl != "" ||
|
|
msg.AdminURL != "" ||
|
|
msg.PreSharedKey != "" || //nolint:staticcheck // SA1019: legacy proto field still accepted by Login
|
|
msg.OptionalPreSharedKey != nil ||
|
|
len(msg.CustomDNSAddress) > 0 ||
|
|
len(msg.NatExternalIPs) > 0 || msg.CleanNATExternalIPs ||
|
|
msg.RosenpassEnabled != nil ||
|
|
msg.InterfaceName != nil ||
|
|
msg.WireguardPort != nil ||
|
|
msg.DisableAutoConnect != nil ||
|
|
msg.ServerSSHAllowed != nil ||
|
|
msg.RosenpassPermissive != nil ||
|
|
len(msg.ExtraIFaceBlacklist) > 0 ||
|
|
msg.NetworkMonitor != nil ||
|
|
msg.DnsRouteInterval != nil ||
|
|
msg.DisableClientRoutes != nil ||
|
|
msg.DisableServerRoutes != nil ||
|
|
msg.DisableDns != nil ||
|
|
msg.DisableFirewall != nil ||
|
|
msg.BlockLanAccess != nil ||
|
|
msg.DisableNotifications != nil ||
|
|
len(msg.DnsLabels) > 0 || msg.CleanDNSLabels ||
|
|
msg.BlockInbound != nil
|
|
}
|
|
|
|
// 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 == mdm.PreSharedKeyRedactedSentinel {
|
|
pskGot = ""
|
|
}
|
|
|
|
return mdm.ResolveConflicts(policy, []mdm.ConflictCheck{
|
|
mdm.ConflictURL(mdm.KeyManagementURL, msg.ManagementUrl),
|
|
mdm.ConflictString(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.KeyDisableClientRoutes, msg.DisableClientRoutes),
|
|
mdm.ConflictBool(mdm.KeyDisableServerRoutes, msg.DisableServerRoutes),
|
|
mdm.ConflictBool(mdm.KeyBlockInbound, msg.BlockInbound),
|
|
mdm.ConflictInt64(mdm.KeyWireguardPort, msg.WireguardPort),
|
|
})
|
|
}
|
|
|
|
// 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()
|
|
}
|