mirror of
https://github.com/netbirdio/netbird.git
synced 2026-09-23 07:09:08 +02:00
[client] Move MDM enforcement logic into a shared Go layer (#7319)
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.
This commit is contained in:
@@ -99,7 +99,8 @@ type Client struct {
|
||||
// init). Each Run passes this loader to the resolved Config so
|
||||
// applyMDMPolicy picks up the active overlay. Nil means "MDM
|
||||
// enforcement off for this Client".
|
||||
mdmLoader *mdm.Loader
|
||||
mdmLoader *mdm.Loader
|
||||
mdmDetector *mdm.ChangeDetector
|
||||
|
||||
stateMu sync.RWMutex
|
||||
connectClient *internal.ConnectClient
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
|
||||
"github.com/netbirdio/netbird/client/internal/auth"
|
||||
"github.com/netbirdio/netbird/client/internal/profilemanager"
|
||||
"github.com/netbirdio/netbird/client/mdm"
|
||||
"github.com/netbirdio/netbird/client/system"
|
||||
)
|
||||
|
||||
@@ -41,8 +42,16 @@ type Auth struct {
|
||||
cfgPath string
|
||||
}
|
||||
|
||||
// NewAuth instantiate Auth struct and validate the management URL
|
||||
func NewAuth(cfgPath string, mgmURL string) (*Auth, error) {
|
||||
// NewAuth instantiate Auth struct and validate the management URL.
|
||||
// Auth is constructed under the active MDM policy: a managed management URL
|
||||
// replaces the caller-supplied one before the config is persisted, and the
|
||||
// policy is overlaid on the resolved config so the login runs against the
|
||||
// enforced values. A nil fetcher disables MDM enforcement.
|
||||
func NewAuth(cfgPath string, mgmURL string, fetcher PolicyFetcher) (*Auth, error) {
|
||||
policy := loaderFor(fetcher).Load()
|
||||
if v, ok := policy.GetString(mdm.KeyManagementURL); ok {
|
||||
mgmURL = v
|
||||
}
|
||||
inputCfg := profilemanager.ConfigInput{
|
||||
ConfigPath: cfgPath,
|
||||
ManagementURL: mgmURL,
|
||||
@@ -66,6 +75,7 @@ func NewAuth(cfgPath string, mgmURL string) (*Auth, error) {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cfg.ApplyMDMPolicy(policy)
|
||||
|
||||
// Use a cancellable context so Stop() can abort an in-progress interactive
|
||||
// login. The PKCE flow's WaitToken blocks (and keeps its loopback HTTP server
|
||||
|
||||
@@ -3,80 +3,50 @@
|
||||
package NetBirdSDK
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
|
||||
"github.com/netbirdio/netbird/client/internal/profilemanager"
|
||||
"github.com/netbirdio/netbird/client/mdm"
|
||||
)
|
||||
|
||||
// PolicyFetcher is the mobile-side bridge for the MDM managed-config
|
||||
// snapshot. The native layer (Swift) implements this and registers
|
||||
// the instance per Client via Client.SetMDMPolicyFetcher. Every
|
||||
// invocation of fetchJSON must read the current
|
||||
// UserDefaults.standard.dictionary(forKey: "com.apple.configuration.managed")
|
||||
// and return the result as a JSON-encoded map[string]any string.
|
||||
//
|
||||
// JSON is used because gomobile does not support map[string]any
|
||||
// crossing the Objective-C boundary — the adapter on the Go side
|
||||
// parses the string back into the map[string]any expected by
|
||||
// mdm.Loader.
|
||||
//
|
||||
// Return value contract:
|
||||
// - "" (empty) : interpreted as "no MDM source / no managed keys"
|
||||
// - "{}" : managed config explicitly empty
|
||||
// - "{...}" : JSON object with key/value pairs
|
||||
// - malformed JSON : logged and treated as empty
|
||||
// PolicyFetcher is implemented by the native layer to return the current
|
||||
// managed configuration as a JSON-encoded object string; "" means no MDM
|
||||
// source is present.
|
||||
type PolicyFetcher interface {
|
||||
FetchJSON() string
|
||||
}
|
||||
|
||||
// jsonFetcherAdapter wraps a gomobile-exposed PolicyFetcher into the
|
||||
// internal mdm.PolicyFetcher interface, taking care of JSON decoding
|
||||
// on every Fetch.
|
||||
type jsonFetcherAdapter struct {
|
||||
inner PolicyFetcher
|
||||
}
|
||||
|
||||
func (a *jsonFetcherAdapter) Fetch() map[string]any {
|
||||
raw := a.inner.FetchJSON()
|
||||
if raw == "" {
|
||||
return nil
|
||||
}
|
||||
var out map[string]any
|
||||
if err := json.Unmarshal([]byte(raw), &out); err != nil {
|
||||
log.Warnf("MDM mobile fetcher: invalid JSON payload from native: %v", err)
|
||||
return nil
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// SetMDMPolicyFetcher registers the native-provided MDM policy fetcher
|
||||
// on this Client. Call once from the gomobile-init code (Swift
|
||||
// AppDelegate / PacketTunnelProvider.startTunnel) before invoking Run.
|
||||
// Passing nil disables MDM enforcement on this Client.
|
||||
//
|
||||
// The fetcher is held as a *mdm.Loader instance on the Client (no
|
||||
// package-level state) — multiple Clients in the same process get
|
||||
// independent Loaders, and tests can inject fakes per Client.
|
||||
// SetMDMPolicyFetcher registers the native-provided MDM policy fetcher on
|
||||
// this Client; passing nil disables MDM enforcement.
|
||||
func (c *Client) SetMDMPolicyFetcher(p PolicyFetcher) {
|
||||
if p == nil {
|
||||
c.mdmLoader = mdm.NewLoader(nil)
|
||||
return
|
||||
c.mdmLoader = loaderFor(p)
|
||||
c.mdmDetector = mdm.NewChangeDetector(c.mdmLoader)
|
||||
}
|
||||
|
||||
// HasMDMPolicyChanged re-reads the managed configuration and reports whether
|
||||
// it changed since the last observation; call it from the native OS-change
|
||||
// notification and restart the engine only on true.
|
||||
func (c *Client) HasMDMPolicyChanged() bool {
|
||||
if c.mdmDetector == nil {
|
||||
return false
|
||||
}
|
||||
c.mdmLoader = mdm.NewLoader(&jsonFetcherAdapter{inner: p})
|
||||
return c.mdmDetector.Changed()
|
||||
}
|
||||
|
||||
// GetRestrictionsJSON returns the UI enforcement snapshot derived from the
|
||||
// active MDM policy, in the JSON shape shared with the desktop frontend.
|
||||
func (c *Client) GetRestrictionsJSON() (string, error) {
|
||||
return mdm.BuildRestrictions(c.mdmLoader.Load()).JSON()
|
||||
}
|
||||
|
||||
// applyMDMOverlay applies the Client-held MDM Loader's current policy
|
||||
// on top of the just-read Config. Called immediately after every
|
||||
// UpdateOrCreateConfig / DirectUpdateOrCreateConfig — profilemanager's
|
||||
// apply() initialises the policy to empty and leaves overlay
|
||||
// responsibility to the lifecycle owner. No-op when no fetcher was
|
||||
// registered.
|
||||
func (c *Client) applyMDMOverlay(cfg *profilemanager.Config) {
|
||||
if cfg == nil || c.mdmLoader == nil {
|
||||
return
|
||||
}
|
||||
cfg.ApplyMDMPolicy(c.mdmLoader.Load())
|
||||
}
|
||||
|
||||
func loaderFor(p PolicyFetcher) *mdm.Loader {
|
||||
if p == nil {
|
||||
return mdm.NewJSONLoader(nil)
|
||||
}
|
||||
return mdm.NewJSONLoader(p.FetchJSON)
|
||||
}
|
||||
|
||||
@@ -4,11 +4,13 @@ package NetBirdSDK
|
||||
|
||||
import (
|
||||
"github.com/netbirdio/netbird/client/internal/profilemanager"
|
||||
"github.com/netbirdio/netbird/client/mdm"
|
||||
)
|
||||
|
||||
// Preferences export a subset of the internal config for gomobile
|
||||
type Preferences struct {
|
||||
configInput profilemanager.ConfigInput
|
||||
mdmLoader *mdm.Loader
|
||||
}
|
||||
|
||||
// NewPreferences create new Preferences instance
|
||||
@@ -17,11 +19,30 @@ func NewPreferences(configPath string, stateFilePath string) *Preferences {
|
||||
ConfigPath: configPath,
|
||||
StateFilePath: stateFilePath,
|
||||
}
|
||||
return &Preferences{ci}
|
||||
return &Preferences{configInput: ci}
|
||||
}
|
||||
|
||||
// SetMDMPolicyFetcher registers the native-provided MDM policy fetcher on
|
||||
// this Preferences instance; passing nil disables MDM enforcement.
|
||||
func (p *Preferences) SetMDMPolicyFetcher(f PolicyFetcher) {
|
||||
p.mdmLoader = loaderFor(f)
|
||||
}
|
||||
|
||||
// GetRestrictionsJSON returns the UI enforcement snapshot derived from the
|
||||
// active MDM policy, in the JSON shape shared with the desktop frontend.
|
||||
func (p *Preferences) GetRestrictionsJSON() (string, error) {
|
||||
return mdm.BuildRestrictions(p.policy()).JSON()
|
||||
}
|
||||
|
||||
func (p *Preferences) policy() *mdm.Policy {
|
||||
return p.mdmLoader.Load()
|
||||
}
|
||||
|
||||
// GetManagementURL read url from config file
|
||||
func (p *Preferences) GetManagementURL() (string, error) {
|
||||
if v, ok := p.policy().GetString(mdm.KeyManagementURL); ok {
|
||||
return mdm.CanonicalURL(v), nil
|
||||
}
|
||||
if p.configInput.ManagementURL != "" {
|
||||
return p.configInput.ManagementURL, nil
|
||||
}
|
||||
@@ -56,17 +77,21 @@ func (p *Preferences) SetAdminURL(url string) {
|
||||
p.configInput.AdminURL = url
|
||||
}
|
||||
|
||||
// GetPreSharedKey read preshared key from config file
|
||||
func (p *Preferences) GetPreSharedKey() (string, error) {
|
||||
// HasPreSharedKey reports whether a pre-shared key is staged, persisted, or
|
||||
// enforced by MDM; the key itself is never handed to the native layer.
|
||||
func (p *Preferences) HasPreSharedKey() (bool, error) {
|
||||
if _, ok := p.policy().GetString(mdm.KeyPreSharedKey); ok {
|
||||
return true, nil
|
||||
}
|
||||
if p.configInput.PreSharedKey != nil {
|
||||
return *p.configInput.PreSharedKey, nil
|
||||
return *p.configInput.PreSharedKey != "", nil
|
||||
}
|
||||
|
||||
cfg, err := profilemanager.ReadConfig(p.configInput.ConfigPath)
|
||||
if err != nil {
|
||||
return "", err
|
||||
return false, err
|
||||
}
|
||||
return cfg.PreSharedKey, err
|
||||
return cfg.PreSharedKey != "", nil
|
||||
}
|
||||
|
||||
// SetPreSharedKey store the given key and wait for commit
|
||||
@@ -81,6 +106,9 @@ func (p *Preferences) SetRosenpassEnabled(enabled bool) {
|
||||
|
||||
// GetRosenpassEnabled read rosenpass enabled from config file
|
||||
func (p *Preferences) GetRosenpassEnabled() (bool, error) {
|
||||
if v, ok := p.policy().GetBool(mdm.KeyRosenpassEnabled); ok {
|
||||
return v, nil
|
||||
}
|
||||
if p.configInput.RosenpassEnabled != nil {
|
||||
return *p.configInput.RosenpassEnabled, nil
|
||||
}
|
||||
@@ -99,6 +127,9 @@ func (p *Preferences) SetRosenpassPermissive(permissive bool) {
|
||||
|
||||
// GetRosenpassPermissive read rosenpass permissive from config file
|
||||
func (p *Preferences) GetRosenpassPermissive() (bool, error) {
|
||||
if v, ok := p.policy().GetBool(mdm.KeyRosenpassPermissive); ok {
|
||||
return v, nil
|
||||
}
|
||||
if p.configInput.RosenpassPermissive != nil {
|
||||
return *p.configInput.RosenpassPermissive, nil
|
||||
}
|
||||
@@ -130,6 +161,9 @@ func (p *Preferences) SetDisableIPv6(disable bool) {
|
||||
|
||||
// Commit write out the changes into config file
|
||||
func (p *Preferences) Commit() error {
|
||||
if err := profilemanager.CheckMDMConflicts(p.configInput, p.policy()); err != nil {
|
||||
return err
|
||||
}
|
||||
// Use DirectUpdateOrCreateConfig to avoid atomic file operations (temp file + rename)
|
||||
// which are blocked by the tvOS sandbox in App Group containers
|
||||
_, err := profilemanager.DirectUpdateOrCreateConfig(p.configInput)
|
||||
|
||||
@@ -31,14 +31,13 @@ func TestPreferences_DefaultValues(t *testing.T) {
|
||||
t.Errorf("invalid default management url: %s", defaultVar)
|
||||
}
|
||||
|
||||
var preSharedKey string
|
||||
preSharedKey, err = p.GetPreSharedKey()
|
||||
hasPSK, err := p.HasPreSharedKey()
|
||||
if err != nil {
|
||||
t.Fatalf("failed to read default preshared key: %s", err)
|
||||
t.Fatalf("failed to read default preshared key presence: %s", err)
|
||||
}
|
||||
|
||||
if preSharedKey != "" {
|
||||
t.Errorf("invalid preshared key: %s", preSharedKey)
|
||||
if hasPSK {
|
||||
t.Errorf("unexpected preshared key presence on fresh config")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -69,13 +68,13 @@ func TestPreferences_ReadUncommitedValues(t *testing.T) {
|
||||
}
|
||||
|
||||
p.SetPreSharedKey(exampleString)
|
||||
resp, err = p.GetPreSharedKey()
|
||||
hasPSK, err := p.HasPreSharedKey()
|
||||
if err != nil {
|
||||
t.Fatalf("failed to read preshared key: %s", err)
|
||||
t.Fatalf("failed to read preshared key presence: %s", err)
|
||||
}
|
||||
|
||||
if resp != exampleString {
|
||||
t.Errorf("unexpected preshared key: %s", resp)
|
||||
if !hasPSK {
|
||||
t.Errorf("expected preshared key presence after staging one")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -114,12 +113,12 @@ func TestPreferences_Commit(t *testing.T) {
|
||||
t.Errorf("unexpected management url: %s", resp)
|
||||
}
|
||||
|
||||
resp, err = p.GetPreSharedKey()
|
||||
hasPSK, err := p.HasPreSharedKey()
|
||||
if err != nil {
|
||||
t.Fatalf("failed to read preshared key: %s", err)
|
||||
t.Fatalf("failed to read preshared key presence: %s", err)
|
||||
}
|
||||
|
||||
if resp != examplePresharedKey {
|
||||
t.Errorf("unexpected preshared key: %s", resp)
|
||||
if !hasPSK {
|
||||
t.Errorf("expected preshared key presence after commit")
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user