Skip to content
Merged
Show file tree
Hide file tree
Changes from 24 commits
Commits
Show all changes
35 commits
Select commit Hold shift + click to select a range
db2c9b6
MDM Android mobile wiring
riccardomanfrin Jun 15, 2026
bec26d5
Removes dead code
riccardomanfrin Jun 15, 2026
8741954
Removes static vars
riccardomanfrin Jun 15, 2026
0340893
Now we need to apply MDM in the GetConfig
riccardomanfrin Jun 15, 2026
b2c5732
You now need to explicitly call these around
riccardomanfrin Jun 15, 2026
7715c38
Adds iOS wiring
riccardomanfrin Jun 16, 2026
e1ffb16
Merge branch 'main' into mdm_integration (with unresolved conflict ma…
riccardomanfrin Jul 28, 2026
e92aa7d
Resolve merge conflicts from main
riccardomanfrin Jul 28, 2026
f91f9fc
Convey MDM overlay config to Debug Bundle output
riccardomanfrin Jul 28, 2026
76fd144
Merge remote-tracking branch 'origin/main' into mdm_integration
riccardomanfrin Aug 7, 2026
982626c
Solved conflict in client.go
riccardomanfrin Aug 7, 2026
faa1962
Fixup helper withMDMPolicy -> configWithMDM
riccardomanfrin Aug 7, 2026
e4d4b44
Fixup after merge
riccardomanfrin Aug 7, 2026
12789d1
Merge branch 'main' into mdm_integration
riccardomanfrin Aug 25, 2026
c41d439
Resolve merge conflicts
riccardomanfrin Aug 25, 2026
c281b15
[client] Move MDM enforcement logic into a shared Go layer (#7319)
pappz Aug 26, 2026
ec4b99b
Merge branch 'main' into mdm_integration
pappz Aug 26, 2026
116e1d3
Merge branch 'main' into mdm_integration
riccardomanfrin Sep 4, 2026
5d15ac0
[client] Resolve the main merge conflicts in the MDM integration
riccardomanfrin Sep 4, 2026
d96d22a
[client] Reject explicit empty PSK when MDM enforces a pre-shared key
pappz Sep 4, 2026
47238e0
[client] Apply MDM overlay on the preloaded iOS config in Run
pappz Sep 4, 2026
42f0a63
[client] Gate non-active profile logout behind the MDM profiles switch
pappz Sep 4, 2026
3234ce7
[client] Resolve the managed management URL through the MDM overlay o…
pappz Sep 4, 2026
047715a
[client] Clean up MDM review leftovers
pappz Sep 4, 2026
c1e2e20
[client] Check remote jobs and local metrics keys in the mobile MDM c…
pappz Sep 4, 2026
a52c7ba
[client] Silence the deprecated PreSharedKey lint in the login confli…
pappz Sep 4, 2026
86d6f7d
[client] Publish the mobile MDM loader and detector atomically
pappz Sep 4, 2026
a2b1c99
[client] Report the MDM-overlaid remote jobs value from mobile Prefer…
pappz Sep 4, 2026
1b0dd66
[client] Stop persisting the MDM-overlaid config after mobile logins
pappz Sep 7, 2026
ca548ea
[client] Document that the MDM overlay on Config is not reversible
pappz Sep 7, 2026
cb5efcd
[client] Re-resolve the tvOS preloaded config before every MDM overlay
pappz Sep 7, 2026
c019700
[client] Serve the MDM-managed management URL without touching the co…
pappz Sep 7, 2026
204360e
[client] Align the MDM loader docs with the fetcher precedence and ma…
pappz Sep 7, 2026
9364512
[client] Clean up MDM review nits
pappz Sep 7, 2026
b65f493
[client] Fix int policy values coercing to bool in the MDM test helper
pappz Sep 8, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions client/android/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import (
"github.com/netbirdio/netbird/client/internal/profilemanager"
"github.com/netbirdio/netbird/client/internal/routemanager"
"github.com/netbirdio/netbird/client/internal/stdnet"
"github.com/netbirdio/netbird/client/mdm"
"github.com/netbirdio/netbird/client/net"
"github.com/netbirdio/netbird/client/netevents"
"github.com/netbirdio/netbird/client/system"
Expand Down Expand Up @@ -90,6 +91,15 @@ type Client struct {
connectClient *internal.ConnectClient
config *profilemanager.Config
cacheDir string

// mdmLoader holds the per-Client MDM policy source. Set by
// SetMDMPolicyFetcher (called from the Kotlin side). 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
mdmDetector *mdm.ChangeDetector

// Identifies the running profile for the SSO login hint; see profile_state.go.
cfgPath string

Expand Down Expand Up @@ -178,6 +188,7 @@ func (c *Client) Run(platformFiles PlatformFiles, urlOpener URLOpener, isAndroid
if err != nil {
return err
}
c.applyMDMOverlay(cfg)
Comment thread
pappz marked this conversation as resolved.
c.recorder.UpdateManagementAddress(cfg.ManagementURL.String())
c.recorder.UpdateRosenpass(cfg.RosenpassEnabled, cfg.RosenpassPermissive)

Expand Down Expand Up @@ -229,6 +240,7 @@ func (c *Client) RunWithoutLogin(platformFiles PlatformFiles, dns *DNSList, dnsR
if err != nil {
return err
}
c.applyMDMOverlay(cfg)
c.recorder.UpdateManagementAddress(cfg.ManagementURL.String())
c.recorder.UpdateRosenpass(cfg.RosenpassEnabled, cfg.RosenpassPermissive)

Expand Down Expand Up @@ -327,6 +339,7 @@ func (c *Client) DebugBundle(platformFiles PlatformFiles, anonymize bool, anonym
if err != nil {
return "", fmt.Errorf("load config: %w", err)
}
c.applyMDMOverlay(cfg)
cacheDir = platformFiles.CacheDir()
}

Expand Down
38 changes: 38 additions & 0 deletions client/android/client_mdm.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
//go:build android

package android

import (
"github.com/netbirdio/netbird/client/internal/profilemanager"
"github.com/netbirdio/netbird/client/mdm"
)

// SetMDMPolicyFetcher registers the native-provided MDM policy fetcher on
// this Client; passing nil disables MDM enforcement.
func (c *Client) SetMDMPolicyFetcher(p PolicyFetcher) {
c.mdmLoader = loaderFor(p)
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
Outdated
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
}
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()
}

func (c *Client) applyMDMOverlay(cfg *profilemanager.Config) {
if cfg == nil || c.mdmLoader == nil {
return
}
cfg.ApplyMDMPolicy(c.mdmLoader.Load())
}
9 changes: 8 additions & 1 deletion client/android/login.go
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,13 @@ type Auth struct {
// an earlier call is orphaned on the server. It also breaks a client that enrols and then runs from
// the persisted config, because the identity it registered is not the one it runs with — the
// management stream rejects it with "no peer auth method provided".
func NewAuth(cfgPath string, mgmURL string) (*Auth, error) {
//
// Auth is constructed under the active MDM policy: the policy is overlaid on
// the resolved config so the login runs against the enforced values, while
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
// the persisted config keeps the caller-supplied ones. A nil fetcher disables
// MDM enforcement.
func NewAuth(cfgPath string, mgmURL string, fetcher PolicyFetcher) (*Auth, error) {
policy := loaderFor(fetcher).Load()
inputCfg := profilemanager.ConfigInput{
ConfigPath: cfgPath,
ManagementURL: mgmURL,
Expand All @@ -56,6 +62,7 @@ func NewAuth(cfgPath string, mgmURL string) (*Auth, error) {
if err != nil {
return nil, err
}
cfg.ApplyMDMPolicy(policy)

return &Auth{
ctx: context.Background(),
Expand Down
6 changes: 3 additions & 3 deletions client/android/login_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,15 +16,15 @@ import (
func TestNewAuth_ReusesPersistedIdentity(t *testing.T) {
cfgPath := filepath.Join(t.TempDir(), "config.json")

first, err := NewAuth(cfgPath, "https://api.example.com:443")
first, err := NewAuth(cfgPath, "https://api.example.com:443", nil)
if err != nil {
t.Fatalf("first NewAuth: %v", err)
}
if first.config.PrivateKey == "" {
t.Fatal("first NewAuth produced no private key")
}

second, err := NewAuth(cfgPath, "https://api.example.com:443")
second, err := NewAuth(cfgPath, "https://api.example.com:443", nil)
if err != nil {
t.Fatalf("second NewAuth: %v", err)
}
Expand All @@ -38,7 +38,7 @@ func TestNewAuth_ReusesPersistedIdentity(t *testing.T) {
func TestNewAuth_CreatesConfigWhenAbsent(t *testing.T) {
cfgPath := filepath.Join(t.TempDir(), "config.json")

auth, err := NewAuth(cfgPath, "https://api.example.com:443")
auth, err := NewAuth(cfgPath, "https://api.example.com:443", nil)
if err != nil {
t.Fatalf("NewAuth: %v", err)
}
Expand Down
19 changes: 19 additions & 0 deletions client/android/mdm.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
package android

import (
"github.com/netbirdio/netbird/client/mdm"
)

// 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
}

func loaderFor(p PolicyFetcher) *mdm.Loader {
if p == nil {
return mdm.NewJSONLoader(nil)
}
return mdm.NewJSONLoader(p.FetchJSON)
}
61 changes: 53 additions & 8 deletions client/android/preferences.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,32 +2,52 @@ package android

import (
"github.com/netbirdio/netbird/client/internal/profilemanager"
"github.com/netbirdio/netbird/client/mdm"
)

// Preferences exports a subset of the internal config for gomobile
type Preferences struct {
configInput profilemanager.ConfigInput
mdmLoader *mdm.Loader
}

// NewPreferences creates a new Preferences instance
func NewPreferences(configPath string) *Preferences {
ci := profilemanager.ConfigInput{
ConfigPath: configPath,
}
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 reads URL from config file
func (p *Preferences) GetManagementURL() (string, error) {
if p.configInput.ManagementURL != "" {
policy := p.policy()
if !policy.HasKey(mdm.KeyManagementURL) && p.configInput.ManagementURL != "" {
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
Outdated
return p.configInput.ManagementURL, nil
}

cfg, err := profilemanager.ReadConfig(p.configInput.ConfigPath)
if err != nil {
return "", err
}
return cfg.ManagementURL.String(), err
cfg.ApplyMDMPolicy(policy)
return cfg.ManagementURL.String(), nil
}

// SetManagementURL stores the given URL and waits for commit
Expand All @@ -53,17 +73,21 @@ func (p *Preferences) SetAdminURL(url string) {
p.configInput.AdminURL = url
}

// GetPreSharedKey reads pre-shared 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 stores the given key and waits for commit
Expand All @@ -78,6 +102,9 @@ func (p *Preferences) SetRosenpassEnabled(enabled bool) {

// GetRosenpassEnabled reads Rosenpass enabled status 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
}
Expand All @@ -96,6 +123,9 @@ func (p *Preferences) SetRosenpassPermissive(permissive bool) {

// GetRosenpassPermissive reads Rosenpass permissive setting 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
}
Expand All @@ -109,6 +139,9 @@ func (p *Preferences) GetRosenpassPermissive() (bool, error) {

// GetDisableClientRoutes reads disable client routes setting from config file
func (p *Preferences) GetDisableClientRoutes() (bool, error) {
if v, ok := p.policy().GetBool(mdm.KeyDisableClientRoutes); ok {
return v, nil
}
if p.configInput.DisableClientRoutes != nil {
return *p.configInput.DisableClientRoutes, nil
}
Expand All @@ -127,6 +160,9 @@ func (p *Preferences) SetDisableClientRoutes(disable bool) {

// GetDisableServerRoutes reads disable server routes setting from config file
func (p *Preferences) GetDisableServerRoutes() (bool, error) {
if v, ok := p.policy().GetBool(mdm.KeyDisableServerRoutes); ok {
return v, nil
}
if p.configInput.DisableServerRoutes != nil {
return *p.configInput.DisableServerRoutes, nil
}
Expand Down Expand Up @@ -181,6 +217,9 @@ func (p *Preferences) SetDisableFirewall(disable bool) {

// GetServerSSHAllowed reads server SSH allowed setting from config file
func (p *Preferences) GetServerSSHAllowed() (bool, error) {
if v, ok := p.policy().GetBool(mdm.KeyAllowServerSSH); ok {
return v, nil
}
if p.configInput.ServerSSHAllowed != nil {
return *p.configInput.ServerSSHAllowed, nil
}
Expand Down Expand Up @@ -291,6 +330,9 @@ func (p *Preferences) SetEnableSSHRemotePortForwarding(enabled bool) {

// GetBlockInbound reads block inbound setting from config file
func (p *Preferences) GetBlockInbound() (bool, error) {
if v, ok := p.policy().GetBool(mdm.KeyBlockInbound); ok {
return v, nil
}
if p.configInput.BlockInbound != nil {
return *p.configInput.BlockInbound, nil
}
Expand Down Expand Up @@ -348,6 +390,9 @@ func (p *Preferences) SetRemoteJobsAllowed(allowed bool) {

// Commit writes out the changes to the config file
func (p *Preferences) Commit() error {
if err := profilemanager.CheckMDMConflicts(p.configInput, p.policy()); err != nil {
return err
}
_, err := profilemanager.UpdateOrCreateConfig(p.configInput)
return err
}
25 changes: 12 additions & 13 deletions client/android/preferences_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,14 +28,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")
}
}

Expand Down Expand Up @@ -65,13 +64,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")
}
}

Expand Down Expand Up @@ -109,12 +108,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")
}
}
6 changes: 6 additions & 0 deletions client/android/profile_manager.go
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,12 @@ func NewProfileManager(configDir string) *ProfileManager {
return &ProfileManager{impl: mobile.NewProfileManager(configDir, androidUsername)}
}

// SetMDMPolicyFetcher registers the native-provided MDM policy fetcher on
// this ProfileManager; passing nil disables MDM enforcement.
func (pm *ProfileManager) SetMDMPolicyFetcher(f PolicyFetcher) {
pm.impl.SetMDMLoader(loaderFor(f))
}

// ListProfiles returns all available profiles, including the default profile,
// with their active status set.
func (pm *ProfileManager) ListProfiles() (*ProfileArray, error) {
Expand Down
6 changes: 6 additions & 0 deletions client/cmd/login.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import (
"github.com/netbirdio/netbird/client/internal"
"github.com/netbirdio/netbird/client/internal/auth"
"github.com/netbirdio/netbird/client/internal/profilemanager"
"github.com/netbirdio/netbird/client/mdm"
nbnet "github.com/netbirdio/netbird/client/net"
"github.com/netbirdio/netbird/client/proto"
"github.com/netbirdio/netbird/client/server"
Expand Down Expand Up @@ -330,6 +331,11 @@ func doForegroundLogin(ctx context.Context, cmd *cobra.Command, setupKey string,
if err != nil {
return fmt.Errorf("read config file %s: %v", configFilePath, err)
}
// CLI standalone login: profilemanager no longer auto-applies MDM,
// so layer in the OS-native policy here. Desktop builds construct
// a Loader with no fetcher — the build-tagged loadPlatform reads
// the registry/plist directly.
config.ApplyMDMPolicy(mdm.NewLoader(nil).Load())

// Mirror runInForegroundMode: recover residual state (DNS, firewall,
// ssh config, legacy routing) from a previous unclean shutdown and
Expand Down
Loading
Loading