From db2c9b6f49ac88343f546c14476c47e2a3fe7268 Mon Sep 17 00:00:00 2001 From: riccardom Date: Mon, 15 Jun 2026 12:04:26 +0200 Subject: [PATCH] MDM Android mobile wiring --- client/android/mdm.go | 79 +++++++++++++++++++++++++++++++++++++ client/mdm/policy_mobile.go | 46 ++++++++++++++++----- 2 files changed, 116 insertions(+), 9 deletions(-) create mode 100644 client/android/mdm.go diff --git a/client/android/mdm.go b/client/android/mdm.go new file mode 100644 index 000000000..5f76de602 --- /dev/null +++ b/client/android/mdm.go @@ -0,0 +1,79 @@ +//go:build android + +package android + +import ( + "encoding/json" + + log "github.com/sirupsen/logrus" + + "github.com/netbirdio/netbird/client/mdm" +) + +// PolicyFetcher is the mobile-side bridge for the MDM managed-config +// snapshot. The native layer (Kotlin) implements this and registers +// the instance via SetMobilePolicyFetcher at app start. Every +// invocation must read the current RestrictionsManager state 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 JNI boundary — the adapter on the Go side parses the +// string back into the map[string]any expected by mdm.LoadPolicy. +// +// 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 +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 +} + +// SetMobilePolicyFetcher registers the native-provided MDM policy +// fetcher. Call exactly once from the gomobile-init code (Kotlin +// Application.onCreate) before the daemon starts. Passing nil +// effectively disables MDM enforcement on this build. +func SetMobilePolicyFetcher(p PolicyFetcher) { + if p == nil { + mdm.SetMobilePolicyFetcher(nil) + return + } + mdm.SetMobilePolicyFetcher(&jsonFetcherAdapter{inner: p}) +} + +// OnMDMPolicyChanged is the mobile entry point the native layer calls +// when the OS broadcasts a managed-config change +// (ACTION_APPLICATION_RESTRICTIONS_CHANGED on Android, +// UserDefaults.didChangeNotification on iOS). The hook cancels the +// current engine context so the current Run() returns; the native +// loop is expected to re-invoke Run() afterwards, which re-reads +// the config (including the fresh MDM overlay via the registered +// PolicyFetcher). +// +// No payload is passed — the OS notification only signals "something +// changed"; the actual values are read on-demand by the next +// loadPlatformPolicy call inside Config.apply. +func (c *Client) OnMDMPolicyChanged() { + log.Info("MDM policy change signaled by native; stopping engine to restart with fresh config") + c.Stop() +} diff --git a/client/mdm/policy_mobile.go b/client/mdm/policy_mobile.go index ec25d4bb1..40ec0ceab 100644 --- a/client/mdm/policy_mobile.go +++ b/client/mdm/policy_mobile.go @@ -2,13 +2,41 @@ package mdm -// loadPlatformPolicy is unused on mobile: the native layer (Swift on iOS, -// Kotlin/Java on Android) reads the OS managed-config store and pushes the -// resulting dictionary in-process via a gomobile entry point that lands in -// Phase 5 / Phase 6. The stub keeps the package compilable for mobile -// builds and returns (nil, nil) — the platform-absent sentinel that -// LoadPolicy in policy.go treats as "no MDM source present". -func loadPlatformPolicy() (map[string]any, error) { - //nolint:nilnil // (nil, nil) is the documented platform-absent sentinel; see LoadPolicy. - return nil, nil +// PolicyFetcher is the bridge between Go and the mobile native layer +// (Kotlin/Java on Android, Swift on iOS). The native layer registers +// an implementation at gomobile init via SetMobilePolicyFetcher; +// thereafter every call to loadPlatformPolicy delegates to the +// registered fetcher, which reads the OS-native managed-config store +// (RestrictionsManager on Android, com.apple.configuration.managed +// UserDefaults on iOS) and returns the current snapshot. +// +// Set-once at init, never mutated at runtime → no synchronisation +// required for the read path. The native layer must register before +// any Go code starts polling or processing MDM events. +type PolicyFetcher interface { + Fetch() map[string]any +} + +var fetcher PolicyFetcher + +// SetMobilePolicyFetcher registers the native-provided fetcher. Call +// exactly once from the gomobile init code (Kotlin Application.onCreate +// / Swift AppDelegate) before the daemon starts. Passing nil disables +// MDM enforcement on this build (loadPlatformPolicy returns +// (nil, nil) — the platform-absent sentinel that LoadPolicy treats as +// "no MDM source present"). +func SetMobilePolicyFetcher(p PolicyFetcher) { + fetcher = p +} + +// loadPlatformPolicy delegates to the native-provided fetcher. Returns +// (nil, nil) — the platform-absent sentinel — when no fetcher has been +// registered yet, so the package behaves identically to a desktop +// device without an MDM source. +func loadPlatformPolicy() (map[string]any, error) { + if fetcher == nil { + //nolint:nilnil // (nil, nil) is the documented platform-absent sentinel; see LoadPolicy. + return nil, nil + } + return fetcher.Fetch(), nil }