mirror of
https://github.com/netbirdio/netbird.git
synced 2026-09-15 19:29:08 +02:00
Merge branch 'main' into embedded-vnc
This commit is contained in:
+29
-10
@@ -270,6 +270,8 @@ type Engine struct {
|
||||
// checks are the client-applied posture checks that need to be evaluated on the client
|
||||
checks []*mgmProto.Checks
|
||||
|
||||
infoSource system.InfoSource
|
||||
|
||||
relayManager *relayClient.Manager
|
||||
stateManager *statemanager.Manager
|
||||
portForwardManager *portforward.Manager
|
||||
@@ -1251,9 +1253,7 @@ func (e *Engine) updateChecksIfNew(checks []*mgmProto.Checks) error {
|
||||
if isChecksEqual(e.checks, checks) {
|
||||
return nil
|
||||
}
|
||||
e.checks = checks
|
||||
|
||||
info, ok := system.GetInfoWithChecksTimeout(e.ctx, systemInfoTimeout, checks, e.overlayAddresses()...)
|
||||
info, ok := e.infoSource.Refresh(e.ctx, systemInfoTimeout, checks, e.overlayAddresses()...)
|
||||
if !ok {
|
||||
// Gathering timed out; skip the meta sync this cycle rather than blocking the
|
||||
// sync loop (and syncMsgMux) on a stuck system call. A later sync will retry.
|
||||
@@ -1264,6 +1264,7 @@ func (e *Engine) updateChecksIfNew(checks []*mgmProto.Checks) error {
|
||||
if err := e.mgmClient.SyncMeta(info); err != nil {
|
||||
return fmt.Errorf("could not sync meta: error %s", err)
|
||||
}
|
||||
e.checks = checks
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -1291,6 +1292,28 @@ func (e *Engine) applyInfoFlags(info *system.Info) {
|
||||
)
|
||||
}
|
||||
|
||||
func (e *Engine) currentSystemInfo(ctx context.Context) *system.Info {
|
||||
info := e.infoSource.Current(ctx, e.overlayAddresses()...)
|
||||
e.applyInfoFlags(info)
|
||||
return info
|
||||
}
|
||||
|
||||
// syncInfoFunc returns the info callback for the management sync stream. The
|
||||
// first connect sends the info refreshed right before it instead of gathering
|
||||
// again; every reconnect gathers a fresh one. The stream retry loop calls the
|
||||
// callback sequentially, so the handoff needs no synchronization.
|
||||
func (e *Engine) syncInfoFunc(refreshed *system.Info) func(ctx context.Context) *system.Info {
|
||||
return func(ctx context.Context) *system.Info {
|
||||
if refreshed == nil {
|
||||
return e.currentSystemInfo(ctx)
|
||||
}
|
||||
info := refreshed
|
||||
refreshed = nil
|
||||
e.applyInfoFlags(info)
|
||||
return info
|
||||
}
|
||||
}
|
||||
|
||||
// overlayAddresses returns our own WireGuard overlay address (v4 and v6) so it
|
||||
// can be excluded from the reported network addresses; the interface coming and
|
||||
// going otherwise churns the peer meta on the management server.
|
||||
@@ -1488,15 +1511,11 @@ func (e *Engine) receiveManagementEvents() {
|
||||
e.shutdownWg.Add(1)
|
||||
go func() {
|
||||
defer e.shutdownWg.Done()
|
||||
info, ok := system.GetInfoWithChecksTimeout(e.ctx, systemInfoTimeout, e.checks, e.overlayAddresses()...)
|
||||
info, ok := e.infoSource.Refresh(e.ctx, systemInfoTimeout, e.checks, e.overlayAddresses()...)
|
||||
if !ok {
|
||||
// Gathering timed out; connect the stream with base info so management
|
||||
// connectivity still comes up rather than blocking here.
|
||||
info = system.GetInfo(e.ctx)
|
||||
log.Warnf("posture checks not refreshed before the sync connect, sending the previous results")
|
||||
}
|
||||
e.applyInfoFlags(info)
|
||||
|
||||
err := e.mgmClient.Sync(e.ctx, info, e.handleSync)
|
||||
err := e.mgmClient.Sync(e.ctx, e.syncInfoFunc(info), e.handleSync)
|
||||
if err != nil {
|
||||
// happens if management is unavailable for a long time.
|
||||
// We want to cancel the operation of the whole client
|
||||
|
||||
@@ -193,7 +193,7 @@ func TestEngine_Sync(t *testing.T) {
|
||||
// feed updates to Engine via mocked Management client
|
||||
updates := make(chan *mgmtProto.SyncResponse)
|
||||
defer close(updates)
|
||||
syncFunc := func(ctx context.Context, info *system.Info, msgHandler func(msg *mgmtProto.SyncResponse) error) error {
|
||||
syncFunc := func(ctx context.Context, _ func(context.Context) *system.Info, msgHandler func(msg *mgmtProto.SyncResponse) error) error {
|
||||
for msg := range updates {
|
||||
err := msgHandler(msg)
|
||||
if err != nil {
|
||||
|
||||
@@ -2,6 +2,7 @@ package internal
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/netip"
|
||||
@@ -31,6 +32,7 @@ import (
|
||||
icemaker "github.com/netbirdio/netbird/client/internal/peer/ice"
|
||||
"github.com/netbirdio/netbird/client/internal/profilemanager"
|
||||
"github.com/netbirdio/netbird/client/internal/routemanager"
|
||||
"github.com/netbirdio/netbird/client/system"
|
||||
nbdns "github.com/netbirdio/netbird/dns"
|
||||
"github.com/netbirdio/netbird/monotime"
|
||||
"github.com/netbirdio/netbird/route"
|
||||
@@ -253,6 +255,118 @@ func TestEngine_SSHServerConsistency(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
func TestEngine_FirstSyncInfoCarriesLoginChecks(t *testing.T) {
|
||||
key, err := wgtypes.GeneratePrivateKey()
|
||||
require.NoError(t, err)
|
||||
|
||||
exe, err := os.Executable()
|
||||
require.NoError(t, err)
|
||||
|
||||
ctx, cancel := context.WithCancel(CtxInitState(context.Background()))
|
||||
defer cancel()
|
||||
|
||||
infos := make(chan *system.Info, 1)
|
||||
mgmClient := &mgmt.MockClient{
|
||||
SyncFunc: func(ctx context.Context, getInfo func(context.Context) *system.Info, _ func(*mgmtProto.SyncResponse) error) error {
|
||||
infos <- getInfo(ctx)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
relayMgr := relayClient.NewManager(ctx, nil, key.PublicKey().String(), iface.DefaultMTU)
|
||||
engine := NewEngine(ctx, cancel, &EngineConfig{
|
||||
WgIfaceName: "utun104",
|
||||
WgAddr: wgaddr.MustParseWGAddress("100.64.0.1/24"),
|
||||
WgPrivateKey: key,
|
||||
WgPort: 33100,
|
||||
MTU: iface.DefaultMTU,
|
||||
}, EngineServices{
|
||||
SignalClient: &signal.MockClient{},
|
||||
MgmClient: mgmClient,
|
||||
RelayManager: relayMgr,
|
||||
StatusRecorder: peer.NewRecorder("https://mgm"),
|
||||
Checks: []*mgmtProto.Checks{{Files: []string{exe}}},
|
||||
}, MobileDependency{})
|
||||
|
||||
engine.receiveManagementEvents()
|
||||
|
||||
select {
|
||||
case info := <-infos:
|
||||
require.Len(t, info.Files, 1)
|
||||
assert.Equal(t, exe, info.Files[0].Path)
|
||||
assert.True(t, info.Files[0].Exist)
|
||||
case <-time.After(20 * time.Second):
|
||||
t.Fatal("timeout waiting for the first sync info")
|
||||
}
|
||||
engine.shutdownWg.Wait()
|
||||
}
|
||||
|
||||
func TestEngine_SyncInfoFuncReusesRefreshedInfoOnce(t *testing.T) {
|
||||
engine := &Engine{config: &EngineConfig{}}
|
||||
|
||||
refreshed := &system.Info{Hostname: "from-refresh"}
|
||||
getInfo := engine.syncInfoFunc(refreshed)
|
||||
|
||||
first := getInfo(context.Background())
|
||||
assert.Same(t, refreshed, first, "the first connect should send the refreshed info instead of gathering again")
|
||||
|
||||
second := getInfo(context.Background())
|
||||
assert.NotSame(t, refreshed, second, "the reconnect should gather a fresh info")
|
||||
assert.NotEqual(t, "from-refresh", second.Hostname, "the fresh info should not carry the refreshed hostname")
|
||||
}
|
||||
|
||||
func TestEngine_SyncInfoFuncGathersWhenRefreshFailed(t *testing.T) {
|
||||
engine := &Engine{config: &EngineConfig{}}
|
||||
|
||||
info := engine.syncInfoFunc(nil)(context.Background())
|
||||
require.NotNil(t, info, "a failed refresh should fall back to gathering the info")
|
||||
assert.NotEmpty(t, info.Hostname, "the gathered info should carry the hostname")
|
||||
}
|
||||
|
||||
func TestEngine_UpdateChecksIfNewRetriesAfterFailedSyncMeta(t *testing.T) {
|
||||
key, err := wgtypes.GeneratePrivateKey()
|
||||
require.NoError(t, err)
|
||||
|
||||
exe, err := os.Executable()
|
||||
require.NoError(t, err)
|
||||
|
||||
ctx, cancel := context.WithCancel(CtxInitState(context.Background()))
|
||||
defer cancel()
|
||||
|
||||
syncMetaCalls := 0
|
||||
mgmClient := &mgmt.MockClient{
|
||||
SyncMetaFunc: func(*system.Info) error {
|
||||
syncMetaCalls++
|
||||
if syncMetaCalls == 1 {
|
||||
return errors.New("management unavailable")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
relayMgr := relayClient.NewManager(ctx, nil, key.PublicKey().String(), iface.DefaultMTU)
|
||||
engine := NewEngine(ctx, cancel, &EngineConfig{
|
||||
WgIfaceName: "utun105",
|
||||
WgAddr: wgaddr.MustParseWGAddress("100.64.0.1/24"),
|
||||
WgPrivateKey: key,
|
||||
WgPort: 33100,
|
||||
MTU: iface.DefaultMTU,
|
||||
}, EngineServices{
|
||||
SignalClient: &signal.MockClient{},
|
||||
MgmClient: mgmClient,
|
||||
RelayManager: relayMgr,
|
||||
StatusRecorder: peer.NewRecorder("https://mgm"),
|
||||
}, MobileDependency{})
|
||||
|
||||
checks := []*mgmtProto.Checks{{Files: []string{exe}}}
|
||||
|
||||
require.Error(t, engine.updateChecksIfNew(checks))
|
||||
require.NoError(t, engine.updateChecksIfNew(checks))
|
||||
require.NoError(t, engine.updateChecksIfNew(checks))
|
||||
|
||||
assert.Equal(t, 2, syncMetaCalls)
|
||||
}
|
||||
|
||||
func TestEngine_UpdateNetworkMap(t *testing.T) {
|
||||
// test setup
|
||||
key, err := wgtypes.GeneratePrivateKey()
|
||||
|
||||
@@ -58,10 +58,6 @@ var DefaultInterfaceBlacklist = []string{
|
||||
"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
|
||||
type ConfigInput struct {
|
||||
ManagementURL string
|
||||
@@ -206,14 +202,26 @@ type Config struct {
|
||||
|
||||
MTU uint16
|
||||
|
||||
// policy is the MDM policy that produced the currently-set values for
|
||||
// any MDM-enforced fields. Set by applyMDMPolicy at the tail of apply()
|
||||
// and reset on every apply() invocation. Never persisted to disk.
|
||||
// Callers query enforcement state via Policy() and the mdm.Policy API
|
||||
// (HasKey, ManagedKeys, IsEmpty).
|
||||
// policy is the MDM policy that produced the currently-set values
|
||||
// for any MDM-enforced fields. Set by ApplyMDMPolicy on every
|
||||
// invocation. Never persisted to disk. Callers query enforcement
|
||||
// state via Policy() and the mdm.Policy API (HasKey, ManagedKeys,
|
||||
// IsEmpty).
|
||||
policy *mdm.Policy `json:"-"`
|
||||
}
|
||||
|
||||
// ApplyMDMPolicy overlays the supplied MDM Policy on top of the current
|
||||
// Config values and records it as Policy(). The overlay is not reversible:
|
||||
// an empty Policy only clears the enforcement metadata, so resolve the base
|
||||
// Config again (from disk or JSON) before applying a changed policy, the way
|
||||
// the lifecycle owners do on every load.
|
||||
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
|
||||
// empty Policy when MDM enforcement is inactive; callers can always invoke
|
||||
// HasKey / ManagedKeys / IsEmpty without a nil check.
|
||||
@@ -743,9 +751,11 @@ func (config *Config) apply(input ConfigInput) (updated bool, err error) {
|
||||
updated = true
|
||||
}
|
||||
|
||||
// MDM is the last override layer: any key present in the policy
|
||||
// supersedes defaults, on-disk config, env vars and CLI input.
|
||||
config.applyMDMPolicy(loadMDMPolicy())
|
||||
// Initialise the MDM overlay to "no enforcement" so Config.Policy()
|
||||
// never returns a stale or nil policy on a freshly applied Config.
|
||||
// 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
|
||||
}
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
package profilemanager
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"github.com/netbirdio/netbird/client/mdm"
|
||||
)
|
||||
|
||||
// ErrMDMManagedFields marks a config change rejected because it diverges from
|
||||
// MDM-enforced values.
|
||||
var ErrMDMManagedFields = errors.New("fields managed by MDM cannot be modified")
|
||||
|
||||
// MDMConflicts returns the names of MDM-managed keys whose requested value in
|
||||
// the ConfigInput differs from the policy-enforced value; a field set to the
|
||||
// enforced value is a no-op echo, not a conflict.
|
||||
func MDMConflicts(input ConfigInput, policy *mdm.Policy) []string {
|
||||
pskGot := input.PreSharedKey
|
||||
if isPreSharedKeyHidden(pskGot) {
|
||||
pskGot = nil
|
||||
}
|
||||
var port *int64
|
||||
if input.WireguardPort != nil {
|
||||
v := int64(*input.WireguardPort)
|
||||
port = &v
|
||||
}
|
||||
return mdm.ResolveConflicts(policy, []mdm.ConflictCheck{
|
||||
mdm.ConflictURL(mdm.KeyManagementURL, input.ManagementURL),
|
||||
mdm.ConflictStringPtr(mdm.KeyPreSharedKey, pskGot),
|
||||
mdm.ConflictBool(mdm.KeyRosenpassEnabled, input.RosenpassEnabled),
|
||||
mdm.ConflictBool(mdm.KeyRosenpassPermissive, input.RosenpassPermissive),
|
||||
mdm.ConflictBool(mdm.KeyDisableAutoConnect, input.DisableAutoConnect),
|
||||
mdm.ConflictBool(mdm.KeyAllowServerSSH, input.ServerSSHAllowed),
|
||||
mdm.ConflictBool(mdm.KeyRemoteJobsAllowed, input.RemoteJobsAllowed),
|
||||
mdm.ConflictBool(mdm.KeyDisableClientRoutes, input.DisableClientRoutes),
|
||||
mdm.ConflictBool(mdm.KeyDisableServerRoutes, input.DisableServerRoutes),
|
||||
mdm.ConflictBool(mdm.KeyBlockInbound, input.BlockInbound),
|
||||
mdm.ConflictInt64(mdm.KeyWireguardPort, port),
|
||||
mdm.ConflictBool(mdm.KeyEnableLocalMetrics, input.LocalMetricsEnabled),
|
||||
mdm.ConflictStringPtr(mdm.KeyLocalMetricsAddress, input.LocalMetricsAddress),
|
||||
})
|
||||
}
|
||||
|
||||
// CheckMDMConflicts returns an ErrMDMManagedFields-wrapped error naming the
|
||||
// conflicting keys, or nil when the input does not fight the policy.
|
||||
func CheckMDMConflicts(input ConfigInput, policy *mdm.Policy) error {
|
||||
conflicts := MDMConflicts(input, policy)
|
||||
if len(conflicts) == 0 {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("%w: %v", ErrMDMManagedFields, conflicts)
|
||||
}
|
||||
@@ -10,24 +10,58 @@ import (
|
||||
"github.com/netbirdio/netbird/client/mdm"
|
||||
)
|
||||
|
||||
// withMDMPolicy temporarily overrides the package-level loadMDMPolicy hook so
|
||||
// apply() observes the supplied Policy. The original loader is restored at
|
||||
// test cleanup.
|
||||
func withMDMPolicy(t *testing.T, policy *mdm.Policy) {
|
||||
// fakeFetcher implements mdm.PolicyFetcher returning a pre-set policy
|
||||
// map. Test helper used to construct a Loader without touching the OS
|
||||
// or any package-level state.
|
||||
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.GetInt(k); ok {
|
||||
values[k] = v
|
||||
continue
|
||||
}
|
||||
if v, ok := policy.GetBool(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()
|
||||
prev := loadMDMPolicy
|
||||
loadMDMPolicy = func() *mdm.Policy { return policy }
|
||||
t.Cleanup(func() { loadMDMPolicy = prev })
|
||||
cfg, err := UpdateOrCreateConfig(input)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, cfg)
|
||||
cfg.ApplyMDMPolicy(loaderFor(policy).Load())
|
||||
return cfg
|
||||
}
|
||||
|
||||
func TestApply_MDMEmpty_NoEnforcement(t *testing.T) {
|
||||
withMDMPolicy(t, mdm.NewPolicy(nil))
|
||||
|
||||
cfg, err := UpdateOrCreateConfig(ConfigInput{
|
||||
cfg := configWithMDM(t, ConfigInput{
|
||||
ConfigPath: filepath.Join(t.TempDir(), "config.json"),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, cfg)
|
||||
}, mdm.NewPolicy(nil))
|
||||
|
||||
assert.True(t, cfg.Policy().IsEmpty(), "no MDM source ⇒ empty Policy")
|
||||
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) {
|
||||
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.KeyDisableClientRoutes: 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.True(t, cfg.DisableClientRoutes)
|
||||
assert.True(t, cfg.BlockInbound)
|
||||
@@ -65,16 +96,12 @@ func TestApply_MDMBeatsCLIInput(t *testing.T) {
|
||||
const mdmURL = "https://mdm.example.com:443"
|
||||
const cliURL = "https://cli.example.com:443"
|
||||
|
||||
withMDMPolicy(t, mdm.NewPolicy(map[string]any{
|
||||
mdm.KeyManagementURL: mdmURL,
|
||||
}))
|
||||
|
||||
cfg, err := UpdateOrCreateConfig(ConfigInput{
|
||||
cfg := configWithMDM(t, ConfigInput{
|
||||
ConfigPath: filepath.Join(t.TempDir(), "config.json"),
|
||||
ManagementURL: cliURL,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, cfg)
|
||||
}, mdm.NewPolicy(map[string]any{
|
||||
mdm.KeyManagementURL: mdmURL,
|
||||
}))
|
||||
|
||||
// MDM wins over CLI-supplied management URL.
|
||||
assert.Equal(t, mdmURL, cfg.ManagementURL.String())
|
||||
@@ -82,16 +109,12 @@ func TestApply_MDMBeatsCLIInput(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",
|
||||
}))
|
||||
|
||||
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
|
||||
// to keep the client functional.
|
||||
assert.Equal(t, DefaultManagementURL, cfg.ManagementURL.String())
|
||||
@@ -106,24 +129,20 @@ func TestApply_MDMBoolKeysOverrideOnDiskValue(t *testing.T) {
|
||||
tmp := filepath.Join(t.TempDir(), "config.json")
|
||||
|
||||
// Seed without MDM.
|
||||
withMDMPolicy(t, mdm.NewPolicy(nil))
|
||||
_, err := UpdateOrCreateConfig(ConfigInput{
|
||||
configWithMDM(t, ConfigInput{
|
||||
ConfigPath: tmp,
|
||||
DisableClientRoutes: boolPtr(false),
|
||||
RosenpassEnabled: boolPtr(false),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
}, mdm.NewPolicy(nil))
|
||||
|
||||
// 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.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.RosenpassEnabled)
|
||||
assert.True(t, cfg.Policy().HasKey(mdm.KeyDisableClientRoutes))
|
||||
@@ -164,22 +183,19 @@ func TestApply_MDMLocalMetrics(t *testing.T) {
|
||||
tmp := filepath.Join(t.TempDir(), "config.json")
|
||||
|
||||
// Seed without MDM.
|
||||
withMDMPolicy(t, mdm.NewPolicy(nil))
|
||||
_, err := UpdateOrCreateConfig(ConfigInput{
|
||||
configWithMDM(t, ConfigInput{
|
||||
ConfigPath: tmp,
|
||||
LocalMetricsEnabled: boolPtr(false),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
}, mdm.NewPolicy(nil))
|
||||
|
||||
withMDMPolicy(t, mdm.NewPolicy(map[string]any{
|
||||
// Now enable MDM enforcement for these keys.
|
||||
cfg := configWithMDM(t, ConfigInput{
|
||||
ConfigPath: tmp,
|
||||
}, mdm.NewPolicy(map[string]any{
|
||||
mdm.KeyEnableLocalMetrics: true,
|
||||
mdm.KeyLocalMetricsAddress: "127.0.0.1:9292",
|
||||
}))
|
||||
|
||||
cfg, err := UpdateOrCreateConfig(ConfigInput{ConfigPath: tmp})
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, cfg)
|
||||
|
||||
assert.True(t, cfg.LocalMetricsEnabled, "MDM override should flip on-disk false to true")
|
||||
assert.Equal(t, "127.0.0.1:9292", cfg.LocalMetricsAddress)
|
||||
assert.True(t, cfg.Policy().HasKey(mdm.KeyEnableLocalMetrics))
|
||||
@@ -201,16 +217,12 @@ func TestApply_MDMLazyConnection(t *testing.T) {
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(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.KeyLazyConnection: c.raw,
|
||||
}))
|
||||
|
||||
cfg, err := UpdateOrCreateConfig(ConfigInput{
|
||||
ConfigPath: filepath.Join(t.TempDir(), "config.json"),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, cfg)
|
||||
|
||||
assert.Equal(t, c.want, cfg.LazyConnection)
|
||||
assert.True(t, cfg.Policy().HasKey(mdm.KeyLazyConnection))
|
||||
})
|
||||
@@ -218,22 +230,83 @@ func TestApply_MDMLazyConnection(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestApply_MDMPreSharedKeyRedactionSentinelRejected(t *testing.T) {
|
||||
const maskSentinel = "**********"
|
||||
const maskSentinel = mdm.PreSharedKeyRedactedSentinel
|
||||
|
||||
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,
|
||||
}))
|
||||
|
||||
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.
|
||||
assert.NotEqual(t, maskSentinel, cfg.PreSharedKey)
|
||||
// Key still marked managed so user writes are still rejected.
|
||||
assert.True(t, cfg.Policy().HasKey(mdm.KeyPreSharedKey))
|
||||
}
|
||||
|
||||
func TestMDMConflicts_PreSharedKey(t *testing.T) {
|
||||
policy := mdm.NewPolicy(map[string]any{
|
||||
mdm.KeyPreSharedKey: "mdm-enforced-psk",
|
||||
})
|
||||
empty := ""
|
||||
sentinel := mdm.PreSharedKeyRedactedSentinel
|
||||
same := "mdm-enforced-psk"
|
||||
other := "user-psk"
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
psk *string
|
||||
want []string
|
||||
}{
|
||||
{name: "unset", psk: nil, want: nil},
|
||||
{name: "explicit empty", psk: &empty, want: []string{mdm.KeyPreSharedKey}},
|
||||
{name: "sentinel echo", psk: &sentinel, want: nil},
|
||||
{name: "same value", psk: &same, want: nil},
|
||||
{name: "divergent", psk: &other, want: []string{mdm.KeyPreSharedKey}},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
assert.Equal(t, tc.want, MDMConflicts(ConfigInput{PreSharedKey: tc.psk}, policy))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestMDMConflicts_RemoteJobsAndLocalMetrics(t *testing.T) {
|
||||
policy := mdm.NewPolicy(map[string]any{
|
||||
mdm.KeyRemoteJobsAllowed: false,
|
||||
mdm.KeyEnableLocalMetrics: true,
|
||||
mdm.KeyLocalMetricsAddress: "127.0.0.1:9999",
|
||||
})
|
||||
sameAddr := "127.0.0.1:9999"
|
||||
otherAddr := "0.0.0.0:9999"
|
||||
emptyAddr := ""
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
input ConfigInput
|
||||
want []string
|
||||
}{
|
||||
{name: "unset", input: ConfigInput{}, want: nil},
|
||||
{name: "echo", input: ConfigInput{
|
||||
RemoteJobsAllowed: boolPtr(false),
|
||||
LocalMetricsEnabled: boolPtr(true),
|
||||
LocalMetricsAddress: &sameAddr,
|
||||
}, want: nil},
|
||||
{name: "remote jobs divergent", input: ConfigInput{RemoteJobsAllowed: boolPtr(true)}, want: []string{mdm.KeyRemoteJobsAllowed}},
|
||||
{name: "metrics disabled", input: ConfigInput{LocalMetricsEnabled: boolPtr(false)}, want: []string{mdm.KeyEnableLocalMetrics}},
|
||||
{name: "metrics address divergent", input: ConfigInput{LocalMetricsAddress: &otherAddr}, want: []string{mdm.KeyLocalMetricsAddress}},
|
||||
{name: "metrics address explicit empty", input: ConfigInput{LocalMetricsAddress: &emptyAddr}, want: []string{mdm.KeyLocalMetricsAddress}},
|
||||
{name: "all divergent", input: ConfigInput{
|
||||
RemoteJobsAllowed: boolPtr(true),
|
||||
LocalMetricsEnabled: boolPtr(false),
|
||||
LocalMetricsAddress: &otherAddr,
|
||||
}, want: []string{mdm.KeyRemoteJobsAllowed, mdm.KeyEnableLocalMetrics, mdm.KeyLocalMetricsAddress}},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
assert.Equal(t, tc.want, MDMConflicts(tc.input, policy))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func boolPtr(b bool) *bool { return &b }
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"net/netip"
|
||||
"net/url"
|
||||
"runtime"
|
||||
"slices"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
@@ -472,27 +473,13 @@ func (m *DefaultManager) CurrentRouteRange() []string {
|
||||
m.mux.Lock()
|
||||
defer m.mux.Unlock()
|
||||
|
||||
if m.disableClientRoutes {
|
||||
return nil
|
||||
}
|
||||
|
||||
filtered := m.routeSelector.FilterSelectedExitNodes(m.clientRoutes)
|
||||
var nets []string
|
||||
for _, routes := range filtered {
|
||||
for _, r := range routes {
|
||||
if r.IsDynamic() {
|
||||
continue
|
||||
}
|
||||
nets = append(nets, r.NetString())
|
||||
}
|
||||
}
|
||||
|
||||
if m.fakeIPManager != nil {
|
||||
nets = append(nets, m.fakeIPManager.GetFakeIPBlock().String(), m.fakeIPManager.GetFakeIPv6Block().String())
|
||||
nets := m.overlayNetworks()
|
||||
if !m.disableClientRoutes {
|
||||
nets = append(nets, m.clientRouteRange()...)
|
||||
}
|
||||
|
||||
sort.Strings(nets)
|
||||
return nets
|
||||
return slices.Compact(nets)
|
||||
}
|
||||
|
||||
// GetRouteSelector returns the route selector
|
||||
@@ -856,6 +843,42 @@ func (m *DefaultManager) logExitNodeUpdate(info exitNodeInfo, preferred route.Ne
|
||||
len(info.allIDs), preferred, len(info.userSelected), len(info.userDeselected), len(info.selectedByManagement))
|
||||
}
|
||||
|
||||
// overlayNetworks returns the v4 and v6 overlay networks of the WireGuard interface, each only when it is set.
|
||||
func (m *DefaultManager) overlayNetworks() []string {
|
||||
if m.wgInterface == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
addr := m.wgInterface.Address()
|
||||
var nets []string
|
||||
if addr.Network.IsValid() {
|
||||
nets = append(nets, addr.Network.String())
|
||||
}
|
||||
if addr.IPv6Net.IsValid() {
|
||||
nets = append(nets, addr.IPv6Net.String())
|
||||
}
|
||||
return nets
|
||||
}
|
||||
|
||||
// clientRouteRange returns the static client route networks of the selected exit nodes together with the fake IP blocks.
|
||||
func (m *DefaultManager) clientRouteRange() []string {
|
||||
filtered := m.routeSelector.FilterSelectedExitNodes(m.clientRoutes)
|
||||
var nets []string
|
||||
for _, routes := range filtered {
|
||||
for _, r := range routes {
|
||||
if r.IsDynamic() {
|
||||
continue
|
||||
}
|
||||
nets = append(nets, r.NetString())
|
||||
}
|
||||
}
|
||||
|
||||
if m.fakeIPManager != nil {
|
||||
nets = append(nets, m.fakeIPManager.GetFakeIPBlock().String(), m.fakeIPManager.GetFakeIPv6Block().String())
|
||||
}
|
||||
return nets
|
||||
}
|
||||
|
||||
// minNetID returns the lexicographically smallest NetID, for a deterministic
|
||||
// default pick that stays stable across restarts.
|
||||
func minNetID(ids []route.NetID) route.NetID {
|
||||
|
||||
@@ -17,11 +17,12 @@ import (
|
||||
"github.com/netbirdio/netbird/client/internal/routemanager/refcounter"
|
||||
)
|
||||
|
||||
// reconcileWGMock is a minimal iface.WGIface that only records AddAllowedIP calls; every other
|
||||
// method is an inert stub because ReconcilePeerAllowedIPs exercises none of them.
|
||||
// reconcileWGMock is a minimal iface.WGIface that records AddAllowedIP calls and reports the
|
||||
// configured address; every other method is an inert stub because the tests exercise none of them.
|
||||
type reconcileWGMock struct {
|
||||
mu sync.Mutex
|
||||
adds map[string][]netip.Prefix
|
||||
addr wgaddr.Address
|
||||
}
|
||||
|
||||
func (m *reconcileWGMock) AddAllowedIP(peerKey string, allowedIP netip.Prefix) error {
|
||||
@@ -42,7 +43,7 @@ func (m *reconcileWGMock) added(peerKey string) []netip.Prefix {
|
||||
|
||||
func (m *reconcileWGMock) RemoveAllowedIP(string, netip.Prefix) error { return nil }
|
||||
func (m *reconcileWGMock) Name() string { return "utun-test" }
|
||||
func (m *reconcileWGMock) Address() wgaddr.Address { return wgaddr.Address{} }
|
||||
func (m *reconcileWGMock) Address() wgaddr.Address { return m.addr }
|
||||
func (m *reconcileWGMock) ToInterface() *net.Interface { return nil }
|
||||
func (m *reconcileWGMock) IsUserspaceBind() bool { return false }
|
||||
func (m *reconcileWGMock) GetFilter() device.PacketFilter { return nil }
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
//go:build !windows
|
||||
|
||||
package routemanager
|
||||
|
||||
import (
|
||||
"net/netip"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
|
||||
"github.com/netbirdio/netbird/client/iface/wgaddr"
|
||||
"github.com/netbirdio/netbird/client/internal/routeselector"
|
||||
"github.com/netbirdio/netbird/route"
|
||||
)
|
||||
|
||||
func TestCurrentRouteRange_OverlayNetworkWithClientRoutesDisabled(t *testing.T) {
|
||||
m := &DefaultManager{
|
||||
wgInterface: &reconcileWGMock{addr: wgaddr.MustParseWGAddress("100.91.96.107/16")},
|
||||
disableClientRoutes: true,
|
||||
}
|
||||
|
||||
assert.Equal(t, []string{"100.91.0.0/16"}, m.CurrentRouteRange(), "overlay network must be routed even when client routes are disabled")
|
||||
}
|
||||
|
||||
func TestCurrentRouteRange_OverlayNetworksAndClientRoutes(t *testing.T) {
|
||||
addr := wgaddr.MustParseWGAddress("100.91.96.107/16")
|
||||
addr.IPv6 = netip.MustParseAddr("fd00:1234::1")
|
||||
addr.IPv6Net = netip.MustParsePrefix("fd00:1234::/64")
|
||||
|
||||
static := &route.Route{ID: "static", NetID: "lan", Network: netip.MustParsePrefix("192.168.50.0/24"), NetworkType: route.IPv4Network}
|
||||
dynamic := &route.Route{ID: "dynamic", NetID: "dyn", NetworkType: route.DomainNetwork}
|
||||
|
||||
m := &DefaultManager{
|
||||
wgInterface: &reconcileWGMock{addr: addr},
|
||||
routeSelector: routeselector.NewRouteSelector(),
|
||||
clientRoutes: route.HAMap{
|
||||
static.GetHAUniqueID(): {static},
|
||||
dynamic.GetHAUniqueID(): {dynamic},
|
||||
},
|
||||
}
|
||||
|
||||
assert.Equal(t, []string{"100.91.0.0/16", "192.168.50.0/24", "fd00:1234::/64"}, m.CurrentRouteRange(), "overlay networks and static client routes must be listed, dynamic routes skipped")
|
||||
}
|
||||
|
||||
func TestCurrentRouteRange_NoInterfaceAddress(t *testing.T) {
|
||||
m := &DefaultManager{
|
||||
wgInterface: &reconcileWGMock{},
|
||||
disableClientRoutes: true,
|
||||
}
|
||||
|
||||
assert.Empty(t, m.CurrentRouteRange(), "an unset interface address must not produce a route entry")
|
||||
}
|
||||
|
||||
func TestCurrentRouteRange_IPv6WithoutIPv4Network(t *testing.T) {
|
||||
addr := wgaddr.Address{
|
||||
IPv6: netip.MustParseAddr("fd00:1234::1"),
|
||||
IPv6Net: netip.MustParsePrefix("fd00:1234::/64"),
|
||||
}
|
||||
m := &DefaultManager{
|
||||
wgInterface: &reconcileWGMock{addr: addr},
|
||||
disableClientRoutes: true,
|
||||
}
|
||||
|
||||
assert.Equal(t, []string{"fd00:1234::/64"}, m.CurrentRouteRange(), "a v6 overlay network must not depend on a v4 network being set")
|
||||
}
|
||||
|
||||
func TestCurrentRouteRange_IPv6AddressWithoutNetwork(t *testing.T) {
|
||||
addr := wgaddr.MustParseWGAddress("100.91.96.107/16")
|
||||
addr.IPv6 = netip.MustParseAddr("fd00:1234::1")
|
||||
|
||||
m := &DefaultManager{
|
||||
wgInterface: &reconcileWGMock{addr: addr},
|
||||
disableClientRoutes: true,
|
||||
}
|
||||
|
||||
assert.Equal(t, []string{"100.91.0.0/16"}, m.CurrentRouteRange(), "a v6 address without a network must not produce a route entry")
|
||||
}
|
||||
|
||||
func TestCurrentRouteRange_DeduplicatesPrefixes(t *testing.T) {
|
||||
// Two HA peers serve the same prefix, and a client route announces the overlay network itself.
|
||||
haPeerA := &route.Route{ID: "ha-a", NetID: "lan", Peer: "peer-a", Network: netip.MustParsePrefix("192.168.50.0/24"), NetworkType: route.IPv4Network}
|
||||
haPeerB := &route.Route{ID: "ha-b", NetID: "lan", Peer: "peer-b", Network: netip.MustParsePrefix("192.168.50.0/24"), NetworkType: route.IPv4Network}
|
||||
overlay := &route.Route{ID: "overlay", NetID: "overlay", Network: netip.MustParsePrefix("100.91.0.0/16"), NetworkType: route.IPv4Network}
|
||||
|
||||
m := &DefaultManager{
|
||||
wgInterface: &reconcileWGMock{addr: wgaddr.MustParseWGAddress("100.91.96.107/16")},
|
||||
routeSelector: routeselector.NewRouteSelector(),
|
||||
clientRoutes: route.HAMap{
|
||||
haPeerA.GetHAUniqueID(): {haPeerA, haPeerB},
|
||||
overlay.GetHAUniqueID(): {overlay},
|
||||
},
|
||||
}
|
||||
|
||||
assert.Equal(t, []string{"100.91.0.0/16", "192.168.50.0/24"}, m.CurrentRouteRange(), "every prefix must be listed once regardless of how many routes carry it")
|
||||
}
|
||||
Reference in New Issue
Block a user