mirror of
https://github.com/netbirdio/netbird.git
synced 2026-10-27 23:09:08 +01:00
Merge remote-tracking branch 'origin/main' into dmitri-catch-disconnected-peer
Signed-off-by: Dmitri Dolguikh <dmitri.external@netbird.io>
This commit is contained in:
@@ -27,7 +27,22 @@ jobs:
|
|||||||
push: false
|
push: false
|
||||||
archive: false
|
archive: false
|
||||||
pr_comment: false
|
pr_comment: false
|
||||||
build: false
|
|
||||||
lint: false
|
lint: false
|
||||||
format: false
|
format: false
|
||||||
breaking: true
|
# A push that creates a branch carries no `before` commit, so the
|
||||||
|
# action's default baseline is the all-zero SHA and `buf breaking`
|
||||||
|
# dies cloning it. Skipping costs nothing: every commit on a freshly
|
||||||
|
# cut release branch should have already passed this check on main.
|
||||||
|
breaking: ${{ !github.event.created }}
|
||||||
|
# The alternative is to compare against the default branch instead of
|
||||||
|
# skipping. Not used: buf clones the baseline when the job runs, so a
|
||||||
|
# main that has moved on since the branch was cut reads as protos
|
||||||
|
# deleted on the release branch. Resolving to an empty string on every
|
||||||
|
# other event is what keeps the action's own default in place, which
|
||||||
|
# stacked pull requests need.
|
||||||
|
# breaking_against: >-
|
||||||
|
# ${{ github.event.created
|
||||||
|
# && format('{0}#format=git,branch={1}',
|
||||||
|
# github.event.repository.clone_url,
|
||||||
|
# github.event.repository.default_branch)
|
||||||
|
# || '' }}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import (
|
|||||||
"slices"
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
|
"sync/atomic"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"golang.org/x/exp/maps"
|
"golang.org/x/exp/maps"
|
||||||
@@ -90,6 +91,14 @@ type Client struct {
|
|||||||
connectClient *internal.ConnectClient
|
connectClient *internal.ConnectClient
|
||||||
config *profilemanager.Config
|
config *profilemanager.Config
|
||||||
cacheDir string
|
cacheDir string
|
||||||
|
|
||||||
|
// mdmSource holds the per-Client MDM policy source and its change
|
||||||
|
// detector as one unit. Set by SetMDMPolicyFetcher (called from the
|
||||||
|
// Kotlin side). Each Run passes the loader to the resolved Config so
|
||||||
|
// applyMDMPolicy picks up the active overlay. Nil means "MDM
|
||||||
|
// enforcement off for this Client".
|
||||||
|
mdmSource atomic.Pointer[mdmSource]
|
||||||
|
|
||||||
// Identifies the running profile for the SSO login hint; see profile_state.go.
|
// Identifies the running profile for the SSO login hint; see profile_state.go.
|
||||||
cfgPath string
|
cfgPath string
|
||||||
|
|
||||||
@@ -178,6 +187,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)
|
||||||
|
|
||||||
@@ -229,6 +239,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)
|
||||||
|
|
||||||
@@ -327,6 +338,7 @@ func (c *Client) DebugBundle(platformFiles PlatformFiles, anonymize bool, anonym
|
|||||||
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()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,52 @@
|
|||||||
|
//go:build android
|
||||||
|
|
||||||
|
package android
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/netbirdio/netbird/client/internal/profilemanager"
|
||||||
|
"github.com/netbirdio/netbird/client/mdm"
|
||||||
|
)
|
||||||
|
|
||||||
|
type mdmSource struct {
|
||||||
|
loader *mdm.Loader
|
||||||
|
detector *mdm.ChangeDetector
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetMDMPolicyFetcher registers the native-provided MDM policy fetcher on
|
||||||
|
// this Client; passing nil disables MDM enforcement.
|
||||||
|
func (c *Client) SetMDMPolicyFetcher(p PolicyFetcher) {
|
||||||
|
loader := loaderFor(p)
|
||||||
|
c.mdmSource.Store(&mdmSource{loader: loader, detector: mdm.NewChangeDetector(loader)})
|
||||||
|
}
|
||||||
|
|
||||||
|
// HasMDMPolicyChanged re-reads the managed configuration and reports whether
|
||||||
|
// it changed since the last observation; call it from the native OS-change
|
||||||
|
// notification and restart the engine only on true.
|
||||||
|
func (c *Client) HasMDMPolicyChanged() bool {
|
||||||
|
src := c.mdmSource.Load()
|
||||||
|
if src == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return src.detector.Changed()
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetRestrictionsJSON returns the UI enforcement snapshot derived from the
|
||||||
|
// active MDM policy, in the JSON shape shared with the desktop frontend.
|
||||||
|
func (c *Client) GetRestrictionsJSON() (string, error) {
|
||||||
|
return mdm.BuildRestrictions(c.mdmLoader().Load()).JSON()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) applyMDMOverlay(cfg *profilemanager.Config) {
|
||||||
|
loader := c.mdmLoader()
|
||||||
|
if cfg == nil || loader == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
cfg.ApplyMDMPolicy(loader.Load())
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) mdmLoader() *mdm.Loader {
|
||||||
|
if src := c.mdmSource.Load(); src != nil {
|
||||||
|
return src.loader
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
+17
-16
@@ -8,6 +8,7 @@ import (
|
|||||||
|
|
||||||
"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/mobile"
|
"github.com/netbirdio/netbird/client/mobile"
|
||||||
"github.com/netbirdio/netbird/client/system"
|
"github.com/netbirdio/netbird/client/system"
|
||||||
)
|
)
|
||||||
@@ -46,16 +47,24 @@ type Auth struct {
|
|||||||
// an earlier call is orphaned on the server. It also breaks a client that enrols and then runs from
|
// an earlier call is orphaned on the server. It also breaks a client that enrols and then runs from
|
||||||
// the persisted config, because the identity it registered is not the one it runs with — the
|
// the persisted config, because the identity it registered is not the one it runs with — the
|
||||||
// management stream rejects it with "no peer auth method provided".
|
// management stream rejects it with "no peer auth method provided".
|
||||||
func NewAuth(cfgPath string, mgmURL string) (*Auth, error) {
|
//
|
||||||
inputCfg := profilemanager.ConfigInput{
|
// Auth is constructed under the active MDM policy: the policy is overlaid on
|
||||||
ConfigPath: cfgPath,
|
// the resolved config so the login runs against the enforced values, while
|
||||||
ManagementURL: mgmURL,
|
// the persisted config keeps the caller-supplied ones; a caller-supplied
|
||||||
|
// management URL is ignored while MDM manages that key. A nil fetcher
|
||||||
|
// disables MDM enforcement.
|
||||||
|
func NewAuth(cfgPath string, mgmURL string, fetcher PolicyFetcher) (*Auth, error) {
|
||||||
|
policy := loaderFor(fetcher).Load()
|
||||||
|
inputCfg := profilemanager.ConfigInput{ConfigPath: cfgPath}
|
||||||
|
if _, managed := policy.GetString(mdm.KeyManagementURL); !managed {
|
||||||
|
inputCfg.ManagementURL = mgmURL
|
||||||
}
|
}
|
||||||
|
|
||||||
cfg, err := profilemanager.UpdateOrCreateConfig(inputCfg)
|
cfg, err := profilemanager.UpdateOrCreateConfig(inputCfg)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
cfg.ApplyMDMPolicy(policy)
|
||||||
|
|
||||||
return &Auth{
|
return &Auth{
|
||||||
ctx: context.Background(),
|
ctx: context.Background(),
|
||||||
@@ -75,9 +84,7 @@ func NewAuthWithConfig(ctx context.Context, config *profilemanager.Config, cfgPa
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// SaveConfigIfSSOSupported test the connectivity with the management server by retrieving the server device flow info.
|
// SaveConfigIfSSOSupported reports whether the management server supports SSO; the config is already persisted by NewAuth.
|
||||||
// If it returns a flow info than save the configuration and return true. If it gets a codes.NotFound, it means that SSO
|
|
||||||
// is not supported and returns false without saving the configuration. For other errors return false.
|
|
||||||
func (a *Auth) SaveConfigIfSSOSupported(listener SSOListener) {
|
func (a *Auth) SaveConfigIfSSOSupported(listener SSOListener) {
|
||||||
go func() {
|
go func() {
|
||||||
sso, err := a.saveConfigIfSSOSupported()
|
sso, err := a.saveConfigIfSSOSupported()
|
||||||
@@ -101,15 +108,10 @@ func (a *Auth) saveConfigIfSSOSupported() (bool, error) {
|
|||||||
return false, fmt.Errorf("failed to check SSO support: %v", err)
|
return false, fmt.Errorf("failed to check SSO support: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if !supportsSSO {
|
return supportsSSO, nil
|
||||||
return false, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
err = profilemanager.WriteOutConfig(a.cfgPath, a.config)
|
|
||||||
return true, err
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// LoginWithSetupKeyAndSaveConfig test the connectivity with the management server with the setup key.
|
// LoginWithSetupKeyAndSaveConfig registers the peer with the setup key; the config is already persisted by NewAuth.
|
||||||
func (a *Auth) LoginWithSetupKeyAndSaveConfig(resultListener ErrListener, setupKey string, deviceName string) {
|
func (a *Auth) LoginWithSetupKeyAndSaveConfig(resultListener ErrListener, setupKey string, deviceName string) {
|
||||||
go func() {
|
go func() {
|
||||||
err := a.loginWithSetupKeyAndSaveConfig(setupKey, deviceName)
|
err := a.loginWithSetupKeyAndSaveConfig(setupKey, deviceName)
|
||||||
@@ -134,8 +136,7 @@ func (a *Auth) loginWithSetupKeyAndSaveConfig(setupKey string, deviceName string
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("login failed: %v", err)
|
return fmt.Errorf("login failed: %v", err)
|
||||||
}
|
}
|
||||||
|
return nil
|
||||||
return profilemanager.WriteOutConfig(a.cfgPath, a.config)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Login try register the client on the server
|
// Login try register the client on the server
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ import (
|
|||||||
func TestNewAuth_ReusesPersistedIdentity(t *testing.T) {
|
func TestNewAuth_ReusesPersistedIdentity(t *testing.T) {
|
||||||
cfgPath := filepath.Join(t.TempDir(), "config.json")
|
cfgPath := filepath.Join(t.TempDir(), "config.json")
|
||||||
|
|
||||||
first, err := NewAuth(cfgPath, "https://api.example.com:443")
|
first, err := NewAuth(cfgPath, "https://api.example.com:443", nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("first NewAuth: %v", err)
|
t.Fatalf("first NewAuth: %v", err)
|
||||||
}
|
}
|
||||||
@@ -24,7 +24,7 @@ func TestNewAuth_ReusesPersistedIdentity(t *testing.T) {
|
|||||||
t.Fatal("first NewAuth produced no private key")
|
t.Fatal("first NewAuth produced no private key")
|
||||||
}
|
}
|
||||||
|
|
||||||
second, err := NewAuth(cfgPath, "https://api.example.com:443")
|
second, err := NewAuth(cfgPath, "https://api.example.com:443", nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("second NewAuth: %v", err)
|
t.Fatalf("second NewAuth: %v", err)
|
||||||
}
|
}
|
||||||
@@ -38,7 +38,7 @@ func TestNewAuth_ReusesPersistedIdentity(t *testing.T) {
|
|||||||
func TestNewAuth_CreatesConfigWhenAbsent(t *testing.T) {
|
func TestNewAuth_CreatesConfigWhenAbsent(t *testing.T) {
|
||||||
cfgPath := filepath.Join(t.TempDir(), "config.json")
|
cfgPath := filepath.Join(t.TempDir(), "config.json")
|
||||||
|
|
||||||
auth, err := NewAuth(cfgPath, "https://api.example.com:443")
|
auth, err := NewAuth(cfgPath, "https://api.example.com:443", nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("NewAuth: %v", err)
|
t.Fatalf("NewAuth: %v", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
package android
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/netbirdio/netbird/client/mdm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// PolicyFetcher is implemented by the native layer to return the current
|
||||||
|
// managed configuration as a JSON-encoded object string; "" means no MDM
|
||||||
|
// source is present.
|
||||||
|
type PolicyFetcher interface {
|
||||||
|
FetchJSON() string
|
||||||
|
}
|
||||||
|
|
||||||
|
func loaderFor(p PolicyFetcher) *mdm.Loader {
|
||||||
|
if p == nil {
|
||||||
|
return mdm.NewJSONLoader(nil)
|
||||||
|
}
|
||||||
|
return mdm.NewJSONLoader(p.FetchJSON)
|
||||||
|
}
|
||||||
@@ -1,12 +1,16 @@
|
|||||||
package android
|
package android
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"sync/atomic"
|
||||||
|
|
||||||
"github.com/netbirdio/netbird/client/internal/profilemanager"
|
"github.com/netbirdio/netbird/client/internal/profilemanager"
|
||||||
|
"github.com/netbirdio/netbird/client/mdm"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Preferences exports a subset of the internal config for gomobile
|
// Preferences exports a subset of the internal config for gomobile
|
||||||
type Preferences struct {
|
type Preferences struct {
|
||||||
configInput profilemanager.ConfigInput
|
configInput profilemanager.ConfigInput
|
||||||
|
mdmLoader atomic.Pointer[mdm.Loader]
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewPreferences creates a new Preferences instance
|
// NewPreferences creates a new Preferences instance
|
||||||
@@ -14,11 +18,30 @@ func NewPreferences(configPath string) *Preferences {
|
|||||||
ci := profilemanager.ConfigInput{
|
ci := profilemanager.ConfigInput{
|
||||||
ConfigPath: configPath,
|
ConfigPath: configPath,
|
||||||
}
|
}
|
||||||
return &Preferences{ci}
|
return &Preferences{configInput: ci}
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetMDMPolicyFetcher registers the native-provided MDM policy fetcher on
|
||||||
|
// this Preferences instance; passing nil disables MDM enforcement.
|
||||||
|
func (p *Preferences) SetMDMPolicyFetcher(f PolicyFetcher) {
|
||||||
|
p.mdmLoader.Store(loaderFor(f))
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetRestrictionsJSON returns the UI enforcement snapshot derived from the
|
||||||
|
// active MDM policy, in the JSON shape shared with the desktop frontend.
|
||||||
|
func (p *Preferences) GetRestrictionsJSON() (string, error) {
|
||||||
|
return mdm.BuildRestrictions(p.policy()).JSON()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Preferences) policy() *mdm.Policy {
|
||||||
|
return p.mdmLoader.Load().Load()
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetManagementURL reads URL from config file
|
// GetManagementURL reads URL from config file
|
||||||
func (p *Preferences) GetManagementURL() (string, error) {
|
func (p *Preferences) GetManagementURL() (string, error) {
|
||||||
|
if v, ok := p.policy().GetString(mdm.KeyManagementURL); ok {
|
||||||
|
return mdm.CanonicalURL(v), nil
|
||||||
|
}
|
||||||
if p.configInput.ManagementURL != "" {
|
if p.configInput.ManagementURL != "" {
|
||||||
return p.configInput.ManagementURL, nil
|
return p.configInput.ManagementURL, nil
|
||||||
}
|
}
|
||||||
@@ -27,7 +50,7 @@ func (p *Preferences) GetManagementURL() (string, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
return cfg.ManagementURL.String(), err
|
return cfg.ManagementURL.String(), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// SetManagementURL stores the given URL and waits for commit
|
// SetManagementURL stores the given URL and waits for commit
|
||||||
@@ -53,17 +76,21 @@ func (p *Preferences) SetAdminURL(url string) {
|
|||||||
p.configInput.AdminURL = url
|
p.configInput.AdminURL = url
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetPreSharedKey reads pre-shared key from config file
|
// HasPreSharedKey reports whether a pre-shared key is staged, persisted, or
|
||||||
func (p *Preferences) GetPreSharedKey() (string, error) {
|
// enforced by MDM; the key itself is never handed to the native layer.
|
||||||
|
func (p *Preferences) HasPreSharedKey() (bool, error) {
|
||||||
|
if _, ok := p.policy().GetString(mdm.KeyPreSharedKey); ok {
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
if p.configInput.PreSharedKey != nil {
|
if p.configInput.PreSharedKey != nil {
|
||||||
return *p.configInput.PreSharedKey, nil
|
return *p.configInput.PreSharedKey != "", nil
|
||||||
}
|
}
|
||||||
|
|
||||||
cfg, err := profilemanager.ReadConfig(p.configInput.ConfigPath)
|
cfg, err := profilemanager.ReadConfig(p.configInput.ConfigPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return false, err
|
||||||
}
|
}
|
||||||
return cfg.PreSharedKey, err
|
return cfg.PreSharedKey != "", nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// SetPreSharedKey stores the given key and waits for commit
|
// SetPreSharedKey stores the given key and waits for commit
|
||||||
@@ -78,6 +105,9 @@ func (p *Preferences) SetRosenpassEnabled(enabled bool) {
|
|||||||
|
|
||||||
// GetRosenpassEnabled reads Rosenpass enabled status from config file
|
// GetRosenpassEnabled reads Rosenpass enabled status from config file
|
||||||
func (p *Preferences) GetRosenpassEnabled() (bool, error) {
|
func (p *Preferences) GetRosenpassEnabled() (bool, error) {
|
||||||
|
if v, ok := p.policy().GetBool(mdm.KeyRosenpassEnabled); ok {
|
||||||
|
return v, nil
|
||||||
|
}
|
||||||
if p.configInput.RosenpassEnabled != nil {
|
if p.configInput.RosenpassEnabled != nil {
|
||||||
return *p.configInput.RosenpassEnabled, nil
|
return *p.configInput.RosenpassEnabled, nil
|
||||||
}
|
}
|
||||||
@@ -96,6 +126,9 @@ func (p *Preferences) SetRosenpassPermissive(permissive bool) {
|
|||||||
|
|
||||||
// GetRosenpassPermissive reads Rosenpass permissive setting from config file
|
// GetRosenpassPermissive reads Rosenpass permissive setting from config file
|
||||||
func (p *Preferences) GetRosenpassPermissive() (bool, error) {
|
func (p *Preferences) GetRosenpassPermissive() (bool, error) {
|
||||||
|
if v, ok := p.policy().GetBool(mdm.KeyRosenpassPermissive); ok {
|
||||||
|
return v, nil
|
||||||
|
}
|
||||||
if p.configInput.RosenpassPermissive != nil {
|
if p.configInput.RosenpassPermissive != nil {
|
||||||
return *p.configInput.RosenpassPermissive, nil
|
return *p.configInput.RosenpassPermissive, nil
|
||||||
}
|
}
|
||||||
@@ -109,6 +142,9 @@ func (p *Preferences) GetRosenpassPermissive() (bool, error) {
|
|||||||
|
|
||||||
// GetDisableClientRoutes reads disable client routes setting from config file
|
// GetDisableClientRoutes reads disable client routes setting from config file
|
||||||
func (p *Preferences) GetDisableClientRoutes() (bool, error) {
|
func (p *Preferences) GetDisableClientRoutes() (bool, error) {
|
||||||
|
if v, ok := p.policy().GetBool(mdm.KeyDisableClientRoutes); ok {
|
||||||
|
return v, nil
|
||||||
|
}
|
||||||
if p.configInput.DisableClientRoutes != nil {
|
if p.configInput.DisableClientRoutes != nil {
|
||||||
return *p.configInput.DisableClientRoutes, nil
|
return *p.configInput.DisableClientRoutes, nil
|
||||||
}
|
}
|
||||||
@@ -127,6 +163,9 @@ func (p *Preferences) SetDisableClientRoutes(disable bool) {
|
|||||||
|
|
||||||
// GetDisableServerRoutes reads disable server routes setting from config file
|
// GetDisableServerRoutes reads disable server routes setting from config file
|
||||||
func (p *Preferences) GetDisableServerRoutes() (bool, error) {
|
func (p *Preferences) GetDisableServerRoutes() (bool, error) {
|
||||||
|
if v, ok := p.policy().GetBool(mdm.KeyDisableServerRoutes); ok {
|
||||||
|
return v, nil
|
||||||
|
}
|
||||||
if p.configInput.DisableServerRoutes != nil {
|
if p.configInput.DisableServerRoutes != nil {
|
||||||
return *p.configInput.DisableServerRoutes, nil
|
return *p.configInput.DisableServerRoutes, nil
|
||||||
}
|
}
|
||||||
@@ -181,6 +220,9 @@ func (p *Preferences) SetDisableFirewall(disable bool) {
|
|||||||
|
|
||||||
// GetServerSSHAllowed reads server SSH allowed setting from config file
|
// GetServerSSHAllowed reads server SSH allowed setting from config file
|
||||||
func (p *Preferences) GetServerSSHAllowed() (bool, error) {
|
func (p *Preferences) GetServerSSHAllowed() (bool, error) {
|
||||||
|
if v, ok := p.policy().GetBool(mdm.KeyAllowServerSSH); ok {
|
||||||
|
return v, nil
|
||||||
|
}
|
||||||
if p.configInput.ServerSSHAllowed != nil {
|
if p.configInput.ServerSSHAllowed != nil {
|
||||||
return *p.configInput.ServerSSHAllowed, nil
|
return *p.configInput.ServerSSHAllowed, nil
|
||||||
}
|
}
|
||||||
@@ -291,6 +333,9 @@ func (p *Preferences) SetEnableSSHRemotePortForwarding(enabled bool) {
|
|||||||
|
|
||||||
// GetBlockInbound reads block inbound setting from config file
|
// GetBlockInbound reads block inbound setting from config file
|
||||||
func (p *Preferences) GetBlockInbound() (bool, error) {
|
func (p *Preferences) GetBlockInbound() (bool, error) {
|
||||||
|
if v, ok := p.policy().GetBool(mdm.KeyBlockInbound); ok {
|
||||||
|
return v, nil
|
||||||
|
}
|
||||||
if p.configInput.BlockInbound != nil {
|
if p.configInput.BlockInbound != nil {
|
||||||
return *p.configInput.BlockInbound, nil
|
return *p.configInput.BlockInbound, nil
|
||||||
}
|
}
|
||||||
@@ -325,8 +370,34 @@ func (p *Preferences) SetDisableIPv6(disable bool) {
|
|||||||
p.configInput.DisableIPv6 = &disable
|
p.configInput.DisableIPv6 = &disable
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetRemoteJobsAllowed reads the remote jobs opt-in from config file
|
||||||
|
func (p *Preferences) GetRemoteJobsAllowed() (bool, error) {
|
||||||
|
policy := p.policy()
|
||||||
|
if !policy.HasKey(mdm.KeyRemoteJobsAllowed) && p.configInput.RemoteJobsAllowed != nil {
|
||||||
|
return *p.configInput.RemoteJobsAllowed, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg, err := profilemanager.ReadConfig(p.configInput.ConfigPath)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
cfg.ApplyMDMPolicy(policy)
|
||||||
|
if cfg.RemoteJobsAllowed == nil {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
return *cfg.RemoteJobsAllowed, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetRemoteJobsAllowed stores the given value and waits for commit
|
||||||
|
func (p *Preferences) SetRemoteJobsAllowed(allowed bool) {
|
||||||
|
p.configInput.RemoteJobsAllowed = &allowed
|
||||||
|
}
|
||||||
|
|
||||||
// Commit writes out the changes to the config file
|
// Commit writes out the changes to the config file
|
||||||
func (p *Preferences) Commit() error {
|
func (p *Preferences) Commit() error {
|
||||||
|
if err := profilemanager.CheckMDMConflicts(p.configInput, p.policy()); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
_, err := profilemanager.UpdateOrCreateConfig(p.configInput)
|
_, err := profilemanager.UpdateOrCreateConfig(p.configInput)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,14 +28,13 @@ func TestPreferences_DefaultValues(t *testing.T) {
|
|||||||
t.Errorf("invalid default management url: %s", defaultVar)
|
t.Errorf("invalid default management url: %s", defaultVar)
|
||||||
}
|
}
|
||||||
|
|
||||||
var preSharedKey string
|
hasPSK, err := p.HasPreSharedKey()
|
||||||
preSharedKey, err = p.GetPreSharedKey()
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("failed to read default preshared key: %s", err)
|
t.Fatalf("failed to read default preshared key presence: %s", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if preSharedKey != "" {
|
if hasPSK {
|
||||||
t.Errorf("invalid preshared key: %s", preSharedKey)
|
t.Errorf("unexpected preshared key presence on fresh config")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -65,13 +64,13 @@ func TestPreferences_ReadUncommitedValues(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
p.SetPreSharedKey(exampleString)
|
p.SetPreSharedKey(exampleString)
|
||||||
resp, err = p.GetPreSharedKey()
|
hasPSK, err := p.HasPreSharedKey()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("failed to read preshared key: %s", err)
|
t.Fatalf("failed to read preshared key presence: %s", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if resp != exampleString {
|
if !hasPSK {
|
||||||
t.Errorf("unexpected preshared key: %s", resp)
|
t.Errorf("expected preshared key presence after staging one")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -109,12 +108,12 @@ func TestPreferences_Commit(t *testing.T) {
|
|||||||
t.Errorf("unexpected management url: %s", resp)
|
t.Errorf("unexpected management url: %s", resp)
|
||||||
}
|
}
|
||||||
|
|
||||||
resp, err = p.GetPreSharedKey()
|
hasPSK, err := p.HasPreSharedKey()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("failed to read preshared key: %s", err)
|
t.Fatalf("failed to read preshared key presence: %s", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if resp != examplePresharedKey {
|
if !hasPSK {
|
||||||
t.Errorf("unexpected preshared key: %s", resp)
|
t.Errorf("expected preshared key presence after commit")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -54,6 +54,12 @@ func NewProfileManager(configDir string) *ProfileManager {
|
|||||||
return &ProfileManager{impl: mobile.NewProfileManager(configDir, androidUsername)}
|
return &ProfileManager{impl: mobile.NewProfileManager(configDir, androidUsername)}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SetMDMPolicyFetcher registers the native-provided MDM policy fetcher on
|
||||||
|
// this ProfileManager; passing nil disables MDM enforcement.
|
||||||
|
func (pm *ProfileManager) SetMDMPolicyFetcher(f PolicyFetcher) {
|
||||||
|
pm.impl.SetMDMLoader(loaderFor(f))
|
||||||
|
}
|
||||||
|
|
||||||
// ListProfiles returns all available profiles, including the default profile,
|
// ListProfiles returns all available profiles, including the default profile,
|
||||||
// with their active status set.
|
// with their active status set.
|
||||||
func (pm *ProfileManager) ListProfiles() (*ProfileArray, error) {
|
func (pm *ProfileManager) ListProfiles() (*ProfileArray, error) {
|
||||||
|
|||||||
@@ -15,6 +15,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"
|
||||||
nbnet "github.com/netbirdio/netbird/client/net"
|
nbnet "github.com/netbirdio/netbird/client/net"
|
||||||
"github.com/netbirdio/netbird/client/proto"
|
"github.com/netbirdio/netbird/client/proto"
|
||||||
"github.com/netbirdio/netbird/client/server"
|
"github.com/netbirdio/netbird/client/server"
|
||||||
@@ -330,6 +331,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())
|
||||||
|
|
||||||
// Mirror runInForegroundMode: recover residual state (DNS, firewall,
|
// Mirror runInForegroundMode: recover residual state (DNS, firewall,
|
||||||
// ssh config, legacy routing) from a previous unclean shutdown and
|
// ssh config, legacy routing) from a previous unclean shutdown and
|
||||||
|
|||||||
@@ -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"
|
||||||
nbnet "github.com/netbirdio/netbird/client/net"
|
nbnet "github.com/netbirdio/netbird/client/net"
|
||||||
"github.com/netbirdio/netbird/client/proto"
|
"github.com/netbirdio/netbird/client/proto"
|
||||||
"github.com/netbirdio/netbird/client/server"
|
"github.com/netbirdio/netbird/client/server"
|
||||||
@@ -234,6 +235,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"
|
||||||
nbssh "github.com/netbirdio/netbird/client/ssh"
|
nbssh "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"
|
||||||
@@ -229,6 +230,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
|
||||||
|
|||||||
@@ -63,7 +63,12 @@ func (t *WGTunDevice) Create(routes []string, dns string, searchDomains []string
|
|||||||
searchDomainsToString = ""
|
searchDomainsToString = ""
|
||||||
}
|
}
|
||||||
|
|
||||||
fd, err := t.tunAdapter.ConfigureInterface(t.address.String(), t.address.IPv6String(), int(t.mtu), dns, searchDomainsToString, routesString)
|
ipv6Host := ""
|
||||||
|
if t.address.HasIPv6() {
|
||||||
|
ipv6Host = t.address.IPv6HostPrefix().String()
|
||||||
|
}
|
||||||
|
|
||||||
|
fd, err := t.tunAdapter.ConfigureInterface(t.address.HostPrefix().String(), ipv6Host, int(t.mtu), dns, searchDomainsToString, routesString)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Errorf("failed to create Android interface: %s", err)
|
log.Errorf("failed to create Android interface: %s", err)
|
||||||
return nil, err
|
return nil, err
|
||||||
|
|||||||
@@ -59,6 +59,19 @@ func (addr Address) IPv6Prefix() netip.Prefix {
|
|||||||
return netip.PrefixFrom(addr.IPv6, addr.IPv6Net.Bits())
|
return netip.PrefixFrom(addr.IPv6, addr.IPv6Net.Bits())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// HostPrefix returns the v4 address as a single-host prefix.
|
||||||
|
func (addr Address) HostPrefix() netip.Prefix {
|
||||||
|
return netip.PrefixFrom(addr.IP, addr.IP.BitLen())
|
||||||
|
}
|
||||||
|
|
||||||
|
// IPv6HostPrefix returns the v6 address as a single-host prefix, or an invalid prefix when no v6 overlay address is assigned.
|
||||||
|
func (addr Address) IPv6HostPrefix() netip.Prefix {
|
||||||
|
if !addr.HasIPv6() {
|
||||||
|
return netip.Prefix{}
|
||||||
|
}
|
||||||
|
return netip.PrefixFrom(addr.IPv6, addr.IPv6.BitLen())
|
||||||
|
}
|
||||||
|
|
||||||
// SetIPv6FromCompact decodes a compact prefix (5 or 17 bytes) and sets the IPv6 fields.
|
// SetIPv6FromCompact decodes a compact prefix (5 or 17 bytes) and sets the IPv6 fields.
|
||||||
// Returns an error if the bytes are invalid. A nil or empty input is a no-op.
|
// Returns an error if the bytes are invalid. A nil or empty input is a no-op.
|
||||||
//
|
//
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
package wgaddr
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/netip"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestAddress_HostPrefix(t *testing.T) {
|
||||||
|
addr := MustParseWGAddress("100.91.96.107/16")
|
||||||
|
|
||||||
|
assert.Equal(t, netip.MustParsePrefix("100.91.96.107/32"), addr.HostPrefix(), "v4 host prefix must be a single host")
|
||||||
|
assert.Equal(t, netip.MustParsePrefix("100.91.0.0/16"), addr.Network, "network must keep the overlay prefix length")
|
||||||
|
assert.False(t, addr.IPv6HostPrefix().IsValid(), "no v6 overlay means no v6 host prefix")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAddress_IPv6HostPrefix(t *testing.T) {
|
||||||
|
addr := MustParseWGAddress("100.91.96.107/16")
|
||||||
|
addr.IPv6 = netip.MustParseAddr("fd00:1234::1")
|
||||||
|
addr.IPv6Net = netip.MustParsePrefix("fd00:1234::/64")
|
||||||
|
|
||||||
|
assert.Equal(t, netip.MustParsePrefix("fd00:1234::1/128"), addr.IPv6HostPrefix(), "v6 host prefix must be a single host")
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
"net"
|
"net"
|
||||||
"net/netip"
|
"net/netip"
|
||||||
"runtime"
|
"runtime"
|
||||||
|
"slices"
|
||||||
"strconv"
|
"strconv"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
@@ -17,17 +18,20 @@ import (
|
|||||||
nberrors "github.com/netbirdio/netbird/client/errors"
|
nberrors "github.com/netbirdio/netbird/client/errors"
|
||||||
|
|
||||||
firewall "github.com/netbirdio/netbird/client/firewall/manager"
|
firewall "github.com/netbirdio/netbird/client/firewall/manager"
|
||||||
"github.com/netbirdio/netbird/client/internal/ebpf"
|
|
||||||
ebpfMgr "github.com/netbirdio/netbird/client/internal/ebpf/manager"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
customPort = 5053
|
customPort = 5053
|
||||||
|
// randomPortAttempts bounds the search for a port free on both protocols.
|
||||||
|
randomPortAttempts = 5
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
defaultIP = netip.MustParseAddr("127.0.0.1")
|
defaultIP = netip.MustParseAddr("127.0.0.1")
|
||||||
customIP = netip.MustParseAddr("127.0.0.153")
|
customIP = netip.MustParseAddr("127.0.0.153")
|
||||||
|
|
||||||
|
// dnatProtocols are the protocols the port 53 redirect covers.
|
||||||
|
dnatProtocols = []firewall.Protocol{firewall.ProtocolUDP, firewall.ProtocolTCP}
|
||||||
)
|
)
|
||||||
|
|
||||||
type serviceViaListener struct {
|
type serviceViaListener struct {
|
||||||
@@ -40,9 +44,20 @@ type serviceViaListener struct {
|
|||||||
listenPort uint16
|
listenPort uint16
|
||||||
listenerIsRunning bool
|
listenerIsRunning bool
|
||||||
listenerFlagLock sync.Mutex
|
listenerFlagLock sync.Mutex
|
||||||
ebpfService ebpfMgr.Manager
|
|
||||||
firewall Firewall
|
firewall Firewall
|
||||||
tcpDNATConfigured bool
|
// dnatRules holds the port 53 redirects that are installed and not yet
|
||||||
|
// removed, so a removal that fails can be retried.
|
||||||
|
dnatRules []dnatRule
|
||||||
|
}
|
||||||
|
|
||||||
|
// dnatRule is a port 53 redirect as it was installed. The target is kept with
|
||||||
|
// the rule because the listener can come back on a different address or port,
|
||||||
|
// and a retried removal has to name the address and port the rule was added
|
||||||
|
// with, not the ones in use now.
|
||||||
|
type dnatRule struct {
|
||||||
|
protocol firewall.Protocol
|
||||||
|
ip netip.Addr
|
||||||
|
port uint16
|
||||||
}
|
}
|
||||||
|
|
||||||
func newServiceViaListener(wgIface WGIface, customAddr *netip.AddrPort, fw Firewall) *serviceViaListener {
|
func newServiceViaListener(wgIface WGIface, customAddr *netip.AddrPort, fw Firewall) *serviceViaListener {
|
||||||
@@ -112,34 +127,93 @@ func (s *serviceViaListener) Listen() error {
|
|||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
|
||||||
// When eBPF redirects UDP port 53 to our listen port, TCP still needs
|
if s.listenPort != DefaultPort {
|
||||||
// a DNAT rule because eBPF only handles UDP.
|
s.setupDNAT()
|
||||||
if s.ebpfService != nil && s.firewall != nil && s.listenPort != DefaultPort {
|
|
||||||
if err := s.firewall.AddOutputDNAT(s.listenIP, firewall.ProtocolTCP, DefaultPort, s.listenPort); err != nil {
|
|
||||||
log.Warnf("failed to add DNS TCP DNAT rule, TCP DNS on port 53 will not work: %v", err)
|
|
||||||
} else {
|
|
||||||
s.tcpDNATConfigured = true
|
|
||||||
log.Infof("added DNS TCP DNAT rule: %s:%d -> %s:%d", s.listenIP, DefaultPort, s.listenIP, s.listenPort)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// setupDNAT redirects port 53 to the port the DNS server actually listens on.
|
||||||
|
// Both protocols must be redirected or none: RuntimePort reports port 53 only
|
||||||
|
// while the full redirect is in place, so a half-configured redirect would
|
||||||
|
// advertise a resolver that answers over one protocol.
|
||||||
|
func (s *serviceViaListener) setupDNAT() {
|
||||||
|
if s.firewall == nil {
|
||||||
|
log.Errorf("no firewall manager available to redirect DNS port %d to %d, "+
|
||||||
|
"clients pointed at %s will not reach the resolver", DefaultPort, s.listenPort, s.listenIP)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear whatever an earlier removal left behind first. Those rules can point
|
||||||
|
// at an address or port this listener no longer uses, and they are matched
|
||||||
|
// before anything added now, so adding a redirect on top of one would keep
|
||||||
|
// sending port 53 traffic to the previous listener while reporting the
|
||||||
|
// redirect as complete. The rules stay recorded for a later attempt.
|
||||||
|
if err := s.removeDNAT(); err != nil {
|
||||||
|
log.Errorf("failed to remove stale DNS DNAT rules, leaving port %d redirected to the previous listener: %v",
|
||||||
|
DefaultPort, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, proto := range dnatProtocols {
|
||||||
|
if err := s.firewall.AddOutputDNAT(s.listenIP, proto, DefaultPort, s.listenPort); err != nil {
|
||||||
|
log.Errorf("failed to add DNS %s DNAT rule, DNS on port %d will not work: %v",
|
||||||
|
proto, DefaultPort, err)
|
||||||
|
if err := s.removeDNAT(); err != nil {
|
||||||
|
log.Warnf("failed to roll back DNS DNAT rules, retrying on stop: %v", err)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.dnatRules = append(s.dnatRules, dnatRule{protocol: proto, ip: s.listenIP, port: s.listenPort})
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Infof("added DNS DNAT rules: %s:%d -> %s:%d (UDP + TCP)", s.listenIP, DefaultPort, s.listenIP, s.listenPort)
|
||||||
|
}
|
||||||
|
|
||||||
|
// removeDNAT removes every installed port 53 redirect. A rule whose removal
|
||||||
|
// fails stays recorded so a later setup or Stop retries it, rather than leaving
|
||||||
|
// port 53 pointing at a resolver that is no longer listening.
|
||||||
|
func (s *serviceViaListener) removeDNAT() error {
|
||||||
|
if s.firewall == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var merr *multierror.Error
|
||||||
|
var remaining []dnatRule
|
||||||
|
for _, rule := range s.dnatRules {
|
||||||
|
if err := s.firewall.RemoveOutputDNAT(rule.ip, rule.protocol, DefaultPort, rule.port); err != nil {
|
||||||
|
merr = multierror.Append(merr, fmt.Errorf("remove DNS %s DNAT rule for %s:%d: %w",
|
||||||
|
rule.protocol, rule.ip, rule.port, err))
|
||||||
|
remaining = append(remaining, rule)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
s.dnatRules = remaining
|
||||||
|
|
||||||
|
return nberrors.FormatErrorOrNil(merr)
|
||||||
|
}
|
||||||
|
|
||||||
func (s *serviceViaListener) Stop() error {
|
func (s *serviceViaListener) Stop() error {
|
||||||
s.listenerFlagLock.Lock()
|
s.listenerFlagLock.Lock()
|
||||||
defer s.listenerFlagLock.Unlock()
|
defer s.listenerFlagLock.Unlock()
|
||||||
|
|
||||||
|
var merr *multierror.Error
|
||||||
|
|
||||||
|
// Redirects are removed even when the listener is already stopped, so that
|
||||||
|
// a removal which failed earlier is retried instead of leaving port 53
|
||||||
|
// pointing at a resolver that no longer listens.
|
||||||
|
if err := s.removeDNAT(); err != nil {
|
||||||
|
merr = multierror.Append(merr, err)
|
||||||
|
}
|
||||||
|
|
||||||
if !s.listenerIsRunning {
|
if !s.listenerIsRunning {
|
||||||
return nil
|
return nberrors.FormatErrorOrNil(merr)
|
||||||
}
|
}
|
||||||
s.listenerIsRunning = false
|
s.listenerIsRunning = false
|
||||||
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
var merr *multierror.Error
|
|
||||||
|
|
||||||
if err := s.server.ShutdownContext(ctx); err != nil {
|
if err := s.server.ShutdownContext(ctx); err != nil {
|
||||||
merr = multierror.Append(merr, fmt.Errorf("stop DNS UDP server: %w", err))
|
merr = multierror.Append(merr, fmt.Errorf("stop DNS UDP server: %w", err))
|
||||||
}
|
}
|
||||||
@@ -148,19 +222,6 @@ func (s *serviceViaListener) Stop() error {
|
|||||||
merr = multierror.Append(merr, fmt.Errorf("stop DNS TCP server: %w", err))
|
merr = multierror.Append(merr, fmt.Errorf("stop DNS TCP server: %w", err))
|
||||||
}
|
}
|
||||||
|
|
||||||
if s.tcpDNATConfigured && s.firewall != nil {
|
|
||||||
if err := s.firewall.RemoveOutputDNAT(s.listenIP, firewall.ProtocolTCP, DefaultPort, s.listenPort); err != nil {
|
|
||||||
merr = multierror.Append(merr, fmt.Errorf("remove DNS TCP DNAT rule: %w", err))
|
|
||||||
}
|
|
||||||
s.tcpDNATConfigured = false
|
|
||||||
}
|
|
||||||
|
|
||||||
if s.ebpfService != nil {
|
|
||||||
if err := s.ebpfService.FreeDNSFwd(); err != nil {
|
|
||||||
merr = multierror.Append(merr, fmt.Errorf("stop traffic forwarder: %w", err))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return nberrors.FormatErrorOrNil(merr)
|
return nberrors.FormatErrorOrNil(merr)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -177,11 +238,23 @@ func (s *serviceViaListener) RuntimePort() int {
|
|||||||
s.listenerFlagLock.Lock()
|
s.listenerFlagLock.Lock()
|
||||||
defer s.listenerFlagLock.Unlock()
|
defer s.listenerFlagLock.Unlock()
|
||||||
|
|
||||||
if s.ebpfService != nil {
|
if s.redirectInstalled() {
|
||||||
return DefaultPort
|
return DefaultPort
|
||||||
} else {
|
|
||||||
return int(s.listenPort)
|
|
||||||
}
|
}
|
||||||
|
return int(s.listenPort)
|
||||||
|
}
|
||||||
|
|
||||||
|
// redirectInstalled reports whether every protocol is redirected from port 53
|
||||||
|
// to the address and port the listener currently serves. Rules left over from
|
||||||
|
// an earlier listener do not count.
|
||||||
|
func (s *serviceViaListener) redirectInstalled() bool {
|
||||||
|
for _, proto := range dnatProtocols {
|
||||||
|
current := dnatRule{protocol: proto, ip: s.listenIP, port: s.listenPort}
|
||||||
|
if !slices.Contains(s.dnatRules, current) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *serviceViaListener) RuntimeIP() netip.Addr {
|
func (s *serviceViaListener) RuntimeIP() netip.Addr {
|
||||||
@@ -190,30 +263,29 @@ func (s *serviceViaListener) RuntimeIP() netip.Addr {
|
|||||||
|
|
||||||
// evalListenAddress figures out the listen address for the DNS server.
|
// evalListenAddress figures out the listen address for the DNS server.
|
||||||
// IPv4-only: all peers have a v4 overlay address, and DNS config points to v4.
|
// IPv4-only: all peers have a v4 overlay address, and DNS config points to v4.
|
||||||
// First checks port 53 on WG interface or lo, then tries eBPF on a random port,
|
// Prefers port 53 on the overlay interface or lo, so no redirect is needed at
|
||||||
// then falls back to port 5053.
|
// all; when it is taken it falls back to port 5053 and then to a random free
|
||||||
|
// port, both of which need the port 53 redirect set up by setupDNAT.
|
||||||
func (s *serviceViaListener) evalListenAddress() (netip.Addr, uint16, error) {
|
func (s *serviceViaListener) evalListenAddress() (netip.Addr, uint16, error) {
|
||||||
if s.customAddr != nil {
|
if s.customAddr != nil {
|
||||||
return s.customAddr.Addr(), s.customAddr.Port(), nil
|
return s.customAddr.Addr(), s.customAddr.Port(), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
ip, ok := s.testFreePort(DefaultPort)
|
if ip, ok := s.testFreePort(DefaultPort); ok {
|
||||||
if ok {
|
|
||||||
return ip, DefaultPort, nil
|
return ip, DefaultPort, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
ebpfSrv, port, ok := s.tryToUseeBPF()
|
if ip, ok := s.testFreePort(customPort); ok {
|
||||||
if ok {
|
|
||||||
s.ebpfService = ebpfSrv
|
|
||||||
return s.wgInterface.Address().IP, port, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
ip, ok = s.testFreePort(customPort)
|
|
||||||
if ok {
|
|
||||||
return ip, customPort, nil
|
return ip, customPort, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
return netip.Addr{}, 0, fmt.Errorf("failed to find a free port for DNS server")
|
ip := s.wgInterface.Address().IP
|
||||||
|
port, err := s.randomFreePort(ip)
|
||||||
|
if err != nil {
|
||||||
|
return netip.Addr{}, 0, fmt.Errorf("find a free port for DNS server: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return ip, port, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *serviceViaListener) testFreePort(port int) (netip.Addr, bool) {
|
func (s *serviceViaListener) testFreePort(port int) (netip.Addr, bool) {
|
||||||
@@ -260,48 +332,25 @@ func (s *serviceViaListener) tryToBind(ip netip.Addr, port int) bool {
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
// tryToUseeBPF decides whether to apply eBPF program to capture DNS traffic on port 53.
|
// randomFreePort returns a port that is free on ip for both UDP and TCP, since
|
||||||
// This is needed because on some operating systems if we start a DNS server not on a default port 53,
|
// the DNS server binds both. The probe listeners are closed again, so the port
|
||||||
// the domain name resolution won't work. So, in case we are running on Linux and picked a free
|
// is only likely, not guaranteed, to still be free when the server binds it.
|
||||||
// port we should fall back to the eBPF solution that will capture traffic on port 53 and forward
|
func (s *serviceViaListener) randomFreePort(ip netip.Addr) (uint16, error) {
|
||||||
// it to a local DNS server running on the chosen port.
|
for range randomPortAttempts {
|
||||||
func (s *serviceViaListener) tryToUseeBPF() (ebpfMgr.Manager, uint16, bool) {
|
probeListener, err := net.ListenUDP("udp4", &net.UDPAddr{})
|
||||||
if runtime.GOOS != "linux" {
|
if err != nil {
|
||||||
return nil, 0, false
|
return 0, fmt.Errorf("bind random port: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
port := uint16(probeListener.LocalAddr().(*net.UDPAddr).Port)
|
||||||
|
if err := probeListener.Close(); err != nil {
|
||||||
|
return 0, fmt.Errorf("free up probed port: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if s.tryToBind(ip, int(port)) {
|
||||||
|
return port, nil
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
port, err := s.generateFreePort() //nolint:staticcheck,unused
|
return 0, fmt.Errorf("no port free for UDP and TCP on %s after %d attempts", ip, randomPortAttempts)
|
||||||
if err != nil {
|
|
||||||
log.Warnf("failed to generate a free port for eBPF DNS forwarder server: %s", err)
|
|
||||||
return nil, 0, false
|
|
||||||
}
|
|
||||||
|
|
||||||
ebpfSrv := ebpf.GetEbpfManagerInstance()
|
|
||||||
err = ebpfSrv.LoadDNSFwd(s.wgInterface.Address().IP, int(port))
|
|
||||||
if err != nil {
|
|
||||||
log.Warnf("failed to load DNS forwarder eBPF program, error: %s", err)
|
|
||||||
return nil, 0, false
|
|
||||||
}
|
|
||||||
|
|
||||||
return ebpfSrv, port, true
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *serviceViaListener) generateFreePort() (uint16, error) {
|
|
||||||
ok := s.tryToBind(s.wgInterface.Address().IP, customPort)
|
|
||||||
if ok {
|
|
||||||
return customPort, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
probeListener, err := net.ListenUDP("udp4", &net.UDPAddr{})
|
|
||||||
if err != nil {
|
|
||||||
log.Debugf("failed to bind random port for DNS: %s", err)
|
|
||||||
return 0, err
|
|
||||||
}
|
|
||||||
|
|
||||||
port := uint16(probeListener.LocalAddr().(*net.UDPAddr).Port)
|
|
||||||
if err = probeListener.Close(); err != nil {
|
|
||||||
log.Debugf("failed to free up DNS port: %s", err)
|
|
||||||
return 0, err
|
|
||||||
}
|
|
||||||
return port, nil
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package dns
|
package dns
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net"
|
"net"
|
||||||
"net/netip"
|
"net/netip"
|
||||||
@@ -10,6 +11,8 @@ import (
|
|||||||
"github.com/miekg/dns"
|
"github.com/miekg/dns"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
|
|
||||||
|
firewall "github.com/netbirdio/netbird/client/firewall/manager"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestServiceViaListener_TCPAndUDP(t *testing.T) {
|
func TestServiceViaListener_TCPAndUDP(t *testing.T) {
|
||||||
@@ -84,3 +87,133 @@ func TestServiceViaListener_TCPAndUDP(t *testing.T) {
|
|||||||
require.NotEmpty(t, tcpResp.Answer)
|
require.NotEmpty(t, tcpResp.Answer)
|
||||||
assert.Contains(t, tcpResp.Answer[0].String(), "192.0.2.1", "TCP response should contain expected IP")
|
assert.Contains(t, tcpResp.Answer[0].String(), "192.0.2.1", "TCP response should contain expected IP")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type dnatCall struct {
|
||||||
|
rule dnatRule
|
||||||
|
added bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// fakeFirewall records DNAT calls and fails the ones named in addErrs/removeErrs.
|
||||||
|
type fakeFirewall struct {
|
||||||
|
calls []dnatCall
|
||||||
|
addErrs map[firewall.Protocol]error
|
||||||
|
removeErrs map[firewall.Protocol]error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeFirewall) AddOutputDNAT(ip netip.Addr, protocol firewall.Protocol, _, translatedPort uint16) error {
|
||||||
|
if err := f.addErrs[protocol]; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
f.calls = append(f.calls, dnatCall{rule: dnatRule{protocol: protocol, ip: ip, port: translatedPort}, added: true})
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeFirewall) RemoveOutputDNAT(ip netip.Addr, protocol firewall.Protocol, _, translatedPort uint16) error {
|
||||||
|
if err := f.removeErrs[protocol]; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
f.calls = append(f.calls, dnatCall{rule: dnatRule{protocol: protocol, ip: ip, port: translatedPort}})
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func newDNATTestService(fw Firewall) *serviceViaListener {
|
||||||
|
return &serviceViaListener{
|
||||||
|
listenIP: netip.MustParseAddr("100.64.0.1"),
|
||||||
|
listenPort: customPort,
|
||||||
|
firewall: fw,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSetupDNAT_BothProtocols(t *testing.T) {
|
||||||
|
svc := newDNATTestService(&fakeFirewall{})
|
||||||
|
|
||||||
|
svc.setupDNAT()
|
||||||
|
|
||||||
|
assert.Len(t, svc.dnatRules, len(dnatProtocols))
|
||||||
|
assert.Equal(t, DefaultPort, svc.RuntimePort(), "port 53 is advertised once both redirects are installed")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSetupDNAT_RollsBackPartialRedirect(t *testing.T) {
|
||||||
|
fw := &fakeFirewall{addErrs: map[firewall.Protocol]error{firewall.ProtocolTCP: errors.New("nftables busy")}}
|
||||||
|
svc := newDNATTestService(fw)
|
||||||
|
|
||||||
|
svc.setupDNAT()
|
||||||
|
|
||||||
|
assert.Empty(t, svc.dnatRules, "the UDP redirect installed before the failure must be rolled back")
|
||||||
|
assert.Equal(t, int(svc.listenPort), svc.RuntimePort(), "an incomplete redirect must not advertise port 53")
|
||||||
|
udp := dnatRule{protocol: firewall.ProtocolUDP, ip: svc.listenIP, port: svc.listenPort}
|
||||||
|
assert.Contains(t, fw.calls, dnatCall{rule: udp}, "UDP removal should have been attempted")
|
||||||
|
}
|
||||||
|
|
||||||
|
// A rollback that fails must keep the rule recorded, so port 53 is not left
|
||||||
|
// redirected to a resolver that no longer listens.
|
||||||
|
func TestStop_RetriesFailedDNATRemoval(t *testing.T) {
|
||||||
|
fw := &fakeFirewall{
|
||||||
|
addErrs: map[firewall.Protocol]error{firewall.ProtocolTCP: errors.New("nftables busy")},
|
||||||
|
removeErrs: map[firewall.Protocol]error{firewall.ProtocolUDP: errors.New("nftables busy")},
|
||||||
|
}
|
||||||
|
svc := newDNATTestService(fw)
|
||||||
|
|
||||||
|
svc.setupDNAT()
|
||||||
|
udp := dnatRule{protocol: firewall.ProtocolUDP, ip: svc.listenIP, port: svc.listenPort}
|
||||||
|
require.Equal(t, []dnatRule{udp}, svc.dnatRules, "a failed rollback keeps the rule for a later retry")
|
||||||
|
|
||||||
|
require.Error(t, svc.Stop(), "the failing removal should be reported")
|
||||||
|
require.Equal(t, []dnatRule{udp}, svc.dnatRules)
|
||||||
|
|
||||||
|
delete(fw.removeErrs, firewall.ProtocolUDP)
|
||||||
|
require.NoError(t, svc.Stop(), "a later stop retries the removal")
|
||||||
|
assert.Empty(t, svc.dnatRules)
|
||||||
|
}
|
||||||
|
|
||||||
|
// A stale rule that cannot be removed is matched before anything added now, so
|
||||||
|
// no new redirect may be installed on top of it and port 53 must not be
|
||||||
|
// advertised as reaching this listener.
|
||||||
|
func TestSetupDNAT_AbortsWhileStaleRuleRemains(t *testing.T) {
|
||||||
|
fw := &fakeFirewall{removeErrs: map[firewall.Protocol]error{firewall.ProtocolUDP: errors.New("nftables busy")}}
|
||||||
|
svc := newDNATTestService(fw)
|
||||||
|
stalePort := svc.listenPort
|
||||||
|
|
||||||
|
svc.setupDNAT()
|
||||||
|
require.Error(t, svc.Stop())
|
||||||
|
staleUDP := dnatRule{protocol: firewall.ProtocolUDP, ip: svc.listenIP, port: stalePort}
|
||||||
|
require.Equal(t, []dnatRule{staleUDP}, svc.dnatRules)
|
||||||
|
|
||||||
|
svc.listenPort = stalePort + 1
|
||||||
|
fw.calls = nil
|
||||||
|
|
||||||
|
svc.setupDNAT()
|
||||||
|
|
||||||
|
assert.Equal(t, []dnatRule{staleUDP}, svc.dnatRules, "the stale rule stays recorded for a later attempt")
|
||||||
|
for _, call := range fw.calls {
|
||||||
|
assert.False(t, call.added, "no redirect may be installed while a stale one is still in place")
|
||||||
|
}
|
||||||
|
assert.Equal(t, int(svc.listenPort), svc.RuntimePort(), "port 53 must not be advertised")
|
||||||
|
}
|
||||||
|
|
||||||
|
// A rule left behind by a failed removal must be removed with the address and
|
||||||
|
// port it was installed with, even when the listener has since moved to another
|
||||||
|
// port, and it must not count towards the redirect the new listener advertises.
|
||||||
|
func TestSetupDNAT_ClearsStaleRuleAfterPortChange(t *testing.T) {
|
||||||
|
fw := &fakeFirewall{removeErrs: map[firewall.Protocol]error{firewall.ProtocolUDP: errors.New("nftables busy")}}
|
||||||
|
svc := newDNATTestService(fw)
|
||||||
|
stalePort := svc.listenPort
|
||||||
|
|
||||||
|
svc.setupDNAT()
|
||||||
|
require.Error(t, svc.Stop())
|
||||||
|
staleUDP := dnatRule{protocol: firewall.ProtocolUDP, ip: svc.listenIP, port: stalePort}
|
||||||
|
require.Equal(t, []dnatRule{staleUDP}, svc.dnatRules)
|
||||||
|
|
||||||
|
delete(fw.removeErrs, firewall.ProtocolUDP)
|
||||||
|
svc.listenPort = stalePort + 1
|
||||||
|
fw.calls = nil
|
||||||
|
|
||||||
|
svc.setupDNAT()
|
||||||
|
|
||||||
|
assert.Contains(t, fw.calls, dnatCall{rule: staleUDP}, "the stale rule must be removed with its original port")
|
||||||
|
assert.Len(t, svc.dnatRules, len(dnatProtocols))
|
||||||
|
assert.Equal(t, DefaultPort, svc.RuntimePort(), "the new listener is fully redirected")
|
||||||
|
for _, rule := range svc.dnatRules {
|
||||||
|
assert.Equal(t, svc.listenPort, rule.port, "only rules for the current listener remain")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -54,12 +54,20 @@ type DNSForwarder struct {
|
|||||||
ttl uint32
|
ttl uint32
|
||||||
statusRecorder *peer.Status
|
statusRecorder *peer.Status
|
||||||
|
|
||||||
dnsServer *dns.Server
|
mux *dns.ServeMux
|
||||||
mux *dns.ServeMux
|
tcpMux *dns.ServeMux
|
||||||
tcpServer *dns.Server
|
|
||||||
tcpMux *dns.ServeMux
|
|
||||||
|
|
||||||
mutex sync.RWMutex
|
mutex sync.RWMutex
|
||||||
|
// closed records that Close has run, so a Listen still in flight does not
|
||||||
|
// go on to serve sockets nobody will shut down.
|
||||||
|
closed bool
|
||||||
|
// The sockets are kept alongside the servers because closing them is the
|
||||||
|
// only stop that always works: a server whose ActivateAndServe has not run
|
||||||
|
// yet refuses to shut down, and would otherwise start serving afterwards.
|
||||||
|
udpConn net.PacketConn
|
||||||
|
tcpLn net.Listener
|
||||||
|
dnsServer *dns.Server
|
||||||
|
tcpServer *dns.Server
|
||||||
fwdEntries []*ForwarderEntry
|
fwdEntries []*ForwarderEntry
|
||||||
firewall firewaller
|
firewall firewaller
|
||||||
resolver resolver
|
resolver resolver
|
||||||
@@ -106,7 +114,7 @@ func (f *DNSForwarder) Listen(entries []*ForwarderEntry) error {
|
|||||||
mux := dns.NewServeMux()
|
mux := dns.NewServeMux()
|
||||||
f.mux = mux
|
f.mux = mux
|
||||||
mux.HandleFunc(".", f.handleDNSQueryUDP)
|
mux.HandleFunc(".", f.handleDNSQueryUDP)
|
||||||
f.dnsServer = &dns.Server{
|
dnsServer := &dns.Server{
|
||||||
PacketConn: udpLn,
|
PacketConn: udpLn,
|
||||||
Handler: mux,
|
Handler: mux,
|
||||||
}
|
}
|
||||||
@@ -114,22 +122,32 @@ func (f *DNSForwarder) Listen(entries []*ForwarderEntry) error {
|
|||||||
tcpMux := dns.NewServeMux()
|
tcpMux := dns.NewServeMux()
|
||||||
f.tcpMux = tcpMux
|
f.tcpMux = tcpMux
|
||||||
tcpMux.HandleFunc(".", f.handleDNSQueryTCP)
|
tcpMux.HandleFunc(".", f.handleDNSQueryTCP)
|
||||||
f.tcpServer = &dns.Server{
|
tcpServer := &dns.Server{
|
||||||
Listener: tcpLn,
|
Listener: tcpLn,
|
||||||
Handler: tcpMux,
|
Handler: tcpMux,
|
||||||
}
|
}
|
||||||
|
|
||||||
f.UpdateDomains(entries)
|
if !f.publish(udpLn, tcpLn, dnsServer, tcpServer, entries) {
|
||||||
|
log.Infof("DNS forwarder on %s was closed before it started serving", addrDesc)
|
||||||
|
if err := udpLn.Close(); err != nil {
|
||||||
|
log.Debugf("close UDP listener of a closed forwarder: %v", err)
|
||||||
|
}
|
||||||
|
if err := tcpLn.Close(); err != nil {
|
||||||
|
log.Debugf("close TCP listener of a closed forwarder: %v", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
log.Debugf("DNS forwarder serving %d domains", len(entries))
|
||||||
|
|
||||||
errCh := make(chan error, 2)
|
errCh := make(chan error, 2)
|
||||||
|
|
||||||
go func() {
|
go func() {
|
||||||
log.Infof("DNS UDP listener running on %s", addrDesc)
|
log.Infof("DNS UDP listener running on %s", addrDesc)
|
||||||
errCh <- f.dnsServer.ActivateAndServe()
|
errCh <- dnsServer.ActivateAndServe()
|
||||||
}()
|
}()
|
||||||
go func() {
|
go func() {
|
||||||
log.Infof("DNS TCP listener running on %s", addrDesc)
|
log.Infof("DNS TCP listener running on %s", addrDesc)
|
||||||
errCh <- f.tcpServer.ActivateAndServe()
|
errCh <- tcpServer.ActivateAndServe()
|
||||||
}()
|
}()
|
||||||
|
|
||||||
return <-errCh
|
return <-errCh
|
||||||
@@ -151,6 +169,46 @@ func (f *DNSForwarder) createTCPListener(netstackNet *netstack.Net) (net.Listene
|
|||||||
return net.ListenTCP("tcp", net.TCPAddrFromAddrPort(f.listenAddress))
|
return net.ListenTCP("tcp", net.TCPAddrFromAddrPort(f.listenAddress))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// publish hands the sockets, servers and entries to the forwarder so Close can
|
||||||
|
// reach them and Domains can report them, and says whether serving may begin.
|
||||||
|
// Listen runs on its own goroutine, so a Close can arrive before it gets this
|
||||||
|
// far; false means the caller must close what it created instead of serving on
|
||||||
|
// it.
|
||||||
|
//
|
||||||
|
// The entries go in under the same lock rather than afterwards. Anything that
|
||||||
|
// reads them in between would otherwise see a forwarder that is listening and
|
||||||
|
// serves no domain, which for a caller rebuilding one means it comes back
|
||||||
|
// refusing every routed query.
|
||||||
|
func (f *DNSForwarder) publish(
|
||||||
|
udpConn net.PacketConn,
|
||||||
|
tcpLn net.Listener,
|
||||||
|
dnsServer, tcpServer *dns.Server,
|
||||||
|
entries []*ForwarderEntry,
|
||||||
|
) bool {
|
||||||
|
f.mutex.Lock()
|
||||||
|
defer f.mutex.Unlock()
|
||||||
|
|
||||||
|
if f.closed {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
f.udpConn = udpConn
|
||||||
|
f.tcpLn = tcpLn
|
||||||
|
f.dnsServer = dnsServer
|
||||||
|
f.tcpServer = tcpServer
|
||||||
|
f.fwdEntries = entries
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Domains returns the entries currently being served. The slice is replaced
|
||||||
|
// wholesale by UpdateDomains rather than mutated, so the caller may read it but
|
||||||
|
// must not write to it.
|
||||||
|
func (f *DNSForwarder) Domains() []*ForwarderEntry {
|
||||||
|
f.mutex.RLock()
|
||||||
|
defer f.mutex.RUnlock()
|
||||||
|
return f.fwdEntries
|
||||||
|
}
|
||||||
|
|
||||||
func (f *DNSForwarder) UpdateDomains(entries []*ForwarderEntry) {
|
func (f *DNSForwarder) UpdateDomains(entries []*ForwarderEntry) {
|
||||||
f.mutex.Lock()
|
f.mutex.Lock()
|
||||||
defer f.mutex.Unlock()
|
defer f.mutex.Unlock()
|
||||||
@@ -189,19 +247,45 @@ func (f *DNSForwarder) removeStaleCacheEntries(oldEntries, newEntries []*Forward
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (f *DNSForwarder) Close(ctx context.Context) error {
|
func (f *DNSForwarder) Close(ctx context.Context) error {
|
||||||
|
// Marked closed under the lock so a Listen that has not published its
|
||||||
|
// servers yet gives up instead of racing this shutdown. The shutdowns
|
||||||
|
// themselves block, so they run outside it.
|
||||||
|
f.mutex.Lock()
|
||||||
|
f.closed = true
|
||||||
|
dnsServer, tcpServer := f.dnsServer, f.tcpServer
|
||||||
|
udpConn, tcpLn := f.udpConn, f.tcpLn
|
||||||
|
f.mutex.Unlock()
|
||||||
|
|
||||||
var result *multierror.Error
|
var result *multierror.Error
|
||||||
|
|
||||||
if f.dnsServer != nil {
|
if dnsServer != nil {
|
||||||
if err := f.dnsServer.ShutdownContext(ctx); err != nil {
|
if err := shutdownServer(ctx, dnsServer); err != nil {
|
||||||
result = multierror.Append(result, fmt.Errorf("UDP shutdown: %w", err))
|
result = multierror.Append(result, fmt.Errorf("UDP shutdown: %w", err))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if f.tcpServer != nil {
|
if tcpServer != nil {
|
||||||
if err := f.tcpServer.ShutdownContext(ctx); err != nil {
|
if err := shutdownServer(ctx, tcpServer); err != nil {
|
||||||
result = multierror.Append(result, fmt.Errorf("TCP shutdown: %w", err))
|
result = multierror.Append(result, fmt.Errorf("TCP shutdown: %w", err))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The sockets are closed even when the shutdowns above reported nothing to
|
||||||
|
// do. A server that has been published but has not reached
|
||||||
|
// ActivateAndServe refuses to shut down, and closing what it was about to
|
||||||
|
// serve on is what stops it: the alternative is a listener still answering
|
||||||
|
// on an interface that has gone away. A shutdown that did run has already
|
||||||
|
// closed these, so the second close is expected to fail.
|
||||||
|
if udpConn != nil {
|
||||||
|
if err := udpConn.Close(); err != nil {
|
||||||
|
log.Debugf("close UDP socket of the DNS forwarder: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if tcpLn != nil {
|
||||||
|
if err := tcpLn.Close(); err != nil {
|
||||||
|
log.Debugf("close TCP socket of the DNS forwarder: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return nberrors.FormatErrorOrNil(result)
|
return nberrors.FormatErrorOrNil(result)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -514,3 +598,16 @@ func attachEDE(resp *dns.Msg, code uint16, text string) {
|
|||||||
}
|
}
|
||||||
opt.Option = append(opt.Option, &dns.EDNS0_EDE{InfoCode: code, ExtraText: text})
|
opt.Option = append(opt.Option, &dns.EDNS0_EDE{InfoCode: code, ExtraText: text})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// shutdownServer shuts a server down gracefully, treating "never started" as
|
||||||
|
// success. A server that was published but has not reached ActivateAndServe
|
||||||
|
// has nothing to wind down, and the caller closes its socket regardless, which
|
||||||
|
// is what actually stops it. dns exports no sentinel for this, so the message
|
||||||
|
// is all there is to match on.
|
||||||
|
func shutdownServer(ctx context.Context, server *dns.Server) error {
|
||||||
|
err := server.ShutdownContext(ctx)
|
||||||
|
if err == nil || strings.Contains(err.Error(), "server not started") {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|||||||
@@ -1238,3 +1238,55 @@ func TestDNSForwarder_EmptyQuery(t *testing.T) {
|
|||||||
|
|
||||||
assert.Nil(t, mockWriter.GetLastResponse(), "Should not write response for empty query")
|
assert.Nil(t, mockWriter.GetLastResponse(), "Should not write response for empty query")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestDNSForwarder_ClosedBeforeItServes covers Listen reaching the point of
|
||||||
|
// serving after the forwarder has already been closed. Listen runs on its own
|
||||||
|
// goroutine, so it can get there late, and a socket it starts serving then is
|
||||||
|
// one nothing will ever close: on Android it keeps answering on an interface
|
||||||
|
// that has been replaced. The close is sequenced first here rather than raced,
|
||||||
|
// which pins the same state deterministically.
|
||||||
|
func TestDNSForwarder_ClosedBeforeItServes(t *testing.T) {
|
||||||
|
f := NewDNSForwarder(netip.MustParseAddrPort("127.0.0.1:0"), 60, nil, nil, nil)
|
||||||
|
|
||||||
|
require.NoError(t, f.Close(context.Background()), "closing a forwarder that never started")
|
||||||
|
|
||||||
|
done := make(chan error, 1)
|
||||||
|
go func() { done <- f.Listen(nil) }()
|
||||||
|
|
||||||
|
select {
|
||||||
|
case err := <-done:
|
||||||
|
assert.NoError(t, err, "a closed forwarder should give up quietly, not serve")
|
||||||
|
case <-time.After(5 * time.Second):
|
||||||
|
t.Fatal("Listen went on to serve after the forwarder was closed")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestDNSForwarder_CloseStopsUnactivatedServers covers the window between
|
||||||
|
// Listen publishing its servers and reaching ActivateAndServe. A server that
|
||||||
|
// has not been activated refuses to shut down, so Close has to close the
|
||||||
|
// sockets itself or they are left serving.
|
||||||
|
func TestDNSForwarder_CloseStopsUnactivatedServers(t *testing.T) {
|
||||||
|
f := NewDNSForwarder(netip.MustParseAddrPort("127.0.0.1:0"), 60, nil, nil, nil)
|
||||||
|
|
||||||
|
udpConn, err := f.createUDPListener(nil)
|
||||||
|
require.NoError(t, err, "create UDP listener")
|
||||||
|
tcpLn, err := f.createTCPListener(nil)
|
||||||
|
require.NoError(t, err, "create TCP listener")
|
||||||
|
|
||||||
|
// Published but deliberately never activated, which is the state Listen is
|
||||||
|
// in for the moment before it starts serving.
|
||||||
|
require.True(t, f.publish(udpConn, tcpLn, &dns.Server{PacketConn: udpConn}, &dns.Server{Listener: tcpLn}, nil),
|
||||||
|
"publishing to an open forwarder")
|
||||||
|
|
||||||
|
tcpAddr := tcpLn.Addr().String()
|
||||||
|
require.NoError(t, f.Close(context.Background()), "close should report no error for servers it could not shut down")
|
||||||
|
|
||||||
|
_, err = tcpLn.Accept()
|
||||||
|
assert.Error(t, err, "the TCP socket should be closed after Close")
|
||||||
|
|
||||||
|
conn, err := net.DialTimeout("tcp", tcpAddr, time.Second)
|
||||||
|
if err == nil {
|
||||||
|
_ = conn.Close()
|
||||||
|
t.Fatal("the forwarder is still accepting connections after Close")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -117,6 +117,16 @@ func (m *Manager) UpdateDomains(entries []*ForwarderEntry) {
|
|||||||
m.dnsForwarder.UpdateDomains(entries)
|
m.dnsForwarder.UpdateDomains(entries)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Domains returns the entries currently being served, or nil when the
|
||||||
|
// forwarder is not running.
|
||||||
|
func (m *Manager) Domains() []*ForwarderEntry {
|
||||||
|
if m.dnsForwarder == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return m.dnsForwarder.Domains()
|
||||||
|
}
|
||||||
|
|
||||||
func (m *Manager) Stop(ctx context.Context) error {
|
func (m *Manager) Stop(ctx context.Context) error {
|
||||||
if m.dnsForwarder == nil {
|
if m.dnsForwarder == nil {
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
// Code generated by bpf2go; DO NOT EDIT.
|
// Code generated by bpf2go; DO NOT EDIT.
|
||||||
//go:build arm64be || armbe || mips || mips64 || mips64p32 || ppc64 || s390 || s390x || sparc || sparc64
|
//go:build mips || mips64 || ppc64 || s390x
|
||||||
|
|
||||||
package ebpf
|
package ebpf
|
||||||
|
|
||||||
@@ -47,9 +47,10 @@ func loadBpfObjects(obj interface{}, opts *ebpf.CollectionOptions) error {
|
|||||||
type bpfSpecs struct {
|
type bpfSpecs struct {
|
||||||
bpfProgramSpecs
|
bpfProgramSpecs
|
||||||
bpfMapSpecs
|
bpfMapSpecs
|
||||||
|
bpfVariableSpecs
|
||||||
}
|
}
|
||||||
|
|
||||||
// bpfSpecs contains programs before they are loaded into the kernel.
|
// bpfProgramSpecs contains programs before they are loaded into the kernel.
|
||||||
//
|
//
|
||||||
// It can be passed ebpf.CollectionSpec.Assign.
|
// It can be passed ebpf.CollectionSpec.Assign.
|
||||||
type bpfProgramSpecs struct {
|
type bpfProgramSpecs struct {
|
||||||
@@ -61,17 +62,28 @@ type bpfProgramSpecs struct {
|
|||||||
// It can be passed ebpf.CollectionSpec.Assign.
|
// It can be passed ebpf.CollectionSpec.Assign.
|
||||||
type bpfMapSpecs struct {
|
type bpfMapSpecs struct {
|
||||||
NbFeatures *ebpf.MapSpec `ebpf:"nb_features"`
|
NbFeatures *ebpf.MapSpec `ebpf:"nb_features"`
|
||||||
NbMapDnsIp *ebpf.MapSpec `ebpf:"nb_map_dns_ip"`
|
|
||||||
NbMapDnsPort *ebpf.MapSpec `ebpf:"nb_map_dns_port"`
|
|
||||||
NbWgProxySettingsMap *ebpf.MapSpec `ebpf:"nb_wg_proxy_settings_map"`
|
NbWgProxySettingsMap *ebpf.MapSpec `ebpf:"nb_wg_proxy_settings_map"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// bpfVariableSpecs contains global variables before they are loaded into the kernel.
|
||||||
|
//
|
||||||
|
// It can be passed ebpf.CollectionSpec.Assign.
|
||||||
|
type bpfVariableSpecs struct {
|
||||||
|
FlagFeatureWgProxy *ebpf.VariableSpec `ebpf:"flag_feature_wg_proxy"`
|
||||||
|
MapKeyFeatures *ebpf.VariableSpec `ebpf:"map_key_features"`
|
||||||
|
MapKeyProxyPort *ebpf.VariableSpec `ebpf:"map_key_proxy_port"`
|
||||||
|
MapKeyWgPort *ebpf.VariableSpec `ebpf:"map_key_wg_port"`
|
||||||
|
ProxyPort *ebpf.VariableSpec `ebpf:"proxy_port"`
|
||||||
|
WgPort *ebpf.VariableSpec `ebpf:"wg_port"`
|
||||||
|
}
|
||||||
|
|
||||||
// bpfObjects contains all objects after they have been loaded into the kernel.
|
// bpfObjects contains all objects after they have been loaded into the kernel.
|
||||||
//
|
//
|
||||||
// It can be passed to loadBpfObjects or ebpf.CollectionSpec.LoadAndAssign.
|
// It can be passed to loadBpfObjects or ebpf.CollectionSpec.LoadAndAssign.
|
||||||
type bpfObjects struct {
|
type bpfObjects struct {
|
||||||
bpfPrograms
|
bpfPrograms
|
||||||
bpfMaps
|
bpfMaps
|
||||||
|
bpfVariables
|
||||||
}
|
}
|
||||||
|
|
||||||
func (o *bpfObjects) Close() error {
|
func (o *bpfObjects) Close() error {
|
||||||
@@ -86,20 +98,28 @@ func (o *bpfObjects) Close() error {
|
|||||||
// It can be passed to loadBpfObjects or ebpf.CollectionSpec.LoadAndAssign.
|
// It can be passed to loadBpfObjects or ebpf.CollectionSpec.LoadAndAssign.
|
||||||
type bpfMaps struct {
|
type bpfMaps struct {
|
||||||
NbFeatures *ebpf.Map `ebpf:"nb_features"`
|
NbFeatures *ebpf.Map `ebpf:"nb_features"`
|
||||||
NbMapDnsIp *ebpf.Map `ebpf:"nb_map_dns_ip"`
|
|
||||||
NbMapDnsPort *ebpf.Map `ebpf:"nb_map_dns_port"`
|
|
||||||
NbWgProxySettingsMap *ebpf.Map `ebpf:"nb_wg_proxy_settings_map"`
|
NbWgProxySettingsMap *ebpf.Map `ebpf:"nb_wg_proxy_settings_map"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *bpfMaps) Close() error {
|
func (m *bpfMaps) Close() error {
|
||||||
return _BpfClose(
|
return _BpfClose(
|
||||||
m.NbFeatures,
|
m.NbFeatures,
|
||||||
m.NbMapDnsIp,
|
|
||||||
m.NbMapDnsPort,
|
|
||||||
m.NbWgProxySettingsMap,
|
m.NbWgProxySettingsMap,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// bpfVariables contains all global variables after they have been loaded into the kernel.
|
||||||
|
//
|
||||||
|
// It can be passed to loadBpfObjects or ebpf.CollectionSpec.LoadAndAssign.
|
||||||
|
type bpfVariables struct {
|
||||||
|
FlagFeatureWgProxy *ebpf.Variable `ebpf:"flag_feature_wg_proxy"`
|
||||||
|
MapKeyFeatures *ebpf.Variable `ebpf:"map_key_features"`
|
||||||
|
MapKeyProxyPort *ebpf.Variable `ebpf:"map_key_proxy_port"`
|
||||||
|
MapKeyWgPort *ebpf.Variable `ebpf:"map_key_wg_port"`
|
||||||
|
ProxyPort *ebpf.Variable `ebpf:"proxy_port"`
|
||||||
|
WgPort *ebpf.Variable `ebpf:"wg_port"`
|
||||||
|
}
|
||||||
|
|
||||||
// bpfPrograms contains all programs after they have been loaded into the kernel.
|
// bpfPrograms contains all programs after they have been loaded into the kernel.
|
||||||
//
|
//
|
||||||
// It can be passed to loadBpfObjects or ebpf.CollectionSpec.LoadAndAssign.
|
// It can be passed to loadBpfObjects or ebpf.CollectionSpec.LoadAndAssign.
|
||||||
|
|||||||
Binary file not shown.
@@ -1,5 +1,5 @@
|
|||||||
// Code generated by bpf2go; DO NOT EDIT.
|
// Code generated by bpf2go; DO NOT EDIT.
|
||||||
//go:build 386 || amd64 || amd64p32 || arm || arm64 || loong64 || mips64le || mips64p32le || mipsle || ppc64le || riscv64
|
//go:build 386 || amd64 || arm || arm64 || loong64 || mips64le || mipsle || ppc64le || riscv64 || wasm
|
||||||
|
|
||||||
package ebpf
|
package ebpf
|
||||||
|
|
||||||
@@ -47,9 +47,10 @@ func loadBpfObjects(obj interface{}, opts *ebpf.CollectionOptions) error {
|
|||||||
type bpfSpecs struct {
|
type bpfSpecs struct {
|
||||||
bpfProgramSpecs
|
bpfProgramSpecs
|
||||||
bpfMapSpecs
|
bpfMapSpecs
|
||||||
|
bpfVariableSpecs
|
||||||
}
|
}
|
||||||
|
|
||||||
// bpfSpecs contains programs before they are loaded into the kernel.
|
// bpfProgramSpecs contains programs before they are loaded into the kernel.
|
||||||
//
|
//
|
||||||
// It can be passed ebpf.CollectionSpec.Assign.
|
// It can be passed ebpf.CollectionSpec.Assign.
|
||||||
type bpfProgramSpecs struct {
|
type bpfProgramSpecs struct {
|
||||||
@@ -61,17 +62,28 @@ type bpfProgramSpecs struct {
|
|||||||
// It can be passed ebpf.CollectionSpec.Assign.
|
// It can be passed ebpf.CollectionSpec.Assign.
|
||||||
type bpfMapSpecs struct {
|
type bpfMapSpecs struct {
|
||||||
NbFeatures *ebpf.MapSpec `ebpf:"nb_features"`
|
NbFeatures *ebpf.MapSpec `ebpf:"nb_features"`
|
||||||
NbMapDnsIp *ebpf.MapSpec `ebpf:"nb_map_dns_ip"`
|
|
||||||
NbMapDnsPort *ebpf.MapSpec `ebpf:"nb_map_dns_port"`
|
|
||||||
NbWgProxySettingsMap *ebpf.MapSpec `ebpf:"nb_wg_proxy_settings_map"`
|
NbWgProxySettingsMap *ebpf.MapSpec `ebpf:"nb_wg_proxy_settings_map"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// bpfVariableSpecs contains global variables before they are loaded into the kernel.
|
||||||
|
//
|
||||||
|
// It can be passed ebpf.CollectionSpec.Assign.
|
||||||
|
type bpfVariableSpecs struct {
|
||||||
|
FlagFeatureWgProxy *ebpf.VariableSpec `ebpf:"flag_feature_wg_proxy"`
|
||||||
|
MapKeyFeatures *ebpf.VariableSpec `ebpf:"map_key_features"`
|
||||||
|
MapKeyProxyPort *ebpf.VariableSpec `ebpf:"map_key_proxy_port"`
|
||||||
|
MapKeyWgPort *ebpf.VariableSpec `ebpf:"map_key_wg_port"`
|
||||||
|
ProxyPort *ebpf.VariableSpec `ebpf:"proxy_port"`
|
||||||
|
WgPort *ebpf.VariableSpec `ebpf:"wg_port"`
|
||||||
|
}
|
||||||
|
|
||||||
// bpfObjects contains all objects after they have been loaded into the kernel.
|
// bpfObjects contains all objects after they have been loaded into the kernel.
|
||||||
//
|
//
|
||||||
// It can be passed to loadBpfObjects or ebpf.CollectionSpec.LoadAndAssign.
|
// It can be passed to loadBpfObjects or ebpf.CollectionSpec.LoadAndAssign.
|
||||||
type bpfObjects struct {
|
type bpfObjects struct {
|
||||||
bpfPrograms
|
bpfPrograms
|
||||||
bpfMaps
|
bpfMaps
|
||||||
|
bpfVariables
|
||||||
}
|
}
|
||||||
|
|
||||||
func (o *bpfObjects) Close() error {
|
func (o *bpfObjects) Close() error {
|
||||||
@@ -86,20 +98,28 @@ func (o *bpfObjects) Close() error {
|
|||||||
// It can be passed to loadBpfObjects or ebpf.CollectionSpec.LoadAndAssign.
|
// It can be passed to loadBpfObjects or ebpf.CollectionSpec.LoadAndAssign.
|
||||||
type bpfMaps struct {
|
type bpfMaps struct {
|
||||||
NbFeatures *ebpf.Map `ebpf:"nb_features"`
|
NbFeatures *ebpf.Map `ebpf:"nb_features"`
|
||||||
NbMapDnsIp *ebpf.Map `ebpf:"nb_map_dns_ip"`
|
|
||||||
NbMapDnsPort *ebpf.Map `ebpf:"nb_map_dns_port"`
|
|
||||||
NbWgProxySettingsMap *ebpf.Map `ebpf:"nb_wg_proxy_settings_map"`
|
NbWgProxySettingsMap *ebpf.Map `ebpf:"nb_wg_proxy_settings_map"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *bpfMaps) Close() error {
|
func (m *bpfMaps) Close() error {
|
||||||
return _BpfClose(
|
return _BpfClose(
|
||||||
m.NbFeatures,
|
m.NbFeatures,
|
||||||
m.NbMapDnsIp,
|
|
||||||
m.NbMapDnsPort,
|
|
||||||
m.NbWgProxySettingsMap,
|
m.NbWgProxySettingsMap,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// bpfVariables contains all global variables after they have been loaded into the kernel.
|
||||||
|
//
|
||||||
|
// It can be passed to loadBpfObjects or ebpf.CollectionSpec.LoadAndAssign.
|
||||||
|
type bpfVariables struct {
|
||||||
|
FlagFeatureWgProxy *ebpf.Variable `ebpf:"flag_feature_wg_proxy"`
|
||||||
|
MapKeyFeatures *ebpf.Variable `ebpf:"map_key_features"`
|
||||||
|
MapKeyProxyPort *ebpf.Variable `ebpf:"map_key_proxy_port"`
|
||||||
|
MapKeyWgPort *ebpf.Variable `ebpf:"map_key_wg_port"`
|
||||||
|
ProxyPort *ebpf.Variable `ebpf:"proxy_port"`
|
||||||
|
WgPort *ebpf.Variable `ebpf:"wg_port"`
|
||||||
|
}
|
||||||
|
|
||||||
// bpfPrograms contains all programs after they have been loaded into the kernel.
|
// bpfPrograms contains all programs after they have been loaded into the kernel.
|
||||||
//
|
//
|
||||||
// It can be passed to loadBpfObjects or ebpf.CollectionSpec.LoadAndAssign.
|
// It can be passed to loadBpfObjects or ebpf.CollectionSpec.LoadAndAssign.
|
||||||
|
|||||||
Binary file not shown.
@@ -1,52 +0,0 @@
|
|||||||
package ebpf
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/binary"
|
|
||||||
"fmt"
|
|
||||||
"net/netip"
|
|
||||||
|
|
||||||
log "github.com/sirupsen/logrus"
|
|
||||||
)
|
|
||||||
|
|
||||||
const (
|
|
||||||
mapKeyDNSIP uint32 = 0
|
|
||||||
mapKeyDNSPort uint32 = 1
|
|
||||||
)
|
|
||||||
|
|
||||||
func (tf *GeneralManager) LoadDNSFwd(ip netip.Addr, dnsPort int) error {
|
|
||||||
log.Debugf("load eBPF DNS forwarder, watching addr: %s:53, redirect to port: %d", ip, dnsPort)
|
|
||||||
tf.lock.Lock()
|
|
||||||
defer tf.lock.Unlock()
|
|
||||||
|
|
||||||
err := tf.loadXdp()
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
if !ip.Is4() {
|
|
||||||
return fmt.Errorf("eBPF DNS forwarder only supports IPv4, got %s", ip)
|
|
||||||
}
|
|
||||||
ip4 := ip.As4()
|
|
||||||
err = tf.bpfObjs.NbMapDnsIp.Put(mapKeyDNSIP, binary.BigEndian.Uint32(ip4[:]))
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
err = tf.bpfObjs.NbMapDnsPort.Put(mapKeyDNSPort, uint16(dnsPort))
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
tf.setFeatureFlag(featureFlagDnsForwarder)
|
|
||||||
err = tf.bpfObjs.NbFeatures.Put(mapKeyFeatures, tf.featureFlags)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (tf *GeneralManager) FreeDNSFwd() error {
|
|
||||||
log.Debugf("free ebpf DNS forwarder")
|
|
||||||
return tf.unsetFeatureFlag(featureFlagDnsForwarder)
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -15,8 +15,7 @@ import (
|
|||||||
const (
|
const (
|
||||||
mapKeyFeatures uint32 = 0
|
mapKeyFeatures uint32 = 0
|
||||||
|
|
||||||
featureFlagWGProxy = 0b00000001
|
featureFlagWGProxy = 0b00000001
|
||||||
featureFlagDnsForwarder = 0b00000010
|
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
@@ -28,9 +27,9 @@ var (
|
|||||||
|
|
||||||
// GeneralManager is used to load multiple eBPF programs with a custom check (if then) done in prog.c
|
// GeneralManager is used to load multiple eBPF programs with a custom check (if then) done in prog.c
|
||||||
// The manager simply adds a feature (byte) of each program to a map that is shared between the userspace and kernel.
|
// The manager simply adds a feature (byte) of each program to a map that is shared between the userspace and kernel.
|
||||||
// When packet arrives, the C code checks for each feature (if it is set) and executes each enabled program (e.g., dns_fwd.c and wg_proxy.c).
|
// When packet arrives, the C code checks for each feature (if it is set) and executes each enabled program (e.g., wg_proxy.c).
|
||||||
//
|
//
|
||||||
//go:generate go run github.com/cilium/ebpf/cmd/bpf2go -cc clang-14 bpf src/prog.c -- -I /usr/x86_64-linux-gnu/include
|
//go:generate go run github.com/cilium/ebpf/cmd/bpf2go -cc clang-14 bpf src/prog.c -- -I /usr/x86_64-linux-gnu/include -include src/bpf_map_def.h
|
||||||
type GeneralManager struct {
|
type GeneralManager struct {
|
||||||
lock sync.Mutex
|
lock sync.Mutex
|
||||||
link link.Link
|
link link.Link
|
||||||
|
|||||||
@@ -7,33 +7,24 @@ import (
|
|||||||
func TestManager_setFeatureFlag(t *testing.T) {
|
func TestManager_setFeatureFlag(t *testing.T) {
|
||||||
mgr := GeneralManager{}
|
mgr := GeneralManager{}
|
||||||
mgr.setFeatureFlag(featureFlagWGProxy)
|
mgr.setFeatureFlag(featureFlagWGProxy)
|
||||||
if mgr.featureFlags != 1 {
|
if mgr.featureFlags != featureFlagWGProxy {
|
||||||
t.Errorf("invalid feature state")
|
t.Errorf("invalid feature state")
|
||||||
}
|
}
|
||||||
|
|
||||||
mgr.setFeatureFlag(featureFlagDnsForwarder)
|
mgr.setFeatureFlag(featureFlagWGProxy)
|
||||||
if mgr.featureFlags != 3 {
|
if mgr.featureFlags != featureFlagWGProxy {
|
||||||
t.Errorf("invalid feature state")
|
t.Errorf("setting a flag twice must be idempotent, got: %d", mgr.featureFlags)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestManager_unsetFeatureFlag(t *testing.T) {
|
func TestManager_unsetFeatureFlag(t *testing.T) {
|
||||||
mgr := GeneralManager{}
|
mgr := GeneralManager{}
|
||||||
mgr.setFeatureFlag(featureFlagWGProxy)
|
mgr.setFeatureFlag(featureFlagWGProxy)
|
||||||
mgr.setFeatureFlag(featureFlagDnsForwarder)
|
|
||||||
|
|
||||||
err := mgr.unsetFeatureFlag(featureFlagWGProxy)
|
err := mgr.unsetFeatureFlag(featureFlagWGProxy)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Errorf("unexpected error: %s", err)
|
t.Errorf("unexpected error: %s", err)
|
||||||
}
|
}
|
||||||
if mgr.featureFlags != 2 {
|
|
||||||
t.Errorf("invalid feature state, expected: %d, got: %d", 2, mgr.featureFlags)
|
|
||||||
}
|
|
||||||
|
|
||||||
err = mgr.unsetFeatureFlag(featureFlagDnsForwarder)
|
|
||||||
if err != nil {
|
|
||||||
t.Errorf("unexpected error: %s", err)
|
|
||||||
}
|
|
||||||
if mgr.featureFlags != 0 {
|
if mgr.featureFlags != 0 {
|
||||||
t.Errorf("invalid feature state, expected: %d, got: %d", 0, mgr.featureFlags)
|
t.Errorf("invalid feature state, expected: %d, got: %d", 0, mgr.featureFlags)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
// libbpf 1.0 removed struct bpf_map_def, but the programs here keep the legacy
|
||||||
|
// map definitions: they load on kernels built without BTF, which BTF-style
|
||||||
|
// (SEC(".maps")) definitions do not. Define the struct ourselves so the
|
||||||
|
// programs compile against current libbpf headers.
|
||||||
|
#ifndef NB_BPF_MAP_DEF_H
|
||||||
|
#define NB_BPF_MAP_DEF_H
|
||||||
|
|
||||||
|
struct bpf_map_def {
|
||||||
|
unsigned int type;
|
||||||
|
unsigned int key_size;
|
||||||
|
unsigned int value_size;
|
||||||
|
unsigned int max_entries;
|
||||||
|
unsigned int map_flags;
|
||||||
|
};
|
||||||
|
|
||||||
|
#endif
|
||||||
@@ -1,67 +0,0 @@
|
|||||||
const __u32 map_key_dns_ip = 0;
|
|
||||||
const __u32 map_key_dns_port = 1;
|
|
||||||
|
|
||||||
struct bpf_map_def SEC("maps") nb_map_dns_ip = {
|
|
||||||
.type = BPF_MAP_TYPE_ARRAY,
|
|
||||||
.key_size = sizeof(__u32),
|
|
||||||
.value_size = sizeof(__u32),
|
|
||||||
.max_entries = 10,
|
|
||||||
};
|
|
||||||
|
|
||||||
struct bpf_map_def SEC("maps") nb_map_dns_port = {
|
|
||||||
.type = BPF_MAP_TYPE_ARRAY,
|
|
||||||
.key_size = sizeof(__u32),
|
|
||||||
.value_size = sizeof(__u16),
|
|
||||||
.max_entries = 10,
|
|
||||||
};
|
|
||||||
|
|
||||||
__be32 dns_ip = 0;
|
|
||||||
__be16 dns_port = 0;
|
|
||||||
|
|
||||||
// 13568 is 53 in big endian
|
|
||||||
__be16 GENERAL_DNS_PORT = 13568;
|
|
||||||
|
|
||||||
bool read_settings() {
|
|
||||||
__u16 *port_value;
|
|
||||||
__u32 *ip_value;
|
|
||||||
|
|
||||||
// read dns ip
|
|
||||||
ip_value = bpf_map_lookup_elem(&nb_map_dns_ip, &map_key_dns_ip);
|
|
||||||
if(!ip_value) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
dns_ip = htonl(*ip_value);
|
|
||||||
|
|
||||||
// read dns port
|
|
||||||
port_value = bpf_map_lookup_elem(&nb_map_dns_port, &map_key_dns_port);
|
|
||||||
if (!port_value) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
dns_port = htons(*port_value);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
int xdp_dns_fwd(struct iphdr *ip, struct udphdr *udp) {
|
|
||||||
if (dns_port == 0) {
|
|
||||||
if(!read_settings()){
|
|
||||||
return XDP_PASS;
|
|
||||||
}
|
|
||||||
// bpf_printk("dns port: %d", ntohs(dns_port));
|
|
||||||
// bpf_printk("dns ip: %d", ntohl(dns_ip));
|
|
||||||
}
|
|
||||||
|
|
||||||
if (udp->dest == GENERAL_DNS_PORT && ip->daddr == dns_ip) {
|
|
||||||
udp->dest = dns_port;
|
|
||||||
// Clear the now-stale checksum; zero means "not computed" for IPv4.
|
|
||||||
udp->check = 0;
|
|
||||||
return XDP_PASS;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (udp->source == dns_port && ip->saddr == dns_ip) {
|
|
||||||
udp->source = GENERAL_DNS_PORT;
|
|
||||||
udp->check = 0;
|
|
||||||
return XDP_PASS;
|
|
||||||
}
|
|
||||||
|
|
||||||
return XDP_PASS;
|
|
||||||
}
|
|
||||||
@@ -5,11 +5,9 @@
|
|||||||
#include <netinet/in.h>
|
#include <netinet/in.h>
|
||||||
#include <linux/bpf.h>
|
#include <linux/bpf.h>
|
||||||
#include <bpf/bpf_helpers.h>
|
#include <bpf/bpf_helpers.h>
|
||||||
#include "dns_fwd.c"
|
|
||||||
#include "wg_proxy.c"
|
#include "wg_proxy.c"
|
||||||
|
|
||||||
const __u16 flag_feature_wg_proxy = 0b01;
|
const __u16 flag_feature_wg_proxy = 0b01;
|
||||||
const __u16 flag_feature_dns_fwd = 0b10;
|
|
||||||
|
|
||||||
const __u32 map_key_features = 0;
|
const __u32 map_key_features = 0;
|
||||||
struct bpf_map_def SEC("maps") nb_features = {
|
struct bpf_map_def SEC("maps") nb_features = {
|
||||||
@@ -48,10 +46,6 @@ int nb_xdp_prog(struct xdp_md *ctx) {
|
|||||||
return XDP_PASS;
|
return XDP_PASS;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (*features & flag_feature_dns_fwd) {
|
|
||||||
xdp_dns_fwd(ip, udp);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (*features & flag_feature_wg_proxy) {
|
if (*features & flag_feature_wg_proxy) {
|
||||||
xdp_wg_proxy(ip, udp);
|
xdp_wg_proxy(ip, udp);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,18 @@
|
|||||||
# DNS forwarder
|
# XDP programs
|
||||||
|
|
||||||
The agent attach the XDP program to the lo device. We can not use fake address in eBPF because the
|
`prog.c` is attached to the `lo` device and dispatches to the features enabled in the
|
||||||
traffic does not appear in the eBPF program. The program capture the traffic on wg_ip:53 and
|
`nb_features` map. The only feature is the WireGuard proxy (`wg_proxy.c`): it rewrites
|
||||||
overwrite in it the destination port to 5053.
|
loopback UDP sent from the WireGuard listen port so it reaches the userspace relay proxy
|
||||||
|
port instead, and swaps the peer endpoint port into the source so the proxy can tell
|
||||||
|
peers apart.
|
||||||
|
|
||||||
|
Maps use the legacy `struct bpf_map_def` form, defined in `bpf_map_def.h` because libbpf
|
||||||
|
1.0 removed it. They load on kernels built without BTF, which BTF-style (`SEC(".maps")`)
|
||||||
|
definitions do not.
|
||||||
|
|
||||||
|
Regenerate the objects with `go generate ./client/internal/ebpf/ebpf/`; it needs
|
||||||
|
`clang-14`. Loading a regenerated object needs root, attaching it needs `bpf_link`
|
||||||
|
(kernel >= 5.7), and only one XDP program can own `lo` at a time.
|
||||||
|
|
||||||
# Debug
|
# Debug
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,7 @@
|
|||||||
package manager
|
package manager
|
||||||
|
|
||||||
import "net/netip"
|
// Manager is used to load multiple eBPF programs. E.g., the WireGuard proxy
|
||||||
|
|
||||||
// Manager is used to load multiple eBPF programs. E.g., current DNS programs and WireGuard proxy
|
|
||||||
type Manager interface {
|
type Manager interface {
|
||||||
LoadDNSFwd(ip netip.Addr, dnsPort int) error
|
|
||||||
FreeDNSFwd() error
|
|
||||||
LoadWgProxy(proxyPort, wgPort int) error
|
LoadWgProxy(proxyPort, wgPort int) error
|
||||||
FreeWGProxy() error
|
FreeWGProxy() error
|
||||||
}
|
}
|
||||||
|
|||||||
+114
-12
@@ -94,6 +94,13 @@ const (
|
|||||||
// exec, os.Stat); without this bound a single stuck call freezes handleSync, and
|
// exec, os.Stat); without this bound a single stuck call freezes handleSync, and
|
||||||
// thus syncMsgMux, for as long as the call hangs (observed multi-minute freezes).
|
// thus syncMsgMux, for as long as the call hangs (observed multi-minute freezes).
|
||||||
systemInfoTimeout = 15 * time.Second
|
systemInfoTimeout = 15 * time.Second
|
||||||
|
|
||||||
|
// dnsForwarderStopTimeout bounds how long stopping the DNS forwarder waits
|
||||||
|
// for the queries still in flight. One waiting on an unresponsive upstream
|
||||||
|
// would otherwise hold the stop for the whole upstream timeout, and the
|
||||||
|
// stop runs with syncMsgMux held. The sockets are closed either way, so
|
||||||
|
// giving up costs a query that was already failing.
|
||||||
|
dnsForwarderStopTimeout = 2 * time.Second
|
||||||
)
|
)
|
||||||
|
|
||||||
var ErrResetConnection = fmt.Errorf("reset connection")
|
var ErrResetConnection = fmt.Errorf("reset connection")
|
||||||
@@ -258,6 +265,8 @@ type Engine struct {
|
|||||||
// checks are the client-applied posture checks that need to be evaluated on the client
|
// checks are the client-applied posture checks that need to be evaluated on the client
|
||||||
checks []*mgmProto.Checks
|
checks []*mgmProto.Checks
|
||||||
|
|
||||||
|
infoSource system.InfoSource
|
||||||
|
|
||||||
relayManager *relayClient.Manager
|
relayManager *relayClient.Manager
|
||||||
stateManager *statemanager.Manager
|
stateManager *statemanager.Manager
|
||||||
portForwardManager *portforward.Manager
|
portForwardManager *portforward.Manager
|
||||||
@@ -321,6 +330,10 @@ type localIpUpdater interface {
|
|||||||
UpdateLocalIPs() error
|
UpdateLocalIPs() error
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// overlayRebind rebuilds one subsystem's sockets on the current interface. The
|
||||||
|
// error it returns names its own subsystem, since the caller can only log it.
|
||||||
|
type overlayRebind func() error
|
||||||
|
|
||||||
// NewEngine creates a new Connection Engine with probes attached
|
// NewEngine creates a new Connection Engine with probes attached
|
||||||
func NewEngine(
|
func NewEngine(
|
||||||
clientCtx context.Context,
|
clientCtx context.Context,
|
||||||
@@ -1230,9 +1243,7 @@ func (e *Engine) updateChecksIfNew(checks []*mgmProto.Checks) error {
|
|||||||
if isChecksEqual(e.checks, checks) {
|
if isChecksEqual(e.checks, checks) {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
e.checks = checks
|
info, ok := e.infoSource.Refresh(e.ctx, systemInfoTimeout, checks, e.overlayAddresses()...)
|
||||||
|
|
||||||
info, ok := system.GetInfoWithChecksTimeout(e.ctx, systemInfoTimeout, checks, e.overlayAddresses()...)
|
|
||||||
if !ok {
|
if !ok {
|
||||||
// Gathering timed out; skip the meta sync this cycle rather than blocking the
|
// 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.
|
// sync loop (and syncMsgMux) on a stuck system call. A later sync will retry.
|
||||||
@@ -1243,6 +1254,7 @@ func (e *Engine) updateChecksIfNew(checks []*mgmProto.Checks) error {
|
|||||||
if err := e.mgmClient.SyncMeta(info); err != nil {
|
if err := e.mgmClient.SyncMeta(info); err != nil {
|
||||||
return fmt.Errorf("could not sync meta: error %s", err)
|
return fmt.Errorf("could not sync meta: error %s", err)
|
||||||
}
|
}
|
||||||
|
e.checks = checks
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1269,6 +1281,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
|
// overlayAddresses returns our own WireGuard overlay address (v4 and v6) so it
|
||||||
// can be excluded from the reported network addresses; the interface coming and
|
// can be excluded from the reported network addresses; the interface coming and
|
||||||
// going otherwise churns the peer meta on the management server.
|
// going otherwise churns the peer meta on the management server.
|
||||||
@@ -1462,15 +1496,11 @@ func (e *Engine) receiveManagementEvents() {
|
|||||||
e.shutdownWg.Add(1)
|
e.shutdownWg.Add(1)
|
||||||
go func() {
|
go func() {
|
||||||
defer e.shutdownWg.Done()
|
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 {
|
if !ok {
|
||||||
// Gathering timed out; connect the stream with base info so management
|
log.Warnf("posture checks not refreshed before the sync connect, sending the previous results")
|
||||||
// connectivity still comes up rather than blocking here.
|
|
||||||
info = system.GetInfo(e.ctx)
|
|
||||||
}
|
}
|
||||||
e.applyInfoFlags(info)
|
err := e.mgmClient.Sync(e.ctx, e.syncInfoFunc(info), e.handleSync)
|
||||||
|
|
||||||
err := e.mgmClient.Sync(e.ctx, info, e.handleSync)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// happens if management is unavailable for a long time.
|
// happens if management is unavailable for a long time.
|
||||||
// We want to cancel the operation of the whole client
|
// We want to cancel the operation of the whole client
|
||||||
@@ -2502,7 +2532,72 @@ func (e *Engine) RenewTun(fd int) error {
|
|||||||
return fmt.Errorf("wireguard interface not initialized")
|
return fmt.Errorf("wireguard interface not initialized")
|
||||||
}
|
}
|
||||||
|
|
||||||
return wgInterface.RenewTun(fd)
|
if err := wgInterface.RenewTun(fd); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
e.rebindOverlayListeners()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// rebindOverlayListeners gives the servers that listen on an overlay address
|
||||||
|
// sockets on the interface as it is now.
|
||||||
|
//
|
||||||
|
// A socket belongs to the interface generation it was created on. Renewing the
|
||||||
|
// TUN builds a new interface and moves the overlay addresses to it, which
|
||||||
|
// leaves the old sockets in LISTEN with the uspfilter still logging packets
|
||||||
|
// arriving for them, while every accept fails with EINVAL for the life of the
|
||||||
|
// socket: from the outside the server looks alive and answers nothing. On
|
||||||
|
// Android this happens during a normal startup, where the first TUN is
|
||||||
|
// established before the routes are known and replaced once they arrive.
|
||||||
|
//
|
||||||
|
// Rebinding costs whatever those sockets were carrying, which the renewal has
|
||||||
|
// already broken. Errors are logged rather than returned: the renewal itself
|
||||||
|
// succeeded, and failing it would hand the caller a working interface and an
|
||||||
|
// error.
|
||||||
|
func (e *Engine) rebindOverlayListeners() {
|
||||||
|
e.syncMsgMux.Lock()
|
||||||
|
defer e.syncMsgMux.Unlock()
|
||||||
|
|
||||||
|
for _, rebind := range e.overlayRebinds() {
|
||||||
|
if err := rebind(); err != nil {
|
||||||
|
log.Errorf("after TUN renewal: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// overlayRebinds is every subsystem of this engine that holds sockets bound to
|
||||||
|
// an overlay address, and how to rebuild each one's.
|
||||||
|
//
|
||||||
|
// A subsystem that starts listening on an overlay address belongs in this list.
|
||||||
|
// Leaving it out costs nothing that review would notice and produces a listener
|
||||||
|
// that stays in LISTEN, is logged as receiving packets, and refuses every
|
||||||
|
// connection for the life of the process.
|
||||||
|
func (e *Engine) overlayRebinds() []overlayRebind {
|
||||||
|
return []overlayRebind{
|
||||||
|
e.restartSSHListeners,
|
||||||
|
e.restartDNSForwarder,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// restartDNSForwarder rebuilds the DNS forwarder serving the same domains.
|
||||||
|
// No-op when it is not running. See Engine.rebindOverlayListeners.
|
||||||
|
func (e *Engine) restartDNSForwarder() error {
|
||||||
|
if e.dnsForwardMgr == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
// Read from the forwarder before it goes away, so the replacement serves
|
||||||
|
// the domains in force now rather than a copy kept somewhere else.
|
||||||
|
entries := e.dnsForwardMgr.Domains()
|
||||||
|
e.stopDNSForwarder()
|
||||||
|
// Both halves log their own failures, so the only thing left to report is
|
||||||
|
// the outcome: a start that failed left the manager nil, and the forwarder
|
||||||
|
// is now down rather than merely rebound.
|
||||||
|
e.startDNSForwarder(entries)
|
||||||
|
if e.dnsForwardMgr == nil {
|
||||||
|
return errors.New("rebind DNS forwarder: it did not come back up")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// updateDNSForwarder start or stop the DNS forwarder based on the domains and the feature flag
|
// updateDNSForwarder start or stop the DNS forwarder based on the domains and the feature flag
|
||||||
@@ -2548,7 +2643,14 @@ func (e *Engine) stopDNSForwarder() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := e.dnsForwardMgr.Stop(context.Background()); err != nil {
|
// Bounded because the shutdown waits for queries still in flight, and one
|
||||||
|
// waiting on an unresponsive upstream holds it for as long as that lookup
|
||||||
|
// is allowed to take. This runs with syncMsgMux held, so that wait is one
|
||||||
|
// the whole engine spends.
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), dnsForwarderStopTimeout)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
if err := e.dnsForwardMgr.Stop(ctx); err != nil {
|
||||||
log.Errorf("failed to stop DNS forward: %v", err)
|
log.Errorf("failed to stop DNS forward: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -193,7 +193,7 @@ func TestEngine_Sync(t *testing.T) {
|
|||||||
// feed updates to Engine via mocked Management client
|
// feed updates to Engine via mocked Management client
|
||||||
updates := make(chan *mgmtProto.SyncResponse)
|
updates := make(chan *mgmtProto.SyncResponse)
|
||||||
defer close(updates)
|
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 {
|
for msg := range updates {
|
||||||
err := msgHandler(msg)
|
err := msgHandler(msg)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -24,6 +24,8 @@ type sshServer interface {
|
|||||||
Stop() error
|
Stop() error
|
||||||
GetStatus() (bool, []sshserver.SessionInfo)
|
GetStatus() (bool, []sshserver.SessionInfo)
|
||||||
UpdateSSHAuth(config *sshauth.Config)
|
UpdateSSHAuth(config *sshauth.Config)
|
||||||
|
JWTConfig() *sshserver.JWTConfig
|
||||||
|
AuthConfig() *sshauth.Config
|
||||||
}
|
}
|
||||||
|
|
||||||
func (e *Engine) setupSSHPortRedirection() error {
|
func (e *Engine) setupSSHPortRedirection() error {
|
||||||
@@ -77,7 +79,7 @@ func (e *Engine) updateSSH(sshConf *mgmProto.SSHConfig) error {
|
|||||||
|
|
||||||
if e.config.DisableSSHAuth != nil && *e.config.DisableSSHAuth {
|
if e.config.DisableSSHAuth != nil && *e.config.DisableSSHAuth {
|
||||||
log.Info("starting SSH server without JWT authentication (authentication disabled by config)")
|
log.Info("starting SSH server without JWT authentication (authentication disabled by config)")
|
||||||
return e.startSSHServer(nil)
|
return e.startSSHServer(nil, nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
if protoJWT := sshConf.GetJwtConfig(); protoJWT != nil {
|
if protoJWT := sshConf.GetJwtConfig(); protoJWT != nil {
|
||||||
@@ -95,7 +97,7 @@ func (e *Engine) updateSSH(sshConf *mgmProto.SSHConfig) error {
|
|||||||
MaxTokenAge: protoJWT.GetMaxTokenAge(),
|
MaxTokenAge: protoJWT.GetMaxTokenAge(),
|
||||||
}
|
}
|
||||||
|
|
||||||
return e.startSSHServer(jwtConfig)
|
return e.startSSHServer(jwtConfig, nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
return errors.New("SSH server requires valid JWT configuration")
|
return errors.New("SSH server requires valid JWT configuration")
|
||||||
@@ -231,8 +233,33 @@ func (e *Engine) cleanupSSHConfig() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// startSSHServer initializes and starts the SSH server with proper configuration.
|
// restartSSHListeners rebuilds the SSH server so it listens on new sockets, on
|
||||||
func (e *Engine) startSSHServer(jwtConfig *sshserver.JWTConfig) error {
|
// the same terms it was started with. No-op when it is not running. See
|
||||||
|
// Engine.rebindOverlayListeners for why this is needed.
|
||||||
|
func (e *Engine) restartSSHListeners() error {
|
||||||
|
if e.sshServer == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
// Read from the server before it goes away. A rebuilt one starts with an
|
||||||
|
// empty authorizer, which fails closed, so without carrying the
|
||||||
|
// authorization over every JWT login is refused until the next network map
|
||||||
|
// happens to bring one.
|
||||||
|
jwtConfig, authConfig := e.sshServer.JWTConfig(), e.sshServer.AuthConfig()
|
||||||
|
if err := e.stopSSHServer(); err != nil {
|
||||||
|
return fmt.Errorf("rebind SSH listeners: %w", err)
|
||||||
|
}
|
||||||
|
if err := e.startSSHServer(jwtConfig, authConfig); err != nil {
|
||||||
|
return fmt.Errorf("rebind SSH listeners: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// startSSHServer initializes and starts the SSH server with proper
|
||||||
|
// configuration. authConfig is the fine-grained authorization to open with, and
|
||||||
|
// is applied before the server accepts anything: a server that starts listening
|
||||||
|
// with an empty authorizer refuses the logins that arrive in the meantime.
|
||||||
|
// Nil leaves it as management has not sent one yet.
|
||||||
|
func (e *Engine) startSSHServer(jwtConfig *sshserver.JWTConfig, authConfig *sshauth.Config) error {
|
||||||
if e.wgInterface == nil {
|
if e.wgInterface == nil {
|
||||||
return errors.New("wg interface not initialized")
|
return errors.New("wg interface not initialized")
|
||||||
}
|
}
|
||||||
@@ -240,6 +267,7 @@ func (e *Engine) startSSHServer(jwtConfig *sshserver.JWTConfig) error {
|
|||||||
serverConfig := &sshserver.Config{
|
serverConfig := &sshserver.Config{
|
||||||
HostKeyPEM: e.config.SSHKey,
|
HostKeyPEM: e.config.SSHKey,
|
||||||
JWT: jwtConfig,
|
JWT: jwtConfig,
|
||||||
|
Auth: authConfig,
|
||||||
}
|
}
|
||||||
server := sshserver.New(serverConfig)
|
server := sshserver.New(serverConfig)
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package internal
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net"
|
"net"
|
||||||
"net/netip"
|
"net/netip"
|
||||||
@@ -31,6 +32,7 @@ import (
|
|||||||
icemaker "github.com/netbirdio/netbird/client/internal/peer/ice"
|
icemaker "github.com/netbirdio/netbird/client/internal/peer/ice"
|
||||||
"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/system"
|
||||||
nbdns "github.com/netbirdio/netbird/dns"
|
nbdns "github.com/netbirdio/netbird/dns"
|
||||||
"github.com/netbirdio/netbird/monotime"
|
"github.com/netbirdio/netbird/monotime"
|
||||||
"github.com/netbirdio/netbird/route"
|
"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) {
|
func TestEngine_UpdateNetworkMap(t *testing.T) {
|
||||||
// test setup
|
// test setup
|
||||||
key, err := wgtypes.GeneratePrivateKey()
|
key, err := wgtypes.GeneratePrivateKey()
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -202,14 +198,26 @@ 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 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
|
// 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.
|
||||||
@@ -712,9 +720,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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"
|
"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.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()
|
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))
|
||||||
@@ -134,22 +153,19 @@ func TestApply_MDMLocalMetrics(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,
|
||||||
LocalMetricsEnabled: boolPtr(false),
|
LocalMetricsEnabled: boolPtr(false),
|
||||||
})
|
}, mdm.NewPolicy(nil))
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
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.KeyEnableLocalMetrics: true,
|
||||||
mdm.KeyLocalMetricsAddress: "127.0.0.1:9292",
|
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.True(t, cfg.LocalMetricsEnabled, "MDM override should flip on-disk false to true")
|
||||||
assert.Equal(t, "127.0.0.1:9292", cfg.LocalMetricsAddress)
|
assert.Equal(t, "127.0.0.1:9292", cfg.LocalMetricsAddress)
|
||||||
assert.True(t, cfg.Policy().HasKey(mdm.KeyEnableLocalMetrics))
|
assert.True(t, cfg.Policy().HasKey(mdm.KeyEnableLocalMetrics))
|
||||||
@@ -171,16 +187,12 @@ func TestApply_MDMLazyConnection(t *testing.T) {
|
|||||||
}
|
}
|
||||||
for _, c := range cases {
|
for _, c := range cases {
|
||||||
t.Run(c.name, func(t *testing.T) {
|
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,
|
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.Equal(t, c.want, cfg.LazyConnection)
|
||||||
assert.True(t, cfg.Policy().HasKey(mdm.KeyLazyConnection))
|
assert.True(t, cfg.Policy().HasKey(mdm.KeyLazyConnection))
|
||||||
})
|
})
|
||||||
@@ -188,22 +200,83 @@ func TestApply_MDMLazyConnection(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestApply_MDMPreSharedKeyRedactionSentinelRejected(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,
|
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.
|
||||||
assert.True(t, cfg.Policy().HasKey(mdm.KeyPreSharedKey))
|
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 }
|
func boolPtr(b bool) *bool { return &b }
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
"net/netip"
|
"net/netip"
|
||||||
"net/url"
|
"net/url"
|
||||||
"runtime"
|
"runtime"
|
||||||
|
"slices"
|
||||||
"sort"
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
@@ -472,27 +473,13 @@ func (m *DefaultManager) CurrentRouteRange() []string {
|
|||||||
m.mux.Lock()
|
m.mux.Lock()
|
||||||
defer m.mux.Unlock()
|
defer m.mux.Unlock()
|
||||||
|
|
||||||
if m.disableClientRoutes {
|
nets := m.overlayNetworks()
|
||||||
return nil
|
if !m.disableClientRoutes {
|
||||||
}
|
nets = append(nets, m.clientRouteRange()...)
|
||||||
|
|
||||||
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())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
sort.Strings(nets)
|
sort.Strings(nets)
|
||||||
return nets
|
return slices.Compact(nets)
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetRouteSelector returns the route selector
|
// 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))
|
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
|
// minNetID returns the lexicographically smallest NetID, for a deterministic
|
||||||
// default pick that stays stable across restarts.
|
// default pick that stays stable across restarts.
|
||||||
func minNetID(ids []route.NetID) route.NetID {
|
func minNetID(ids []route.NetID) route.NetID {
|
||||||
|
|||||||
@@ -4,8 +4,6 @@ package notifier
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"net/netip"
|
"net/netip"
|
||||||
"slices"
|
|
||||||
"sort"
|
|
||||||
"sync"
|
"sync"
|
||||||
|
|
||||||
"github.com/netbirdio/netbird/client/internal/listener"
|
"github.com/netbirdio/netbird/client/internal/listener"
|
||||||
@@ -75,19 +73,3 @@ func (n *Notifier) notifyLocked() {
|
|||||||
func (n *Notifier) Close() {
|
func (n *Notifier) Close() {
|
||||||
// unused
|
// unused
|
||||||
}
|
}
|
||||||
|
|
||||||
func routesToStrings(routes []*route.Route) []string {
|
|
||||||
nets := make([]string, 0, len(routes))
|
|
||||||
for _, r := range routes {
|
|
||||||
nets = append(nets, r.NetString())
|
|
||||||
}
|
|
||||||
return nets
|
|
||||||
}
|
|
||||||
|
|
||||||
func hasRouteDiff(a []*route.Route, b []*route.Route) bool {
|
|
||||||
as := routesToStrings(a)
|
|
||||||
bs := routesToStrings(b)
|
|
||||||
sort.Strings(as)
|
|
||||||
sort.Strings(bs)
|
|
||||||
return !slices.Equal(as, bs)
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
package notifier
|
||||||
|
|
||||||
|
import (
|
||||||
|
"slices"
|
||||||
|
"sort"
|
||||||
|
|
||||||
|
"github.com/netbirdio/netbird/route"
|
||||||
|
)
|
||||||
|
|
||||||
|
// routePrefixes returns the distinct prefixes a route set covers, sorted.
|
||||||
|
// Duplicates are dropped deliberately: an HA group hands us one route per
|
||||||
|
// peer serving the same prefix, and the platform is given the prefix, not the
|
||||||
|
// candidates. Counting them would report a change every time a peer joins or
|
||||||
|
// leaves a group, and on Android each report renews the TUN.
|
||||||
|
func routePrefixes(routes []*route.Route) []string {
|
||||||
|
nets := make([]string, 0, len(routes))
|
||||||
|
for _, r := range routes {
|
||||||
|
nets = append(nets, r.NetString())
|
||||||
|
}
|
||||||
|
sort.Strings(nets)
|
||||||
|
return slices.Compact(nets)
|
||||||
|
}
|
||||||
|
|
||||||
|
// hasRouteDiff reports whether the prefixes the two route sets cover differ.
|
||||||
|
func hasRouteDiff(a []*route.Route, b []*route.Route) bool {
|
||||||
|
return !slices.Equal(routePrefixes(a), routePrefixes(b))
|
||||||
|
}
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
package notifier
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/netip"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
|
||||||
|
"github.com/netbirdio/netbird/route"
|
||||||
|
)
|
||||||
|
|
||||||
|
func routeFor(id route.ID, prefix string) *route.Route {
|
||||||
|
return &route.Route{
|
||||||
|
ID: id,
|
||||||
|
NetID: "net",
|
||||||
|
Network: netip.MustParsePrefix(prefix),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestHasRouteDiff_IgnoresHACandidateCount is the reason the comparison
|
||||||
|
// deduplicates. Every notification renews the TUN, and a renewed TUN
|
||||||
|
// invalidates the sockets the embedded servers are listening on, so a peer
|
||||||
|
// joining or leaving an HA group must not count as a route change when the
|
||||||
|
// prefixes the TUN carries are identical.
|
||||||
|
func TestHasRouteDiff_IgnoresHACandidateCount(t *testing.T) {
|
||||||
|
onePeer := []*route.Route{routeFor("a", "10.0.0.0/24")}
|
||||||
|
twoPeers := []*route.Route{
|
||||||
|
routeFor("a", "10.0.0.0/24"),
|
||||||
|
routeFor("b", "10.0.0.0/24"),
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.False(t, hasRouteDiff(onePeer, twoPeers),
|
||||||
|
"a second peer serving the same prefix is not a route change")
|
||||||
|
assert.False(t, hasRouteDiff(twoPeers, onePeer),
|
||||||
|
"losing one of two peers serving the same prefix is not a route change")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHasRouteDiff_ReportsRealChanges(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
a []*route.Route
|
||||||
|
b []*route.Route
|
||||||
|
want bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "added prefix",
|
||||||
|
a: []*route.Route{routeFor("a", "10.0.0.0/24")},
|
||||||
|
b: []*route.Route{routeFor("a", "10.0.0.0/24"), routeFor("b", "10.0.1.0/24")},
|
||||||
|
want: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "removed prefix",
|
||||||
|
a: []*route.Route{routeFor("a", "10.0.0.0/24"), routeFor("b", "10.0.1.0/24")},
|
||||||
|
b: []*route.Route{routeFor("a", "10.0.0.0/24")},
|
||||||
|
want: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "replaced prefix",
|
||||||
|
a: []*route.Route{routeFor("a", "10.0.0.0/24")},
|
||||||
|
b: []*route.Route{routeFor("a", "10.0.1.0/24")},
|
||||||
|
want: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "same prefix, different order",
|
||||||
|
a: []*route.Route{routeFor("a", "10.0.1.0/24"), routeFor("b", "10.0.0.0/24")},
|
||||||
|
b: []*route.Route{routeFor("b", "10.0.0.0/24"), routeFor("a", "10.0.1.0/24")},
|
||||||
|
want: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "all routes gone",
|
||||||
|
a: []*route.Route{routeFor("a", "10.0.0.0/24")},
|
||||||
|
b: nil,
|
||||||
|
want: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "both empty",
|
||||||
|
a: nil,
|
||||||
|
b: nil,
|
||||||
|
want: false,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, tc := range tests {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
assert.Equal(t, tc.want, hasRouteDiff(tc.a, tc.b),
|
||||||
|
"route diff for %s", tc.name)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -17,11 +17,12 @@ import (
|
|||||||
"github.com/netbirdio/netbird/client/internal/routemanager/refcounter"
|
"github.com/netbirdio/netbird/client/internal/routemanager/refcounter"
|
||||||
)
|
)
|
||||||
|
|
||||||
// reconcileWGMock is a minimal iface.WGIface that only records AddAllowedIP calls; every other
|
// reconcileWGMock is a minimal iface.WGIface that records AddAllowedIP calls and reports the
|
||||||
// method is an inert stub because ReconcilePeerAllowedIPs exercises none of them.
|
// configured address; every other method is an inert stub because the tests exercise none of them.
|
||||||
type reconcileWGMock struct {
|
type reconcileWGMock struct {
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
adds map[string][]netip.Prefix
|
adds map[string][]netip.Prefix
|
||||||
|
addr wgaddr.Address
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *reconcileWGMock) AddAllowedIP(peerKey string, allowedIP netip.Prefix) error {
|
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) RemoveAllowedIP(string, netip.Prefix) error { return nil }
|
||||||
func (m *reconcileWGMock) Name() string { return "utun-test" }
|
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) ToInterface() *net.Interface { return nil }
|
||||||
func (m *reconcileWGMock) IsUserspaceBind() bool { return false }
|
func (m *reconcileWGMock) IsUserspaceBind() bool { return false }
|
||||||
func (m *reconcileWGMock) GetFilter() device.PacketFilter { return nil }
|
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")
|
||||||
|
}
|
||||||
@@ -88,9 +88,15 @@ type Client struct {
|
|||||||
// netMgr outlives engine restarts: it mirrors the OS connectivity, not
|
// netMgr outlives engine restarts: it mirrors the OS connectivity, not
|
||||||
// the engine lifecycle. Run injects its state and sweeper into each new
|
// the engine lifecycle. Run injects its state and sweeper into each new
|
||||||
// ConnectClient.
|
// ConnectClient.
|
||||||
netMgr *netevents.Manager
|
netMgr *netevents.Manager
|
||||||
// preloadedConfig holds config loaded from JSON (used on tvOS where file writes are blocked)
|
preloadedConfigJSON atomic.Pointer[string]
|
||||||
preloadedConfig *profilemanager.Config
|
|
||||||
|
// mdmSource holds the per-Client MDM policy source and its change
|
||||||
|
// detector as one unit. Set by SetMDMPolicyFetcher (called from the
|
||||||
|
// Swift side at extension init). Each Run passes the loader to the
|
||||||
|
// resolved Config so applyMDMPolicy picks up the active overlay. Nil
|
||||||
|
// means "MDM enforcement off for this Client".
|
||||||
|
mdmSource atomic.Pointer[mdmSource]
|
||||||
|
|
||||||
// stateMu guards the run lifecycle as one unit: the cancel installed by
|
// stateMu guards the run lifecycle as one unit: the cancel installed by
|
||||||
// the current run, the channel it closes on exit, and the state it
|
// the current run, the channel it closes on exit, and the state it
|
||||||
@@ -122,44 +128,44 @@ func NewClient(cfgFile, stateFile, cacheDir, logFilePath, deviceName string, osV
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// SetConfigFromJSON loads config from a JSON string into memory.
|
// SetConfigFromJSON stores the JSON config that later loads resolve instead of the config file (tvOS).
|
||||||
// This is used on tvOS where file writes to App Group containers are blocked.
|
|
||||||
// When set, IsLoginRequired() and Run() will use this preloaded config instead of reading from file.
|
|
||||||
func (c *Client) SetConfigFromJSON(jsonStr string) error {
|
func (c *Client) SetConfigFromJSON(jsonStr string) error {
|
||||||
cfg, err := profilemanager.ConfigFromJSON(jsonStr)
|
if _, err := profilemanager.ConfigFromJSON(jsonStr); err != nil {
|
||||||
if err != nil {
|
|
||||||
log.Errorf("SetConfigFromJSON: failed to parse config JSON: %v", err)
|
log.Errorf("SetConfigFromJSON: failed to parse config JSON: %v", err)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
c.preloadedConfig = cfg
|
c.preloadedConfigJSON.Store(&jsonStr)
|
||||||
log.Infof("SetConfigFromJSON: config loaded successfully from JSON")
|
log.Infof("SetConfigFromJSON: config loaded successfully from JSON")
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (c *Client) loadConfig(input profilemanager.ConfigInput) (*profilemanager.Config, error) {
|
||||||
|
var cfg *profilemanager.Config
|
||||||
|
var err error
|
||||||
|
if preloaded := c.preloadedConfigJSON.Load(); preloaded != nil {
|
||||||
|
cfg, err = profilemanager.ConfigFromJSON(*preloaded)
|
||||||
|
} else {
|
||||||
|
cfg, err = profilemanager.DirectUpdateOrCreateConfig(input)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
c.applyMDMOverlay(cfg)
|
||||||
|
return cfg, nil
|
||||||
|
}
|
||||||
|
|
||||||
// Run start the internal client. It is a blocker function
|
// Run start the internal client. It is a blocker function
|
||||||
func (c *Client) Run(fd int32, interfaceName string, envList *EnvList) error {
|
func (c *Client) Run(fd int32, interfaceName string, envList *EnvList) error {
|
||||||
exportEnvList(envList)
|
exportEnvList(envList)
|
||||||
log.Infof("Starting NetBird client")
|
log.Infof("Starting NetBird client")
|
||||||
log.Debugf("Tunnel uses interface: %s", interfaceName)
|
log.Debugf("Tunnel uses interface: %s", interfaceName)
|
||||||
|
|
||||||
var cfg *profilemanager.Config
|
cfg, err := c.loadConfig(profilemanager.ConfigInput{
|
||||||
var err error
|
ConfigPath: c.cfgFile,
|
||||||
|
StateFilePath: c.stateFile,
|
||||||
// Use preloaded config if available (tvOS where file writes are blocked)
|
})
|
||||||
if c.preloadedConfig != nil {
|
if err != nil {
|
||||||
log.Infof("Run: using preloaded config from memory")
|
return err
|
||||||
cfg = c.preloadedConfig
|
|
||||||
} else {
|
|
||||||
log.Infof("Run: loading config from file")
|
|
||||||
// Use DirectUpdateOrCreateConfig to avoid atomic file operations (temp file + rename)
|
|
||||||
// which are blocked by the tvOS sandbox in App Group containers
|
|
||||||
cfg, err = profilemanager.DirectUpdateOrCreateConfig(profilemanager.ConfigInput{
|
|
||||||
ConfigPath: c.cfgFile,
|
|
||||||
StateFilePath: c.stateFile,
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
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)
|
||||||
@@ -274,19 +280,13 @@ func (c *Client) DebugBundle(anonymize bool, anonymizeLevel string) (string, err
|
|||||||
|
|
||||||
// If the engine hasn't been started, load config so we can reach management.
|
// If the engine hasn't been started, load config so we can reach management.
|
||||||
if cfg == nil {
|
if cfg == nil {
|
||||||
if c.preloadedConfig != nil {
|
var err error
|
||||||
cfg = c.preloadedConfig
|
cfg, err = c.loadConfig(profilemanager.ConfigInput{
|
||||||
} else {
|
ConfigPath: c.cfgFile,
|
||||||
var err error
|
StateFilePath: c.stateFile,
|
||||||
// Use DirectUpdateOrCreateConfig to avoid atomic file operations
|
})
|
||||||
// (temp file + rename) blocked by the tvOS sandbox.
|
if err != nil {
|
||||||
cfg, err = profilemanager.DirectUpdateOrCreateConfig(profilemanager.ConfigInput{
|
return "", fmt.Errorf("load config: %w", err)
|
||||||
ConfigPath: c.cfgFile,
|
|
||||||
StateFilePath: c.stateFile,
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
return "", fmt.Errorf("load config: %w", err)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -421,29 +421,9 @@ func (c *Client) IsLoginRequired() bool {
|
|||||||
ctx, cancel := context.WithCancel(ctxWithValues)
|
ctx, cancel := context.WithCancel(ctxWithValues)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
var cfg *profilemanager.Config
|
cfg, err := c.loadConfig(profilemanager.ConfigInput{ConfigPath: c.cfgFile})
|
||||||
var err error
|
if err != nil {
|
||||||
|
log.Errorf("IsLoginRequired: failed to load config: %v", err)
|
||||||
// Use preloaded config if available (tvOS where file writes are blocked)
|
|
||||||
if c.preloadedConfig != nil {
|
|
||||||
log.Infof("IsLoginRequired: using preloaded config from memory")
|
|
||||||
cfg = c.preloadedConfig
|
|
||||||
} else {
|
|
||||||
log.Infof("IsLoginRequired: loading config from file")
|
|
||||||
// Use DirectUpdateOrCreateConfig to avoid atomic file operations (temp file + rename)
|
|
||||||
// which are blocked by the tvOS sandbox in App Group containers
|
|
||||||
cfg, err = profilemanager.DirectUpdateOrCreateConfig(profilemanager.ConfigInput{
|
|
||||||
ConfigPath: c.cfgFile,
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
log.Errorf("IsLoginRequired: failed to load config: %v", err)
|
|
||||||
// If we can't load config, assume login is required
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if cfg == nil {
|
|
||||||
log.Errorf("IsLoginRequired: config is nil")
|
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -493,6 +473,7 @@ func (c *Client) LoginForMobile() string {
|
|||||||
log.Errorf("LoginForMobile: failed to load config: %v", err)
|
log.Errorf("LoginForMobile: failed to load config: %v", err)
|
||||||
return fmt.Sprintf("failed to load config: %v", err)
|
return fmt.Sprintf("failed to load config: %v", err)
|
||||||
}
|
}
|
||||||
|
c.applyMDMOverlay(cfg)
|
||||||
|
|
||||||
oAuthFlow, err := auth.NewOAuthFlow(ctx, cfg, false, false, "")
|
oAuthFlow, err := auth.NewOAuthFlow(ctx, cfg, false, false, "")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import (
|
|||||||
|
|
||||||
"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/mobile"
|
"github.com/netbirdio/netbird/client/mobile"
|
||||||
"github.com/netbirdio/netbird/client/system"
|
"github.com/netbirdio/netbird/client/system"
|
||||||
)
|
)
|
||||||
@@ -39,14 +40,22 @@ type Auth struct {
|
|||||||
ctx context.Context
|
ctx context.Context
|
||||||
cancel context.CancelFunc
|
cancel context.CancelFunc
|
||||||
config *profilemanager.Config
|
config *profilemanager.Config
|
||||||
|
base *profilemanager.Config
|
||||||
|
policy *mdm.Policy
|
||||||
cfgPath string
|
cfgPath string
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewAuth instantiate Auth struct and validate the management URL
|
// NewAuth instantiate Auth struct and validate the management URL.
|
||||||
func NewAuth(cfgPath string, mgmURL string) (*Auth, error) {
|
// Auth is constructed under the active MDM policy: the policy is overlaid on
|
||||||
inputCfg := profilemanager.ConfigInput{
|
// the resolved config so the login runs against the enforced values, while
|
||||||
ConfigPath: cfgPath,
|
// the persisted config keeps the caller-supplied ones; a caller-supplied
|
||||||
ManagementURL: mgmURL,
|
// management URL is ignored while MDM manages that key. A nil fetcher
|
||||||
|
// disables MDM enforcement.
|
||||||
|
func NewAuth(cfgPath string, mgmURL string, fetcher PolicyFetcher) (*Auth, error) {
|
||||||
|
policy := loaderFor(fetcher).Load()
|
||||||
|
inputCfg := profilemanager.ConfigInput{ConfigPath: cfgPath}
|
||||||
|
if _, managed := policy.GetString(mdm.KeyManagementURL); !managed {
|
||||||
|
inputCfg.ManagementURL = mgmURL
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load the existing config when a config file is already present so an
|
// Load the existing config when a config file is already present so an
|
||||||
@@ -67,6 +76,10 @@ func NewAuth(cfgPath string, mgmURL string) (*Auth, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
a := &Auth{policy: policy, cfgPath: cfgPath}
|
||||||
|
if err := a.setBaseConfig(cfg); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
// Use a cancellable context so Stop() can abort an in-progress interactive
|
// Use a cancellable context so Stop() can abort an in-progress interactive
|
||||||
// login. The PKCE flow's WaitToken blocks (and keeps its loopback HTTP server
|
// login. The PKCE flow's WaitToken blocks (and keeps its loopback HTTP server
|
||||||
@@ -76,14 +89,8 @@ func NewAuth(cfgPath string, mgmURL string) (*Auth, error) {
|
|||||||
// process (decoupled from the network extension), so without this the server
|
// process (decoupled from the network extension), so without this the server
|
||||||
// lingers after the user dismisses the browser and the next connect stalls
|
// lingers after the user dismisses the browser and the next connect stalls
|
||||||
// trying to bind the same port.
|
// trying to bind the same port.
|
||||||
ctx, cancel := context.WithCancel(context.Background())
|
a.ctx, a.cancel = context.WithCancel(context.Background())
|
||||||
|
return a, nil
|
||||||
return &Auth{
|
|
||||||
ctx: ctx,
|
|
||||||
cancel: cancel,
|
|
||||||
config: cfg,
|
|
||||||
cfgPath: cfgPath,
|
|
||||||
}, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewAuthWithConfig instantiate Auth based on existing config
|
// NewAuthWithConfig instantiate Auth based on existing config
|
||||||
@@ -106,9 +113,7 @@ func (a *Auth) Stop() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// SaveConfigIfSSOSupported test the connectivity with the management server by retrieving the server device flow info.
|
// SaveConfigIfSSOSupported reports whether the management server supports SSO; the config is already persisted by NewAuth.
|
||||||
// If it returns a flow info than save the configuration and return true. If it gets a codes.NotFound, it means that SSO
|
|
||||||
// is not supported and returns false without saving the configuration. For other errors return false.
|
|
||||||
func (a *Auth) SaveConfigIfSSOSupported(listener SSOListener) {
|
func (a *Auth) SaveConfigIfSSOSupported(listener SSOListener) {
|
||||||
if listener == nil {
|
if listener == nil {
|
||||||
log.Errorf("SaveConfigIfSSOSupported: listener is nil")
|
log.Errorf("SaveConfigIfSSOSupported: listener is nil")
|
||||||
@@ -136,17 +141,10 @@ func (a *Auth) saveConfigIfSSOSupported() (bool, error) {
|
|||||||
return false, fmt.Errorf("failed to check SSO support: %v", err)
|
return false, fmt.Errorf("failed to check SSO support: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if !supportsSSO {
|
return supportsSSO, nil
|
||||||
return false, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Use DirectWriteOutConfig to avoid atomic file operations (temp file + rename)
|
|
||||||
// which are blocked by the tvOS sandbox in App Group containers
|
|
||||||
err = profilemanager.DirectWriteOutConfig(a.cfgPath, a.config)
|
|
||||||
return true, err
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// LoginWithSetupKeyAndSaveConfig test the connectivity with the management server with the setup key.
|
// LoginWithSetupKeyAndSaveConfig registers the peer with the setup key; the config is already persisted by NewAuth.
|
||||||
func (a *Auth) LoginWithSetupKeyAndSaveConfig(resultListener ErrListener, setupKey string, deviceName string) {
|
func (a *Auth) LoginWithSetupKeyAndSaveConfig(resultListener ErrListener, setupKey string, deviceName string) {
|
||||||
if resultListener == nil {
|
if resultListener == nil {
|
||||||
log.Errorf("LoginWithSetupKeyAndSaveConfig: resultListener is nil")
|
log.Errorf("LoginWithSetupKeyAndSaveConfig: resultListener is nil")
|
||||||
@@ -175,10 +173,7 @@ func (a *Auth) loginWithSetupKeyAndSaveConfig(setupKey string, deviceName string
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("login failed: %v", err)
|
return fmt.Errorf("login failed: %v", err)
|
||||||
}
|
}
|
||||||
|
return nil
|
||||||
// Use DirectWriteOutConfig to avoid atomic file operations (temp file + rename)
|
|
||||||
// which are blocked by the tvOS sandbox in App Group containers
|
|
||||||
return profilemanager.DirectWriteOutConfig(a.cfgPath, a.config)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// LoginSync performs a synchronous login check without UI interaction
|
// LoginSync performs a synchronous login check without UI interaction
|
||||||
@@ -312,19 +307,6 @@ func (a *Auth) login(urlOpener URLOpener, forceDeviceAuth bool, deviceName strin
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Save the config before notifying success to ensure persistence completes
|
|
||||||
// before the callback potentially triggers teardown on the Swift side.
|
|
||||||
// Note: This differs from Android which doesn't save config after login.
|
|
||||||
// On iOS/tvOS, we save here because:
|
|
||||||
// 1. The config may have been modified during login (e.g., new tokens)
|
|
||||||
// 2. On tvOS, the Network Extension context may be the only place with
|
|
||||||
// write permissions to the App Group container
|
|
||||||
if a.cfgPath != "" {
|
|
||||||
if err := profilemanager.DirectWriteOutConfig(a.cfgPath, a.config); err != nil {
|
|
||||||
log.Warnf("failed to save config after login: %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Notify caller of successful login synchronously before returning
|
// Notify caller of successful login synchronously before returning
|
||||||
urlOpener.OnLoginSuccess()
|
urlOpener.OnLoginSuccess()
|
||||||
|
|
||||||
@@ -375,23 +357,44 @@ func (a *Auth) foregroundGetTokenInfo(authClient *auth.Auth, urlOpener URLOpener
|
|||||||
return &tokenInfo, nil
|
return &tokenInfo, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetConfigJSON returns the current config as a JSON string.
|
// GetConfigJSON returns the config without the MDM overlay as JSON, for persisting it outside the config file (tvOS).
|
||||||
// This can be used by the caller to persist the config via alternative storage
|
|
||||||
// mechanisms (e.g., UserDefaults on tvOS where file writes are blocked).
|
|
||||||
func (a *Auth) GetConfigJSON() (string, error) {
|
func (a *Auth) GetConfigJSON() (string, error) {
|
||||||
if a.config == nil {
|
cfg := a.base
|
||||||
|
if cfg == nil {
|
||||||
|
cfg = a.config
|
||||||
|
}
|
||||||
|
if cfg == nil {
|
||||||
return "", fmt.Errorf("no config available")
|
return "", fmt.Errorf("no config available")
|
||||||
}
|
}
|
||||||
return profilemanager.ConfigToJSON(a.config)
|
return profilemanager.ConfigToJSON(cfg)
|
||||||
}
|
}
|
||||||
|
|
||||||
// SetConfigFromJSON loads config from a JSON string.
|
// SetConfigFromJSON replaces the config from JSON; the MDM overlay is applied on top for the login.
|
||||||
// This can be used to restore config from alternative storage mechanisms.
|
|
||||||
func (a *Auth) SetConfigFromJSON(jsonStr string) error {
|
func (a *Auth) SetConfigFromJSON(jsonStr string) error {
|
||||||
cfg, err := profilemanager.ConfigFromJSON(jsonStr)
|
cfg, err := profilemanager.ConfigFromJSON(jsonStr)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
a.config = cfg
|
return a.setBaseConfig(cfg)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *Auth) setBaseConfig(base *profilemanager.Config) error {
|
||||||
|
overlaid, err := copyConfig(base)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if a.policy != nil {
|
||||||
|
overlaid.ApplyMDMPolicy(a.policy)
|
||||||
|
}
|
||||||
|
a.base = base
|
||||||
|
a.config = overlaid
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func copyConfig(cfg *profilemanager.Config) (*profilemanager.Config, error) {
|
||||||
|
raw, err := profilemanager.ConfigToJSON(cfg)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return profilemanager.ConfigFromJSON(raw)
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,66 @@
|
|||||||
|
//go:build ios
|
||||||
|
|
||||||
|
package NetBirdSDK
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/netbirdio/netbird/client/internal/profilemanager"
|
||||||
|
"github.com/netbirdio/netbird/client/mdm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// PolicyFetcher is implemented by the native layer to return the current
|
||||||
|
// managed configuration as a JSON-encoded object string; "" means no MDM
|
||||||
|
// source is present.
|
||||||
|
type PolicyFetcher interface {
|
||||||
|
FetchJSON() string
|
||||||
|
}
|
||||||
|
|
||||||
|
type mdmSource struct {
|
||||||
|
loader *mdm.Loader
|
||||||
|
detector *mdm.ChangeDetector
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetMDMPolicyFetcher registers the native-provided MDM policy fetcher on
|
||||||
|
// this Client; passing nil disables MDM enforcement.
|
||||||
|
func (c *Client) SetMDMPolicyFetcher(p PolicyFetcher) {
|
||||||
|
loader := loaderFor(p)
|
||||||
|
c.mdmSource.Store(&mdmSource{loader: loader, detector: mdm.NewChangeDetector(loader)})
|
||||||
|
}
|
||||||
|
|
||||||
|
// HasMDMPolicyChanged re-reads the managed configuration and reports whether
|
||||||
|
// it changed since the last observation; call it from the native OS-change
|
||||||
|
// notification and restart the engine only on true.
|
||||||
|
func (c *Client) HasMDMPolicyChanged() bool {
|
||||||
|
src := c.mdmSource.Load()
|
||||||
|
if src == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return src.detector.Changed()
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetRestrictionsJSON returns the UI enforcement snapshot derived from the
|
||||||
|
// active MDM policy, in the JSON shape shared with the desktop frontend.
|
||||||
|
func (c *Client) GetRestrictionsJSON() (string, error) {
|
||||||
|
return mdm.BuildRestrictions(c.mdmLoader().Load()).JSON()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) applyMDMOverlay(cfg *profilemanager.Config) {
|
||||||
|
loader := c.mdmLoader()
|
||||||
|
if cfg == nil || loader == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
cfg.ApplyMDMPolicy(loader.Load())
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) mdmLoader() *mdm.Loader {
|
||||||
|
if src := c.mdmSource.Load(); src != nil {
|
||||||
|
return src.loader
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func loaderFor(p PolicyFetcher) *mdm.Loader {
|
||||||
|
if p == nil {
|
||||||
|
return mdm.NewJSONLoader(nil)
|
||||||
|
}
|
||||||
|
return mdm.NewJSONLoader(p.FetchJSON)
|
||||||
|
}
|
||||||
@@ -3,12 +3,16 @@
|
|||||||
package NetBirdSDK
|
package NetBirdSDK
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"sync/atomic"
|
||||||
|
|
||||||
"github.com/netbirdio/netbird/client/internal/profilemanager"
|
"github.com/netbirdio/netbird/client/internal/profilemanager"
|
||||||
|
"github.com/netbirdio/netbird/client/mdm"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Preferences export a subset of the internal config for gomobile
|
// Preferences export a subset of the internal config for gomobile
|
||||||
type Preferences struct {
|
type Preferences struct {
|
||||||
configInput profilemanager.ConfigInput
|
configInput profilemanager.ConfigInput
|
||||||
|
mdmLoader atomic.Pointer[mdm.Loader]
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewPreferences create new Preferences instance
|
// NewPreferences create new Preferences instance
|
||||||
@@ -17,11 +21,30 @@ func NewPreferences(configPath string, stateFilePath string) *Preferences {
|
|||||||
ConfigPath: configPath,
|
ConfigPath: configPath,
|
||||||
StateFilePath: stateFilePath,
|
StateFilePath: stateFilePath,
|
||||||
}
|
}
|
||||||
return &Preferences{ci}
|
return &Preferences{configInput: ci}
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetMDMPolicyFetcher registers the native-provided MDM policy fetcher on
|
||||||
|
// this Preferences instance; passing nil disables MDM enforcement.
|
||||||
|
func (p *Preferences) SetMDMPolicyFetcher(f PolicyFetcher) {
|
||||||
|
p.mdmLoader.Store(loaderFor(f))
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetRestrictionsJSON returns the UI enforcement snapshot derived from the
|
||||||
|
// active MDM policy, in the JSON shape shared with the desktop frontend.
|
||||||
|
func (p *Preferences) GetRestrictionsJSON() (string, error) {
|
||||||
|
return mdm.BuildRestrictions(p.policy()).JSON()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Preferences) policy() *mdm.Policy {
|
||||||
|
return p.mdmLoader.Load().Load()
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetManagementURL read url from config file
|
// GetManagementURL read url from config file
|
||||||
func (p *Preferences) GetManagementURL() (string, error) {
|
func (p *Preferences) GetManagementURL() (string, error) {
|
||||||
|
if v, ok := p.policy().GetString(mdm.KeyManagementURL); ok {
|
||||||
|
return mdm.CanonicalURL(v), nil
|
||||||
|
}
|
||||||
if p.configInput.ManagementURL != "" {
|
if p.configInput.ManagementURL != "" {
|
||||||
return p.configInput.ManagementURL, nil
|
return p.configInput.ManagementURL, nil
|
||||||
}
|
}
|
||||||
@@ -30,7 +53,7 @@ func (p *Preferences) GetManagementURL() (string, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
return cfg.ManagementURL.String(), err
|
return cfg.ManagementURL.String(), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// SetManagementURL store the given url and wait for commit
|
// SetManagementURL store the given url and wait for commit
|
||||||
@@ -56,17 +79,21 @@ func (p *Preferences) SetAdminURL(url string) {
|
|||||||
p.configInput.AdminURL = url
|
p.configInput.AdminURL = url
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetPreSharedKey read preshared key from config file
|
// HasPreSharedKey reports whether a pre-shared key is staged, persisted, or
|
||||||
func (p *Preferences) GetPreSharedKey() (string, error) {
|
// enforced by MDM; the key itself is never handed to the native layer.
|
||||||
|
func (p *Preferences) HasPreSharedKey() (bool, error) {
|
||||||
|
if _, ok := p.policy().GetString(mdm.KeyPreSharedKey); ok {
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
if p.configInput.PreSharedKey != nil {
|
if p.configInput.PreSharedKey != nil {
|
||||||
return *p.configInput.PreSharedKey, nil
|
return *p.configInput.PreSharedKey != "", nil
|
||||||
}
|
}
|
||||||
|
|
||||||
cfg, err := profilemanager.ReadConfig(p.configInput.ConfigPath)
|
cfg, err := profilemanager.ReadConfig(p.configInput.ConfigPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return false, err
|
||||||
}
|
}
|
||||||
return cfg.PreSharedKey, err
|
return cfg.PreSharedKey != "", nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// SetPreSharedKey store the given key and wait for commit
|
// SetPreSharedKey store the given key and wait for commit
|
||||||
@@ -81,6 +108,9 @@ func (p *Preferences) SetRosenpassEnabled(enabled bool) {
|
|||||||
|
|
||||||
// GetRosenpassEnabled read rosenpass enabled from config file
|
// GetRosenpassEnabled read rosenpass enabled from config file
|
||||||
func (p *Preferences) GetRosenpassEnabled() (bool, error) {
|
func (p *Preferences) GetRosenpassEnabled() (bool, error) {
|
||||||
|
if v, ok := p.policy().GetBool(mdm.KeyRosenpassEnabled); ok {
|
||||||
|
return v, nil
|
||||||
|
}
|
||||||
if p.configInput.RosenpassEnabled != nil {
|
if p.configInput.RosenpassEnabled != nil {
|
||||||
return *p.configInput.RosenpassEnabled, nil
|
return *p.configInput.RosenpassEnabled, nil
|
||||||
}
|
}
|
||||||
@@ -99,6 +129,9 @@ func (p *Preferences) SetRosenpassPermissive(permissive bool) {
|
|||||||
|
|
||||||
// GetRosenpassPermissive read rosenpass permissive from config file
|
// GetRosenpassPermissive read rosenpass permissive from config file
|
||||||
func (p *Preferences) GetRosenpassPermissive() (bool, error) {
|
func (p *Preferences) GetRosenpassPermissive() (bool, error) {
|
||||||
|
if v, ok := p.policy().GetBool(mdm.KeyRosenpassPermissive); ok {
|
||||||
|
return v, nil
|
||||||
|
}
|
||||||
if p.configInput.RosenpassPermissive != nil {
|
if p.configInput.RosenpassPermissive != nil {
|
||||||
return *p.configInput.RosenpassPermissive, nil
|
return *p.configInput.RosenpassPermissive, nil
|
||||||
}
|
}
|
||||||
@@ -128,8 +161,34 @@ func (p *Preferences) SetDisableIPv6(disable bool) {
|
|||||||
p.configInput.DisableIPv6 = &disable
|
p.configInput.DisableIPv6 = &disable
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetRemoteJobsAllowed reads the remote jobs opt-in from config file
|
||||||
|
func (p *Preferences) GetRemoteJobsAllowed() (bool, error) {
|
||||||
|
policy := p.policy()
|
||||||
|
if !policy.HasKey(mdm.KeyRemoteJobsAllowed) && p.configInput.RemoteJobsAllowed != nil {
|
||||||
|
return *p.configInput.RemoteJobsAllowed, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg, err := profilemanager.ReadConfig(p.configInput.ConfigPath)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
cfg.ApplyMDMPolicy(policy)
|
||||||
|
if cfg.RemoteJobsAllowed == nil {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
return *cfg.RemoteJobsAllowed, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetRemoteJobsAllowed stores the given value and waits for commit
|
||||||
|
func (p *Preferences) SetRemoteJobsAllowed(allowed bool) {
|
||||||
|
p.configInput.RemoteJobsAllowed = &allowed
|
||||||
|
}
|
||||||
|
|
||||||
// Commit write out the changes into config file
|
// Commit write out the changes into config file
|
||||||
func (p *Preferences) Commit() error {
|
func (p *Preferences) Commit() error {
|
||||||
|
if err := profilemanager.CheckMDMConflicts(p.configInput, p.policy()); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
// Use DirectUpdateOrCreateConfig to avoid atomic file operations (temp file + rename)
|
// Use DirectUpdateOrCreateConfig to avoid atomic file operations (temp file + rename)
|
||||||
// which are blocked by the tvOS sandbox in App Group containers
|
// which are blocked by the tvOS sandbox in App Group containers
|
||||||
_, err := profilemanager.DirectUpdateOrCreateConfig(p.configInput)
|
_, err := profilemanager.DirectUpdateOrCreateConfig(p.configInput)
|
||||||
|
|||||||
@@ -31,14 +31,13 @@ func TestPreferences_DefaultValues(t *testing.T) {
|
|||||||
t.Errorf("invalid default management url: %s", defaultVar)
|
t.Errorf("invalid default management url: %s", defaultVar)
|
||||||
}
|
}
|
||||||
|
|
||||||
var preSharedKey string
|
hasPSK, err := p.HasPreSharedKey()
|
||||||
preSharedKey, err = p.GetPreSharedKey()
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("failed to read default preshared key: %s", err)
|
t.Fatalf("failed to read default preshared key presence: %s", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if preSharedKey != "" {
|
if hasPSK {
|
||||||
t.Errorf("invalid preshared key: %s", preSharedKey)
|
t.Errorf("unexpected preshared key presence on fresh config")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -69,13 +68,13 @@ func TestPreferences_ReadUncommitedValues(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
p.SetPreSharedKey(exampleString)
|
p.SetPreSharedKey(exampleString)
|
||||||
resp, err = p.GetPreSharedKey()
|
hasPSK, err := p.HasPreSharedKey()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("failed to read preshared key: %s", err)
|
t.Fatalf("failed to read preshared key presence: %s", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if resp != exampleString {
|
if !hasPSK {
|
||||||
t.Errorf("unexpected preshared key: %s", resp)
|
t.Errorf("expected preshared key presence after staging one")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -114,12 +113,12 @@ func TestPreferences_Commit(t *testing.T) {
|
|||||||
t.Errorf("unexpected management url: %s", resp)
|
t.Errorf("unexpected management url: %s", resp)
|
||||||
}
|
}
|
||||||
|
|
||||||
resp, err = p.GetPreSharedKey()
|
hasPSK, err := p.HasPreSharedKey()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("failed to read preshared key: %s", err)
|
t.Fatalf("failed to read preshared key presence: %s", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if resp != examplePresharedKey {
|
if !hasPSK {
|
||||||
t.Errorf("unexpected preshared key: %s", resp)
|
t.Errorf("expected preshared key presence after commit")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -52,6 +52,12 @@ func NewProfileManager(configDir string) *ProfileManager {
|
|||||||
return &ProfileManager{impl: mobile.NewProfileManager(configDir, iosUsername)}
|
return &ProfileManager{impl: mobile.NewProfileManager(configDir, iosUsername)}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SetMDMPolicyFetcher registers the native-provided MDM policy fetcher on
|
||||||
|
// this ProfileManager; passing nil disables MDM enforcement.
|
||||||
|
func (pm *ProfileManager) SetMDMPolicyFetcher(f PolicyFetcher) {
|
||||||
|
pm.impl.SetMDMLoader(loaderFor(f))
|
||||||
|
}
|
||||||
|
|
||||||
// ListProfiles returns all available profiles, including the default profile,
|
// ListProfiles returns all available profiles, including the default profile,
|
||||||
// with their active status set.
|
// with their active status set.
|
||||||
func (pm *ProfileManager) ListProfiles() (*ProfileArray, error) {
|
func (pm *ProfileManager) ListProfiles() (*ProfileArray, error) {
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
package mdm
|
||||||
|
|
||||||
|
import "sync"
|
||||||
|
|
||||||
|
// ChangeDetector tracks the last observed policy of a Loader so an
|
||||||
|
// OS-notification-driven caller can ask whether the managed configuration
|
||||||
|
// actually changed before restarting anything.
|
||||||
|
type ChangeDetector struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
loader *Loader
|
||||||
|
prev *Policy
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewChangeDetector constructs a ChangeDetector seeded with the loader's
|
||||||
|
// current policy, so only a later change reports as changed.
|
||||||
|
func NewChangeDetector(loader *Loader) *ChangeDetector {
|
||||||
|
return &ChangeDetector{
|
||||||
|
loader: loader,
|
||||||
|
prev: loader.Load(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Changed re-reads the policy, logs the per-key diff, and reports whether it
|
||||||
|
// diverged from the last observation; the new snapshot becomes the baseline.
|
||||||
|
func (d *ChangeDetector) Changed() bool {
|
||||||
|
d.mu.Lock()
|
||||||
|
defer d.mu.Unlock()
|
||||||
|
curr := d.loader.Load()
|
||||||
|
if !policyChanged(d.prev, curr) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
d.prev = curr
|
||||||
|
return true
|
||||||
|
}
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
package mdm
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/url"
|
||||||
|
|
||||||
|
"github.com/netbirdio/netbird/util"
|
||||||
|
)
|
||||||
|
|
||||||
|
// PreSharedKeyRedactedSentinel is the redaction mask returned in place of a
|
||||||
|
// real pre-shared key; an incoming value equal to it is a round-trip echo,
|
||||||
|
// never an override.
|
||||||
|
const PreSharedKeyRedactedSentinel = "**********"
|
||||||
|
|
||||||
|
// ConflictCheck is a value-aware comparison between a single requested field
|
||||||
|
// and the corresponding MDM-enforced value.
|
||||||
|
type ConflictCheck struct {
|
||||||
|
Key string
|
||||||
|
Check func(*Policy) bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// ConflictBool builds a ConflictCheck for a boolean MDM key.
|
||||||
|
func ConflictBool(key string, p *bool) ConflictCheck {
|
||||||
|
return ConflictCheck{
|
||||||
|
Key: key,
|
||||||
|
Check: func(pol *Policy) bool {
|
||||||
|
if p == nil {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
want, ok := pol.GetBool(key)
|
||||||
|
return ok && want == *p
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ConflictStringPtr builds a ConflictCheck for an optional string MDM key,
|
||||||
|
// where an explicit empty value is still a request to change the setting. A
|
||||||
|
// nil p means "field not set" (no override requested).
|
||||||
|
func ConflictStringPtr(key string, p *string) ConflictCheck {
|
||||||
|
return ConflictCheck{
|
||||||
|
Key: key,
|
||||||
|
Check: func(pol *Policy) bool {
|
||||||
|
if p == nil {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
want, ok := pol.GetString(key)
|
||||||
|
return ok && want == *p
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ConflictURL builds a ConflictCheck for a URL-typed MDM key. The two sides are
|
||||||
|
// compared as the endpoints they address, not as strings: see
|
||||||
|
// util.SameServiceURL.
|
||||||
|
func ConflictURL(key, got string) ConflictCheck {
|
||||||
|
return ConflictCheck{
|
||||||
|
Key: key,
|
||||||
|
Check: func(pol *Policy) bool {
|
||||||
|
if got == "" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
want, ok := pol.GetString(key)
|
||||||
|
return ok && util.SameServiceURLStrings(want, got)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ConflictInt64 builds a ConflictCheck for an integer MDM key.
|
||||||
|
func ConflictInt64(key string, p *int64) ConflictCheck {
|
||||||
|
return ConflictCheck{
|
||||||
|
Key: key,
|
||||||
|
Check: func(pol *Policy) bool {
|
||||||
|
if p == nil {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
want, ok := pol.GetInt(key)
|
||||||
|
return ok && want == *p
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ResolveConflicts returns the names of keys whose requested value diverges
|
||||||
|
// from the policy-enforced value; keys the policy does not manage are skipped,
|
||||||
|
// a managed key without a Check counts as a conflict.
|
||||||
|
func ResolveConflicts(policy *Policy, checks []ConflictCheck) []string {
|
||||||
|
if policy.IsEmpty() {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var conflicts []string
|
||||||
|
for _, c := range checks {
|
||||||
|
if !policy.HasKey(c.Key) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if c.Check == nil || !c.Check(policy) {
|
||||||
|
conflicts = append(conflicts, c.Key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return conflicts
|
||||||
|
}
|
||||||
|
|
||||||
|
// CanonicalURL normalizes a service URL by appending the scheme default port
|
||||||
|
// when none is present; unparseable input is returned unchanged.
|
||||||
|
func CanonicalURL(s string) string {
|
||||||
|
u, err := url.ParseRequestURI(s)
|
||||||
|
if err != nil {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
if u.Port() == "" {
|
||||||
|
switch u.Scheme {
|
||||||
|
case "https":
|
||||||
|
u.Host += ":443"
|
||||||
|
case "http":
|
||||||
|
u.Host += ":80"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return u.String()
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
package mdm
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The same spellings, through the conflict check that decides whether a request
|
||||||
|
// is refused. An enforced URL restated in another spelling addresses the very
|
||||||
|
// server the policy names, so it must not be reported as a conflict.
|
||||||
|
func TestConflictURLComparesEndpoints(t *testing.T) {
|
||||||
|
policy := NewPolicy(map[string]any{KeyManagementURL: "https://mgmt.example.com"})
|
||||||
|
require.True(t, policy.HasKey(KeyManagementURL))
|
||||||
|
|
||||||
|
for _, restated := range []string{
|
||||||
|
"https://mgmt.example.com",
|
||||||
|
"https://mgmt.example.com:443",
|
||||||
|
"https://mgmt.example.com/",
|
||||||
|
"https://MGMT.example.com",
|
||||||
|
"https://mgmt.example.com:0443",
|
||||||
|
} {
|
||||||
|
conflicts := ResolveConflicts(policy, []ConflictCheck{ConflictURL(KeyManagementURL, restated)})
|
||||||
|
assert.Empty(t, conflicts, "%q is the enforced endpoint written differently", restated)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, diverging := range []string{
|
||||||
|
"https://other.example.com",
|
||||||
|
"http://mgmt.example.com",
|
||||||
|
"https://mgmt.example.com:8443",
|
||||||
|
"https://mgmt.example.com/other",
|
||||||
|
} {
|
||||||
|
conflicts := ResolveConflicts(policy, []ConflictCheck{ConflictURL(KeyManagementURL, diverging)})
|
||||||
|
assert.Equal(t, []string{KeyManagementURL}, conflicts, "%q addresses another endpoint", diverging)
|
||||||
|
}
|
||||||
|
|
||||||
|
// An unset field is not a request to change anything.
|
||||||
|
assert.Empty(t, ResolveConflicts(policy, []ConflictCheck{ConflictURL(KeyManagementURL, "")}))
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
package mdm
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
|
||||||
|
log "github.com/sirupsen/logrus"
|
||||||
|
)
|
||||||
|
|
||||||
|
type jsonPolicyFetcher struct {
|
||||||
|
fetch func() string
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewJSONLoader constructs a Loader whose policy source is a JSON-encoded
|
||||||
|
// object string, as produced by the mobile native layers; a nil fetch
|
||||||
|
// disables MDM enforcement.
|
||||||
|
func NewJSONLoader(fetch func() string) *Loader {
|
||||||
|
if fetch == nil {
|
||||||
|
return NewLoader(nil)
|
||||||
|
}
|
||||||
|
return NewLoader(&jsonPolicyFetcher{fetch: fetch})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *jsonPolicyFetcher) Fetch() map[string]any {
|
||||||
|
raw := f.fetch()
|
||||||
|
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
|
||||||
|
}
|
||||||
+38
-6
@@ -119,16 +119,46 @@ 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 supplies the managed configuration to a Loader. Mobile
|
||||||
// empty (but non-nil) Policy when no source is present, the source is
|
// platforms (Android / iOS) implement it to push the OS-managed values
|
||||||
// empty, or the platform is unsupported.
|
// into the Go runtime. On every platform a non-nil fetcher takes
|
||||||
|
// precedence over the native source, which is the test seam for the
|
||||||
|
// registry / plist loaders; a nil fetcher leaves the native source in
|
||||||
|
// charge, or disables MDM enforcement where there is none.
|
||||||
|
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. A non-nil fetcher takes precedence over
|
||||||
|
// the platform-native source; production desktop callers pass nil so the
|
||||||
|
// registry / plist stays authoritative.
|
||||||
|
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{}}
|
||||||
@@ -205,6 +235,8 @@ func (p *Policy) GetBool(key string) (bool, bool) {
|
|||||||
return t != 0, true
|
return t != 0, true
|
||||||
case int64:
|
case int64:
|
||||||
return t != 0, true
|
return t != 0, true
|
||||||
|
case float64:
|
||||||
|
return t != 0, true
|
||||||
}
|
}
|
||||||
return false, false
|
return false, false
|
||||||
}
|
}
|
||||||
@@ -270,7 +302,7 @@ func (p *Policy) GetStringSlice(key string) ([]string, bool) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// sortedKeys returns the keys of m as a deterministic, lexicographically
|
// sortedKeys returns the keys of m as a deterministic, lexicographically
|
||||||
// sorted slice. Used internally by Policy.ManagedKeys and LoadPolicy's
|
// sorted slice. Used internally by Policy.ManagedKeys and Loader.Load's
|
||||||
// diagnostic log line so callers see a stable key order across runs
|
// diagnostic log line so callers see a stable key order across runs
|
||||||
// regardless of Go's randomised map iteration.
|
// regardless of Go's randomised map iteration.
|
||||||
func sortedKeys(m map[string]any) []string {
|
func sortedKeys(m map[string]any) []string {
|
||||||
|
|||||||
@@ -25,8 +25,9 @@ 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, unless a fetcher was
|
||||||
|
// injected, in which case its values are returned instead. 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,13 +40,19 @@ 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) {
|
||||||
// Not enrolled for NetBird. Caller treats nil as
|
// Not enrolled for NetBird. Caller treats nil as
|
||||||
// "no MDM source present".
|
// "no MDM source present".
|
||||||
//nolint:nilnil // (nil, nil) is the documented platform-absent sentinel; see LoadPolicy.
|
//nolint:nilnil // (nil, nil) is the documented platform-absent sentinel; see Loader.Load.
|
||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
return nil, fmt.Errorf("open %s: %w", policyPlistPath, err)
|
return nil, fmt.Errorf("open %s: %w", policyPlistPath, err)
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package mdm
|
package mdm
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"runtime"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
@@ -95,7 +96,8 @@ func TestPolicy_GetBool(t *testing.T) {
|
|||||||
{"int64 nonzero", int64(2), true, true},
|
{"int64 nonzero", int64(2), true, true},
|
||||||
{"int64 zero", int64(0), false, true},
|
{"int64 zero", int64(0), false, true},
|
||||||
{"string garbage", "maybe", false, false},
|
{"string garbage", "maybe", false, false},
|
||||||
{"float unsupported", 1.0, false, false},
|
{"float nonzero", 1.0, true, true},
|
||||||
|
{"float zero", 0.0, false, true},
|
||||||
}
|
}
|
||||||
for _, c := range cases {
|
for _, c := range cases {
|
||||||
t.Run(c.name, func(t *testing.T) {
|
t.Run(c.name, func(t *testing.T) {
|
||||||
@@ -155,10 +157,29 @@ func TestPolicy_GetStringSlice(t *testing.T) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestLoadPolicy_PlatformStubReturnsEmpty(t *testing.T) {
|
// encoding/json decodes every JSON number into float64, so the mobile
|
||||||
// loadPlatformPolicy is a stub on every OS for Phase 1. LoadPolicy must
|
// loaders never see int.
|
||||||
// degrade gracefully and never return nil.
|
func TestJSONLoader_BoolFromNumber(t *testing.T) {
|
||||||
p := LoadPolicy()
|
p := NewJSONLoader(func() string { return `{"blockInbound":1,"disableProfiles":0}` }).Load()
|
||||||
|
|
||||||
|
got, ok := p.GetBool(KeyBlockInbound)
|
||||||
|
assert.True(t, ok)
|
||||||
|
assert.True(t, got)
|
||||||
|
|
||||||
|
got, ok = p.GetBool(KeyDisableProfiles)
|
||||||
|
assert.True(t, ok)
|
||||||
|
assert.False(t, got)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoader_NilFetcherReturnsEmpty(t *testing.T) {
|
||||||
|
// Loader.Load with no fetcher (desktop construction) must degrade
|
||||||
|
// gracefully and never return nil; on linux loadPlatform is a stub
|
||||||
|
// returning (nil, nil), and Load is expected to translate that
|
||||||
|
// into a non-nil empty Policy.
|
||||||
|
if runtime.GOOS == "windows" || runtime.GOOS == "darwin" {
|
||||||
|
t.Skip("a nil fetcher reads the OS-managed policy on this platform")
|
||||||
|
}
|
||||||
|
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,9 @@ 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, unless a fetcher was
|
||||||
|
// injected, in which case its values are returned instead. 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,12 +71,18 @@ 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) {
|
||||||
// Not enrolled. Caller treats nil as "no MDM source present".
|
// Not enrolled. Caller treats nil as "no MDM source present".
|
||||||
//nolint:nilnil // (nil, nil) is the documented platform-absent sentinel; see LoadPolicy.
|
//nolint:nilnil // (nil, nil) is the documented platform-absent sentinel; see Loader.Load.
|
||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
return nil, fmt.Errorf("open %s: %w", policyRegistryPath, err)
|
return nil, fmt.Errorf("open %s: %w", policyRegistryPath, err)
|
||||||
|
|||||||
@@ -0,0 +1,89 @@
|
|||||||
|
package mdm
|
||||||
|
|
||||||
|
import "encoding/json"
|
||||||
|
|
||||||
|
// Fields carries the per-key MDM enforcement state for a UI: value-typed
|
||||||
|
// fields hold the enforced value (nil pointer = not managed), boolean
|
||||||
|
// fields report that the key is managed.
|
||||||
|
type Fields struct {
|
||||||
|
ManagementURL string `json:"managementURL"`
|
||||||
|
PreSharedKey bool `json:"preSharedKey"`
|
||||||
|
WireguardPort bool `json:"wireguardPort"`
|
||||||
|
RosenpassEnabled bool `json:"rosenpassEnabled"`
|
||||||
|
RosenpassPermissive bool `json:"rosenpassPermissive"`
|
||||||
|
DisableClientRoutes bool `json:"disableClientRoutes"`
|
||||||
|
DisableServerRoutes bool `json:"disableServerRoutes"`
|
||||||
|
AllowServerSSH *bool `json:"allowServerSSH"`
|
||||||
|
DisableAutoConnect bool `json:"disableAutoConnect"`
|
||||||
|
DisableAutostart bool `json:"disableAutostart"`
|
||||||
|
BlockInbound bool `json:"blockInbound"`
|
||||||
|
DisableMetricsCollection bool `json:"disableMetricsCollection"`
|
||||||
|
SplitTunnelMode bool `json:"splitTunnelMode"`
|
||||||
|
SplitTunnelApps bool `json:"splitTunnelApps"`
|
||||||
|
DisableAdvancedView *bool `json:"disableAdvancedView"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Features carries the feature gates a UI must honor.
|
||||||
|
type Features struct {
|
||||||
|
DisableProfiles bool `json:"disableProfiles"`
|
||||||
|
DisableNetworks bool `json:"disableNetworks"`
|
||||||
|
DisableUpdateSettings bool `json:"disableUpdateSettings"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Restrictions is the UI-facing enforcement snapshot; the JSON shape is
|
||||||
|
// shared by the desktop frontend and the mobile bridges.
|
||||||
|
type Restrictions struct {
|
||||||
|
MDM Fields `json:"mdm"`
|
||||||
|
Features Features `json:"features"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// BuildRestrictions derives the UI enforcement snapshot from the active
|
||||||
|
// policy.
|
||||||
|
func BuildRestrictions(policy *Policy) Restrictions {
|
||||||
|
var r Restrictions
|
||||||
|
if policy.IsEmpty() {
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
|
||||||
|
if v, ok := policy.GetString(KeyManagementURL); ok {
|
||||||
|
r.MDM.ManagementURL = CanonicalURL(v)
|
||||||
|
}
|
||||||
|
r.MDM.PreSharedKey = policy.HasKey(KeyPreSharedKey)
|
||||||
|
r.MDM.WireguardPort = policy.HasKey(KeyWireguardPort)
|
||||||
|
r.MDM.RosenpassEnabled = policy.HasKey(KeyRosenpassEnabled)
|
||||||
|
r.MDM.RosenpassPermissive = policy.HasKey(KeyRosenpassPermissive)
|
||||||
|
r.MDM.DisableClientRoutes = policy.HasKey(KeyDisableClientRoutes)
|
||||||
|
r.MDM.DisableServerRoutes = policy.HasKey(KeyDisableServerRoutes)
|
||||||
|
r.MDM.DisableAutoConnect = policy.HasKey(KeyDisableAutoConnect)
|
||||||
|
r.MDM.DisableAutostart = policy.HasKey(KeyDisableAutostart)
|
||||||
|
r.MDM.BlockInbound = policy.HasKey(KeyBlockInbound)
|
||||||
|
r.MDM.DisableMetricsCollection = policy.HasKey(KeyDisableMetricsCollection)
|
||||||
|
r.MDM.SplitTunnelMode = policy.HasKey(KeySplitTunnelMode)
|
||||||
|
r.MDM.SplitTunnelApps = policy.HasKey(KeySplitTunnelApps)
|
||||||
|
if v, ok := policy.GetBool(KeyAllowServerSSH); ok {
|
||||||
|
r.MDM.AllowServerSSH = &v
|
||||||
|
}
|
||||||
|
if v, ok := policy.GetBool(KeyDisableAdvancedView); ok {
|
||||||
|
r.MDM.DisableAdvancedView = &v
|
||||||
|
}
|
||||||
|
|
||||||
|
if v, ok := policy.GetBool(KeyDisableProfiles); ok {
|
||||||
|
r.Features.DisableProfiles = v
|
||||||
|
}
|
||||||
|
if v, ok := policy.GetBool(KeyDisableNetworks); ok {
|
||||||
|
r.Features.DisableNetworks = v
|
||||||
|
}
|
||||||
|
if v, ok := policy.GetBool(KeyDisableUpdateSettings); ok {
|
||||||
|
r.Features.DisableUpdateSettings = v
|
||||||
|
}
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
|
||||||
|
// JSON renders the snapshot in the shared UI JSON shape.
|
||||||
|
func (r Restrictions) JSON() (string, error) {
|
||||||
|
b, err := json.Marshal(r)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return string(b), nil
|
||||||
|
}
|
||||||
+26
-20
@@ -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,13 +58,10 @@ 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 !policyChanged(t.prev, curr) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
added, removed, changed := diffPolicies(t.prev, curr)
|
|
||||||
log.Infof("MDM policy changed: added=%v removed=%v changed=%v",
|
|
||||||
added, removed, changed)
|
|
||||||
prev := t.prev
|
prev := t.prev
|
||||||
if err := onChange(prev, curr); err != nil {
|
if err := onChange(prev, curr); err != nil {
|
||||||
log.Errorf("MDM policy change handler failed (retrying in 1 minute): %v", err)
|
log.Errorf("MDM policy change handler failed (retrying in 1 minute): %v", err)
|
||||||
@@ -127,3 +124,12 @@ func mapOf(p *Policy) map[string]any {
|
|||||||
}
|
}
|
||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func policyChanged(prev, curr *Policy) bool {
|
||||||
|
if policiesEqual(prev, curr) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
added, removed, changed := diffPolicies(prev, curr)
|
||||||
|
log.Infof("MDM policy changed: added=%v removed=%v changed=%v", added, removed, changed)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|||||||
+38
-29
@@ -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")
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
package mobile
|
package mobile
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
@@ -11,6 +12,7 @@ import (
|
|||||||
log "github.com/sirupsen/logrus"
|
log "github.com/sirupsen/logrus"
|
||||||
|
|
||||||
"github.com/netbirdio/netbird/client/internal/profilemanager"
|
"github.com/netbirdio/netbird/client/internal/profilemanager"
|
||||||
|
"github.com/netbirdio/netbird/client/mdm"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@@ -22,6 +24,9 @@ const (
|
|||||||
profilesSubdir = "profiles"
|
profilesSubdir = "profiles"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// ErrProfilesDisabled marks a profile mutation rejected by MDM policy.
|
||||||
|
var ErrProfilesDisabled = errors.New("profile management is disabled by MDM policy")
|
||||||
|
|
||||||
/*
|
/*
|
||||||
|
|
||||||
<configDir>/ ← app-writable config root
|
<configDir>/ ← app-writable config root
|
||||||
@@ -55,6 +60,7 @@ type ProfileManager struct {
|
|||||||
configDir string
|
configDir string
|
||||||
username string
|
username string
|
||||||
serviceMgr *profilemanager.ServiceManager
|
serviceMgr *profilemanager.ServiceManager
|
||||||
|
mdmLoader *mdm.Loader
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewProfileManager creates a profile manager rooted at configDir, the
|
// NewProfileManager creates a profile manager rooted at configDir, the
|
||||||
@@ -127,6 +133,9 @@ func (pm *ProfileManager) GetActiveProfile() (*Profile, error) {
|
|||||||
// SwitchProfile records the given profile ID as the active profile. The caller
|
// SwitchProfile records the given profile ID as the active profile. The caller
|
||||||
// must stop the VPN tunnel before switching.
|
// must stop the VPN tunnel before switching.
|
||||||
func (pm *ProfileManager) SwitchProfile(id string) error {
|
func (pm *ProfileManager) SwitchProfile(id string) error {
|
||||||
|
if err := pm.checkProfilesAllowed(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
if err := pm.serviceMgr.SetActiveProfileState(&profilemanager.ActiveProfileState{
|
if err := pm.serviceMgr.SetActiveProfileState(&profilemanager.ActiveProfileState{
|
||||||
ID: profilemanager.ID(id),
|
ID: profilemanager.ID(id),
|
||||||
Username: pm.username,
|
Username: pm.username,
|
||||||
@@ -141,6 +150,9 @@ func (pm *ProfileManager) SwitchProfile(id string) error {
|
|||||||
// AddProfile creates a new profile with the given display name and a
|
// AddProfile creates a new profile with the given display name and a
|
||||||
// generated ID. It returns the created profile so the caller learns the ID.
|
// generated ID. It returns the created profile so the caller learns the ID.
|
||||||
func (pm *ProfileManager) AddProfile(displayName string) (*Profile, error) {
|
func (pm *ProfileManager) AddProfile(displayName string) (*Profile, error) {
|
||||||
|
if err := pm.checkProfilesAllowed(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
profile, err := pm.serviceMgr.AddProfile(displayName, pm.username)
|
profile, err := pm.serviceMgr.AddProfile(displayName, pm.username)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("add profile: %w", err)
|
return nil, fmt.Errorf("add profile: %w", err)
|
||||||
@@ -153,6 +165,9 @@ func (pm *ProfileManager) AddProfile(displayName string) (*Profile, error) {
|
|||||||
// RenameProfile changes the display name of the profile identified by id. The
|
// RenameProfile changes the display name of the profile identified by id. The
|
||||||
// on-disk filename (the ID) is left unchanged.
|
// on-disk filename (the ID) is left unchanged.
|
||||||
func (pm *ProfileManager) RenameProfile(id string, newName string) error {
|
func (pm *ProfileManager) RenameProfile(id string, newName string) error {
|
||||||
|
if err := pm.checkProfilesAllowed(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
if err := pm.serviceMgr.RenameProfile(profilemanager.ID(id), pm.username, newName); err != nil {
|
if err := pm.serviceMgr.RenameProfile(profilemanager.ID(id), pm.username, newName); err != nil {
|
||||||
return fmt.Errorf("rename profile: %w", err)
|
return fmt.Errorf("rename profile: %w", err)
|
||||||
}
|
}
|
||||||
@@ -165,6 +180,9 @@ func (pm *ProfileManager) RenameProfile(id string, newName string) error {
|
|||||||
// private key and SSH key from the config, forcing a re-login. The management
|
// private key and SSH key from the config, forcing a re-login. The management
|
||||||
// URL and other settings are preserved.
|
// URL and other settings are preserved.
|
||||||
func (pm *ProfileManager) LogoutProfile(id string) error {
|
func (pm *ProfileManager) LogoutProfile(id string) error {
|
||||||
|
if err := pm.checkProfileLogoutAllowed(id); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
configPath, err := pm.getProfileConfigPath(id)
|
configPath, err := pm.getProfileConfigPath(id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -196,6 +214,9 @@ func (pm *ProfileManager) LogoutProfile(id string) error {
|
|||||||
// RemoveProfile deletes a profile. The default profile and the active profile
|
// RemoveProfile deletes a profile. The default profile and the active profile
|
||||||
// cannot be removed.
|
// cannot be removed.
|
||||||
func (pm *ProfileManager) RemoveProfile(id string) error {
|
func (pm *ProfileManager) RemoveProfile(id string) error {
|
||||||
|
if err := pm.checkProfilesAllowed(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
configPath, err := pm.getProfileConfigPath(id)
|
configPath, err := pm.getProfileConfigPath(id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -267,6 +288,27 @@ func (pm *ProfileManager) GetActiveStateFilePath() (string, error) {
|
|||||||
return pm.GetStateFilePath(activeProfile.ID)
|
return pm.GetStateFilePath(activeProfile.ID)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SetMDMLoader registers the MDM policy source consulted before profile
|
||||||
|
// mutations; a nil loader disables enforcement.
|
||||||
|
func (pm *ProfileManager) SetMDMLoader(loader *mdm.Loader) {
|
||||||
|
pm.mdmLoader = loader
|
||||||
|
}
|
||||||
|
|
||||||
|
func (pm *ProfileManager) checkProfilesAllowed() error {
|
||||||
|
if v, ok := pm.mdmLoader.Load().GetBool(mdm.KeyDisableProfiles); ok && v {
|
||||||
|
return ErrProfilesDisabled
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (pm *ProfileManager) checkProfileLogoutAllowed(id string) error {
|
||||||
|
active, err := pm.serviceMgr.GetActiveProfileState()
|
||||||
|
if err == nil && active.ID.String() == id {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return pm.checkProfilesAllowed()
|
||||||
|
}
|
||||||
|
|
||||||
// profileEmail returns the account email recorded for a profile. Display-only,
|
// profileEmail returns the account email recorded for a profile. Display-only,
|
||||||
// so an unresolvable path degrades to "" rather than an error.
|
// so an unresolvable path degrades to "" rather than an error.
|
||||||
func (pm *ProfileManager) profileEmail(id string) string {
|
func (pm *ProfileManager) profileEmail(id string) string {
|
||||||
|
|||||||
@@ -0,0 +1,83 @@
|
|||||||
|
package mobile
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
|
||||||
|
"github.com/netbirdio/netbird/client/internal/profilemanager"
|
||||||
|
"github.com/netbirdio/netbird/client/mdm"
|
||||||
|
)
|
||||||
|
|
||||||
|
type fakeFetcher struct{ values map[string]any }
|
||||||
|
|
||||||
|
func (f *fakeFetcher) Fetch() map[string]any { return f.values }
|
||||||
|
|
||||||
|
func newTestProfileManager(t *testing.T) *ProfileManager {
|
||||||
|
t.Helper()
|
||||||
|
origDir := profilemanager.DefaultConfigPathDir
|
||||||
|
origPath := profilemanager.DefaultConfigPath
|
||||||
|
origActive := profilemanager.ActiveProfileStatePath
|
||||||
|
t.Cleanup(func() {
|
||||||
|
profilemanager.DefaultConfigPathDir = origDir
|
||||||
|
profilemanager.DefaultConfigPath = origPath
|
||||||
|
profilemanager.ActiveProfileStatePath = origActive
|
||||||
|
})
|
||||||
|
|
||||||
|
configDir := t.TempDir()
|
||||||
|
pm := NewProfileManager(configDir, "mobile")
|
||||||
|
_, err := profilemanager.UpdateOrCreateConfig(profilemanager.ConfigInput{
|
||||||
|
ConfigPath: filepath.Join(configDir, defaultConfigFilename),
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
return pm
|
||||||
|
}
|
||||||
|
|
||||||
|
func privateKeyOf(t *testing.T, pm *ProfileManager, id string) string {
|
||||||
|
t.Helper()
|
||||||
|
path, err := pm.getProfileConfigPath(id)
|
||||||
|
require.NoError(t, err)
|
||||||
|
raw, err := os.ReadFile(path)
|
||||||
|
require.NoError(t, err)
|
||||||
|
var cfg struct{ PrivateKey string }
|
||||||
|
require.NoError(t, json.Unmarshal(raw, &cfg))
|
||||||
|
return cfg.PrivateKey
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLogoutProfile_DisableProfiles(t *testing.T) {
|
||||||
|
pm := newTestProfileManager(t)
|
||||||
|
other, err := pm.AddProfile("work")
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NoError(t, pm.SwitchProfile(profilemanager.DefaultProfileName))
|
||||||
|
require.NotEmpty(t, privateKeyOf(t, pm, profilemanager.DefaultProfileName))
|
||||||
|
require.NotEmpty(t, privateKeyOf(t, pm, other.ID))
|
||||||
|
|
||||||
|
pm.SetMDMLoader(mdm.NewLoader(&fakeFetcher{values: map[string]any{
|
||||||
|
mdm.KeyDisableProfiles: true,
|
||||||
|
}}))
|
||||||
|
|
||||||
|
err = pm.LogoutProfile(other.ID)
|
||||||
|
assert.ErrorIs(t, err, ErrProfilesDisabled)
|
||||||
|
assert.NotEmpty(t, privateKeyOf(t, pm, other.ID))
|
||||||
|
|
||||||
|
require.NoError(t, pm.LogoutProfile(profilemanager.DefaultProfileName))
|
||||||
|
assert.Empty(t, privateKeyOf(t, pm, profilemanager.DefaultProfileName))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLogoutProfile_ProfilesAllowed(t *testing.T) {
|
||||||
|
pm := newTestProfileManager(t)
|
||||||
|
other, err := pm.AddProfile("work")
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NoError(t, pm.SwitchProfile(profilemanager.DefaultProfileName))
|
||||||
|
|
||||||
|
pm.SetMDMLoader(mdm.NewLoader(&fakeFetcher{values: map[string]any{
|
||||||
|
mdm.KeyDisableProfiles: false,
|
||||||
|
}}))
|
||||||
|
|
||||||
|
require.NoError(t, pm.LogoutProfile(other.ID))
|
||||||
|
assert.Empty(t, privateKeyOf(t, pm, other.ID))
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
#!/bin/bash
|
#!/usr/bin/env bash
|
||||||
set -e
|
set -e
|
||||||
|
|
||||||
if ! which realpath >/dev/null 2>&1; then
|
if ! which realpath >/dev/null 2>&1; then
|
||||||
|
|||||||
+36
-187
@@ -3,7 +3,6 @@ package server
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/url"
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
log "github.com/sirupsen/logrus"
|
log "github.com/sirupsen/logrus"
|
||||||
@@ -14,28 +13,6 @@ import (
|
|||||||
"github.com/netbirdio/netbird/client/proto"
|
"github.com/netbirdio/netbird/client/proto"
|
||||||
)
|
)
|
||||||
|
|
||||||
// preSharedKeyRedactedSentinel is the value GetConfig returns in place
|
|
||||||
// of an actual PSK, so a UI that round-trips the field back to the
|
|
||||||
// daemon (via SetConfig / Login) can be distinguished from a deliberate
|
|
||||||
// override. Any incoming PSK that equals this sentinel is treated as
|
|
||||||
// a no-op echo, never as a conflict with the policy.
|
|
||||||
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
|
|
||||||
// the incoming request and the corresponding MDM-enforced value. It
|
|
||||||
// runs only when the field was actually set in the request (presence
|
|
||||||
// already filtered upstream); ok=true reports the policy value, ok=false
|
|
||||||
// means the policy is silent on the key — both are treated as conflicts
|
|
||||||
// to be safe (an MDM key declared as managed must hold a value).
|
|
||||||
type conflictCheck struct {
|
|
||||||
key string
|
|
||||||
check func(*mdm.Policy) (match bool)
|
|
||||||
}
|
|
||||||
|
|
||||||
// onMDMPolicyChange is invoked by the MDM reload ticker every time the
|
// onMDMPolicyChange is invoked by the MDM reload ticker every time the
|
||||||
// OS-native managed-config store reports a diff vs the last observation.
|
// OS-native managed-config store reports a diff vs the last observation.
|
||||||
//
|
//
|
||||||
@@ -168,126 +145,6 @@ func (s *Server) restartEngineForMDMLocked() error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// conflictBool builds a conflictCheck for a boolean MDM key. If p is nil
|
|
||||||
// the field is treated as matching (no override requested); otherwise the
|
|
||||||
// check returns true only when the policy contains the key and its
|
|
||||||
// boolean value equals *p.
|
|
||||||
func conflictBool(key string, p *bool) conflictCheck {
|
|
||||||
return conflictCheck{
|
|
||||||
key: key,
|
|
||||||
check: func(pol *mdm.Policy) bool {
|
|
||||||
if p == nil {
|
|
||||||
return true // absent → match by definition
|
|
||||||
}
|
|
||||||
want, ok := pol.GetBool(key)
|
|
||||||
return ok && want == *p
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func canonicalURL(s string) string {
|
|
||||||
u, err := url.ParseRequestURI(s)
|
|
||||||
if err != nil {
|
|
||||||
return s
|
|
||||||
}
|
|
||||||
if u.Port() == "" {
|
|
||||||
switch u.Scheme {
|
|
||||||
case "https":
|
|
||||||
u.Host += ":443"
|
|
||||||
case "http":
|
|
||||||
u.Host += ":80"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return u.String()
|
|
||||||
}
|
|
||||||
|
|
||||||
// conflictURL is conflictString for URL-typed keys: both sides are
|
|
||||||
// normalized via canonicalURL before comparison.
|
|
||||||
func conflictURL(key, got string) conflictCheck {
|
|
||||||
return conflictCheck{
|
|
||||||
key: key,
|
|
||||||
check: func(pol *mdm.Policy) bool {
|
|
||||||
if got == "" {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
want, ok := pol.GetString(key)
|
|
||||||
return ok && canonicalURL(want) == canonicalURL(got)
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// conflictString builds a conflictCheck for a string MDM key. An empty
|
|
||||||
// `got` is treated as "field not set" (no override requested); otherwise
|
|
||||||
// the check returns true only when the policy contains the key and its
|
|
||||||
// value equals got.
|
|
||||||
func conflictString(key, got string) conflictCheck {
|
|
||||||
return conflictCheck{
|
|
||||||
key: key,
|
|
||||||
check: func(pol *mdm.Policy) bool {
|
|
||||||
if got == "" {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
want, ok := pol.GetString(key)
|
|
||||||
return ok && want == got
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// conflictStringPtr is conflictString for optional proto fields, where an
|
|
||||||
// explicit empty value is still a request to change the setting. If p is
|
|
||||||
// nil the field is treated as matching (no override requested); otherwise
|
|
||||||
// the check returns true only when the policy contains the key and its
|
|
||||||
// value equals *p.
|
|
||||||
func conflictStringPtr(key string, p *string) conflictCheck {
|
|
||||||
return conflictCheck{
|
|
||||||
key: key,
|
|
||||||
check: func(pol *mdm.Policy) bool {
|
|
||||||
if p == nil {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
want, ok := pol.GetString(key)
|
|
||||||
return ok && want == *p
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// conflictInt64 builds a conflictCheck for an integer MDM key. If p is
|
|
||||||
// nil the field is treated as matching; otherwise the check returns
|
|
||||||
// true only when the policy contains the key and its int value equals *p.
|
|
||||||
func conflictInt64(key string, p *int64) conflictCheck {
|
|
||||||
return conflictCheck{
|
|
||||||
key: key,
|
|
||||||
check: func(pol *mdm.Policy) bool {
|
|
||||||
if p == nil {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
want, ok := pol.GetInt(key)
|
|
||||||
return ok && want == *p
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// resolveConflicts walks the per-field checks against the active MDM
|
|
||||||
// policy and returns the names of keys whose requested value diverges
|
|
||||||
// from the policy-enforced value. Keys not present in the policy are
|
|
||||||
// skipped silently (the gate fires only for keys the admin has
|
|
||||||
// actually pushed). Returns nil for an empty policy.
|
|
||||||
func resolveConflicts(policy *mdm.Policy, checks []conflictCheck) []string {
|
|
||||||
if policy.IsEmpty() {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
var conflicts []string
|
|
||||||
for _, c := range checks {
|
|
||||||
if !policy.HasKey(c.key) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if !c.check(policy) {
|
|
||||||
conflicts = append(conflicts, c.key)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return conflicts
|
|
||||||
}
|
|
||||||
|
|
||||||
// mdmManagedFieldConflicts returns the names of MDM-managed keys whose
|
// mdmManagedFieldConflicts returns the names of MDM-managed keys whose
|
||||||
// requested value in the SetConfigRequest differs from the MDM-enforced
|
// requested value in the SetConfigRequest differs from the MDM-enforced
|
||||||
// value. A field set to the same value the policy already enforces is
|
// value. A field set to the same value the policy already enforces is
|
||||||
@@ -301,27 +158,25 @@ func mdmManagedFieldConflicts(msg *proto.SetConfigRequest, policy *mdm.Policy) [
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// PSK round-trip echo: collapse the sentinel to empty so the
|
pskGot := msg.OptionalPreSharedKey
|
||||||
// shared check treats it as "field not set".
|
if pskGot != nil && *pskGot == mdm.PreSharedKeyRedactedSentinel {
|
||||||
pskGot := ""
|
pskGot = nil
|
||||||
if msg.OptionalPreSharedKey != nil && *msg.OptionalPreSharedKey != preSharedKeyRedactedSentinel {
|
|
||||||
pskGot = *msg.OptionalPreSharedKey
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return resolveConflicts(policy, []conflictCheck{
|
return mdm.ResolveConflicts(policy, []mdm.ConflictCheck{
|
||||||
conflictURL(mdm.KeyManagementURL, msg.ManagementUrl),
|
mdm.ConflictURL(mdm.KeyManagementURL, msg.ManagementUrl),
|
||||||
conflictString(mdm.KeyPreSharedKey, pskGot),
|
mdm.ConflictStringPtr(mdm.KeyPreSharedKey, pskGot),
|
||||||
conflictBool(mdm.KeyRosenpassEnabled, msg.RosenpassEnabled),
|
mdm.ConflictBool(mdm.KeyRosenpassEnabled, msg.RosenpassEnabled),
|
||||||
conflictBool(mdm.KeyRosenpassPermissive, msg.RosenpassPermissive),
|
mdm.ConflictBool(mdm.KeyRosenpassPermissive, msg.RosenpassPermissive),
|
||||||
conflictBool(mdm.KeyDisableAutoConnect, msg.DisableAutoConnect),
|
mdm.ConflictBool(mdm.KeyDisableAutoConnect, msg.DisableAutoConnect),
|
||||||
conflictBool(mdm.KeyAllowServerSSH, msg.ServerSSHAllowed),
|
mdm.ConflictBool(mdm.KeyAllowServerSSH, msg.ServerSSHAllowed),
|
||||||
conflictBool(mdm.KeyRemoteJobsAllowed, msg.RemoteJobsAllowed),
|
mdm.ConflictBool(mdm.KeyRemoteJobsAllowed, msg.RemoteJobsAllowed),
|
||||||
conflictBool(mdm.KeyDisableClientRoutes, msg.DisableClientRoutes),
|
mdm.ConflictBool(mdm.KeyDisableClientRoutes, msg.DisableClientRoutes),
|
||||||
conflictBool(mdm.KeyDisableServerRoutes, msg.DisableServerRoutes),
|
mdm.ConflictBool(mdm.KeyDisableServerRoutes, msg.DisableServerRoutes),
|
||||||
conflictBool(mdm.KeyBlockInbound, msg.BlockInbound),
|
mdm.ConflictBool(mdm.KeyBlockInbound, msg.BlockInbound),
|
||||||
conflictInt64(mdm.KeyWireguardPort, msg.WireguardPort),
|
mdm.ConflictInt64(mdm.KeyWireguardPort, msg.WireguardPort),
|
||||||
conflictBool(mdm.KeyEnableLocalMetrics, msg.EnableLocalMetrics),
|
mdm.ConflictBool(mdm.KeyEnableLocalMetrics, msg.EnableLocalMetrics),
|
||||||
conflictStringPtr(mdm.KeyLocalMetricsAddress, msg.LocalMetricsAddress),
|
mdm.ConflictStringPtr(mdm.KeyLocalMetricsAddress, msg.LocalMetricsAddress),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -424,34 +279,28 @@ func loginRequestMDMConflicts(msg *proto.LoginRequest, policy *mdm.Policy) []str
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Collapse the two PSK fields + the redaction sentinel down to a
|
pskGot := msg.OptionalPreSharedKey
|
||||||
// single "got" string the shared check can compare against the
|
if pskGot == nil && msg.PreSharedKey != "" { //nolint:staticcheck // SA1019: legacy proto field still accepted by Login
|
||||||
// policy: OptionalPreSharedKey wins if set; PreSharedKey (deprecated)
|
pskGot = &msg.PreSharedKey //nolint:staticcheck // SA1019
|
||||||
// is the fallback; sentinel echo is treated as "field not set".
|
|
||||||
pskGot := ""
|
|
||||||
if msg.OptionalPreSharedKey != nil {
|
|
||||||
pskGot = *msg.OptionalPreSharedKey
|
|
||||||
} else if msg.PreSharedKey != "" { //nolint:staticcheck // SA1019: legacy proto field still accepted by Login
|
|
||||||
pskGot = msg.PreSharedKey //nolint:staticcheck // SA1019
|
|
||||||
}
|
}
|
||||||
if pskGot == preSharedKeyRedactedSentinel {
|
if pskGot != nil && *pskGot == mdm.PreSharedKeyRedactedSentinel {
|
||||||
pskGot = ""
|
pskGot = nil
|
||||||
}
|
}
|
||||||
|
|
||||||
return resolveConflicts(policy, []conflictCheck{
|
return mdm.ResolveConflicts(policy, []mdm.ConflictCheck{
|
||||||
conflictURL(mdm.KeyManagementURL, msg.ManagementUrl),
|
mdm.ConflictURL(mdm.KeyManagementURL, msg.ManagementUrl),
|
||||||
conflictString(mdm.KeyPreSharedKey, pskGot),
|
mdm.ConflictStringPtr(mdm.KeyPreSharedKey, pskGot),
|
||||||
conflictBool(mdm.KeyRosenpassEnabled, msg.RosenpassEnabled),
|
mdm.ConflictBool(mdm.KeyRosenpassEnabled, msg.RosenpassEnabled),
|
||||||
conflictBool(mdm.KeyRosenpassPermissive, msg.RosenpassPermissive),
|
mdm.ConflictBool(mdm.KeyRosenpassPermissive, msg.RosenpassPermissive),
|
||||||
conflictBool(mdm.KeyDisableAutoConnect, msg.DisableAutoConnect),
|
mdm.ConflictBool(mdm.KeyDisableAutoConnect, msg.DisableAutoConnect),
|
||||||
conflictBool(mdm.KeyAllowServerSSH, msg.ServerSSHAllowed),
|
mdm.ConflictBool(mdm.KeyAllowServerSSH, msg.ServerSSHAllowed),
|
||||||
conflictBool(mdm.KeyRemoteJobsAllowed, msg.RemoteJobsAllowed),
|
mdm.ConflictBool(mdm.KeyRemoteJobsAllowed, msg.RemoteJobsAllowed),
|
||||||
conflictBool(mdm.KeyDisableClientRoutes, msg.DisableClientRoutes),
|
mdm.ConflictBool(mdm.KeyDisableClientRoutes, msg.DisableClientRoutes),
|
||||||
conflictBool(mdm.KeyDisableServerRoutes, msg.DisableServerRoutes),
|
mdm.ConflictBool(mdm.KeyDisableServerRoutes, msg.DisableServerRoutes),
|
||||||
conflictBool(mdm.KeyBlockInbound, msg.BlockInbound),
|
mdm.ConflictBool(mdm.KeyBlockInbound, msg.BlockInbound),
|
||||||
conflictInt64(mdm.KeyWireguardPort, msg.WireguardPort),
|
mdm.ConflictInt64(mdm.KeyWireguardPort, msg.WireguardPort),
|
||||||
conflictBool(mdm.KeyEnableLocalMetrics, msg.EnableLocalMetrics),
|
mdm.ConflictBool(mdm.KeyEnableLocalMetrics, msg.EnableLocalMetrics),
|
||||||
conflictStringPtr(mdm.KeyLocalMetricsAddress, msg.LocalMetricsAddress),
|
mdm.ConflictStringPtr(mdm.KeyLocalMetricsAddress, msg.LocalMetricsAddress),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+32
-3
@@ -138,6 +138,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
|
||||||
@@ -246,8 +255,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)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -493,7 +508,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
|
||||||
}
|
}
|
||||||
@@ -636,7 +651,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
|
||||||
}
|
}
|
||||||
@@ -1487,6 +1502,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
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1543,6 +1564,9 @@ func (s *Server) logoutFromProfile(ctx context.Context, profile *profilemanager.
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("profile '%s' not found", profile.ID)
|
return fmt.Errorf("profile '%s' not found", profile.ID)
|
||||||
}
|
}
|
||||||
|
// 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)
|
||||||
}
|
}
|
||||||
@@ -2177,6 +2201,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.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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
s.mdmLoader = mdm.NewLoader(&fakeMDMFetcher{values: values})
|
||||||
}
|
}
|
||||||
|
|
||||||
// setupServerWithProfile mirrors the boilerplate of TestSetConfig_AllFieldsSaved:
|
// setupServerWithProfile mirrors the boilerplate of TestSetConfig_AllFieldsSaved:
|
||||||
@@ -93,12 +119,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,
|
||||||
@@ -110,14 +135,13 @@ 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{
|
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",
|
||||||
mdm.KeyBlockInbound: true,
|
mdm.KeyBlockInbound: true,
|
||||||
mdm.KeyRosenpassEnabled: true,
|
mdm.KeyRosenpassEnabled: true,
|
||||||
}))
|
}))
|
||||||
|
|
||||||
s, ctx, profName, username, _ := setupServerWithProfile(t)
|
|
||||||
|
|
||||||
blockInbound := false
|
blockInbound := false
|
||||||
rosenpassEnabled := false
|
rosenpassEnabled := false
|
||||||
_, err := s.SetConfig(ctx, &proto.SetConfigRequest{
|
_, err := s.SetConfig(ctx, &proto.SetConfigRequest{
|
||||||
@@ -137,13 +161,12 @@ func TestSetConfig_MDMReject_MultipleFields(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestSetConfig_MDMReject_LocalMetrics(t *testing.T) {
|
func TestSetConfig_MDMReject_LocalMetrics(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.KeyEnableLocalMetrics: true,
|
mdm.KeyEnableLocalMetrics: true,
|
||||||
mdm.KeyLocalMetricsAddress: "127.0.0.1:9191",
|
mdm.KeyLocalMetricsAddress: "127.0.0.1:9191",
|
||||||
}))
|
}))
|
||||||
|
|
||||||
s, ctx, profName, username, _ := setupServerWithProfile(t)
|
|
||||||
|
|
||||||
enabled := false
|
enabled := false
|
||||||
addr := "0.0.0.0:9999"
|
addr := "0.0.0.0:9999"
|
||||||
_, err := s.SetConfig(ctx, &proto.SetConfigRequest{
|
_, err := s.SetConfig(ctx, &proto.SetConfigRequest{
|
||||||
@@ -164,12 +187,11 @@ func TestSetConfig_MDMReject_LocalMetrics(t *testing.T) {
|
|||||||
// (the manager falls back to the default), so presence must be honored
|
// (the manager falls back to the default), so presence must be honored
|
||||||
// rather than collapsed to "field not set".
|
// rather than collapsed to "field not set".
|
||||||
func TestSetConfig_MDMReject_LocalMetricsEmptyAddress(t *testing.T) {
|
func TestSetConfig_MDMReject_LocalMetricsEmptyAddress(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.KeyLocalMetricsAddress: "127.0.0.1:9999",
|
mdm.KeyLocalMetricsAddress: "127.0.0.1:9999",
|
||||||
}))
|
}))
|
||||||
|
|
||||||
s, ctx, profName, username, _ := setupServerWithProfile(t)
|
|
||||||
|
|
||||||
addr := ""
|
addr := ""
|
||||||
_, err := s.SetConfig(ctx, &proto.SetConfigRequest{
|
_, err := s.SetConfig(ctx, &proto.SetConfigRequest{
|
||||||
ProfileName: profName,
|
ProfileName: profName,
|
||||||
@@ -181,17 +203,80 @@ func TestSetConfig_MDMReject_LocalMetricsEmptyAddress(t *testing.T) {
|
|||||||
assert.ElementsMatch(t, []string{mdm.KeyLocalMetricsAddress}, v.GetFields())
|
assert.ElementsMatch(t, []string{mdm.KeyLocalMetricsAddress}, v.GetFields())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestSetConfig_MDMReject_EmptyPreSharedKey(t *testing.T) {
|
||||||
|
s, ctx, profName, username, _ := setupServerWithProfile(t)
|
||||||
|
withMDMPolicy(t, s, mdm.NewPolicy(map[string]any{
|
||||||
|
mdm.KeyPreSharedKey: "mdm-enforced-psk",
|
||||||
|
}))
|
||||||
|
|
||||||
|
psk := ""
|
||||||
|
_, err := s.SetConfig(ctx, &proto.SetConfigRequest{
|
||||||
|
ProfileName: profName,
|
||||||
|
Username: username,
|
||||||
|
OptionalPreSharedKey: &psk,
|
||||||
|
})
|
||||||
|
|
||||||
|
v := extractViolation(t, err)
|
||||||
|
assert.ElementsMatch(t, []string{mdm.KeyPreSharedKey}, v.GetFields())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSetConfig_MDMAllow_PreSharedKeySentinelEcho(t *testing.T) {
|
||||||
|
s, ctx, profName, username, _ := setupServerWithProfile(t)
|
||||||
|
withMDMPolicy(t, s, mdm.NewPolicy(map[string]any{
|
||||||
|
mdm.KeyPreSharedKey: "mdm-enforced-psk",
|
||||||
|
}))
|
||||||
|
|
||||||
|
psk := mdm.PreSharedKeyRedactedSentinel
|
||||||
|
resp, err := s.SetConfig(ctx, &proto.SetConfigRequest{
|
||||||
|
ProfileName: profName,
|
||||||
|
Username: username,
|
||||||
|
OptionalPreSharedKey: &psk,
|
||||||
|
})
|
||||||
|
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotNil(t, resp)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoginRequestMDMConflicts_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
|
||||||
|
msg *proto.LoginRequest
|
||||||
|
want []string
|
||||||
|
}{
|
||||||
|
{name: "unset", msg: &proto.LoginRequest{}, want: nil},
|
||||||
|
{name: "optional empty", msg: &proto.LoginRequest{OptionalPreSharedKey: &empty}, want: []string{mdm.KeyPreSharedKey}},
|
||||||
|
{name: "optional sentinel echo", msg: &proto.LoginRequest{OptionalPreSharedKey: &sentinel}, want: nil},
|
||||||
|
{name: "optional same value", msg: &proto.LoginRequest{OptionalPreSharedKey: &same}, want: nil},
|
||||||
|
{name: "optional divergent", msg: &proto.LoginRequest{OptionalPreSharedKey: &other}, want: []string{mdm.KeyPreSharedKey}},
|
||||||
|
{name: "legacy empty is unset", msg: &proto.LoginRequest{PreSharedKey: ""}, want: nil}, //nolint:staticcheck // SA1019: legacy proto field still accepted by Login
|
||||||
|
{name: "legacy sentinel echo", msg: &proto.LoginRequest{PreSharedKey: sentinel}, want: nil}, //nolint:staticcheck // SA1019: legacy proto field still accepted by Login
|
||||||
|
{name: "legacy divergent", msg: &proto.LoginRequest{PreSharedKey: other}, want: []string{mdm.KeyPreSharedKey}}, //nolint:staticcheck // SA1019: legacy proto field still accepted by Login
|
||||||
|
}
|
||||||
|
for _, tc := range tests {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
assert.Equal(t, tc.want, loginRequestMDMConflicts(tc.msg, policy))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestSetConfig_MDMReject_AllOrNothing(t *testing.T) {
|
func TestSetConfig_MDMReject_AllOrNothing(t *testing.T) {
|
||||||
// MDM enforces ManagementURL only; user request touches both the
|
// MDM enforces ManagementURL only; user request touches both the
|
||||||
// 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,
|
||||||
@@ -213,12 +298,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,
|
||||||
@@ -247,12 +331,11 @@ func TestSetConfig_MDMAllow_ManagementURLPortNormalized(t *testing.T) {
|
|||||||
|
|
||||||
for _, tc := range tests {
|
for _, tc := range tests {
|
||||||
t.Run(tc.name, func(t *testing.T) {
|
t.Run(tc.name, func(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: tc.mdmURL,
|
mdm.KeyManagementURL: tc.mdmURL,
|
||||||
}))
|
}))
|
||||||
|
|
||||||
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,
|
||||||
@@ -269,9 +352,8 @@ func TestSetConfig_MDMAllow_ManagementURLPortNormalized(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,
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package auth
|
|||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"slices"
|
||||||
"sync"
|
"sync"
|
||||||
|
|
||||||
log "github.com/sirupsen/logrus"
|
log "github.com/sirupsen/logrus"
|
||||||
@@ -155,6 +156,24 @@ func (a *Authorizer) GetUserIDClaim() string {
|
|||||||
return a.userIDClaim
|
return a.userIDClaim
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Config returns the authorization currently in force. The user list and the
|
||||||
|
// machine-user map are copies; the originals stay in use here.
|
||||||
|
func (a *Authorizer) Config() *Config {
|
||||||
|
a.mu.RLock()
|
||||||
|
defer a.mu.RUnlock()
|
||||||
|
|
||||||
|
machineUsers := make(map[string][]uint32, len(a.machineUsers))
|
||||||
|
for osUser, indexes := range a.machineUsers {
|
||||||
|
machineUsers[osUser] = slices.Clone(indexes)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &Config{
|
||||||
|
UserIDClaim: a.userIDClaim,
|
||||||
|
AuthorizedUsers: slices.Clone(a.authorizedUsers),
|
||||||
|
MachineUsers: machineUsers,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// findUserIndex finds the index of a hashed user ID in the authorized users list
|
// findUserIndex finds the index of a hashed user ID in the authorized users list
|
||||||
// Returns the index and true if found, 0 and false if not found
|
// Returns the index and true if found, 0 and false if not found
|
||||||
func (a *Authorizer) findUserIndex(hashedUserID sshuserhash.UserIDHash) (int, bool) {
|
func (a *Authorizer) findUserIndex(hashedUserID sshuserhash.UserIDHash) (int, bool) {
|
||||||
|
|||||||
+18
-15
@@ -5,7 +5,6 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"net"
|
"net"
|
||||||
"time"
|
|
||||||
|
|
||||||
log "github.com/sirupsen/logrus"
|
log "github.com/sirupsen/logrus"
|
||||||
"golang.org/x/crypto/ssh"
|
"golang.org/x/crypto/ssh"
|
||||||
@@ -13,26 +12,23 @@ import (
|
|||||||
|
|
||||||
// Handshake runs the SSH client handshake on an already dialed conn and
|
// Handshake runs the SSH client handshake on an already dialed conn and
|
||||||
// returns the resulting client. Dialing bounds only the TCP establishment;
|
// returns the resulting client. Dialing bounds only the TCP establishment;
|
||||||
// without a deadline on the socket a peer that accepts and then goes silent
|
// a peer that accepts and then goes silent would block the handshake forever,
|
||||||
// blocks the handshake forever, so the context deadline is applied to conn
|
// so conn is closed as soon as ctx is done, which unblocks the handshake and
|
||||||
// for the duration of the handshake. conn is closed on any error.
|
// surfaces the context error. conn is closed on any error.
|
||||||
func Handshake(ctx context.Context, conn net.Conn, addr string, config *ssh.ClientConfig) (*ssh.Client, error) {
|
func Handshake(ctx context.Context, conn net.Conn, addr string, config *ssh.ClientConfig) (*ssh.Client, error) {
|
||||||
if deadline, ok := ctx.Deadline(); ok {
|
stop := context.AfterFunc(ctx, func() { closeHandshake(conn, "conn on context done") })
|
||||||
if err := conn.SetDeadline(deadline); err != nil {
|
|
||||||
closeHandshake(conn, "conn after deadline error")
|
|
||||||
return nil, fmt.Errorf("set handshake deadline: %w", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
sshConn, chans, reqs, err := ssh.NewClientConn(conn, addr, config)
|
sshConn, chans, reqs, err := ssh.NewClientConn(conn, addr, config)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
closeHandshake(conn, "conn after handshake error")
|
if stop() {
|
||||||
return nil, fmt.Errorf("ssh handshake: %w", err)
|
closeHandshake(conn, "conn after handshake error")
|
||||||
|
}
|
||||||
|
return nil, handshakeError(ctx, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := conn.SetDeadline(time.Time{}); err != nil {
|
if !stop() {
|
||||||
closeHandshake(sshConn, "ssh conn after deadline clear error")
|
closeHandshake(sshConn, "ssh conn after context done")
|
||||||
return nil, fmt.Errorf("clear handshake deadline: %w", err)
|
return nil, fmt.Errorf("ssh handshake: %w", ctx.Err())
|
||||||
}
|
}
|
||||||
|
|
||||||
return ssh.NewClient(sshConn, chans, reqs), nil
|
return ssh.NewClient(sshConn, chans, reqs), nil
|
||||||
@@ -43,3 +39,10 @@ func closeHandshake(c io.Closer, label string) {
|
|||||||
log.Debugf("ssh: close %s: %v", label, err)
|
log.Debugf("ssh: close %s: %v", label, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func handshakeError(ctx context.Context, err error) error {
|
||||||
|
if ctxErr := ctx.Err(); ctxErr != nil {
|
||||||
|
return fmt.Errorf("ssh handshake: %w: %w", ctxErr, err)
|
||||||
|
}
|
||||||
|
return fmt.Errorf("ssh handshake: %w", err)
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,90 @@
|
|||||||
|
package ssh
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"net"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
"golang.org/x/crypto/ssh"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestHandshake_ContextDeadlineWrapped(t *testing.T) {
|
||||||
|
conn := dialSilentServer(t)
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
_, err := Handshake(ctx, conn, conn.RemoteAddr().String(), testClientConfig())
|
||||||
|
require.Error(t, err)
|
||||||
|
require.True(t, errors.Is(err, context.DeadlineExceeded), "expected context.DeadlineExceeded, got: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandshake_ContextCancelUnblocks(t *testing.T) {
|
||||||
|
conn := dialSilentServer(t)
|
||||||
|
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
defer cancel()
|
||||||
|
time.AfterFunc(50*time.Millisecond, cancel)
|
||||||
|
|
||||||
|
errCh := make(chan error, 1)
|
||||||
|
go func() {
|
||||||
|
_, err := Handshake(ctx, conn, conn.RemoteAddr().String(), testClientConfig())
|
||||||
|
errCh <- err
|
||||||
|
}()
|
||||||
|
|
||||||
|
select {
|
||||||
|
case err := <-errCh:
|
||||||
|
require.Error(t, err)
|
||||||
|
require.True(t, errors.Is(err, context.Canceled), "expected context.Canceled, got: %v", err)
|
||||||
|
case <-time.After(5 * time.Second):
|
||||||
|
t.Fatal("handshake did not return after context cancellation")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandshake_NonContextErrorNotWrapped(t *testing.T) {
|
||||||
|
conn := dialSilentServer(t)
|
||||||
|
require.NoError(t, conn.Close())
|
||||||
|
|
||||||
|
_, err := Handshake(context.Background(), conn, conn.RemoteAddr().String(), testClientConfig())
|
||||||
|
require.Error(t, err)
|
||||||
|
require.False(t, errors.Is(err, context.Canceled))
|
||||||
|
require.False(t, errors.Is(err, context.DeadlineExceeded))
|
||||||
|
}
|
||||||
|
|
||||||
|
func testClientConfig() *ssh.ClientConfig {
|
||||||
|
return &ssh.ClientConfig{
|
||||||
|
User: "test",
|
||||||
|
HostKeyCallback: ssh.InsecureIgnoreHostKey(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// dialSilentServer returns a client conn to a server that accepts and never
|
||||||
|
// sends anything, so the SSH handshake blocks until the context is done.
|
||||||
|
func dialSilentServer(t *testing.T) net.Conn {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
listener, err := net.Listen("tcp", "127.0.0.1:0")
|
||||||
|
require.NoError(t, err)
|
||||||
|
t.Cleanup(func() { _ = listener.Close() })
|
||||||
|
|
||||||
|
done := make(chan struct{})
|
||||||
|
t.Cleanup(func() { close(done) })
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
c, err := listener.Accept()
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer func() { _ = c.Close() }()
|
||||||
|
<-done
|
||||||
|
}()
|
||||||
|
|
||||||
|
conn, err := net.Dial("tcp", listener.Addr().String())
|
||||||
|
require.NoError(t, err)
|
||||||
|
t.Cleanup(func() { _ = conn.Close() })
|
||||||
|
|
||||||
|
return conn
|
||||||
|
}
|
||||||
@@ -197,6 +197,12 @@ type Config struct {
|
|||||||
|
|
||||||
// HostKey is the SSH server host key in PEM format
|
// HostKey is the SSH server host key in PEM format
|
||||||
HostKeyPEM []byte
|
HostKeyPEM []byte
|
||||||
|
|
||||||
|
// Auth is the fine-grained authorization to open with. Nil starts with an
|
||||||
|
// empty authorizer, which authorizes nobody until UpdateSSHAuth is called.
|
||||||
|
// Setting it here rather than afterwards means the server never accepts a
|
||||||
|
// login before it knows who is allowed.
|
||||||
|
Auth *sshauth.Config
|
||||||
}
|
}
|
||||||
|
|
||||||
// SessionInfo contains information about an active SSH session
|
// SessionInfo contains information about an active SSH session
|
||||||
@@ -220,7 +226,11 @@ func New(config *Config) *Server {
|
|||||||
connections: make(map[connKey]*connState),
|
connections: make(map[connKey]*connState),
|
||||||
jwtEnabled: config.JWT != nil,
|
jwtEnabled: config.JWT != nil,
|
||||||
jwtConfig: config.JWT,
|
jwtConfig: config.JWT,
|
||||||
authorizer: sshauth.NewAuthorizer(), // Initialize with empty config
|
authorizer: sshauth.NewAuthorizer(),
|
||||||
|
}
|
||||||
|
|
||||||
|
if config.Auth != nil {
|
||||||
|
s.authorizer.Update(config.Auth)
|
||||||
}
|
}
|
||||||
|
|
||||||
return s
|
return s
|
||||||
@@ -461,6 +471,27 @@ func (s *Server) UpdateSSHAuth(config *sshauth.Config) {
|
|||||||
s.authorizer.Update(config)
|
s.authorizer.Update(config)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// JWTConfig returns the JWT authentication this server was built with, or nil
|
||||||
|
// when JWT authentication is disabled.
|
||||||
|
func (s *Server) JWTConfig() *JWTConfig {
|
||||||
|
s.mu.RLock()
|
||||||
|
defer s.mu.RUnlock()
|
||||||
|
return s.jwtConfig
|
||||||
|
}
|
||||||
|
|
||||||
|
// AuthConfig returns the fine-grained authorization currently in force, or nil
|
||||||
|
// when the server has no authorizer.
|
||||||
|
func (s *Server) AuthConfig() *sshauth.Config {
|
||||||
|
s.mu.RLock()
|
||||||
|
authorizer := s.authorizer
|
||||||
|
s.mu.RUnlock()
|
||||||
|
|
||||||
|
if authorizer == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return authorizer.Config()
|
||||||
|
}
|
||||||
|
|
||||||
// ensureJWTValidator initializes the JWT validator and extractor if not already initialized
|
// ensureJWTValidator initializes the JWT validator and extractor if not already initialized
|
||||||
func (s *Server) ensureJWTValidator() error {
|
func (s *Server) ensureJWTValidator() error {
|
||||||
s.mu.RLock()
|
s.mu.RLock()
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
package system
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net/netip"
|
||||||
|
"slices"
|
||||||
|
"sync/atomic"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/netbirdio/netbird/shared/management/proto"
|
||||||
|
)
|
||||||
|
|
||||||
|
// InfoSource gathers the system info sent to management, keeping the posture
|
||||||
|
// check results from the last Refresh for the cheap Current snapshots.
|
||||||
|
type InfoSource struct {
|
||||||
|
files atomic.Pointer[[]File]
|
||||||
|
}
|
||||||
|
|
||||||
|
// Refresh gathers the info with the posture checks evaluated, bounded by timeout.
|
||||||
|
func (s *InfoSource) Refresh(ctx context.Context, timeout time.Duration, checks []*proto.Checks, excludeIPs ...netip.Addr) (*Info, bool) {
|
||||||
|
info, ok := GetInfoWithChecksTimeout(ctx, timeout, checks, excludeIPs...)
|
||||||
|
if !ok {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
files := slices.Clone(info.Files)
|
||||||
|
s.files.Store(&files)
|
||||||
|
return info, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Current gathers the info without evaluating the checks, reusing the last Refresh results.
|
||||||
|
func (s *InfoSource) Current(ctx context.Context, excludeIPs ...netip.Addr) *Info {
|
||||||
|
info := GetInfo(ctx)
|
||||||
|
info.removeAddresses(excludeIPs...)
|
||||||
|
if files := s.files.Load(); files != nil {
|
||||||
|
info.Files = *files
|
||||||
|
}
|
||||||
|
return info
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
package system
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
|
||||||
|
"github.com/netbirdio/netbird/shared/management/proto"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestInfoSource_CurrentBeforeRefresh(t *testing.T) {
|
||||||
|
var src InfoSource
|
||||||
|
|
||||||
|
info := src.Current(context.Background())
|
||||||
|
|
||||||
|
assert.Empty(t, info.Files)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestInfoSource_CurrentReusesRefreshedFiles(t *testing.T) {
|
||||||
|
path := filepath.Join(t.TempDir(), "agent")
|
||||||
|
require.NoError(t, os.WriteFile(path, nil, 0o600))
|
||||||
|
checks := []*proto.Checks{{Files: []string{path}}}
|
||||||
|
|
||||||
|
var src InfoSource
|
||||||
|
refreshed, ok := src.Refresh(context.Background(), 15*time.Second, checks)
|
||||||
|
require.True(t, ok)
|
||||||
|
require.Equal(t, []File{{Path: path, Exist: true}}, refreshed.Files)
|
||||||
|
|
||||||
|
info := src.Current(context.Background())
|
||||||
|
|
||||||
|
assert.Equal(t, refreshed.Files, info.Files)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestInfoSource_CurrentExcludesAddresses(t *testing.T) {
|
||||||
|
addrs := GetInfo(context.Background()).NetworkAddresses
|
||||||
|
if len(addrs) == 0 {
|
||||||
|
t.Skip("no network addresses on this host")
|
||||||
|
}
|
||||||
|
excluded := addrs[0].NetIP.Addr()
|
||||||
|
matching := 0
|
||||||
|
for _, addr := range addrs {
|
||||||
|
if addr.NetIP.Addr() == excluded {
|
||||||
|
matching++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var src InfoSource
|
||||||
|
info := src.Current(context.Background(), excluded)
|
||||||
|
|
||||||
|
assert.Len(t, info.NetworkAddresses, len(addrs)-matching)
|
||||||
|
for _, addr := range info.NetworkAddresses {
|
||||||
|
assert.NotEqual(t, excluded, addr.NetIP.Addr())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -72,7 +72,7 @@ func netbirdFootprintExists() bool {
|
|||||||
// retrying autostart entry writes on every launch. A user's later disable in
|
// retrying autostart entry writes on every launch. A user's later disable in
|
||||||
// Settings is never overridden: the marker guarantees at-most-once, ever.
|
// Settings is never overridden: the marker guarantees at-most-once, ever.
|
||||||
func applyAutostartDefault(ctx context.Context, autostart *services.Autostart, prefs *preferences.Store, prefsFileExisted bool) {
|
func applyAutostartDefault(ctx context.Context, autostart *services.Autostart, prefs *preferences.Store, prefsFileExisted bool) {
|
||||||
mdmDisabled := autostartDisabledByMDM(mdm.LoadPolicy())
|
mdmDisabled := autostartDisabledByMDM(mdm.NewLoader(nil).Load())
|
||||||
|
|
||||||
if mdmDisabled {
|
if mdmDisabled {
|
||||||
if enabled, err := autostart.IsEnabled(ctx); err != nil {
|
if enabled, err := autostart.IsEnabled(ctx); err != nil {
|
||||||
|
|||||||
@@ -11,37 +11,18 @@ import (
|
|||||||
|
|
||||||
"github.com/netbirdio/netbird/client/internal/daemonaddr"
|
"github.com/netbirdio/netbird/client/internal/daemonaddr"
|
||||||
"github.com/netbirdio/netbird/client/internal/ipcauth"
|
"github.com/netbirdio/netbird/client/internal/ipcauth"
|
||||||
|
"github.com/netbirdio/netbird/client/mdm"
|
||||||
"github.com/netbirdio/netbird/client/proto"
|
"github.com/netbirdio/netbird/client/proto"
|
||||||
)
|
)
|
||||||
|
|
||||||
type MDMFields struct {
|
// MDMFields is the shared per-key MDM enforcement snapshot; see mdm.Fields.
|
||||||
ManagementURL string `json:"managementURL"`
|
type MDMFields = mdm.Fields
|
||||||
PreSharedKey bool `json:"preSharedKey"`
|
|
||||||
WireguardPort bool `json:"wireguardPort"`
|
|
||||||
RosenpassEnabled bool `json:"rosenpassEnabled"`
|
|
||||||
RosenpassPermissive bool `json:"rosenpassPermissive"`
|
|
||||||
DisableClientRoutes bool `json:"disableClientRoutes"`
|
|
||||||
DisableServerRoutes bool `json:"disableServerRoutes"`
|
|
||||||
AllowServerSSH *bool `json:"allowServerSSH"`
|
|
||||||
DisableAutoConnect bool `json:"disableAutoConnect"`
|
|
||||||
DisableAutostart bool `json:"disableAutostart"`
|
|
||||||
BlockInbound bool `json:"blockInbound"`
|
|
||||||
DisableMetricsCollection bool `json:"disableMetricsCollection"`
|
|
||||||
SplitTunnelMode bool `json:"splitTunnelMode"`
|
|
||||||
SplitTunnelApps bool `json:"splitTunnelApps"`
|
|
||||||
DisableAdvancedView bool `json:"disableAdvancedView"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type Features struct {
|
// Features is the shared feature-gate snapshot; see mdm.Features.
|
||||||
DisableProfiles bool `json:"disableProfiles"`
|
type Features = mdm.Features
|
||||||
DisableNetworks bool `json:"disableNetworks"`
|
|
||||||
DisableUpdateSettings bool `json:"disableUpdateSettings"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type Restrictions struct {
|
// Restrictions is the shared UI enforcement snapshot; see mdm.Restrictions.
|
||||||
MDM MDMFields `json:"mdm"`
|
type Restrictions = mdm.Restrictions
|
||||||
Features Features `json:"features"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// Privilege tells the frontend whether this process may perform the changes the
|
// Privilege tells the frontend whether this process may perform the changes the
|
||||||
// daemon restricts to root/administrator, whether it can ask the operating
|
// daemon restricts to root/administrator, whether it can ask the operating
|
||||||
@@ -383,7 +364,7 @@ func (s *Settings) GetRestrictions(ctx context.Context) (Restrictions, error) {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
applyMDMRestrictions(&r.MDM, cfgResp)
|
applyMDMRestrictions(&r.MDM, cfgResp)
|
||||||
r.MDM.DisableAdvancedView = featResp.GetDisableAdvancedView()
|
r.MDM.DisableAdvancedView = featResp.DisableAdvancedView
|
||||||
return r, nil
|
return r, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -411,9 +392,6 @@ func applyMDMRestrictions(mdm *MDMFields, cfgResp *proto.GetConfigResponse) {
|
|||||||
if v.Field(i).Kind() != reflect.Bool {
|
if v.Field(i).Kind() != reflect.Bool {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if t.Field(i).Name == "DisableAdvancedView" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if _, ok := set[t.Field(i).Tag.Get("json")]; ok {
|
if _, ok := set[t.Field(i).Tag.Get("json")]; ok {
|
||||||
v.Field(i).SetBool(true)
|
v.Field(i).SetBool(true)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -365,7 +365,6 @@ func setupServerHooks(servers *serverInstances, cfg *CombinedConfig) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func startServers(wg *sync.WaitGroup, srv *relayServer.Server, httpHealthcheck *healthcheck.Server, stunServer *stun.Server, metricsServer *sharedMetrics.Metrics) {
|
func startServers(wg *sync.WaitGroup, srv *relayServer.Server, httpHealthcheck *healthcheck.Server, stunServer *stun.Server, metricsServer *sharedMetrics.Metrics) {
|
||||||
@@ -539,7 +538,7 @@ func createManagementServer(cfg *CombinedConfig, mgmtConfig *nbconfig.Config) (m
|
|||||||
&mgmtServer.Config{
|
&mgmtServer.Config{
|
||||||
NbConfig: mgmtConfig,
|
NbConfig: mgmtConfig,
|
||||||
DNSDomain: "",
|
DNSDomain: "",
|
||||||
MgmtSingleAccModeDomain: "",
|
MgmtSingleAccModeDomain: mgmtServer.DefaultSelfHostedDomain,
|
||||||
AutoResolveDomains: true,
|
AutoResolveDomains: true,
|
||||||
MgmtPort: mgmtPort,
|
MgmtPort: mgmtPort,
|
||||||
MgmtMetricsPort: cfg.Server.MetricsPort,
|
MgmtMetricsPort: cfg.Server.MetricsPort,
|
||||||
@@ -554,7 +553,7 @@ func createManagementServer(cfg *CombinedConfig, mgmtConfig *nbconfig.Config) (m
|
|||||||
}
|
}
|
||||||
|
|
||||||
// createCombinedHandler creates an HTTP handler that multiplexes Management, Signal (via wsproxy), and Relay WebSocket traffic
|
// createCombinedHandler creates an HTTP handler that multiplexes Management, Signal (via wsproxy), and Relay WebSocket traffic
|
||||||
func createCombinedHandler(grpcServer *grpc.Server, httpHandler http.Handler, idpHandler http.Handler, relaySrv *relayServer.Server, meter metric.Meter, cfg *CombinedConfig) http.Handler {
|
func createCombinedHandler(grpcServer *grpc.Server, httpHandler, idpHandler http.Handler, relaySrv *relayServer.Server, meter metric.Meter, cfg *CombinedConfig) http.Handler {
|
||||||
wsProxy := wsproxyserver.New(grpcServer, wsproxyserver.WithOTelMeter(meter))
|
wsProxy := wsproxyserver.New(grpcServer, wsproxyserver.WithOTelMeter(meter))
|
||||||
|
|
||||||
var relayAcceptFn func(conn listener.Conn)
|
var relayAcceptFn func(conn listener.Conn)
|
||||||
|
|||||||
@@ -1,2 +1,2 @@
|
|||||||
#!/bin/bash
|
#!/usr/bin/env bash
|
||||||
protoc -I testprotos/ testprotos/testproto.proto --go_out=.
|
protoc -I testprotos/ testprotos/testproto.proto --go_out=.
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
#!/bin/bash
|
#!/usr/bin/env bash
|
||||||
set -e
|
set -e
|
||||||
|
|
||||||
if ! which realpath > /dev/null 2>&1
|
if ! which realpath > /dev/null 2>&1
|
||||||
|
|||||||
@@ -19,6 +19,14 @@ func TestGetNetworkRouters(t *testing.T) {
|
|||||||
execQuery(t, ctx,
|
execQuery(t, ctx,
|
||||||
`insert into network_routers (id, account_id, public_id, peer, network_id, masquerade, metric, enabled, peer_groups)
|
`insert into network_routers (id, account_id, public_id, peer, network_id, masquerade, metric, enabled, peer_groups)
|
||||||
VALUES('test-nr-id-2','account-1','public-id-2','','network-id-2',TRUE,333,TRUE,'["group-two-resources-id","group-no-resources-id"]')`)
|
VALUES('test-nr-id-2','account-1','public-id-2','','network-id-2',TRUE,333,TRUE,'["group-two-resources-id","group-no-resources-id"]')`)
|
||||||
|
// empty peer_groups
|
||||||
|
execQuery(t, ctx,
|
||||||
|
`insert into network_routers (id, account_id, public_id, peer, network_id, masquerade, metric, enabled, peer_groups)
|
||||||
|
VALUES('test-nr-id-3','account-1','public-id-3','peer-id-3','network-id-3',TRUE,999,TRUE,'[]')`)
|
||||||
|
// nil peer_groups
|
||||||
|
execQuery(t, ctx,
|
||||||
|
`insert into network_routers (id, account_id, public_id, peer, network_id, masquerade, metric, enabled, peer_groups)
|
||||||
|
VALUES('test-nr-id-4','account-1','public-id-4','peer-id-4','network-id-4',TRUE,999,TRUE,null)`)
|
||||||
|
|
||||||
routers, err := conn(t, ctx).GetNetworkRouters(ctx, "account-1")
|
routers, err := conn(t, ctx).GetNetworkRouters(ctx, "account-1")
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
@@ -30,4 +38,8 @@ func TestGetNetworkRouters(t *testing.T) {
|
|||||||
map[string]*nmdata.NetworkRouter{
|
map[string]*nmdata.NetworkRouter{
|
||||||
"peer-id-2": {PublicID: "public-id-2", Masquerade: true, Metric: 333, Enabled: true, PeerGroups: []string{"group-two-resources-id", "group-no-resources-id"}},
|
"peer-id-2": {PublicID: "public-id-2", Masquerade: true, Metric: 333, Enabled: true, PeerGroups: []string{"group-two-resources-id", "group-no-resources-id"}},
|
||||||
"peer-id-3": {PublicID: "public-id-2", Masquerade: true, Metric: 333, Enabled: true, PeerGroups: []string{"group-two-resources-id", "group-no-resources-id"}}})
|
"peer-id-3": {PublicID: "public-id-2", Masquerade: true, Metric: 333, Enabled: true, PeerGroups: []string{"group-two-resources-id", "group-no-resources-id"}}})
|
||||||
|
assert.Equal(t, routers["network-id-3"],
|
||||||
|
map[string]*nmdata.NetworkRouter{"peer-id-3": {PublicID: "public-id-3", Masquerade: true, Metric: 999, Enabled: true, PeerGroups: []string{}}})
|
||||||
|
assert.Equal(t, routers["network-id-4"],
|
||||||
|
map[string]*nmdata.NetworkRouter{"peer-id-4": {PublicID: "public-id-4", Masquerade: true, Metric: 999, Enabled: true, PeerGroups: nil}})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,6 +21,14 @@ func TestGetAllowedUsers(t *testing.T) {
|
|||||||
execQuery(t, ctx,
|
execQuery(t, ctx,
|
||||||
`insert into users (id, name, account_id, auto_groups, blocked, is_service_user)
|
`insert into users (id, name, account_id, auto_groups, blocked, is_service_user)
|
||||||
VALUES('user-3','user-3','account-1','["group-two-resources-id"]',false,false)`)
|
VALUES('user-3','user-3','account-1','["group-two-resources-id"]',false,false)`)
|
||||||
|
// empty auto_groups; shouldn't error out
|
||||||
|
execQuery(t, ctx,
|
||||||
|
`insert into users (id, name, account_id, auto_groups, blocked, is_service_user)
|
||||||
|
VALUES('user-31','user-31','account-1','[]',false,false)`)
|
||||||
|
// null auto_groups; shouldn't error out
|
||||||
|
execQuery(t, ctx,
|
||||||
|
`insert into users (id, name, account_id, auto_groups, blocked, is_service_user)
|
||||||
|
VALUES('user-32','user-32','account-1',null,false,false)`)
|
||||||
// shouldn't be included as it's blocked
|
// shouldn't be included as it's blocked
|
||||||
execQuery(t, ctx,
|
execQuery(t, ctx,
|
||||||
`insert into users (id, name, account_id, auto_groups, blocked, is_service_user)
|
`insert into users (id, name, account_id, auto_groups, blocked, is_service_user)
|
||||||
@@ -43,15 +51,17 @@ func TestGetAllowedUsers(t *testing.T) {
|
|||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
|
|
||||||
assert.Equal(t, userIdx, map[string]struct{}{
|
assert.Equal(t, userIdx, map[string]struct{}{
|
||||||
"user-1": {},
|
"user-1": {},
|
||||||
"user-2": {},
|
"user-2": {},
|
||||||
"user-3": {},
|
"user-3": {},
|
||||||
|
"user-31": {},
|
||||||
|
"user-32": {},
|
||||||
})
|
})
|
||||||
assert.Equal(t, groupIdToUserIds, map[string][]string{
|
assert.Equal(t, groupIdToUserIds, map[string][]string{
|
||||||
"group-one-resource-id": {"user-1", "user-2"},
|
"group-one-resource-id": {"user-1", "user-2"},
|
||||||
"group-two-resources-id": {"user-2", "user-3"},
|
"group-two-resources-id": {"user-2", "user-3"},
|
||||||
"all-group-1": {"user-1", "user-2", "user-3"},
|
"all-group-1": {"user-1", "user-2", "user-3", "user-31", "user-32"},
|
||||||
"all-group-2": {"user-1", "user-2", "user-3"},
|
"all-group-2": {"user-1", "user-2", "user-3", "user-31", "user-32"},
|
||||||
"all-group-3": {"user-1", "user-2", "user-3"},
|
"all-group-3": {"user-1", "user-2", "user-3", "user-31", "user-32"},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -236,6 +236,9 @@ func ApplyEmbeddedIdPConfig(ctx context.Context, cfg *nbconfig.Config) error {
|
|||||||
// Embedded IdP requires single account mode - multiple account mode is not supported
|
// Embedded IdP requires single account mode - multiple account mode is not supported
|
||||||
return fmt.Errorf("embedded IdP requires single account mode; multiple account mode is not supported with embedded IdP. Please remove --disable-single-account-mode flag")
|
return fmt.Errorf("embedded IdP requires single account mode; multiple account mode is not supported with embedded IdP. Please remove --disable-single-account-mode flag")
|
||||||
}
|
}
|
||||||
|
if mgmtSingleAccModeDomain == "" {
|
||||||
|
return fmt.Errorf("embedded IdP requires single account mode; --single-account-mode-domain must not be empty")
|
||||||
|
}
|
||||||
// Enable user deletion from IDP by default if EmbeddedIdP is enabled
|
// Enable user deletion from IDP by default if EmbeddedIdP is enabled
|
||||||
userDeleteFromIDPEnabled = true
|
userDeleteFromIDPEnabled = true
|
||||||
|
|
||||||
|
|||||||
@@ -5,8 +5,12 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/netbirdio/netbird/shared/management/grpc"
|
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
|
||||||
|
nbconfig "github.com/netbirdio/netbird/management/internals/server/config"
|
||||||
|
"github.com/netbirdio/netbird/management/server/idp"
|
||||||
|
"github.com/netbirdio/netbird/shared/management/grpc"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@@ -60,6 +64,22 @@ func Test_LoadMgmtConfig_Empty(t *testing.T) {
|
|||||||
assert.Nil(t, cfg.PerAccountHighestSupportedSyncMessageVersion)
|
assert.Nil(t, cfg.PerAccountHighestSupportedSyncMessageVersion)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestApplyEmbeddedIdPConfigRequiresSingleAccountDomain(t *testing.T) {
|
||||||
|
previousDomain := mgmtSingleAccModeDomain
|
||||||
|
previousDisabled := disableSingleAccMode
|
||||||
|
t.Cleanup(func() {
|
||||||
|
mgmtSingleAccModeDomain = previousDomain
|
||||||
|
disableSingleAccMode = previousDisabled
|
||||||
|
})
|
||||||
|
|
||||||
|
mgmtSingleAccModeDomain = ""
|
||||||
|
disableSingleAccMode = false
|
||||||
|
cfg := &nbconfig.Config{
|
||||||
|
EmbeddedIdP: &idp.EmbeddedIdPConfig{Enabled: true},
|
||||||
|
}
|
||||||
|
require.ErrorContains(t, ApplyEmbeddedIdPConfig(context.Background(), cfg), "embedded IdP requires single account mode")
|
||||||
|
}
|
||||||
|
|
||||||
func createConfig(config string) (string, error) {
|
func createConfig(config string) (string, error) {
|
||||||
tmpfile, err := os.CreateTemp("", "config.json")
|
tmpfile, err := os.CreateTemp("", "config.json")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -7,11 +7,10 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
cachestore "github.com/eko/gocache/lib/v4/store"
|
|
||||||
"go.uber.org/mock/gomock"
|
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
"go.opentelemetry.io/otel/metric/noop"
|
"go.opentelemetry.io/otel/metric/noop"
|
||||||
|
"go.uber.org/mock/gomock"
|
||||||
|
|
||||||
"github.com/netbirdio/netbird/management/internals/modules/reverseproxy/proxy"
|
"github.com/netbirdio/netbird/management/internals/modules/reverseproxy/proxy"
|
||||||
proxymanager "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/proxy/manager"
|
proxymanager "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/proxy/manager"
|
||||||
@@ -31,7 +30,7 @@ import (
|
|||||||
"github.com/netbirdio/netbird/shared/management/status"
|
"github.com/netbirdio/netbird/shared/management/status"
|
||||||
)
|
)
|
||||||
|
|
||||||
func testCacheStore(t *testing.T) cachestore.StoreInterface {
|
func testCacheStore(t *testing.T) nbcache.Store {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
s, err := nbcache.NewStore(context.Background(), 30*time.Minute, 10*time.Minute, 100)
|
s, err := nbcache.NewStore(context.Background(), 30*time.Minute, 10*time.Minute, 100)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
@@ -295,6 +294,7 @@ func TestPersistNewService(t *testing.T) {
|
|||||||
assert.Equal(t, status.AlreadyExists, sErr.Type())
|
assert.Equal(t, status.AlreadyExists, sErr.Type())
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestPreserveExistingAuthSecrets(t *testing.T) {
|
func TestPreserveExistingAuthSecrets(t *testing.T) {
|
||||||
mgr := &Manager{}
|
mgr := &Manager{}
|
||||||
|
|
||||||
|
|||||||
@@ -55,6 +55,8 @@ const (
|
|||||||
SourceEphemeral = "ephemeral"
|
SourceEphemeral = "ephemeral"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
var ErrUnsupportedIPAddressUpstreamHost = errors.New("unsupported ip address for a direct upstream host")
|
||||||
|
|
||||||
type TargetOptions struct {
|
type TargetOptions struct {
|
||||||
SkipTLSVerify bool `json:"skip_tls_verify"`
|
SkipTLSVerify bool `json:"skip_tls_verify"`
|
||||||
RequestTimeout time.Duration `json:"request_timeout,omitempty"`
|
RequestTimeout time.Duration `json:"request_timeout,omitempty"`
|
||||||
@@ -388,6 +390,7 @@ func (s *Service) ToProtoMapping(operation Operation, authToken string, oidcConf
|
|||||||
|
|
||||||
if s.Auth.BearerAuth != nil && s.Auth.BearerAuth.Enabled {
|
if s.Auth.BearerAuth != nil && s.Auth.BearerAuth.Enabled {
|
||||||
auth.Oidc = true
|
auth.Oidc = true
|
||||||
|
auth.AllowedGroupIds = append([]string(nil), s.Auth.BearerAuth.DistributionGroups...)
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, h := range s.Auth.HeaderAuths {
|
for _, h := range s.Auth.HeaderAuths {
|
||||||
@@ -961,8 +964,8 @@ func (s *Service) validateHTTPTargets() error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
case TargetTypeSubnet:
|
case TargetTypeSubnet:
|
||||||
if target.Host == "" {
|
if err := validateSubnetTarget(i, target); err != nil {
|
||||||
return fmt.Errorf("target %d has empty host but target_type is %q", i, target.TargetType)
|
return err
|
||||||
}
|
}
|
||||||
case TargetTypeCluster:
|
case TargetTypeCluster:
|
||||||
if err := validateClusterTarget(i, target); err != nil {
|
if err := validateClusterTarget(i, target); err != nil {
|
||||||
@@ -985,6 +988,34 @@ func (s *Service) validateHTTPTargets() error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func validateSubnetTarget(idx int, target *Target) error {
|
||||||
|
host := strings.TrimSpace(target.Host)
|
||||||
|
if host == "" {
|
||||||
|
return fmt.Errorf("target %d has empty host but target_type is %q", idx, target.TargetType)
|
||||||
|
}
|
||||||
|
if strings.ContainsAny(host, " \t/") {
|
||||||
|
return fmt.Errorf("target %d: host %q contains invalid characters", idx, host)
|
||||||
|
}
|
||||||
|
if _, _, err := net.SplitHostPort(host); err == nil {
|
||||||
|
return fmt.Errorf("target %d: host %q must not include a port (set target.port instead)", idx, host)
|
||||||
|
}
|
||||||
|
noBrackets := strings.TrimSuffix(strings.TrimPrefix(host, "["), "]")
|
||||||
|
maybeip, err := netip.ParseAddr(noBrackets)
|
||||||
|
if err != nil { // not an ip
|
||||||
|
return nil //nolint:nilerr
|
||||||
|
}
|
||||||
|
if maybeip.Zone() != "" {
|
||||||
|
return fmt.Errorf("invalid direct upstream host ip %s %w", maybeip.String(), ErrUnsupportedIPAddressUpstreamHost)
|
||||||
|
}
|
||||||
|
if !target.Options.DirectUpstream {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if maybeip.IsLoopback() || maybeip.IsMulticast() || maybeip.IsLinkLocalUnicast() {
|
||||||
|
return fmt.Errorf("invalid direct upstream host ip %s %w", maybeip.String(), ErrUnsupportedIPAddressUpstreamHost)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// validateClusterTarget cluster targets should not have empty hosts and should have direct upstream enabled.
|
// validateClusterTarget cluster targets should not have empty hosts and should have direct upstream enabled.
|
||||||
func validateClusterTarget(idx int, target *Target) error {
|
func validateClusterTarget(idx int, target *Target) error {
|
||||||
host := strings.TrimSpace(target.Host)
|
host := strings.TrimSpace(target.Host)
|
||||||
@@ -1019,6 +1050,15 @@ func validateDirectUpstreamHost(idx int, target *Target) error {
|
|||||||
if _, _, err := net.SplitHostPort(host); err == nil {
|
if _, _, err := net.SplitHostPort(host); err == nil {
|
||||||
return fmt.Errorf("target %d: host %q must not include a port (set target.port instead)", idx, host)
|
return fmt.Errorf("target %d: host %q must not include a port (set target.port instead)", idx, host)
|
||||||
}
|
}
|
||||||
|
noBrackets := strings.TrimSuffix(strings.TrimPrefix(host, "["), "]")
|
||||||
|
maybeip, err := netip.ParseAddr(noBrackets)
|
||||||
|
if err != nil { // not an ip
|
||||||
|
return nil //nolint:nilerr
|
||||||
|
}
|
||||||
|
if maybeip.Zone() != "" || maybeip.IsLoopback() || maybeip.IsMulticast() || maybeip.IsLinkLocalUnicast() {
|
||||||
|
return fmt.Errorf("invalid direct upstream host ip %s %w", maybeip.String(), ErrUnsupportedIPAddressUpstreamHost)
|
||||||
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -216,6 +216,64 @@ func TestValidateTargetOptions_CustomHeaders(t *testing.T) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestValidate_DirectUpstreamHost(t *testing.T) {
|
||||||
|
target := Target{TargetId: "id-1", TargetType: TargetTypePeer, Host: "10.0.0.1", Port: 80, Protocol: "http", Enabled: true, Options: TargetOptions{DirectUpstream: true}}
|
||||||
|
assert.ErrorIs(t, validateDirectUpstreamHost(0, targetWithHost(&target, "127.0.0.2")), ErrUnsupportedIPAddressUpstreamHost)
|
||||||
|
assert.NotNil(t, validateDirectUpstreamHost(0, targetWithHost(&target, "127.0.0.2:80")), ErrUnsupportedIPAddressUpstreamHost)
|
||||||
|
assert.ErrorIs(t, validateDirectUpstreamHost(0, targetWithHost(&target, "::1")), ErrUnsupportedIPAddressUpstreamHost)
|
||||||
|
assert.ErrorIs(t, validateDirectUpstreamHost(0, targetWithHost(&target, "::1%lo0")), ErrUnsupportedIPAddressUpstreamHost)
|
||||||
|
assert.ErrorIs(t, validateDirectUpstreamHost(0, targetWithHost(&target, "[::1]")), ErrUnsupportedIPAddressUpstreamHost)
|
||||||
|
assert.NotNil(t, validateDirectUpstreamHost(0, targetWithHost(&target, "[::1]:80")), ErrUnsupportedIPAddressUpstreamHost)
|
||||||
|
assert.ErrorIs(t, validateDirectUpstreamHost(0, targetWithHost(&target, "[::1%lo0]")), ErrUnsupportedIPAddressUpstreamHost)
|
||||||
|
assert.NotNil(t, validateDirectUpstreamHost(0, targetWithHost(&target, "[::1%lo0]:80")), ErrUnsupportedIPAddressUpstreamHost)
|
||||||
|
assert.ErrorIs(t, validateDirectUpstreamHost(0, targetWithHost(&target, "169.254.100.100")), ErrUnsupportedIPAddressUpstreamHost)
|
||||||
|
assert.ErrorIs(t, validateDirectUpstreamHost(0, targetWithHost(&target, "fe80::1")), ErrUnsupportedIPAddressUpstreamHost)
|
||||||
|
assert.ErrorIs(t, validateDirectUpstreamHost(0, targetWithHost(&target, "[fe80::1]")), ErrUnsupportedIPAddressUpstreamHost)
|
||||||
|
assert.ErrorIs(t, validateDirectUpstreamHost(0, targetWithHost(&target, "224.100.100.100")), ErrUnsupportedIPAddressUpstreamHost)
|
||||||
|
assert.ErrorIs(t, validateDirectUpstreamHost(0, targetWithHost(&target, "ff00::ffff")), ErrUnsupportedIPAddressUpstreamHost)
|
||||||
|
assert.ErrorIs(t, validateDirectUpstreamHost(0, targetWithHost(&target, "[ff00::ffff]")), ErrUnsupportedIPAddressUpstreamHost)
|
||||||
|
|
||||||
|
// empty host
|
||||||
|
assert.Nil(t, validateDirectUpstreamHost(0, &Target{Options: TargetOptions{DirectUpstream: true}, Host: " "}))
|
||||||
|
// host with a space
|
||||||
|
assert.NotNil(t, validateDirectUpstreamHost(0, &Target{Options: TargetOptions{DirectUpstream: true}, Host: "with space"}))
|
||||||
|
// host with a tab
|
||||||
|
assert.NotNil(t, validateDirectUpstreamHost(0, &Target{Options: TargetOptions{DirectUpstream: true}, Host: "with\ttab"}))
|
||||||
|
// host with a slash
|
||||||
|
assert.NotNil(t, validateDirectUpstreamHost(0, &Target{Options: TargetOptions{DirectUpstream: true}, Host: "with/slash"}))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidate_ValidateSubnetTarget(t *testing.T) {
|
||||||
|
target := Target{TargetId: "id-1", TargetType: TargetTypeSubnet, Host: "10.0.0.1", Port: 80, Protocol: "http", Enabled: true, Options: TargetOptions{DirectUpstream: true}}
|
||||||
|
assert.ErrorIs(t, validateSubnetTarget(0, targetWithHost(&target, "127.0.0.2")), ErrUnsupportedIPAddressUpstreamHost)
|
||||||
|
assert.NotNil(t, validateSubnetTarget(0, targetWithHost(&target, "127.0.0.2:80")), ErrUnsupportedIPAddressUpstreamHost)
|
||||||
|
assert.ErrorIs(t, validateSubnetTarget(0, targetWithHost(&target, "::1")), ErrUnsupportedIPAddressUpstreamHost)
|
||||||
|
assert.NotNil(t, validateSubnetTarget(0, targetWithHost(&target, "[::1]:80")), ErrUnsupportedIPAddressUpstreamHost)
|
||||||
|
assert.ErrorIs(t, validateSubnetTarget(0, targetWithHost(&target, "::1%lo0")), ErrUnsupportedIPAddressUpstreamHost)
|
||||||
|
assert.ErrorIs(t, validateSubnetTarget(0, targetWithHost(&target, "[::1%lo0]")), ErrUnsupportedIPAddressUpstreamHost)
|
||||||
|
assert.NotNil(t, validateSubnetTarget(0, targetWithHost(&target, "[::1%lo0]:80")), ErrUnsupportedIPAddressUpstreamHost)
|
||||||
|
assert.ErrorIs(t, validateSubnetTarget(0, targetWithHost(&target, "169.254.100.100")), ErrUnsupportedIPAddressUpstreamHost)
|
||||||
|
assert.ErrorIs(t, validateSubnetTarget(0, targetWithHost(&target, "fe80::1")), ErrUnsupportedIPAddressUpstreamHost)
|
||||||
|
assert.ErrorIs(t, validateSubnetTarget(0, targetWithHost(&target, "[fe80::1]")), ErrUnsupportedIPAddressUpstreamHost)
|
||||||
|
assert.ErrorIs(t, validateSubnetTarget(0, targetWithHost(&target, "224.100.100.100")), ErrUnsupportedIPAddressUpstreamHost)
|
||||||
|
assert.ErrorIs(t, validateSubnetTarget(0, targetWithHost(&target, "ff00::ffff")), ErrUnsupportedIPAddressUpstreamHost)
|
||||||
|
assert.ErrorIs(t, validateSubnetTarget(0, targetWithHost(&target, "[ff00::ffff]")), ErrUnsupportedIPAddressUpstreamHost)
|
||||||
|
|
||||||
|
// empty host
|
||||||
|
assert.NotNil(t, validateSubnetTarget(0, &Target{Options: TargetOptions{DirectUpstream: true}, Host: " "}))
|
||||||
|
// host with a space
|
||||||
|
assert.NotNil(t, validateSubnetTarget(0, &Target{Options: TargetOptions{DirectUpstream: true}, Host: "with space"}))
|
||||||
|
// host with a tab
|
||||||
|
assert.NotNil(t, validateSubnetTarget(0, &Target{Options: TargetOptions{DirectUpstream: true}, Host: "with\ttab"}))
|
||||||
|
// host with a slash
|
||||||
|
assert.NotNil(t, validateSubnetTarget(0, &Target{Options: TargetOptions{DirectUpstream: true}, Host: "with/slash"}))
|
||||||
|
}
|
||||||
|
|
||||||
|
func targetWithHost(t *Target, host string) *Target {
|
||||||
|
t.Host = host
|
||||||
|
return t
|
||||||
|
}
|
||||||
|
|
||||||
func TestToProtoMapping_TargetOptions(t *testing.T) {
|
func TestToProtoMapping_TargetOptions(t *testing.T) {
|
||||||
rp := &Service{
|
rp := &Service{
|
||||||
ID: "svc-1",
|
ID: "svc-1",
|
||||||
@@ -250,6 +308,44 @@ func TestToProtoMapping_TargetOptions(t *testing.T) {
|
|||||||
assert.Equal(t, int64(30), opts.RequestTimeout.Seconds)
|
assert.Equal(t, int64(30), opts.RequestTimeout.Seconds)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestToProtoMapping_AllowedGroupIds covers the list the proxy gates session
|
||||||
|
// cookies on: without it the proxy can only check a cookie's signature, which
|
||||||
|
// makes a token minted for a user outside the groups a bearer credential.
|
||||||
|
func TestToProtoMapping_AllowedGroupIds(t *testing.T) {
|
||||||
|
t.Run("distribution groups reach the proxy", func(t *testing.T) {
|
||||||
|
rp := &Service{
|
||||||
|
ID: "svc-1",
|
||||||
|
AccountID: "acc-1",
|
||||||
|
Domain: "example.com",
|
||||||
|
Auth: AuthConfig{
|
||||||
|
BearerAuth: &BearerAuthConfig{
|
||||||
|
Enabled: true,
|
||||||
|
DistributionGroups: []string{"grp-1", "grp-2"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
pm := rp.ToProtoMapping(Create, "token", proxy.OIDCValidationConfig{})
|
||||||
|
|
||||||
|
assert.True(t, pm.GetAuth().GetOidc())
|
||||||
|
assert.Equal(t, []string{"grp-1", "grp-2"}, pm.GetAuth().GetAllowedGroupIds())
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("a service open to the account carries no groups", func(t *testing.T) {
|
||||||
|
rp := &Service{
|
||||||
|
ID: "svc-1",
|
||||||
|
AccountID: "acc-1",
|
||||||
|
Domain: "example.com",
|
||||||
|
Auth: AuthConfig{
|
||||||
|
BearerAuth: &BearerAuthConfig{Enabled: true},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
pm := rp.ToProtoMapping(Create, "token", proxy.OIDCValidationConfig{})
|
||||||
|
|
||||||
|
assert.True(t, pm.GetAuth().GetOidc())
|
||||||
|
assert.Empty(t, pm.GetAuth().GetAllowedGroupIds(), "an empty list must not restrict access")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
func TestToProtoMapping_NoOptionsWhenDefault(t *testing.T) {
|
func TestToProtoMapping_NoOptionsWhenDefault(t *testing.T) {
|
||||||
rp := &Service{
|
rp := &Service{
|
||||||
ID: "svc-1",
|
ID: "svc-1",
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ const (
|
|||||||
from zones
|
from zones
|
||||||
left join records as r on r.zone_id = zones.id
|
left join records as r on r.zone_id = zones.id
|
||||||
where zones.account_id=$1 and zones.enabled
|
where zones.account_id=$1 and zones.enabled
|
||||||
|
order by zones.id
|
||||||
`
|
`
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -11,9 +11,11 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
|
// Outer join: a groupless router must survive.
|
||||||
GetNetworkRouterQuery = `
|
GetNetworkRouterQuery = `
|
||||||
select public_id, peer, network_id, masquerade, metric, enabled, peer_groups, group_peers.peer_id
|
select public_id, peer, network_id, masquerade, metric, enabled, peer_groups, group_peers.peer_id
|
||||||
from network_routers, json_each(peer_groups)
|
from network_routers
|
||||||
|
left join json_each(network_routers.peer_groups) on true
|
||||||
left join group_peers on group_peers.account_id=? and group_peers.group_id=json_each.value
|
left join group_peers on group_peers.account_id=? and group_peers.group_id=json_each.value
|
||||||
where network_routers.account_id=?
|
where network_routers.account_id=?
|
||||||
`
|
`
|
||||||
|
|||||||
@@ -42,17 +42,20 @@ func (sc *SqliteStoreConn) GetAllowedUsers(ctx context.Context, accountId string
|
|||||||
userIdIdx := make(map[string]struct{})
|
userIdIdx := make(map[string]struct{})
|
||||||
groupIdToUserIds := make(map[string][]string)
|
groupIdToUserIds := make(map[string][]string)
|
||||||
for _, user := range users {
|
for _, user := range users {
|
||||||
|
for _, allgid := range allGroupIds {
|
||||||
|
groupIdToUserIds[allgid] = append(groupIdToUserIds[allgid], user.ID)
|
||||||
|
}
|
||||||
|
userIdIdx[user.ID] = struct{}{}
|
||||||
autogroups := make([]string, 0)
|
autogroups := make([]string, 0)
|
||||||
|
if user.AutoGroups == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
if err := json.Unmarshal(user.AutoGroups, &autogroups); err != nil {
|
if err := json.Unmarshal(user.AutoGroups, &autogroups); err != nil {
|
||||||
return nil, nil, err
|
return nil, nil, err
|
||||||
}
|
}
|
||||||
userIdIdx[user.ID] = struct{}{}
|
|
||||||
for _, groupId := range autogroups {
|
for _, groupId := range autogroups {
|
||||||
groupIdToUserIds[groupId] = append(groupIdToUserIds[groupId], user.ID)
|
groupIdToUserIds[groupId] = append(groupIdToUserIds[groupId], user.ID)
|
||||||
}
|
}
|
||||||
for _, allgid := range allGroupIds {
|
|
||||||
groupIdToUserIds[allgid] = append(groupIdToUserIds[allgid], user.ID)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return userIdIdx, groupIdToUserIds, nil
|
return userIdIdx, groupIdToUserIds, nil
|
||||||
|
|||||||
@@ -21,8 +21,6 @@ import (
|
|||||||
"google.golang.org/grpc/credentials"
|
"google.golang.org/grpc/credentials"
|
||||||
"google.golang.org/grpc/keepalive"
|
"google.golang.org/grpc/keepalive"
|
||||||
|
|
||||||
cachestore "github.com/eko/gocache/lib/v4/store"
|
|
||||||
|
|
||||||
"github.com/netbirdio/netbird/encryption"
|
"github.com/netbirdio/netbird/encryption"
|
||||||
"github.com/netbirdio/netbird/formatter/hook"
|
"github.com/netbirdio/netbird/formatter/hook"
|
||||||
"github.com/netbirdio/netbird/management/internals/modules/agentnetwork"
|
"github.com/netbirdio/netbird/management/internals/modules/agentnetwork"
|
||||||
@@ -75,8 +73,8 @@ func (s *BaseServer) Metrics() telemetry.AppMetrics {
|
|||||||
|
|
||||||
// CacheStore returns a shared cache store backed by Redis or in-memory depending on the environment.
|
// CacheStore returns a shared cache store backed by Redis or in-memory depending on the environment.
|
||||||
// All consumers should reuse this store to avoid creating multiple Redis connections.
|
// All consumers should reuse this store to avoid creating multiple Redis connections.
|
||||||
func (s *BaseServer) CacheStore() cachestore.StoreInterface {
|
func (s *BaseServer) CacheStore() nbcache.Store {
|
||||||
return Create(s, func() cachestore.StoreInterface {
|
return Create(s, func() nbcache.Store {
|
||||||
cs, err := nbcache.NewStore(context.Background(), nbcache.DefaultStoreMaxTimeout, nbcache.DefaultStoreCleanupInterval, nbcache.DefaultStoreMaxConn)
|
cs, err := nbcache.NewStore(context.Background(), nbcache.DefaultStoreMaxTimeout, nbcache.DefaultStoreCleanupInterval, nbcache.DefaultStoreMaxConn)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatalf("failed to create shared cache store: %v", err)
|
log.Fatalf("failed to create shared cache store: %v", err)
|
||||||
|
|||||||
@@ -5,22 +5,23 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/eko/gocache/lib/v4/cache"
|
|
||||||
"github.com/eko/gocache/lib/v4/store"
|
"github.com/eko/gocache/lib/v4/store"
|
||||||
log "github.com/sirupsen/logrus"
|
log "github.com/sirupsen/logrus"
|
||||||
|
|
||||||
|
nbcache "github.com/netbirdio/netbird/management/server/cache"
|
||||||
)
|
)
|
||||||
|
|
||||||
// PKCEVerifierStore manages PKCE verifiers for OAuth flows.
|
// PKCEVerifierStore manages PKCE verifiers for OAuth flows.
|
||||||
// Supports both in-memory and Redis storage via NB_IDP_CACHE_REDIS_ADDRESS env var.
|
// Supports both in-memory and Redis storage via NB_IDP_CACHE_REDIS_ADDRESS env var.
|
||||||
type PKCEVerifierStore struct {
|
type PKCEVerifierStore struct {
|
||||||
cache *cache.Cache[string]
|
cache nbcache.Store
|
||||||
ctx context.Context
|
ctx context.Context
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewPKCEVerifierStore creates a PKCE verifier store using the provided shared cache store.
|
// NewPKCEVerifierStore creates a PKCE verifier store using the provided shared cache store.
|
||||||
func NewPKCEVerifierStore(ctx context.Context, cacheStore store.StoreInterface) *PKCEVerifierStore {
|
func NewPKCEVerifierStore(ctx context.Context, cacheStore nbcache.Store) *PKCEVerifierStore {
|
||||||
return &PKCEVerifierStore{
|
return &PKCEVerifierStore{
|
||||||
cache: cache.New[string](cacheStore),
|
cache: cacheStore,
|
||||||
ctx: ctx,
|
ctx: ctx,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -40,14 +41,14 @@ func (s *PKCEVerifierStore) Store(state, verifier string, ttl time.Duration) err
|
|||||||
// Returns the verifier and true if found, or empty string and false if not found.
|
// Returns the verifier and true if found, or empty string and false if not found.
|
||||||
// This enforces single-use semantics for PKCE verifiers.
|
// This enforces single-use semantics for PKCE verifiers.
|
||||||
func (s *PKCEVerifierStore) LoadAndDelete(state string) (string, bool) {
|
func (s *PKCEVerifierStore) LoadAndDelete(state string) (string, bool) {
|
||||||
verifier, err := s.cache.Get(s.ctx, state)
|
verifier, found, err := s.cache.GetDel(s.ctx, state)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Debugf("PKCE verifier not found for state")
|
log.Warnf("Failed to consume PKCE verifier: %v", err)
|
||||||
return "", false
|
return "", false
|
||||||
}
|
}
|
||||||
|
if !found {
|
||||||
if err := s.cache.Delete(s.ctx, state); err != nil {
|
log.Debug("PKCE verifier not found for state")
|
||||||
log.Warnf("Failed to delete PKCE verifier for state: %v", err)
|
return "", false
|
||||||
}
|
}
|
||||||
|
|
||||||
return verifier, true
|
return verifier, true
|
||||||
|
|||||||
@@ -0,0 +1,85 @@
|
|||||||
|
package grpc
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestPKCEVerifierStoreLoadAndDelete(t *testing.T) {
|
||||||
|
const (
|
||||||
|
state = "state"
|
||||||
|
verifier = "verifier"
|
||||||
|
attempts = 64
|
||||||
|
)
|
||||||
|
|
||||||
|
t.Run("exactly one concurrent caller consumes the verifier", func(t *testing.T) {
|
||||||
|
store := NewPKCEVerifierStore(context.Background(), testCacheStore(t))
|
||||||
|
if err := store.Store(state, verifier, time.Minute); err != nil {
|
||||||
|
t.Fatalf("couldn't store PKCE verifier: %s", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
start := make(chan struct{})
|
||||||
|
type result struct {
|
||||||
|
verifier string
|
||||||
|
found bool
|
||||||
|
}
|
||||||
|
results := make(chan result, attempts)
|
||||||
|
for range attempts {
|
||||||
|
go func() {
|
||||||
|
<-start
|
||||||
|
verifier, found := store.LoadAndDelete(state)
|
||||||
|
results <- result{verifier: verifier, found: found}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
close(start)
|
||||||
|
|
||||||
|
winners := 0
|
||||||
|
for range attempts {
|
||||||
|
result := <-results
|
||||||
|
if result.found {
|
||||||
|
winners++
|
||||||
|
if result.verifier != verifier {
|
||||||
|
t.Fatalf("unexpected verifier: got %q, expected %q", result.verifier, verifier)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if winners != 1 {
|
||||||
|
t.Fatalf("expected exactly one PKCE verifier consumer, got %d", winners)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("replayed state is rejected", func(t *testing.T) {
|
||||||
|
store := NewPKCEVerifierStore(context.Background(), testCacheStore(t))
|
||||||
|
if err := store.Store(state, verifier, time.Minute); err != nil {
|
||||||
|
t.Fatalf("couldn't store PKCE verifier: %s", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if got, found := store.LoadAndDelete(state); !found || got != verifier {
|
||||||
|
t.Fatalf("first load should return the verifier, got %q, found %t", got, found)
|
||||||
|
}
|
||||||
|
if got, found := store.LoadAndDelete(state); found {
|
||||||
|
t.Fatalf("replayed state should not resolve, got %q", got)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("unknown state is rejected", func(t *testing.T) {
|
||||||
|
store := NewPKCEVerifierStore(context.Background(), testCacheStore(t))
|
||||||
|
|
||||||
|
if got, found := store.LoadAndDelete("never-stored"); found {
|
||||||
|
t.Fatalf("unknown state should not resolve, got %q", got)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("expired verifier is rejected", func(t *testing.T) {
|
||||||
|
store := NewPKCEVerifierStore(context.Background(), testCacheStore(t))
|
||||||
|
if err := store.Store(state, verifier, 50*time.Millisecond); err != nil {
|
||||||
|
t.Fatalf("couldn't store PKCE verifier: %s", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
time.Sleep(100 * time.Millisecond)
|
||||||
|
if got, found := store.LoadAndDelete(state); found {
|
||||||
|
t.Fatalf("expired verifier should not resolve, got %q", got)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -1651,6 +1651,10 @@ var (
|
|||||||
// ErrUserBlocked reports a blocked user, who may not hold a proxy session.
|
// ErrUserBlocked reports a blocked user, who may not hold a proxy session.
|
||||||
ErrUserBlocked = errors.New("user blocked")
|
ErrUserBlocked = errors.New("user blocked")
|
||||||
|
|
||||||
|
// ErrUserNotInGroup reports a user outside the service's distribution
|
||||||
|
// groups, who may not hold a proxy session for it.
|
||||||
|
ErrUserNotInGroup = errors.New("user not in allowed groups")
|
||||||
|
|
||||||
errUserUnresolved = errors.New("user could not be resolved")
|
errUserUnresolved = errors.New("user could not be resolved")
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -1689,8 +1693,10 @@ func sameAccount(userAccountID, serviceAccountID string) bool {
|
|||||||
// GenerateSessionToken creates a signed session JWT for the given domain and
|
// GenerateSessionToken creates a signed session JWT for the given domain and
|
||||||
// user. The user's group memberships are embedded in the token so policy-aware
|
// user. The user's group memberships are embedded in the token so policy-aware
|
||||||
// middlewares on the proxy can authorise without an extra management round-trip.
|
// middlewares on the proxy can authorise without an extra management round-trip.
|
||||||
// A user the store cannot resolve, or whose account is pending approval or
|
// A user the store cannot resolve, whose account is pending approval or blocked,
|
||||||
// blocked, gets no token at all, so the browser never receives a session cookie.
|
// or who is outside the service's distribution groups, gets no token at all: the
|
||||||
|
// token is a bearer credential for the service, so authorisation has to run
|
||||||
|
// before it is signed rather than only when the proxy presents it back.
|
||||||
func (s *ProxyServiceServer) GenerateSessionToken(ctx context.Context, domain, userID string, method proxyauth.Method) (string, error) {
|
func (s *ProxyServiceServer) GenerateSessionToken(ctx context.Context, domain, userID string, method proxyauth.Method) (string, error) {
|
||||||
service, err := s.getServiceByDomain(ctx, domain)
|
service, err := s.getServiceByDomain(ctx, domain)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -1726,6 +1732,14 @@ func (s *ProxyServiceServer) GenerateSessionToken(ctx context.Context, domain, u
|
|||||||
return "", fmt.Errorf("session token for user %s: %w", userID, err)
|
return "", fmt.Errorf("session token for user %s: %w", userID, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if err := s.checkGroupAccess(service, user); err != nil {
|
||||||
|
log.WithContext(ctx).WithFields(log.Fields{
|
||||||
|
"domain": domain,
|
||||||
|
"user_id": userID,
|
||||||
|
}).Debug("GenerateSessionToken: user not in the service's distribution groups")
|
||||||
|
return "", fmt.Errorf("session token for user %s: %w", userID, ErrUserNotInGroup)
|
||||||
|
}
|
||||||
|
|
||||||
groupIDs, groupNames := pairGroupIDsAndNames(userGroups)
|
groupIDs, groupNames := pairGroupIDsAndNames(userGroups)
|
||||||
|
|
||||||
token, err := sessionkey.SignToken(
|
token, err := sessionkey.SignToken(
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
cachestore "github.com/eko/gocache/lib/v4/store"
|
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
"google.golang.org/grpc/codes"
|
"google.golang.org/grpc/codes"
|
||||||
@@ -21,7 +20,7 @@ import (
|
|||||||
"github.com/netbirdio/netbird/shared/management/proto"
|
"github.com/netbirdio/netbird/shared/management/proto"
|
||||||
)
|
)
|
||||||
|
|
||||||
func testCacheStore(t *testing.T) cachestore.StoreInterface {
|
func testCacheStore(t *testing.T) nbcache.Store {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
s, err := nbcache.NewStore(context.Background(), 30*time.Minute, 10*time.Minute, 100)
|
s, err := nbcache.NewStore(context.Background(), 30*time.Minute, 10*time.Minute, 100)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|||||||
@@ -247,17 +247,8 @@ func (s *Server) Sync(req *proto.EncryptedMessage, srv proto.ManagementService_S
|
|||||||
sRealIP := realIP.String()
|
sRealIP := realIP.String()
|
||||||
peerMeta := extractPeerMeta(ctx, syncReq.GetMeta())
|
peerMeta := extractPeerMeta(ctx, syncReq.GetMeta())
|
||||||
|
|
||||||
userID, err := s.accountManager.GetUserIDByPeerKey(ctx, peerKey.String())
|
|
||||||
if err != nil {
|
|
||||||
s.syncSem.Add(-1)
|
|
||||||
if errStatus, ok := internalStatus.FromError(err); ok && errStatus.Type() == internalStatus.NotFound {
|
|
||||||
return status.Errorf(codes.PermissionDenied, "peer is not registered")
|
|
||||||
}
|
|
||||||
return mapError(ctx, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
metahashed := metaHash(peerMeta)
|
metahashed := metaHash(peerMeta)
|
||||||
if userID == "" && !s.loginFilter.allowLogin(peerKey.String(), metahashed) {
|
if !s.loginFilter.allowLogin(peerKey.String(), metahashed) {
|
||||||
if s.appMetrics != nil {
|
if s.appMetrics != nil {
|
||||||
s.appMetrics.GRPCMetrics().CountSyncRequestBlocked()
|
s.appMetrics.GRPCMetrics().CountSyncRequestBlocked()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -431,6 +431,57 @@ func TestValidateSession_MissingToken(t *testing.T) {
|
|||||||
assert.Contains(t, resp.DeniedReason, "missing")
|
assert.Contains(t, resp.DeniedReason, "missing")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestGenerateSessionToken_UserNotInAllowedGroupGetsNoToken is the regression
|
||||||
|
// guard for the group-authorisation bypass: the callback used to hand a signed
|
||||||
|
// token to a user the service denies, and the proxy honoured that token as soon
|
||||||
|
// as the user moved it into the nb_session cookie themselves. Authorisation has
|
||||||
|
// to run before the token is signed.
|
||||||
|
func TestGenerateSessionToken_UserNotInAllowedGroupGetsNoToken(t *testing.T) {
|
||||||
|
setup := setupValidateSessionTest(t)
|
||||||
|
defer setup.cleanup()
|
||||||
|
|
||||||
|
token, err := setup.proxyService.GenerateSessionToken(context.Background(), "restricted-proxy.example.com", "nonGroupUserId", auth.MethodOIDC)
|
||||||
|
|
||||||
|
require.Error(t, err, "a user outside the distribution groups must not receive a token")
|
||||||
|
assert.ErrorIs(t, err, ErrUserNotInGroup, "the callback maps this sentinel onto the access denied page")
|
||||||
|
assert.Empty(t, token, "no token may reach the browser")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGenerateSessionToken_UserInAllowedGroupGetsTokenWithGroups(t *testing.T) {
|
||||||
|
setup := setupValidateSessionTest(t)
|
||||||
|
defer setup.cleanup()
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
svc, err := setup.store.GetServiceByID(ctx, store.LockingStrengthNone, "testAccountId", "restrictedProxyId")
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
token, err := setup.proxyService.GenerateSessionToken(ctx, "restricted-proxy.example.com", "allowedUserId", auth.MethodOIDC)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotEmpty(t, token)
|
||||||
|
|
||||||
|
pubKey, err := base64.StdEncoding.DecodeString(svc.SessionPublicKey)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
userID, _, method, groups, _, err := auth.ValidateSessionJWT(token, "restricted-proxy.example.com", pubKey)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, "allowedUserId", userID)
|
||||||
|
assert.Equal(t, auth.MethodOIDC.String(), method)
|
||||||
|
assert.Equal(t, []string{"allowedGroupId"}, groups, "the proxy gates the cookie on this claim, so it must carry the matched group")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestGenerateSessionToken_UnrestrictedServiceAllowsAnyAccountUser keeps the new
|
||||||
|
// gate scoped: a service without distribution groups is open to every user of
|
||||||
|
// its account, as before.
|
||||||
|
func TestGenerateSessionToken_UnrestrictedServiceAllowsAnyAccountUser(t *testing.T) {
|
||||||
|
setup := setupValidateSessionTest(t)
|
||||||
|
defer setup.cleanup()
|
||||||
|
|
||||||
|
token, err := setup.proxyService.GenerateSessionToken(context.Background(), "test-proxy.example.com", "nonGroupUserId", auth.MethodOIDC)
|
||||||
|
|
||||||
|
require.NoError(t, err, "an unrestricted service must keep working for any user of the account")
|
||||||
|
assert.NotEmpty(t, token)
|
||||||
|
}
|
||||||
|
|
||||||
type testValidateSessionServiceManager struct {
|
type testValidateSessionServiceManager struct {
|
||||||
store store.Store
|
store store.Store
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,10 +14,6 @@ import (
|
|||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/netbirdio/netbird/management/internals/modules/reverseproxy/service"
|
|
||||||
"github.com/netbirdio/netbird/management/server/job"
|
|
||||||
"github.com/netbirdio/netbird/shared/auth"
|
|
||||||
|
|
||||||
cacheStore "github.com/eko/gocache/lib/v4/store"
|
cacheStore "github.com/eko/gocache/lib/v4/store"
|
||||||
"github.com/eko/gocache/store/redis/v4"
|
"github.com/eko/gocache/store/redis/v4"
|
||||||
"github.com/rs/xid"
|
"github.com/rs/xid"
|
||||||
@@ -29,6 +25,7 @@ import (
|
|||||||
"github.com/netbirdio/netbird/formatter/hook"
|
"github.com/netbirdio/netbird/formatter/hook"
|
||||||
"github.com/netbirdio/netbird/idp/dex"
|
"github.com/netbirdio/netbird/idp/dex"
|
||||||
"github.com/netbirdio/netbird/management/internals/controllers/network_map"
|
"github.com/netbirdio/netbird/management/internals/controllers/network_map"
|
||||||
|
"github.com/netbirdio/netbird/management/internals/modules/reverseproxy/service"
|
||||||
nbconfig "github.com/netbirdio/netbird/management/internals/server/config"
|
nbconfig "github.com/netbirdio/netbird/management/internals/server/config"
|
||||||
"github.com/netbirdio/netbird/management/server/account"
|
"github.com/netbirdio/netbird/management/server/account"
|
||||||
"github.com/netbirdio/netbird/management/server/activity"
|
"github.com/netbirdio/netbird/management/server/activity"
|
||||||
@@ -39,6 +36,7 @@ import (
|
|||||||
"github.com/netbirdio/netbird/management/server/idp"
|
"github.com/netbirdio/netbird/management/server/idp"
|
||||||
"github.com/netbirdio/netbird/management/server/integrations/integrated_validator"
|
"github.com/netbirdio/netbird/management/server/integrations/integrated_validator"
|
||||||
"github.com/netbirdio/netbird/management/server/integrations/port_forwarding"
|
"github.com/netbirdio/netbird/management/server/integrations/port_forwarding"
|
||||||
|
"github.com/netbirdio/netbird/management/server/job"
|
||||||
nbpeer "github.com/netbirdio/netbird/management/server/peer"
|
nbpeer "github.com/netbirdio/netbird/management/server/peer"
|
||||||
"github.com/netbirdio/netbird/management/server/permissions"
|
"github.com/netbirdio/netbird/management/server/permissions"
|
||||||
"github.com/netbirdio/netbird/management/server/permissions/modules"
|
"github.com/netbirdio/netbird/management/server/permissions/modules"
|
||||||
@@ -50,6 +48,7 @@ import (
|
|||||||
"github.com/netbirdio/netbird/management/server/types"
|
"github.com/netbirdio/netbird/management/server/types"
|
||||||
"github.com/netbirdio/netbird/management/server/util"
|
"github.com/netbirdio/netbird/management/server/util"
|
||||||
"github.com/netbirdio/netbird/route"
|
"github.com/netbirdio/netbird/route"
|
||||||
|
"github.com/netbirdio/netbird/shared/auth"
|
||||||
nbdomain "github.com/netbirdio/netbird/shared/management/domain"
|
nbdomain "github.com/netbirdio/netbird/shared/management/domain"
|
||||||
"github.com/netbirdio/netbird/shared/management/networkmap/nmdata"
|
"github.com/netbirdio/netbird/shared/management/networkmap/nmdata"
|
||||||
"github.com/netbirdio/netbird/shared/management/status"
|
"github.com/netbirdio/netbird/shared/management/status"
|
||||||
@@ -238,6 +237,10 @@ func BuildManager(
|
|||||||
log.WithContext(ctx).Error(err)
|
log.WithContext(ctx).Error(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if IsEmbeddedIdp(idpManager) && accountsCounter > 1 {
|
||||||
|
log.WithContext(ctx).Warnf("embedded IdP requires a single account, found %d", accountsCounter)
|
||||||
|
}
|
||||||
|
|
||||||
// enable single account mode only if configured by user and number of existing accounts is not grater than 1
|
// enable single account mode only if configured by user and number of existing accounts is not grater than 1
|
||||||
am.singleAccountMode = singleAccountModeDomain != "" && accountsCounter <= 1
|
am.singleAccountMode = singleAccountModeDomain != "" && accountsCounter <= 1
|
||||||
if am.singleAccountMode {
|
if am.singleAccountMode {
|
||||||
@@ -716,8 +719,10 @@ func (am *DefaultAccountManager) schedulePeerLoginExpiration(ctx context.Context
|
|||||||
log.WithContext(ctx).Tracef("peer login expiration job for account %s is already scheduled", accountID)
|
log.WithContext(ctx).Tracef("peer login expiration job for account %s is already scheduled", accountID)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
// The job outlives the request that arms it, so it must not inherit the request's cancellation.
|
||||||
|
jobCtx := context.WithoutCancel(ctx)
|
||||||
if nextRun, ok := am.getNextPeerExpiration(ctx, accountID); ok {
|
if nextRun, ok := am.getNextPeerExpiration(ctx, accountID); ok {
|
||||||
go am.peerLoginExpiry.Schedule(ctx, nextRun, accountID, am.peerLoginExpirationJob(ctx, accountID))
|
go am.peerLoginExpiry.Schedule(jobCtx, nextRun, accountID, am.peerLoginExpirationJob(jobCtx, accountID))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -749,8 +754,9 @@ func (am *DefaultAccountManager) peerInactivityExpirationJob(ctx context.Context
|
|||||||
// checkAndSchedulePeerInactivityExpiration periodically checks for inactive peers to end their sessions
|
// checkAndSchedulePeerInactivityExpiration periodically checks for inactive peers to end their sessions
|
||||||
func (am *DefaultAccountManager) checkAndSchedulePeerInactivityExpiration(ctx context.Context, accountID string) {
|
func (am *DefaultAccountManager) checkAndSchedulePeerInactivityExpiration(ctx context.Context, accountID string) {
|
||||||
am.peerInactivityExpiry.Cancel(ctx, []string{accountID})
|
am.peerInactivityExpiry.Cancel(ctx, []string{accountID})
|
||||||
|
jobCtx := context.WithoutCancel(ctx)
|
||||||
if nextRun, ok := am.getNextInactivePeerExpiration(ctx, accountID); ok {
|
if nextRun, ok := am.getNextInactivePeerExpiration(ctx, accountID); ok {
|
||||||
go am.peerInactivityExpiry.Schedule(ctx, nextRun, accountID, am.peerInactivityExpirationJob(ctx, accountID))
|
go am.peerInactivityExpiry.Schedule(jobCtx, nextRun, accountID, am.peerInactivityExpirationJob(jobCtx, accountID))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1592,7 +1598,10 @@ func (am *DefaultAccountManager) updateUserAuthWithSingleMode(ctx context.Contex
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
userAuth.Domain = domain
|
// Keep the configured single account domain when the existing account has none
|
||||||
|
if domain != "" {
|
||||||
|
userAuth.Domain = domain
|
||||||
|
}
|
||||||
|
|
||||||
log.WithContext(ctx).Debugf("overriding JWT Domain and DomainCategory claims since single account mode is enabled")
|
log.WithContext(ctx).Debugf("overriding JWT Domain and DomainCategory claims since single account mode is enabled")
|
||||||
return nil
|
return nil
|
||||||
@@ -1837,6 +1846,7 @@ func (am *DefaultAccountManager) getAccountIDWithAuthorizationClaims(ctx context
|
|||||||
|
|
||||||
return am.addNewPrivateAccount(ctx, domainAccountID, userAuth)
|
return am.addNewPrivateAccount(ctx, domainAccountID, userAuth)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (am *DefaultAccountManager) getPrivateDomainWithGlobalLock(ctx context.Context, domain string) (string, context.CancelFunc, error) {
|
func (am *DefaultAccountManager) getPrivateDomainWithGlobalLock(ctx context.Context, domain string) (string, context.CancelFunc, error) {
|
||||||
domainAccountID, err := am.Store.GetAccountIDByPrivateDomain(ctx, store.LockingStrengthNone, domain)
|
domainAccountID, err := am.Store.GetAccountIDByPrivateDomain(ctx, store.LockingStrengthNone, domain)
|
||||||
if handleNotFound(err) != nil {
|
if handleNotFound(err) != nil {
|
||||||
|
|||||||
@@ -1920,6 +1920,154 @@ func TestDefaultAccountManager_MarkPeerConnected_PeerLoginExpiration(t *testing.
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestDefaultAccountManager_SchedulePeerLoginExpiration_IncludesOfflinePeers(t *testing.T) {
|
||||||
|
manager, updateManager, err := createManager(t)
|
||||||
|
require.NoError(t, err, "unable to create account manager")
|
||||||
|
|
||||||
|
accountID, err := manager.GetAccountIDByUserID(context.Background(), auth.UserAuth{UserId: userID})
|
||||||
|
require.NoError(t, err, "unable to create an account")
|
||||||
|
|
||||||
|
connectedKey, offlineKey := addExpiringPeers(t, manager)
|
||||||
|
_, err = manager.UpdateAccountSettings(context.Background(), accountID, userID, &types.Settings{
|
||||||
|
PeerLoginExpiration: time.Hour,
|
||||||
|
PeerLoginExpirationEnabled: true,
|
||||||
|
Extra: &types.ExtraSettings{},
|
||||||
|
})
|
||||||
|
require.NoError(t, err, "expecting to update account settings successfully but got error")
|
||||||
|
manager.peerLoginExpiry.CancelAll(context.Background())
|
||||||
|
|
||||||
|
// The connected peer logged in just now, so a job computed from connected peers alone
|
||||||
|
// would be armed for an hour. The offline peer's login expires in two seconds; a
|
||||||
|
// reconnect of that peer must not have to wait for the connected peer's tick.
|
||||||
|
now := time.Now().UTC()
|
||||||
|
setPeerLogin(t, manager, accountID, connectedKey, true, now)
|
||||||
|
setPeerLogin(t, manager, accountID, offlineKey, false, now.Add(-time.Hour+2*time.Second))
|
||||||
|
|
||||||
|
offlinePeer, err := manager.Store.GetPeerByPeerPubKey(context.Background(), store.LockingStrengthNone, offlineKey)
|
||||||
|
require.NoError(t, err)
|
||||||
|
updateManager.CreateChannel(context.Background(), offlinePeer.ID)
|
||||||
|
|
||||||
|
manager.peerLoginExpiry = NewDefaultScheduler()
|
||||||
|
t.Cleanup(func() { manager.peerLoginExpiry.CancelAll(context.Background()) })
|
||||||
|
manager.schedulePeerLoginExpiration(context.Background(), accountID)
|
||||||
|
|
||||||
|
// The flag is committed per peer before the disconnect fans out, so wait for both.
|
||||||
|
require.Eventually(t, func() bool {
|
||||||
|
peer, err := manager.Store.GetPeerByPeerPubKey(context.Background(), store.LockingStrengthNone, offlineKey)
|
||||||
|
return err == nil && peer.Status.LoginExpired && !updateManager.HasChannel(offlinePeer.ID)
|
||||||
|
}, 10*time.Second, 100*time.Millisecond, "offline peer should be expired and disconnected at its own deadline")
|
||||||
|
|
||||||
|
connectedPeer, err := manager.Store.GetPeerByPeerPubKey(context.Background(), store.LockingStrengthNone, connectedKey)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.False(t, connectedPeer.Status.LoginExpired, "connected peer with a fresh login must not expire")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDefaultAccountManager_SchedulePeerLoginExpiration_DetachesRequestContext(t *testing.T) {
|
||||||
|
manager, _, err := createManager(t)
|
||||||
|
require.NoError(t, err, "unable to create account manager")
|
||||||
|
|
||||||
|
accountID, err := manager.GetAccountIDByUserID(context.Background(), auth.UserAuth{UserId: userID})
|
||||||
|
require.NoError(t, err, "unable to create an account")
|
||||||
|
connectedKey, _ := addExpiringPeers(t, manager)
|
||||||
|
setPeerLogin(t, manager, accountID, connectedKey, true, time.Now().UTC())
|
||||||
|
|
||||||
|
scheduled := make(chan context.Context, 1)
|
||||||
|
manager.peerLoginExpiry = &MockScheduler{
|
||||||
|
IsSchedulerRunningFunc: func(string) bool { return false },
|
||||||
|
ScheduleFunc: func(ctx context.Context, _ time.Duration, _ string, _ func() (time.Duration, bool)) {
|
||||||
|
scheduled <- ctx
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
requestCtx, cancel := context.WithCancel(context.Background())
|
||||||
|
manager.schedulePeerLoginExpiration(requestCtx, accountID)
|
||||||
|
cancel()
|
||||||
|
|
||||||
|
select {
|
||||||
|
case jobCtx := <-scheduled:
|
||||||
|
assert.NoError(t, jobCtx.Err(), "the expiration job must outlive the request that armed it")
|
||||||
|
case <-time.After(time.Second):
|
||||||
|
t.Fatal("timeout while waiting for the job to be scheduled")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDefaultAccountManager_ExpireAndUpdatePeers_SkipsPeerThatLoggedInAgain(t *testing.T) {
|
||||||
|
manager, updateManager, err := createManager(t)
|
||||||
|
require.NoError(t, err, "unable to create account manager")
|
||||||
|
|
||||||
|
accountID, err := manager.GetAccountIDByUserID(context.Background(), auth.UserAuth{UserId: userID})
|
||||||
|
require.NoError(t, err, "unable to create an account")
|
||||||
|
|
||||||
|
reloggedKey, staleKey := addExpiringPeers(t, manager)
|
||||||
|
_, err = manager.UpdateAccountSettings(context.Background(), accountID, userID, &types.Settings{
|
||||||
|
PeerLoginExpiration: time.Hour,
|
||||||
|
PeerLoginExpirationEnabled: true,
|
||||||
|
Extra: &types.ExtraSettings{},
|
||||||
|
})
|
||||||
|
require.NoError(t, err, "expecting to update account settings successfully but got error")
|
||||||
|
manager.peerLoginExpiry.CancelAll(context.Background())
|
||||||
|
|
||||||
|
expiredLogin := time.Now().UTC().Add(-2 * time.Hour)
|
||||||
|
setPeerLogin(t, manager, accountID, reloggedKey, true, expiredLogin)
|
||||||
|
setPeerLogin(t, manager, accountID, staleKey, true, expiredLogin)
|
||||||
|
|
||||||
|
expiredPeers, err := manager.getExpiredPeers(context.Background(), accountID)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Len(t, expiredPeers, 2, "both peers should be due for expiration")
|
||||||
|
|
||||||
|
// The job holds the candidate list while one peer completes a fresh login, which
|
||||||
|
// moves its deadline into the future and must win over the stale candidate entry.
|
||||||
|
setPeerLogin(t, manager, accountID, reloggedKey, true, time.Now().UTC())
|
||||||
|
|
||||||
|
reloggedPeer, err := manager.Store.GetPeerByPeerPubKey(context.Background(), store.LockingStrengthNone, reloggedKey)
|
||||||
|
require.NoError(t, err)
|
||||||
|
stalePeer, err := manager.Store.GetPeerByPeerPubKey(context.Background(), store.LockingStrengthNone, staleKey)
|
||||||
|
require.NoError(t, err)
|
||||||
|
updateManager.CreateChannel(context.Background(), reloggedPeer.ID)
|
||||||
|
updateManager.CreateChannel(context.Background(), stalePeer.ID)
|
||||||
|
|
||||||
|
err = manager.expireAndUpdatePeers(context.Background(), accountID, expiredPeers, peerExpirationSessionExpired)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
reloggedPeer, err = manager.Store.GetPeerByPeerPubKey(context.Background(), store.LockingStrengthNone, reloggedKey)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.False(t, reloggedPeer.Status.LoginExpired, "a peer that logged in again must not be flagged from the stale candidate list")
|
||||||
|
assert.True(t, reloggedPeer.Status.Connected, "the re-logged peer must keep its connected status")
|
||||||
|
assert.True(t, updateManager.HasChannel(reloggedPeer.ID), "the re-logged peer's update channel must stay open")
|
||||||
|
|
||||||
|
stalePeer, err = manager.Store.GetPeerByPeerPubKey(context.Background(), store.LockingStrengthNone, staleKey)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.True(t, stalePeer.Status.LoginExpired, "a peer that is still due must be flagged")
|
||||||
|
assert.False(t, updateManager.HasChannel(stalePeer.ID), "the expired peer's update channel must be closed")
|
||||||
|
}
|
||||||
|
|
||||||
|
// addExpiringPeers registers two SSO peers with login expiration enabled and returns their public keys.
|
||||||
|
func addExpiringPeers(t *testing.T, manager *DefaultAccountManager) (string, string) {
|
||||||
|
t.Helper()
|
||||||
|
keys := make([]string, 0, 2)
|
||||||
|
for _, hostname := range []string{"connected-peer", "offline-peer"} {
|
||||||
|
key, err := wgtypes.GenerateKey()
|
||||||
|
require.NoError(t, err, "unable to generate WireGuard key")
|
||||||
|
_, _, _, _, err = manager.AddPeer(context.Background(), "", "", userID, &nbpeer.Peer{
|
||||||
|
Key: key.PublicKey().String(),
|
||||||
|
Meta: nbpeer.PeerSystemMeta{Hostname: hostname},
|
||||||
|
LoginExpirationEnabled: true,
|
||||||
|
}, false)
|
||||||
|
require.NoError(t, err, "unable to add peer")
|
||||||
|
keys = append(keys, key.PublicKey().String())
|
||||||
|
}
|
||||||
|
return keys[0], keys[1]
|
||||||
|
}
|
||||||
|
|
||||||
|
func setPeerLogin(t *testing.T, manager *DefaultAccountManager, accountID, peerKey string, connected bool, lastLogin time.Time) {
|
||||||
|
t.Helper()
|
||||||
|
peer, err := manager.Store.GetPeerByPeerPubKey(context.Background(), store.LockingStrengthNone, peerKey)
|
||||||
|
require.NoError(t, err)
|
||||||
|
peer.Status.Connected = connected
|
||||||
|
peer.LastLogin = &lastLogin
|
||||||
|
require.NoError(t, manager.Store.SavePeer(context.Background(), accountID, peer))
|
||||||
|
}
|
||||||
|
|
||||||
func TestDefaultAccountManager_MarkPeerDisconnected_SchedulesInactivityExpiration(t *testing.T) {
|
func TestDefaultAccountManager_MarkPeerDisconnected_SchedulesInactivityExpiration(t *testing.T) {
|
||||||
manager, _, err := createManager(t)
|
manager, _, err := createManager(t)
|
||||||
require.NoError(t, err, "unable to create account manager")
|
require.NoError(t, err, "unable to create account manager")
|
||||||
@@ -2702,7 +2850,7 @@ func TestAccount_GetNextPeerExpiration(t *testing.T) {
|
|||||||
expectedNextExpiration: time.Duration(0),
|
expectedNextExpiration: time.Duration(0),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "No connected peers, no expiration",
|
name: "Offline peer with expiration, return expiration",
|
||||||
peers: map[string]*nbpeer.Peer{
|
peers: map[string]*nbpeer.Peer{
|
||||||
"peer-1": {
|
"peer-1": {
|
||||||
Status: &nbpeer.PeerStatus{
|
Status: &nbpeer.PeerStatus{
|
||||||
@@ -2721,8 +2869,33 @@ func TestAccount_GetNextPeerExpiration(t *testing.T) {
|
|||||||
},
|
},
|
||||||
expiration: time.Second,
|
expiration: time.Second,
|
||||||
expirationEnabled: false,
|
expirationEnabled: false,
|
||||||
expectedNextRun: false,
|
expectedNextRun: true,
|
||||||
expectedNextExpiration: time.Duration(0),
|
expectedNextExpiration: time.Second,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Offline peer with the earliest deadline defines the next run",
|
||||||
|
peers: map[string]*nbpeer.Peer{
|
||||||
|
"peer-1": {
|
||||||
|
Status: &nbpeer.PeerStatus{
|
||||||
|
Connected: true,
|
||||||
|
},
|
||||||
|
LoginExpirationEnabled: true,
|
||||||
|
LastLogin: util.ToPtr(time.Now().UTC()),
|
||||||
|
UserID: userID,
|
||||||
|
},
|
||||||
|
"peer-2": {
|
||||||
|
Status: &nbpeer.PeerStatus{
|
||||||
|
Connected: false,
|
||||||
|
},
|
||||||
|
LoginExpirationEnabled: true,
|
||||||
|
LastLogin: util.ToPtr(time.Now().UTC().Add(-50 * time.Minute)),
|
||||||
|
UserID: userID,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
expiration: time.Hour,
|
||||||
|
expirationEnabled: true,
|
||||||
|
expectedNextRun: true,
|
||||||
|
expectedNextExpiration: 10 * time.Minute,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "Connected peers with disabled expiration, no expiration",
|
name: "Connected peers with disabled expiration, no expiration",
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user