mirror of
https://github.com/netbirdio/netbird.git
synced 2026-06-29 11:19:56 +00:00
Compare commits
5 Commits
netmap_pro
...
mdm_integr
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b2c5732847 | ||
|
|
0340893854 | ||
|
|
874195440c | ||
|
|
bec26d5a14 | ||
|
|
db2c9b6f49 |
@@ -23,6 +23,7 @@ import (
|
|||||||
"github.com/netbirdio/netbird/client/internal/profilemanager"
|
"github.com/netbirdio/netbird/client/internal/profilemanager"
|
||||||
"github.com/netbirdio/netbird/client/internal/routemanager"
|
"github.com/netbirdio/netbird/client/internal/routemanager"
|
||||||
"github.com/netbirdio/netbird/client/internal/stdnet"
|
"github.com/netbirdio/netbird/client/internal/stdnet"
|
||||||
|
"github.com/netbirdio/netbird/client/mdm"
|
||||||
"github.com/netbirdio/netbird/client/net"
|
"github.com/netbirdio/netbird/client/net"
|
||||||
"github.com/netbirdio/netbird/client/system"
|
"github.com/netbirdio/netbird/client/system"
|
||||||
"github.com/netbirdio/netbird/formatter"
|
"github.com/netbirdio/netbird/formatter"
|
||||||
@@ -75,6 +76,13 @@ type Client struct {
|
|||||||
connectClient *internal.ConnectClient
|
connectClient *internal.ConnectClient
|
||||||
config *profilemanager.Config
|
config *profilemanager.Config
|
||||||
cacheDir string
|
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
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *Client) setState(cfg *profilemanager.Config, cacheDir string, cc *internal.ConnectClient) {
|
func (c *Client) setState(cfg *profilemanager.Config, cacheDir string, cc *internal.ConnectClient) {
|
||||||
@@ -129,6 +137,7 @@ func (c *Client) Run(platformFiles PlatformFiles, urlOpener URLOpener, isAndroid
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
c.applyMDMOverlay(cfg)
|
||||||
c.recorder.UpdateManagementAddress(cfg.ManagementURL.String())
|
c.recorder.UpdateManagementAddress(cfg.ManagementURL.String())
|
||||||
c.recorder.UpdateRosenpass(cfg.RosenpassEnabled, cfg.RosenpassPermissive)
|
c.recorder.UpdateRosenpass(cfg.RosenpassEnabled, cfg.RosenpassPermissive)
|
||||||
|
|
||||||
@@ -173,6 +182,7 @@ func (c *Client) RunWithoutLogin(platformFiles PlatformFiles, dns *DNSList, dnsR
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
c.applyMDMOverlay(cfg)
|
||||||
c.recorder.UpdateManagementAddress(cfg.ManagementURL.String())
|
c.recorder.UpdateManagementAddress(cfg.ManagementURL.String())
|
||||||
c.recorder.UpdateRosenpass(cfg.RosenpassEnabled, cfg.RosenpassPermissive)
|
c.recorder.UpdateRosenpass(cfg.RosenpassEnabled, cfg.RosenpassPermissive)
|
||||||
|
|
||||||
@@ -230,6 +240,7 @@ func (c *Client) DebugBundle(platformFiles PlatformFiles, anonymize bool) (strin
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("load config: %w", err)
|
return "", fmt.Errorf("load config: %w", err)
|
||||||
}
|
}
|
||||||
|
c.applyMDMOverlay(cfg)
|
||||||
cacheDir = platformFiles.CacheDir()
|
cacheDir = platformFiles.CacheDir()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
80
client/android/mdm.go
Normal file
80
client/android/mdm.go
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
//go:build android
|
||||||
|
|
||||||
|
package android
|
||||||
|
|
||||||
|
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 (Kotlin) implements this and registers
|
||||||
|
// the instance per Client via Client.SetMDMPolicyFetcher. Every
|
||||||
|
// invocation of fetchJSON 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.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
|
||||||
|
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 (Kotlin
|
||||||
|
// Application.onCreate or Service onCreate) before invoking Run /
|
||||||
|
// RunWithoutLogin. 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.
|
||||||
|
func (c *Client) SetMDMPolicyFetcher(p PolicyFetcher) {
|
||||||
|
if p == nil {
|
||||||
|
c.mdmLoader = mdm.NewLoader(nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.mdmLoader = mdm.NewLoader(&jsonFetcherAdapter{inner: p})
|
||||||
|
}
|
||||||
|
|
||||||
|
// applyMDMOverlay applies the Client-held MDM Loader's current policy
|
||||||
|
// on top of the just-read Config. Called immediately after every
|
||||||
|
// UpdateOrCreateConfig — 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())
|
||||||
|
}
|
||||||
@@ -17,6 +17,7 @@ import (
|
|||||||
"github.com/netbirdio/netbird/client/internal"
|
"github.com/netbirdio/netbird/client/internal"
|
||||||
"github.com/netbirdio/netbird/client/internal/auth"
|
"github.com/netbirdio/netbird/client/internal/auth"
|
||||||
"github.com/netbirdio/netbird/client/internal/profilemanager"
|
"github.com/netbirdio/netbird/client/internal/profilemanager"
|
||||||
|
"github.com/netbirdio/netbird/client/mdm"
|
||||||
"github.com/netbirdio/netbird/client/proto"
|
"github.com/netbirdio/netbird/client/proto"
|
||||||
"github.com/netbirdio/netbird/client/system"
|
"github.com/netbirdio/netbird/client/system"
|
||||||
"github.com/netbirdio/netbird/util"
|
"github.com/netbirdio/netbird/util"
|
||||||
@@ -248,6 +249,11 @@ func doForegroundLogin(ctx context.Context, cmd *cobra.Command, setupKey string,
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("read config file %s: %v", configFilePath, err)
|
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())
|
||||||
|
|
||||||
err = foregroundLogin(ctx, cmd, config, setupKey, activeProf.Name)
|
err = foregroundLogin(ctx, cmd, config, setupKey, activeProf.Name)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import (
|
|||||||
"github.com/netbirdio/netbird/client/internal"
|
"github.com/netbirdio/netbird/client/internal"
|
||||||
"github.com/netbirdio/netbird/client/internal/peer"
|
"github.com/netbirdio/netbird/client/internal/peer"
|
||||||
"github.com/netbirdio/netbird/client/internal/profilemanager"
|
"github.com/netbirdio/netbird/client/internal/profilemanager"
|
||||||
|
"github.com/netbirdio/netbird/client/mdm"
|
||||||
"github.com/netbirdio/netbird/client/proto"
|
"github.com/netbirdio/netbird/client/proto"
|
||||||
"github.com/netbirdio/netbird/client/system"
|
"github.com/netbirdio/netbird/client/system"
|
||||||
"github.com/netbirdio/netbird/shared/management/domain"
|
"github.com/netbirdio/netbird/shared/management/domain"
|
||||||
@@ -187,6 +188,10 @@ func runInForegroundMode(ctx context.Context, cmd *cobra.Command, activeProf *pr
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("get config file: %v", err)
|
return fmt.Errorf("get config file: %v", err)
|
||||||
}
|
}
|
||||||
|
// CLI foreground path runs without the daemon Server: layer in the
|
||||||
|
// active MDM policy explicitly so a forced ManagementURL / PSK /
|
||||||
|
// other managed key actually takes effect on this run.
|
||||||
|
config.ApplyMDMPolicy(mdm.NewLoader(nil).Load())
|
||||||
|
|
||||||
_, _ = profilemanager.UpdateOldManagementURL(ctx, config, configFilePath)
|
_, _ = profilemanager.UpdateOldManagementURL(ctx, config, configFilePath)
|
||||||
|
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import (
|
|||||||
"github.com/netbirdio/netbird/client/internal/auth"
|
"github.com/netbirdio/netbird/client/internal/auth"
|
||||||
"github.com/netbirdio/netbird/client/internal/peer"
|
"github.com/netbirdio/netbird/client/internal/peer"
|
||||||
"github.com/netbirdio/netbird/client/internal/profilemanager"
|
"github.com/netbirdio/netbird/client/internal/profilemanager"
|
||||||
|
"github.com/netbirdio/netbird/client/mdm"
|
||||||
sshcommon "github.com/netbirdio/netbird/client/ssh"
|
sshcommon "github.com/netbirdio/netbird/client/ssh"
|
||||||
"github.com/netbirdio/netbird/client/system"
|
"github.com/netbirdio/netbird/client/system"
|
||||||
"github.com/netbirdio/netbird/shared/management/domain"
|
"github.com/netbirdio/netbird/shared/management/domain"
|
||||||
@@ -215,6 +216,10 @@ func New(opts Options) (*Client, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("create config: %w", err)
|
return nil, fmt.Errorf("create config: %w", err)
|
||||||
}
|
}
|
||||||
|
// Embedded path runs without the daemon Server: apply the active
|
||||||
|
// MDM policy explicitly so a forced ManagementURL / PSK / other
|
||||||
|
// managed key takes effect on this embedded engine instance.
|
||||||
|
config.ApplyMDMPolicy(mdm.NewLoader(nil).Load())
|
||||||
|
|
||||||
if opts.PrivateKey != "" {
|
if opts.PrivateKey != "" {
|
||||||
config.PrivateKey = opts.PrivateKey
|
config.PrivateKey = opts.PrivateKey
|
||||||
|
|||||||
@@ -58,10 +58,6 @@ var DefaultInterfaceBlacklist = []string{
|
|||||||
"Tailscale", "tailscale", "docker", "veth", "br-", "lo",
|
"Tailscale", "tailscale", "docker", "veth", "br-", "lo",
|
||||||
}
|
}
|
||||||
|
|
||||||
// loadMDMPolicy is the package-level indirection used by apply() to read the
|
|
||||||
// active MDM policy. Tests override this to inject a fake policy.
|
|
||||||
var loadMDMPolicy = mdm.LoadPolicy
|
|
||||||
|
|
||||||
// ConfigInput carries configuration changes to the client
|
// ConfigInput carries configuration changes to the client
|
||||||
type ConfigInput struct {
|
type ConfigInput struct {
|
||||||
ManagementURL string
|
ManagementURL string
|
||||||
@@ -180,14 +176,27 @@ type Config struct {
|
|||||||
|
|
||||||
MTU uint16
|
MTU uint16
|
||||||
|
|
||||||
// policy is the MDM policy that produced the currently-set values for
|
// policy is the MDM policy that produced the currently-set values
|
||||||
// any MDM-enforced fields. Set by applyMDMPolicy at the tail of apply()
|
// for any MDM-enforced fields. Set by ApplyMDMPolicy on every
|
||||||
// and reset on every apply() invocation. Never persisted to disk.
|
// invocation. Never persisted to disk. Callers query enforcement
|
||||||
// Callers query enforcement state via Policy() and the mdm.Policy API
|
// state via Policy() and the mdm.Policy API (HasKey, ManagedKeys,
|
||||||
// (HasKey, ManagedKeys, IsEmpty).
|
// IsEmpty).
|
||||||
policy *mdm.Policy `json:"-"`
|
policy *mdm.Policy `json:"-"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ApplyMDMPolicy overlays the supplied MDM Policy on top of the
|
||||||
|
// currently resolved Config values. Idempotent — pass an empty Policy
|
||||||
|
// to clear any prior overlay. The lifecycle owner (Server.getConfig
|
||||||
|
// on desktop, the Client.Run path on mobile) calls this with
|
||||||
|
// loader.Load() once the per-process Loader is known; the Config
|
||||||
|
// itself holds no reference to the Loader.
|
||||||
|
func (config *Config) ApplyMDMPolicy(policy *mdm.Policy) {
|
||||||
|
if config == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
config.applyMDMPolicy(policy)
|
||||||
|
}
|
||||||
|
|
||||||
// Policy returns the MDM policy applied to this Config. Returns a non-nil
|
// Policy returns the MDM policy applied to this Config. Returns a non-nil
|
||||||
// empty Policy when MDM enforcement is inactive; callers can always invoke
|
// empty Policy when MDM enforcement is inactive; callers can always invoke
|
||||||
// HasKey / ManagedKeys / IsEmpty without a nil check.
|
// HasKey / ManagedKeys / IsEmpty without a nil check.
|
||||||
@@ -634,9 +643,11 @@ func (config *Config) apply(input ConfigInput) (updated bool, err error) {
|
|||||||
updated = true
|
updated = true
|
||||||
}
|
}
|
||||||
|
|
||||||
// MDM is the last override layer: any key present in the policy
|
// Initialise the MDM overlay to "no enforcement" so Config.Policy()
|
||||||
// supersedes defaults, on-disk config, env vars and CLI input.
|
// never returns a stale or nil policy on a freshly applied Config.
|
||||||
config.applyMDMPolicy(loadMDMPolicy())
|
// Lifecycle owners that want to enforce a real MDM policy invoke
|
||||||
|
// Config.ApplyMDMPolicy(loader.Load()) after this returns.
|
||||||
|
config.applyMDMPolicy(mdm.NewPolicy(nil))
|
||||||
|
|
||||||
return updated, nil
|
return updated, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,24 +10,58 @@ import (
|
|||||||
"github.com/netbirdio/netbird/client/mdm"
|
"github.com/netbirdio/netbird/client/mdm"
|
||||||
)
|
)
|
||||||
|
|
||||||
// withMDMPolicy temporarily overrides the package-level loadMDMPolicy hook so
|
// fakeFetcher implements mdm.PolicyFetcher returning a pre-set policy
|
||||||
// apply() observes the supplied Policy. The original loader is restored at
|
// map. Test helper used to construct a Loader without touching the OS
|
||||||
// test cleanup.
|
// or any package-level state.
|
||||||
func withMDMPolicy(t *testing.T, policy *mdm.Policy) {
|
type fakeFetcher struct{ values map[string]any }
|
||||||
|
|
||||||
|
func (f *fakeFetcher) Fetch() map[string]any { return f.values }
|
||||||
|
|
||||||
|
// loaderFor builds an mdm.Loader whose loadPlatform returns the
|
||||||
|
// supplied Policy's underlying values.
|
||||||
|
func loaderFor(policy *mdm.Policy) *mdm.Loader {
|
||||||
|
if policy == nil || policy.IsEmpty() {
|
||||||
|
return mdm.NewLoader(&fakeFetcher{values: nil})
|
||||||
|
}
|
||||||
|
values := make(map[string]any)
|
||||||
|
for _, k := range policy.ManagedKeys() {
|
||||||
|
if v, ok := policy.GetString(k); ok {
|
||||||
|
values[k] = v
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if v, ok := policy.GetBool(k); ok {
|
||||||
|
values[k] = v
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if v, ok := policy.GetInt(k); ok {
|
||||||
|
values[k] = v
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if v, ok := policy.GetStringSlice(k); ok {
|
||||||
|
values[k] = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return mdm.NewLoader(&fakeFetcher{values: values})
|
||||||
|
}
|
||||||
|
|
||||||
|
// configWithMDM is the test convenience that builds a Config via
|
||||||
|
// UpdateOrCreateConfig and overlays the supplied MDM policy on top —
|
||||||
|
// mirrors the production pattern (Server.getConfig / Client.applyMDMOverlay)
|
||||||
|
// where the Loader lives outside Config and the apply step is driven
|
||||||
|
// by the lifecycle owner.
|
||||||
|
func configWithMDM(t *testing.T, input ConfigInput, policy *mdm.Policy) *Config {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
prev := loadMDMPolicy
|
cfg, err := UpdateOrCreateConfig(input)
|
||||||
loadMDMPolicy = func() *mdm.Policy { return policy }
|
require.NoError(t, err)
|
||||||
t.Cleanup(func() { loadMDMPolicy = prev })
|
require.NotNil(t, cfg)
|
||||||
|
cfg.ApplyMDMPolicy(loaderFor(policy).Load())
|
||||||
|
return cfg
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestApply_MDMEmpty_NoEnforcement(t *testing.T) {
|
func TestApply_MDMEmpty_NoEnforcement(t *testing.T) {
|
||||||
withMDMPolicy(t, mdm.NewPolicy(nil))
|
cfg := configWithMDM(t, ConfigInput{
|
||||||
|
|
||||||
cfg, err := UpdateOrCreateConfig(ConfigInput{
|
|
||||||
ConfigPath: filepath.Join(t.TempDir(), "config.json"),
|
ConfigPath: filepath.Join(t.TempDir(), "config.json"),
|
||||||
})
|
}, mdm.NewPolicy(nil))
|
||||||
require.NoError(t, err)
|
|
||||||
require.NotNil(t, cfg)
|
|
||||||
|
|
||||||
assert.True(t, cfg.Policy().IsEmpty(), "no MDM source ⇒ empty Policy")
|
assert.True(t, cfg.Policy().IsEmpty(), "no MDM source ⇒ empty Policy")
|
||||||
assert.False(t, cfg.Policy().HasKey(mdm.KeyManagementURL))
|
assert.False(t, cfg.Policy().HasKey(mdm.KeyManagementURL))
|
||||||
@@ -39,18 +73,15 @@ func TestApply_MDMEmpty_NoEnforcement(t *testing.T) {
|
|||||||
|
|
||||||
func TestApply_MDMOnly_OverridesDefaults(t *testing.T) {
|
func TestApply_MDMOnly_OverridesDefaults(t *testing.T) {
|
||||||
const mdmURL = "https://corp.mdm.example.com:443"
|
const mdmURL = "https://corp.mdm.example.com:443"
|
||||||
withMDMPolicy(t, mdm.NewPolicy(map[string]any{
|
|
||||||
|
cfg := configWithMDM(t, ConfigInput{
|
||||||
|
ConfigPath: filepath.Join(t.TempDir(), "config.json"),
|
||||||
|
}, mdm.NewPolicy(map[string]any{
|
||||||
mdm.KeyManagementURL: mdmURL,
|
mdm.KeyManagementURL: mdmURL,
|
||||||
mdm.KeyDisableClientRoutes: true,
|
mdm.KeyDisableClientRoutes: true,
|
||||||
mdm.KeyBlockInbound: true,
|
mdm.KeyBlockInbound: true,
|
||||||
}))
|
}))
|
||||||
|
|
||||||
cfg, err := UpdateOrCreateConfig(ConfigInput{
|
|
||||||
ConfigPath: filepath.Join(t.TempDir(), "config.json"),
|
|
||||||
})
|
|
||||||
require.NoError(t, err)
|
|
||||||
require.NotNil(t, cfg)
|
|
||||||
|
|
||||||
assert.Equal(t, mdmURL, cfg.ManagementURL.String())
|
assert.Equal(t, mdmURL, cfg.ManagementURL.String())
|
||||||
assert.True(t, cfg.DisableClientRoutes)
|
assert.True(t, cfg.DisableClientRoutes)
|
||||||
assert.True(t, cfg.BlockInbound)
|
assert.True(t, cfg.BlockInbound)
|
||||||
@@ -65,16 +96,12 @@ func TestApply_MDMBeatsCLIInput(t *testing.T) {
|
|||||||
const mdmURL = "https://mdm.example.com:443"
|
const mdmURL = "https://mdm.example.com:443"
|
||||||
const cliURL = "https://cli.example.com:443"
|
const cliURL = "https://cli.example.com:443"
|
||||||
|
|
||||||
withMDMPolicy(t, mdm.NewPolicy(map[string]any{
|
cfg := configWithMDM(t, ConfigInput{
|
||||||
mdm.KeyManagementURL: mdmURL,
|
|
||||||
}))
|
|
||||||
|
|
||||||
cfg, err := UpdateOrCreateConfig(ConfigInput{
|
|
||||||
ConfigPath: filepath.Join(t.TempDir(), "config.json"),
|
ConfigPath: filepath.Join(t.TempDir(), "config.json"),
|
||||||
ManagementURL: cliURL,
|
ManagementURL: cliURL,
|
||||||
})
|
}, mdm.NewPolicy(map[string]any{
|
||||||
require.NoError(t, err)
|
mdm.KeyManagementURL: mdmURL,
|
||||||
require.NotNil(t, cfg)
|
}))
|
||||||
|
|
||||||
// MDM wins over CLI-supplied management URL.
|
// MDM wins over CLI-supplied management URL.
|
||||||
assert.Equal(t, mdmURL, cfg.ManagementURL.String())
|
assert.Equal(t, mdmURL, cfg.ManagementURL.String())
|
||||||
@@ -82,16 +109,12 @@ func TestApply_MDMBeatsCLIInput(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestApply_MDMInvalidURL_KeepsPreviousValue(t *testing.T) {
|
func TestApply_MDMInvalidURL_KeepsPreviousValue(t *testing.T) {
|
||||||
withMDMPolicy(t, mdm.NewPolicy(map[string]any{
|
cfg := configWithMDM(t, ConfigInput{
|
||||||
|
ConfigPath: filepath.Join(t.TempDir(), "config.json"),
|
||||||
|
}, mdm.NewPolicy(map[string]any{
|
||||||
mdm.KeyManagementURL: "not-a-url",
|
mdm.KeyManagementURL: "not-a-url",
|
||||||
}))
|
}))
|
||||||
|
|
||||||
cfg, err := UpdateOrCreateConfig(ConfigInput{
|
|
||||||
ConfigPath: filepath.Join(t.TempDir(), "config.json"),
|
|
||||||
})
|
|
||||||
require.NoError(t, err)
|
|
||||||
require.NotNil(t, cfg)
|
|
||||||
|
|
||||||
// Invalid MDM URL is logged and skipped: default URL stays in place
|
// Invalid MDM URL is logged and skipped: default URL stays in place
|
||||||
// to keep the client functional.
|
// to keep the client functional.
|
||||||
assert.Equal(t, DefaultManagementURL, cfg.ManagementURL.String())
|
assert.Equal(t, DefaultManagementURL, cfg.ManagementURL.String())
|
||||||
@@ -106,24 +129,20 @@ func TestApply_MDMBoolKeysOverrideOnDiskValue(t *testing.T) {
|
|||||||
tmp := filepath.Join(t.TempDir(), "config.json")
|
tmp := filepath.Join(t.TempDir(), "config.json")
|
||||||
|
|
||||||
// Seed without MDM.
|
// Seed without MDM.
|
||||||
withMDMPolicy(t, mdm.NewPolicy(nil))
|
configWithMDM(t, ConfigInput{
|
||||||
_, err := UpdateOrCreateConfig(ConfigInput{
|
|
||||||
ConfigPath: tmp,
|
ConfigPath: tmp,
|
||||||
DisableClientRoutes: boolPtr(false),
|
DisableClientRoutes: boolPtr(false),
|
||||||
RosenpassEnabled: boolPtr(false),
|
RosenpassEnabled: boolPtr(false),
|
||||||
})
|
}, mdm.NewPolicy(nil))
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
// Now enable MDM enforcement for these keys.
|
// Now enable MDM enforcement for these keys.
|
||||||
withMDMPolicy(t, mdm.NewPolicy(map[string]any{
|
cfg := configWithMDM(t, ConfigInput{
|
||||||
|
ConfigPath: tmp,
|
||||||
|
}, mdm.NewPolicy(map[string]any{
|
||||||
mdm.KeyDisableClientRoutes: true,
|
mdm.KeyDisableClientRoutes: true,
|
||||||
mdm.KeyRosenpassEnabled: true,
|
mdm.KeyRosenpassEnabled: true,
|
||||||
}))
|
}))
|
||||||
|
|
||||||
cfg, err := UpdateOrCreateConfig(ConfigInput{ConfigPath: tmp})
|
|
||||||
require.NoError(t, err)
|
|
||||||
require.NotNil(t, cfg)
|
|
||||||
|
|
||||||
assert.True(t, cfg.DisableClientRoutes, "MDM override should flip on-disk false to true")
|
assert.True(t, cfg.DisableClientRoutes, "MDM override should flip on-disk false to true")
|
||||||
assert.True(t, cfg.RosenpassEnabled)
|
assert.True(t, cfg.RosenpassEnabled)
|
||||||
assert.True(t, cfg.Policy().HasKey(mdm.KeyDisableClientRoutes))
|
assert.True(t, cfg.Policy().HasKey(mdm.KeyDisableClientRoutes))
|
||||||
@@ -133,16 +152,12 @@ func TestApply_MDMBoolKeysOverrideOnDiskValue(t *testing.T) {
|
|||||||
func TestApply_MDMPreSharedKeyRedactionSentinelRejected(t *testing.T) {
|
func TestApply_MDMPreSharedKeyRedactionSentinelRejected(t *testing.T) {
|
||||||
const maskSentinel = "**********"
|
const maskSentinel = "**********"
|
||||||
|
|
||||||
withMDMPolicy(t, mdm.NewPolicy(map[string]any{
|
cfg := configWithMDM(t, ConfigInput{
|
||||||
|
ConfigPath: filepath.Join(t.TempDir(), "config.json"),
|
||||||
|
}, mdm.NewPolicy(map[string]any{
|
||||||
mdm.KeyPreSharedKey: maskSentinel,
|
mdm.KeyPreSharedKey: maskSentinel,
|
||||||
}))
|
}))
|
||||||
|
|
||||||
cfg, err := UpdateOrCreateConfig(ConfigInput{
|
|
||||||
ConfigPath: filepath.Join(t.TempDir(), "config.json"),
|
|
||||||
})
|
|
||||||
require.NoError(t, err)
|
|
||||||
require.NotNil(t, cfg)
|
|
||||||
|
|
||||||
// Mask sentinel must not be persisted as the actual PSK.
|
// Mask sentinel must not be persisted as the actual PSK.
|
||||||
assert.NotEqual(t, maskSentinel, cfg.PreSharedKey)
|
assert.NotEqual(t, maskSentinel, cfg.PreSharedKey)
|
||||||
// Key still marked managed so user writes are still rejected.
|
// Key still marked managed so user writes are still rejected.
|
||||||
|
|||||||
@@ -84,16 +84,48 @@ func NewPolicy(values map[string]any) *Policy {
|
|||||||
return &Policy{values: values}
|
return &Policy{values: values}
|
||||||
}
|
}
|
||||||
|
|
||||||
// LoadPolicy reads the platform-native MDM configuration. Returns an
|
// PolicyFetcher is implemented by mobile platforms (Android / iOS) that
|
||||||
// empty (but non-nil) Policy when no source is present, the source is
|
// push the OS-managed configuration into the Go runtime instead of
|
||||||
// empty, or the platform is unsupported.
|
// having Go read an on-disk source directly. Desktop platforms ignore
|
||||||
|
// this interface — Loader.loadPlatform on windows/darwin reads the
|
||||||
|
// registry / plist on its own. A Loader constructed with a non-nil
|
||||||
|
// fetcher delegates to it on mobile; passing nil disables MDM
|
||||||
|
// enforcement (loadPlatform returns nil values).
|
||||||
|
type PolicyFetcher interface {
|
||||||
|
Fetch() map[string]any
|
||||||
|
}
|
||||||
|
|
||||||
|
// Loader is the DI-friendly entry point for reading the active MDM
|
||||||
|
// policy. Construct one at the daemon's lifecycle owner (Server on
|
||||||
|
// desktop, gomobile-exposed bridge on mobile) and pass it to anything
|
||||||
|
// that needs to read MDM state (the reload ticker, profilemanager's
|
||||||
|
// Config). Each callsite has the Loader handed in instead of looking
|
||||||
|
// up package-level state.
|
||||||
|
type Loader struct {
|
||||||
|
fetcher PolicyFetcher
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewLoader constructs a Loader. The fetcher is consulted only on
|
||||||
|
// mobile builds (ios || android); on desktop it is unused but accepted
|
||||||
|
// to keep a single constructor signature across platforms — pass nil
|
||||||
|
// on desktop.
|
||||||
|
func NewLoader(f PolicyFetcher) *Loader {
|
||||||
|
return &Loader{fetcher: f}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load reads the platform-native MDM configuration and returns a
|
||||||
|
// Policy. Returns an empty (but non-nil) Policy when no source is
|
||||||
|
// present, the source is empty, or the platform is unsupported.
|
||||||
//
|
//
|
||||||
// Diagnostic logging differentiates the three states:
|
// Diagnostic logging differentiates the three states:
|
||||||
// - source absent / unsupported platform: trace log only
|
// - source absent / unsupported platform: trace log only
|
||||||
// - source present, zero keys: info "MDM enrolled (no managed keys)"
|
// - source present, zero keys: info "MDM enrolled (no managed keys)"
|
||||||
// - source present, N keys: info "MDM enrolled with N managed keys: [...]"
|
// - source present, N keys: info "MDM enrolled with N managed keys: [...]"
|
||||||
func LoadPolicy() *Policy {
|
func (l *Loader) Load() *Policy {
|
||||||
values, err := loadPlatformPolicy()
|
if l == nil {
|
||||||
|
return &Policy{values: map[string]any{}}
|
||||||
|
}
|
||||||
|
values, err := l.loadPlatform()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Tracef("MDM policy load: %v", err)
|
log.Tracef("MDM policy load: %v", err)
|
||||||
return &Policy{values: map[string]any{}}
|
return &Policy{values: map[string]any{}}
|
||||||
|
|||||||
@@ -25,8 +25,10 @@ import (
|
|||||||
// writable plist, as a defense against tampered installs.
|
// writable plist, as a defense against tampered installs.
|
||||||
const policyPlistPath = "/Library/Managed Preferences/io.netbird.client.plist"
|
const policyPlistPath = "/Library/Managed Preferences/io.netbird.client.plist"
|
||||||
|
|
||||||
// loadPlatformPolicy reads the MDM-managed configuration from the macOS
|
// loadPlatform reads the MDM-managed configuration from the macOS
|
||||||
// managed-preferences plist at policyPlistPath. Returns:
|
// managed-preferences plist at policyPlistPath. The Loader's fetcher
|
||||||
|
// field is unused on this platform — the plist is the authoritative
|
||||||
|
// source. Returns:
|
||||||
// - (nil, nil) when the plist is absent (device not MDM-enrolled for
|
// - (nil, nil) when the plist is absent (device not MDM-enrolled for
|
||||||
// NetBird, or admin has not yet pushed a payload)
|
// NetBird, or admin has not yet pushed a payload)
|
||||||
// - (map, nil) with N entries when N managed values are present
|
// - (map, nil) with N entries when N managed values are present
|
||||||
@@ -39,7 +41,13 @@ const policyPlistPath = "/Library/Managed Preferences/io.netbird.client.plist"
|
|||||||
// skipped so a stray entry in the payload does not block startup.
|
// skipped so a stray entry in the payload does not block startup.
|
||||||
// Native plist value types map naturally onto the Policy accessor
|
// Native plist value types map naturally onto the Policy accessor
|
||||||
// expectations (GetString / GetBool / GetInt / GetStringSlice).
|
// expectations (GetString / GetBool / GetInt / GetStringSlice).
|
||||||
func loadPlatformPolicy() (map[string]any, error) {
|
func (l *Loader) loadPlatform() (map[string]any, error) {
|
||||||
|
// Honour the injected fetcher when present so tests (and any
|
||||||
|
// future non-macOS MDM channel) can short-circuit the plist read
|
||||||
|
// with a scripted policy.
|
||||||
|
if l != nil && l.fetcher != nil {
|
||||||
|
return l.fetcher.Fetch(), nil
|
||||||
|
}
|
||||||
f, err := os.Open(policyPlistPath)
|
f, err := os.Open(policyPlistPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, fs.ErrNotExist) {
|
if errors.Is(err, fs.ErrNotExist) {
|
||||||
|
|||||||
@@ -2,13 +2,14 @@
|
|||||||
|
|
||||||
package mdm
|
package mdm
|
||||||
|
|
||||||
// loadPlatformPolicy is unused on mobile: the native layer (Swift on iOS,
|
// loadPlatform reads the OS-managed configuration via the native
|
||||||
// Kotlin/Java on Android) reads the OS managed-config store and pushes the
|
// PolicyFetcher injected at Loader construction. Returns
|
||||||
// resulting dictionary in-process via a gomobile entry point that lands in
|
// (nil, nil) — the platform-absent sentinel that Loader.Load treats as
|
||||||
// Phase 5 / Phase 6. The stub keeps the package compilable for mobile
|
// "no MDM source present" — when no fetcher was provided.
|
||||||
// builds and returns (nil, nil) — the platform-absent sentinel that
|
func (l *Loader) loadPlatform() (map[string]any, error) {
|
||||||
// LoadPolicy in policy.go treats as "no MDM source present".
|
if l == nil || l.fetcher == nil {
|
||||||
func loadPlatformPolicy() (map[string]any, error) {
|
//nolint:nilnil // (nil, nil) is the documented platform-absent sentinel; see Loader.Load.
|
||||||
//nolint:nilnil // (nil, nil) is the documented platform-absent sentinel; see LoadPolicy.
|
return nil, nil
|
||||||
return nil, nil
|
}
|
||||||
|
return l.fetcher.Fetch(), nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,13 +2,17 @@
|
|||||||
|
|
||||||
package mdm
|
package mdm
|
||||||
|
|
||||||
// loadPlatformPolicy returns no policy on platforms without an MDM channel
|
// loadPlatform reads the MDM policy on platforms without a native MDM
|
||||||
// (Linux, FreeBSD). MDM enforcement is off and the client behaves as if
|
// channel (Linux, FreeBSD). When no fetcher was injected the policy is
|
||||||
// the feature did not exist. Returns (nil, nil) — the platform-absent
|
// (nil, nil) — the platform-absent sentinel that Loader.Load treats as
|
||||||
// sentinel the caller (LoadPolicy in policy.go) treats as "no MDM
|
// "MDM enforcement disabled". A non-nil fetcher takes precedence: it
|
||||||
// source present"; an error here would just translate to the same
|
// is the test-seam used by unit tests to inject a scripted policy
|
||||||
// outcome with an extra log line.
|
// without touching the OS, and the same hook supports any future
|
||||||
func loadPlatformPolicy() (map[string]any, error) {
|
// non-mobile OS that grows an out-of-band MDM channel.
|
||||||
//nolint:nilnil // (nil, nil) is the documented platform-absent sentinel; see LoadPolicy.
|
func (l *Loader) loadPlatform() (map[string]any, error) {
|
||||||
|
if l != nil && l.fetcher != nil {
|
||||||
|
return l.fetcher.Fetch(), nil
|
||||||
|
}
|
||||||
|
//nolint:nilnil // (nil, nil) is the documented platform-absent sentinel; see Loader.Load.
|
||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -150,10 +150,12 @@ func TestPolicy_GetStringSlice(t *testing.T) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestLoadPolicy_PlatformStubReturnsEmpty(t *testing.T) {
|
func TestLoader_NilFetcherReturnsEmpty(t *testing.T) {
|
||||||
// loadPlatformPolicy is a stub on every OS for Phase 1. LoadPolicy must
|
// Loader.Load with no fetcher (desktop construction) must degrade
|
||||||
// degrade gracefully and never return nil.
|
// gracefully and never return nil; on linux loadPlatform is a stub
|
||||||
p := LoadPolicy()
|
// returning (nil, nil), and Load is expected to translate that
|
||||||
|
// into a non-nil empty Policy.
|
||||||
|
p := NewLoader(nil).Load()
|
||||||
require.NotNil(t, p)
|
require.NotNil(t, p)
|
||||||
assert.True(t, p.IsEmpty())
|
assert.True(t, p.IsEmpty())
|
||||||
assert.Empty(t, p.ManagedKeys())
|
assert.Empty(t, p.ManagedKeys())
|
||||||
|
|||||||
@@ -61,8 +61,10 @@ func readRegistryValue(k registry.Key, name, canonical string, out map[string]an
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// loadPlatformPolicy reads the MDM-managed configuration from the
|
// loadPlatform reads the MDM-managed configuration from the Windows
|
||||||
// Windows registry under HKLM\Software\Policies\NetBird. Returns:
|
// registry under HKLM\Software\Policies\NetBird. The Loader's fetcher
|
||||||
|
// field is unused on this platform — the registry is the
|
||||||
|
// authoritative source. Returns:
|
||||||
// - (nil, nil) when the key is absent (device not MDM-enrolled for NetBird)
|
// - (nil, nil) when the key is absent (device not MDM-enrolled for NetBird)
|
||||||
// - (map, nil) with N entries when N managed values are set (N may be 0)
|
// - (map, nil) with N entries when N managed values are set (N may be 0)
|
||||||
// - (nil, err) on open / enumerate registry errors
|
// - (nil, err) on open / enumerate registry errors
|
||||||
@@ -70,7 +72,13 @@ func readRegistryValue(k registry.Key, name, canonical string, out map[string]an
|
|||||||
// Per-value type coercion + skip-on-error is delegated to
|
// Per-value type coercion + skip-on-error is delegated to
|
||||||
// readRegistryValue. Unknown value names are logged and skipped so a
|
// readRegistryValue. Unknown value names are logged and skipped so a
|
||||||
// malformed deployment does not block startup.
|
// malformed deployment does not block startup.
|
||||||
func loadPlatformPolicy() (map[string]any, error) {
|
func (l *Loader) loadPlatform() (map[string]any, error) {
|
||||||
|
// Honour the injected fetcher when present so tests (and any
|
||||||
|
// future non-Windows MDM channel) can short-circuit the registry
|
||||||
|
// read with a scripted policy.
|
||||||
|
if l != nil && l.fetcher != nil {
|
||||||
|
return l.fetcher.Fetch(), nil
|
||||||
|
}
|
||||||
k, err := registry.OpenKey(registry.LOCAL_MACHINE, policyRegistryPath, registry.QUERY_VALUE)
|
k, err := registry.OpenKey(registry.LOCAL_MACHINE, policyRegistryPath, registry.QUERY_VALUE)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, registry.ErrNotExist) {
|
if errors.Is(err, registry.ErrNotExist) {
|
||||||
|
|||||||
@@ -15,33 +15,33 @@ import (
|
|||||||
// instead, hence anticipating the ticker mechanism entirely.
|
// instead, hence anticipating the ticker mechanism entirely.
|
||||||
const DefaultReloadInterval = 1 * time.Minute
|
const DefaultReloadInterval = 1 * time.Minute
|
||||||
|
|
||||||
// policyLoader is the indirection through which the ticker reads the
|
// Ticker periodically re-reads the OS-native MDM policy via the
|
||||||
// OS-native policy, both for the initial observation and on every tick.
|
// injected Loader and invokes the onChange callback (supplied to Run)
|
||||||
// Production points it at LoadPolicy; tests in this package override it to
|
// whenever the observed Policy diverges from the last observation
|
||||||
// feed a scripted sequence of policies without touching the real OS store.
|
// (added / removed / changed keys). Launch with Run from a goroutine;
|
||||||
var policyLoader = LoadPolicy
|
// cancel the supplied context to stop.
|
||||||
|
|
||||||
// Ticker periodically re-reads the OS-native MDM policy via LoadPolicy and
|
|
||||||
// invokes the onChange callback (supplied to Run) whenever the observed
|
|
||||||
// Policy diverges from the last observation (added / removed / changed
|
|
||||||
// keys). Launch with Run from a goroutine; cancel the supplied context
|
|
||||||
// to stop.
|
|
||||||
type Ticker struct {
|
type Ticker struct {
|
||||||
interval time.Duration
|
interval time.Duration
|
||||||
|
loader *Loader
|
||||||
prev *Policy
|
prev *Policy
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewTicker constructs a Ticker that will re-read the OS-native policy
|
// NewTicker constructs a Ticker that will re-read the OS-native policy
|
||||||
// every reloadInterval once Run is called.
|
// every reloadInterval once Run is called. The Loader is injected so
|
||||||
// The initial snapshot is populated by calling policyLoader at
|
// the ticker doesn't depend on any package-level state — production
|
||||||
|
// passes the daemon-owned Loader, tests pass a fake Loader (built with
|
||||||
|
// a fake PolicyFetcher).
|
||||||
|
//
|
||||||
|
// The initial snapshot is populated by calling loader.Load() at
|
||||||
// construction time so the first tick only fires
|
// construction time so the first tick only fires
|
||||||
// onChange when the policy actually changed since boot — without
|
// onChange when the policy actually changed since boot — without
|
||||||
// this baseline the first tick would report every currently-managed
|
// this baseline the first tick would report every currently-managed
|
||||||
// key as "added" and trigger a spurious engine restart.
|
// key as "added" and trigger a spurious engine restart.
|
||||||
func NewTicker(reloadInterval time.Duration) *Ticker {
|
func NewTicker(reloadInterval time.Duration, loader *Loader) *Ticker {
|
||||||
return &Ticker{
|
return &Ticker{
|
||||||
interval: reloadInterval,
|
interval: reloadInterval,
|
||||||
prev: policyLoader(),
|
loader: loader,
|
||||||
|
prev: loader.Load(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -58,7 +58,7 @@ func (t *Ticker) Run(ctx context.Context, onChange func(prev, curr *Policy) erro
|
|||||||
log.Info("MDM policy reload ticker stopped")
|
log.Info("MDM policy reload ticker stopped")
|
||||||
return
|
return
|
||||||
case <-tk.C:
|
case <-tk.C:
|
||||||
curr := policyLoader()
|
curr := t.loader.Load()
|
||||||
if policiesEqual(t.prev, curr) {
|
if policiesEqual(t.prev, curr) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,28 +13,40 @@ import (
|
|||||||
// testReloadInterval for speeding up the ticker cadence under `go test`
|
// testReloadInterval for speeding up the ticker cadence under `go test`
|
||||||
const testReloadInterval = 1 * time.Second
|
const testReloadInterval = 1 * time.Second
|
||||||
|
|
||||||
// withPolicyLoader overrides the package-level policyLoader for the duration
|
// fakePolicyFetcher implements PolicyFetcher returning a scripted
|
||||||
// of the test so the ticker observes a scripted policy instead of the real
|
// policy map. Goroutine-safe so the test can mutate the script while
|
||||||
// OS-native store. The original loader is restored on cleanup.
|
// the ticker is observing it.
|
||||||
func withPolicyLoader(t *testing.T, fn func() *Policy) {
|
type fakePolicyFetcher struct {
|
||||||
t.Helper()
|
mu sync.Mutex
|
||||||
prev := policyLoader
|
values map[string]any
|
||||||
policyLoader = fn
|
}
|
||||||
t.Cleanup(func() { policyLoader = prev })
|
|
||||||
|
func (f *fakePolicyFetcher) Fetch() map[string]any {
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
if f.values == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
out := make(map[string]any, len(f.values))
|
||||||
|
for k, v := range f.values {
|
||||||
|
out[k] = v
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakePolicyFetcher) set(values map[string]any) {
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
f.values = values
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestTicker_FiresOnChangeWithDelta(t *testing.T) {
|
func TestTicker_FiresOnChangeWithDelta(t *testing.T) {
|
||||||
var mu sync.Mutex
|
fetcher := &fakePolicyFetcher{} // initial observation: empty (no enforcement)
|
||||||
current := NewPolicy(nil) // initial observation: empty (no enforcement)
|
loader := NewLoader(fetcher)
|
||||||
withPolicyLoader(t, func() *Policy {
|
|
||||||
mu.Lock()
|
|
||||||
defer mu.Unlock()
|
|
||||||
return current
|
|
||||||
})
|
|
||||||
|
|
||||||
type change struct{ prev, curr *Policy }
|
type change struct{ prev, curr *Policy }
|
||||||
changes := make(chan change, 1)
|
changes := make(chan change, 1)
|
||||||
tk := NewTicker(testReloadInterval)
|
tk := NewTicker(testReloadInterval, loader)
|
||||||
require.Equal(t, testReloadInterval, tk.interval)
|
require.Equal(t, testReloadInterval, tk.interval)
|
||||||
|
|
||||||
ctx, cancel := context.WithCancel(context.Background())
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
@@ -49,15 +61,13 @@ func TestTicker_FiresOnChangeWithDelta(t *testing.T) {
|
|||||||
})
|
})
|
||||||
close(done)
|
close(done)
|
||||||
}()
|
}()
|
||||||
// Stop Run and wait for it to exit before returning, so the policyLoader
|
// Stop Run and wait for it to exit before returning, so the test
|
||||||
// restore in t.Cleanup can't race the ticker goroutine still reading it.
|
// goroutine doesn't race the still-running ticker.
|
||||||
defer func() { cancel(); <-done }()
|
defer func() { cancel(); <-done }()
|
||||||
|
|
||||||
// Flip the OS-observed policy from empty to one managed key. The next
|
// Flip the OS-observed policy from empty to one managed key. The
|
||||||
// tick must detect the diff and invoke onChange.
|
// next tick must detect the diff and invoke onChange.
|
||||||
mu.Lock()
|
fetcher.set(map[string]any{KeyManagementURL: "https://mdm.example.com:443"})
|
||||||
current = NewPolicy(map[string]any{KeyManagementURL: "https://mdm.example.com:443"})
|
|
||||||
mu.Unlock()
|
|
||||||
|
|
||||||
select {
|
select {
|
||||||
case c := <-changes:
|
case c := <-changes:
|
||||||
@@ -69,12 +79,11 @@ func TestTicker_FiresOnChangeWithDelta(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestTicker_NoCallbackWhenPolicyUnchanged(t *testing.T) {
|
func TestTicker_NoCallbackWhenPolicyUnchanged(t *testing.T) {
|
||||||
withPolicyLoader(t, func() *Policy {
|
fetcher := &fakePolicyFetcher{values: map[string]any{KeyBlockInbound: true}}
|
||||||
return NewPolicy(map[string]any{KeyBlockInbound: true})
|
loader := NewLoader(fetcher)
|
||||||
})
|
|
||||||
|
|
||||||
fired := make(chan struct{}, 1)
|
fired := make(chan struct{}, 1)
|
||||||
tk := NewTicker(testReloadInterval)
|
tk := NewTicker(testReloadInterval, loader)
|
||||||
|
|
||||||
ctx, cancel := context.WithCancel(context.Background())
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
done := make(chan struct{})
|
done := make(chan struct{})
|
||||||
@@ -90,8 +99,8 @@ func TestTicker_NoCallbackWhenPolicyUnchanged(t *testing.T) {
|
|||||||
}()
|
}()
|
||||||
defer func() { cancel(); <-done }()
|
defer func() { cancel(); <-done }()
|
||||||
|
|
||||||
// Over ~2 ticks at the 1s test cadence the policy never changes, so the
|
// Over ~2 ticks at the 1s test cadence the policy never changes,
|
||||||
// diff guard must suppress the callback entirely.
|
// so the diff guard must suppress the callback entirely.
|
||||||
select {
|
select {
|
||||||
case <-fired:
|
case <-fired:
|
||||||
t.Fatal("onChange fired despite an unchanged policy")
|
t.Fatal("onChange fired despite an unchanged policy")
|
||||||
|
|||||||
@@ -20,10 +20,6 @@ import (
|
|||||||
// a no-op echo, never as a conflict with the policy.
|
// a no-op echo, never as a conflict with the policy.
|
||||||
const preSharedKeyRedactedSentinel = "**********"
|
const preSharedKeyRedactedSentinel = "**********"
|
||||||
|
|
||||||
// loadMDMPolicy is the indirection used by server handlers to read the
|
|
||||||
// active MDM policy. Tests override this to inject a fake policy.
|
|
||||||
var loadMDMPolicy = mdm.LoadPolicy
|
|
||||||
|
|
||||||
// conflictCheck is a value-aware comparison between a single field in
|
// conflictCheck is a value-aware comparison between a single field in
|
||||||
// the incoming request and the corresponding MDM-enforced value. It
|
// the incoming request and the corresponding MDM-enforced value. It
|
||||||
// runs only when the field was actually set in the request (presence
|
// runs only when the field was actually set in the request (presence
|
||||||
|
|||||||
@@ -110,6 +110,15 @@ type Server struct {
|
|||||||
// stopped by the rootCtx cancellation.
|
// stopped by the rootCtx cancellation.
|
||||||
mdmTicker *mdm.Ticker
|
mdmTicker *mdm.Ticker
|
||||||
|
|
||||||
|
// mdmLoader is the daemon-owned source of the active MDM policy.
|
||||||
|
// Constructed once during Server.Start (with a nil PolicyFetcher on
|
||||||
|
// desktop — the build-tagged Loader.loadPlatform reads the OS
|
||||||
|
// registry / plist directly) and injected into every consumer:
|
||||||
|
// mdmTicker for its periodic reload, the SetConfig / Login MDM
|
||||||
|
// gates for conflict detection, and every Config produced via
|
||||||
|
// getConfig() so its apply() picks up the same overlay.
|
||||||
|
mdmLoader *mdm.Loader
|
||||||
|
|
||||||
updateManager *updater.Manager
|
updateManager *updater.Manager
|
||||||
|
|
||||||
jwtCache *jwtCache
|
jwtCache *jwtCache
|
||||||
@@ -173,8 +182,14 @@ func (s *Server) Start() error {
|
|||||||
// Runs re-resolves Config (re-running profilemanager.Config.apply which
|
// Runs re-resolves Config (re-running profilemanager.Config.apply which
|
||||||
// applies the freshly-read MDM policy as the last layer) and brings
|
// applies the freshly-read MDM policy as the last layer) and brings
|
||||||
// the engine back with the new values.
|
// the engine back with the new values.
|
||||||
|
if s.mdmLoader == nil {
|
||||||
|
// Desktop builds pass a nil PolicyFetcher: the Loader's
|
||||||
|
// build-tagged loadPlatform reads the OS source directly
|
||||||
|
// (registry on Windows, plist on macOS, no-op elsewhere).
|
||||||
|
s.mdmLoader = mdm.NewLoader(nil)
|
||||||
|
}
|
||||||
if s.mdmTicker == nil {
|
if s.mdmTicker == nil {
|
||||||
s.mdmTicker = mdm.NewTicker(mdm.DefaultReloadInterval)
|
s.mdmTicker = mdm.NewTicker(mdm.DefaultReloadInterval, s.mdmLoader)
|
||||||
go s.mdmTicker.Run(s.rootCtx, s.onMDMPolicyChange)
|
go s.mdmTicker.Run(s.rootCtx, s.onMDMPolicyChange)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -370,7 +385,7 @@ func (s *Server) SetConfig(callerCtx context.Context, msg *proto.SetConfigReques
|
|||||||
// by the active MDM policy. The error carries an MDMManagedFields-
|
// by the active MDM policy. The error carries an MDMManagedFields-
|
||||||
// Violation detail listing the offending key names. Non-conflicting
|
// Violation detail listing the offending key names. Non-conflicting
|
||||||
// fields in the same request are not applied either.
|
// fields in the same request are not applied either.
|
||||||
policy := loadMDMPolicy()
|
policy := s.mdmLoader.Load()
|
||||||
if err := rejectMDMManagedFieldConflicts(mdmManagedFieldConflicts(msg, policy)); err != nil {
|
if err := rejectMDMManagedFieldConflicts(mdmManagedFieldConflicts(msg, policy)); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -496,7 +511,7 @@ func (s *Server) Login(callerCtx context.Context, msg *proto.LoginRequest) (*pro
|
|||||||
if s.checkUpdateSettingsDisabled() {
|
if s.checkUpdateSettingsDisabled() {
|
||||||
return nil, gstatus.Errorf(codes.Unavailable, errUpdateSettingsDisabled)
|
return nil, gstatus.Errorf(codes.Unavailable, errUpdateSettingsDisabled)
|
||||||
}
|
}
|
||||||
policy := loadMDMPolicy()
|
policy := s.mdmLoader.Load()
|
||||||
if err := rejectMDMManagedFieldConflicts(loginRequestMDMConflicts(msg, policy)); err != nil {
|
if err := rejectMDMManagedFieldConflicts(loginRequestMDMConflicts(msg, policy)); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -1088,6 +1103,12 @@ func (s *Server) getConfig(activeProf *profilemanager.ActiveProfileState) (*prof
|
|||||||
return nil, false, fmt.Errorf("failed to get config: %w", err)
|
return nil, false, fmt.Errorf("failed to get config: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Apply the daemon-owned MDM policy on top of the just-resolved
|
||||||
|
// Config. profilemanager's apply() initialises the policy to
|
||||||
|
// empty — the Loader lives outside Config, so this overlay step
|
||||||
|
// is driven externally here.
|
||||||
|
config.ApplyMDMPolicy(s.mdmLoader.Load())
|
||||||
|
|
||||||
return config, configExisted, nil
|
return config, configExisted, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1142,6 +1163,9 @@ func (s *Server) logoutFromProfile(ctx context.Context, profileName, username st
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("profile '%s' not found", profileName)
|
return fmt.Errorf("profile '%s' not found", profileName)
|
||||||
}
|
}
|
||||||
|
// Honour any MDM-enforced ManagementURL when issuing the logout
|
||||||
|
// RPC: the user-stored value may have been overridden by policy.
|
||||||
|
config.ApplyMDMPolicy(s.mdmLoader.Load())
|
||||||
|
|
||||||
return s.sendLogoutRequestWithConfig(ctx, config)
|
return s.sendLogoutRequestWithConfig(ctx, config)
|
||||||
}
|
}
|
||||||
@@ -1574,6 +1598,11 @@ func (s *Server) GetConfig(ctx context.Context, req *proto.GetConfigRequest) (*p
|
|||||||
log.Errorf("failed to get active profile config: %v", err)
|
log.Errorf("failed to get active profile config: %v", err)
|
||||||
return nil, fmt.Errorf("failed to get active profile config: %w", err)
|
return nil, fmt.Errorf("failed to get active profile config: %w", err)
|
||||||
}
|
}
|
||||||
|
// Overlay the active MDM policy so the response's MDMManagedFields
|
||||||
|
// list reflects what the GUI / CLI must render as read-only.
|
||||||
|
// profilemanager.GetConfig itself returns a Config without the
|
||||||
|
// overlay (Loader lives outside profilemanager).
|
||||||
|
cfg.ApplyMDMPolicy(s.mdmLoader.Load())
|
||||||
managementURL := cfg.ManagementURL
|
managementURL := cfg.ManagementURL
|
||||||
adminURL := cfg.AdminURL
|
adminURL := cfg.AdminURL
|
||||||
|
|
||||||
|
|||||||
@@ -16,14 +16,40 @@ import (
|
|||||||
"github.com/netbirdio/netbird/client/proto"
|
"github.com/netbirdio/netbird/client/proto"
|
||||||
)
|
)
|
||||||
|
|
||||||
// withMDMPolicy temporarily overrides the server-package loadMDMPolicy hook
|
// fakeMDMFetcher implements mdm.PolicyFetcher returning a pre-set
|
||||||
// so SetConfig observes the supplied Policy. Restores the original loader
|
// policy map. Tests build one per Server instance to inject a
|
||||||
// at test cleanup.
|
// scripted MDM overlay via a Loader rather than via package-level state.
|
||||||
func withMDMPolicy(t *testing.T, policy *mdm.Policy) {
|
type fakeMDMFetcher struct{ values map[string]any }
|
||||||
|
|
||||||
|
func (f *fakeMDMFetcher) Fetch() map[string]any { return f.values }
|
||||||
|
|
||||||
|
// withMDMPolicy installs an mdm.Loader on the given Server whose
|
||||||
|
// loadPlatform returns the supplied Policy's underlying values. Use
|
||||||
|
// after setupServerWithProfile to inject the scripted policy the
|
||||||
|
// SetConfig / Login MDM gates will observe.
|
||||||
|
func withMDMPolicy(t *testing.T, s *Server, policy *mdm.Policy) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
prev := loadMDMPolicy
|
values := map[string]any{}
|
||||||
loadMDMPolicy = func() *mdm.Policy { return policy }
|
if policy != nil {
|
||||||
t.Cleanup(func() { loadMDMPolicy = prev })
|
for _, k := range policy.ManagedKeys() {
|
||||||
|
if v, ok := policy.GetString(k); ok {
|
||||||
|
values[k] = v
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if v, ok := policy.GetBool(k); ok {
|
||||||
|
values[k] = v
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if v, ok := policy.GetInt(k); ok {
|
||||||
|
values[k] = v
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if v, ok := policy.GetStringSlice(k); ok {
|
||||||
|
values[k] = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
s.mdmLoader = mdm.NewLoader(&fakeMDMFetcher{values: values})
|
||||||
}
|
}
|
||||||
|
|
||||||
// setupServerWithProfile mirrors the boilerplate of TestSetConfig_AllFieldsSaved:
|
// setupServerWithProfile mirrors the boilerplate of TestSetConfig_AllFieldsSaved:
|
||||||
@@ -89,12 +115,11 @@ func extractViolation(t *testing.T, err error) *proto.MDMManagedFieldsViolation
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestSetConfig_MDMReject_SingleField(t *testing.T) {
|
func TestSetConfig_MDMReject_SingleField(t *testing.T) {
|
||||||
withMDMPolicy(t, mdm.NewPolicy(map[string]any{
|
s, ctx, profName, username, _ := setupServerWithProfile(t)
|
||||||
|
withMDMPolicy(t, s, mdm.NewPolicy(map[string]any{
|
||||||
mdm.KeyManagementURL: "https://mdm.example.com:443",
|
mdm.KeyManagementURL: "https://mdm.example.com:443",
|
||||||
}))
|
}))
|
||||||
|
|
||||||
s, ctx, profName, username, _ := setupServerWithProfile(t)
|
|
||||||
|
|
||||||
_, err := s.SetConfig(ctx, &proto.SetConfigRequest{
|
_, err := s.SetConfig(ctx, &proto.SetConfigRequest{
|
||||||
ProfileName: profName,
|
ProfileName: profName,
|
||||||
Username: username,
|
Username: username,
|
||||||
@@ -106,13 +131,12 @@ func TestSetConfig_MDMReject_SingleField(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestSetConfig_MDMReject_MultipleFields(t *testing.T) {
|
func TestSetConfig_MDMReject_MultipleFields(t *testing.T) {
|
||||||
withMDMPolicy(t, mdm.NewPolicy(map[string]any{
|
|
||||||
mdm.KeyManagementURL: "https://mdm.example.com:443",
|
|
||||||
mdm.KeyBlockInbound: true,
|
|
||||||
mdm.KeyRosenpassEnabled: true,
|
|
||||||
}))
|
|
||||||
|
|
||||||
s, ctx, profName, username, _ := setupServerWithProfile(t)
|
s, ctx, profName, username, _ := setupServerWithProfile(t)
|
||||||
|
withMDMPolicy(t, s, mdm.NewPolicy(map[string]any{
|
||||||
|
mdm.KeyManagementURL: "https://mdm.example.com:443",
|
||||||
|
mdm.KeyBlockInbound: true,
|
||||||
|
mdm.KeyRosenpassEnabled: true,
|
||||||
|
}))
|
||||||
|
|
||||||
blockInbound := false
|
blockInbound := false
|
||||||
rosenpassEnabled := false
|
rosenpassEnabled := false
|
||||||
@@ -137,12 +161,11 @@ func TestSetConfig_MDMReject_AllOrNothing(t *testing.T) {
|
|||||||
// enforced field AND a non-enforced field (RosenpassEnabled).
|
// enforced field AND a non-enforced field (RosenpassEnabled).
|
||||||
// The whole request must be rejected — non-conflicting fields are not
|
// The whole request must be rejected — non-conflicting fields are not
|
||||||
// applied either.
|
// applied either.
|
||||||
withMDMPolicy(t, mdm.NewPolicy(map[string]any{
|
s, ctx, profName, username, cfgPath := setupServerWithProfile(t)
|
||||||
|
withMDMPolicy(t, s, mdm.NewPolicy(map[string]any{
|
||||||
mdm.KeyManagementURL: "https://mdm.example.com:443",
|
mdm.KeyManagementURL: "https://mdm.example.com:443",
|
||||||
}))
|
}))
|
||||||
|
|
||||||
s, ctx, profName, username, cfgPath := setupServerWithProfile(t)
|
|
||||||
|
|
||||||
rosenpassEnabled := true
|
rosenpassEnabled := true
|
||||||
_, err := s.SetConfig(ctx, &proto.SetConfigRequest{
|
_, err := s.SetConfig(ctx, &proto.SetConfigRequest{
|
||||||
ProfileName: profName,
|
ProfileName: profName,
|
||||||
@@ -164,12 +187,11 @@ func TestSetConfig_MDMReject_AllOrNothing(t *testing.T) {
|
|||||||
func TestSetConfig_MDMAllow_NonManagedFields(t *testing.T) {
|
func TestSetConfig_MDMAllow_NonManagedFields(t *testing.T) {
|
||||||
// MDM enforces ManagementURL but the user only writes RosenpassEnabled.
|
// MDM enforces ManagementURL but the user only writes RosenpassEnabled.
|
||||||
// Request must succeed.
|
// Request must succeed.
|
||||||
withMDMPolicy(t, mdm.NewPolicy(map[string]any{
|
s, ctx, profName, username, _ := setupServerWithProfile(t)
|
||||||
|
withMDMPolicy(t, s, mdm.NewPolicy(map[string]any{
|
||||||
mdm.KeyManagementURL: "https://mdm.example.com:443",
|
mdm.KeyManagementURL: "https://mdm.example.com:443",
|
||||||
}))
|
}))
|
||||||
|
|
||||||
s, ctx, profName, username, _ := setupServerWithProfile(t)
|
|
||||||
|
|
||||||
rosenpassEnabled := true
|
rosenpassEnabled := true
|
||||||
resp, err := s.SetConfig(ctx, &proto.SetConfigRequest{
|
resp, err := s.SetConfig(ctx, &proto.SetConfigRequest{
|
||||||
ProfileName: profName,
|
ProfileName: profName,
|
||||||
@@ -183,9 +205,8 @@ func TestSetConfig_MDMAllow_NonManagedFields(t *testing.T) {
|
|||||||
|
|
||||||
func TestSetConfig_MDMEmpty_NoEnforcement(t *testing.T) {
|
func TestSetConfig_MDMEmpty_NoEnforcement(t *testing.T) {
|
||||||
// No MDM policy active: any field can be written.
|
// No MDM policy active: any field can be written.
|
||||||
withMDMPolicy(t, mdm.NewPolicy(nil))
|
|
||||||
|
|
||||||
s, ctx, profName, username, _ := setupServerWithProfile(t)
|
s, ctx, profName, username, _ := setupServerWithProfile(t)
|
||||||
|
withMDMPolicy(t, s, mdm.NewPolicy(nil))
|
||||||
|
|
||||||
resp, err := s.SetConfig(ctx, &proto.SetConfigRequest{
|
resp, err := s.SetConfig(ctx, &proto.SetConfigRequest{
|
||||||
ProfileName: profName,
|
ProfileName: profName,
|
||||||
|
|||||||
Reference in New Issue
Block a user