mirror of
https://github.com/netbirdio/netbird.git
synced 2026-10-25 05:49:06 +01:00
Merge branch 'main' into embedded-vnc
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
|
||||||
}
|
}
|
||||||
@@ -327,7 +372,8 @@ func (p *Preferences) SetDisableIPv6(disable bool) {
|
|||||||
|
|
||||||
// GetRemoteJobsAllowed reads the remote jobs opt-in from config file
|
// GetRemoteJobsAllowed reads the remote jobs opt-in from config file
|
||||||
func (p *Preferences) GetRemoteJobsAllowed() (bool, error) {
|
func (p *Preferences) GetRemoteJobsAllowed() (bool, error) {
|
||||||
if p.configInput.RemoteJobsAllowed != nil {
|
policy := p.policy()
|
||||||
|
if !policy.HasKey(mdm.KeyRemoteJobsAllowed) && p.configInput.RemoteJobsAllowed != nil {
|
||||||
return *p.configInput.RemoteJobsAllowed, nil
|
return *p.configInput.RemoteJobsAllowed, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -335,10 +381,11 @@ func (p *Preferences) GetRemoteJobsAllowed() (bool, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return false, err
|
return false, err
|
||||||
}
|
}
|
||||||
|
cfg.ApplyMDMPolicy(policy)
|
||||||
if cfg.RemoteJobsAllowed == nil {
|
if cfg.RemoteJobsAllowed == nil {
|
||||||
return false, nil
|
return false, nil
|
||||||
}
|
}
|
||||||
return *cfg.RemoteJobsAllowed, err
|
return *cfg.RemoteJobsAllowed, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// SetRemoteJobsAllowed stores the given value and waits for commit
|
// SetRemoteJobsAllowed stores the given value and waits for commit
|
||||||
@@ -348,6 +395,9 @@ func (p *Preferences) SetRemoteJobsAllowed(allowed bool) {
|
|||||||
|
|
||||||
// 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")
|
||||||
|
}
|
||||||
+29
-10
@@ -270,6 +270,8 @@ type Engine struct {
|
|||||||
// checks are the client-applied posture checks that need to be evaluated on the client
|
// checks 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
|
||||||
@@ -1251,9 +1253,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.
|
||||||
@@ -1264,6 +1264,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
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1291,6 +1292,28 @@ func (e *Engine) applyInfoFlags(info *system.Info) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (e *Engine) currentSystemInfo(ctx context.Context) *system.Info {
|
||||||
|
info := e.infoSource.Current(ctx, e.overlayAddresses()...)
|
||||||
|
e.applyInfoFlags(info)
|
||||||
|
return info
|
||||||
|
}
|
||||||
|
|
||||||
|
// syncInfoFunc returns the info callback for the management sync stream. The
|
||||||
|
// first connect sends the info refreshed right before it instead of gathering
|
||||||
|
// again; every reconnect gathers a fresh one. The stream retry loop calls the
|
||||||
|
// callback sequentially, so the handoff needs no synchronization.
|
||||||
|
func (e *Engine) syncInfoFunc(refreshed *system.Info) func(ctx context.Context) *system.Info {
|
||||||
|
return func(ctx context.Context) *system.Info {
|
||||||
|
if refreshed == nil {
|
||||||
|
return e.currentSystemInfo(ctx)
|
||||||
|
}
|
||||||
|
info := refreshed
|
||||||
|
refreshed = nil
|
||||||
|
e.applyInfoFlags(info)
|
||||||
|
return info
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// overlayAddresses returns our own WireGuard overlay address (v4 and v6) so it
|
// 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.
|
||||||
@@ -1488,15 +1511,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
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -206,14 +202,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.
|
||||||
@@ -743,9 +751,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))
|
||||||
@@ -164,22 +183,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))
|
||||||
@@ -201,16 +217,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))
|
||||||
})
|
})
|
||||||
@@ -218,22 +230,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 {
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
@@ -130,7 +163,8 @@ func (p *Preferences) SetDisableIPv6(disable bool) {
|
|||||||
|
|
||||||
// GetRemoteJobsAllowed reads the remote jobs opt-in from config file
|
// GetRemoteJobsAllowed reads the remote jobs opt-in from config file
|
||||||
func (p *Preferences) GetRemoteJobsAllowed() (bool, error) {
|
func (p *Preferences) GetRemoteJobsAllowed() (bool, error) {
|
||||||
if p.configInput.RemoteJobsAllowed != nil {
|
policy := p.policy()
|
||||||
|
if !policy.HasKey(mdm.KeyRemoteJobsAllowed) && p.configInput.RemoteJobsAllowed != nil {
|
||||||
return *p.configInput.RemoteJobsAllowed, nil
|
return *p.configInput.RemoteJobsAllowed, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -138,10 +172,11 @@ func (p *Preferences) GetRemoteJobsAllowed() (bool, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return false, err
|
return false, err
|
||||||
}
|
}
|
||||||
|
cfg.ApplyMDMPolicy(policy)
|
||||||
if cfg.RemoteJobsAllowed == nil {
|
if cfg.RemoteJobsAllowed == nil {
|
||||||
return false, nil
|
return false, nil
|
||||||
}
|
}
|
||||||
return *cfg.RemoteJobsAllowed, err
|
return *cfg.RemoteJobsAllowed, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// SetRemoteJobsAllowed stores the given value and waits for commit
|
// SetRemoteJobsAllowed stores the given value and waits for commit
|
||||||
@@ -151,6 +186,9 @@ func (p *Preferences) SetRemoteJobsAllowed(allowed bool) {
|
|||||||
|
|
||||||
// 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
@@ -121,16 +121,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{}}
|
||||||
@@ -207,6 +237,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
|
||||||
}
|
}
|
||||||
@@ -272,7 +304,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,91 @@
|
|||||||
|
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"`
|
||||||
|
AllowServerVNC *bool `json:"allowServerVNC"`
|
||||||
|
DisableVNCApproval bool `json:"disableVNCApproval"`
|
||||||
|
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
|
||||||
|
|||||||
+40
-191
@@ -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,29 +158,27 @@ 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.KeyAllowServerVNC, msg.ServerVNCAllowed),
|
mdm.ConflictBool(mdm.KeyAllowServerVNC, msg.ServerVNCAllowed),
|
||||||
conflictBool(mdm.KeyDisableVNCApproval, msg.DisableVNCApproval),
|
mdm.ConflictBool(mdm.KeyDisableVNCApproval, msg.DisableVNCApproval),
|
||||||
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),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -430,36 +285,30 @@ 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.KeyAllowServerVNC, msg.ServerVNCAllowed),
|
mdm.ConflictBool(mdm.KeyAllowServerVNC, msg.ServerVNCAllowed),
|
||||||
conflictBool(mdm.KeyDisableVNCApproval, msg.DisableVNCApproval),
|
mdm.ConflictBool(mdm.KeyDisableVNCApproval, msg.DisableVNCApproval),
|
||||||
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
@@ -142,6 +142,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
|
||||||
@@ -250,8 +259,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)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -497,7 +512,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
|
||||||
}
|
}
|
||||||
@@ -642,7 +657,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
|
||||||
}
|
}
|
||||||
@@ -1493,6 +1508,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
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1549,6 +1570,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)
|
||||||
}
|
}
|
||||||
@@ -2257,6 +2281,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,13 +135,12 @@ func TestSetConfig_MDMReject_SingleField(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestSetConfig_MDMReject_VNCFields(t *testing.T) {
|
func TestSetConfig_MDMReject_VNCFields(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.KeyAllowServerVNC: true,
|
mdm.KeyAllowServerVNC: true,
|
||||||
mdm.KeyDisableVNCApproval: false,
|
mdm.KeyDisableVNCApproval: false,
|
||||||
}))
|
}))
|
||||||
|
|
||||||
s, ctx, profName, username, _ := setupServerWithProfile(t)
|
|
||||||
|
|
||||||
vncAllowed := false
|
vncAllowed := false
|
||||||
disableApproval := true
|
disableApproval := true
|
||||||
_, err := s.SetConfig(ctx, &proto.SetConfigRequest{
|
_, err := s.SetConfig(ctx, &proto.SetConfigRequest{
|
||||||
@@ -134,14 +158,13 @@ func TestSetConfig_MDMReject_VNCFields(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{
|
||||||
@@ -161,13 +184,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{
|
||||||
@@ -188,12 +210,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,
|
||||||
@@ -205,17 +226,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,
|
||||||
@@ -237,12 +321,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,
|
||||||
@@ -271,12 +354,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,
|
||||||
@@ -293,9 +375,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,
|
||||||
|
|||||||
+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
|
||||||
|
}
|
||||||
@@ -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,39 +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"`
|
|
||||||
AllowServerVNC *bool `json:"allowServerVNC"`
|
|
||||||
DisableVNCApproval bool `json:"disableVNCApproval"`
|
|
||||||
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
|
||||||
@@ -399,7 +378,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
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -427,9 +406,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 {
|
||||||
@@ -1592,7 +1595,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 +1843,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 {
|
||||||
|
|||||||
@@ -7,9 +7,6 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/eko/gocache/lib/v4/cache"
|
|
||||||
"github.com/eko/gocache/lib/v4/store"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@@ -22,12 +19,17 @@ var (
|
|||||||
ErrTokenExpired = errors.New("JWT expired")
|
ErrTokenExpired = errors.New("JWT expired")
|
||||||
)
|
)
|
||||||
|
|
||||||
type SessionStore struct {
|
// TokenCache atomically records used JWTs until their expiration.
|
||||||
cache *cache.Cache[string]
|
type TokenCache interface {
|
||||||
|
SetNX(ctx context.Context, key, value string, ttl time.Duration) (bool, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewSessionStore(cacheStore store.StoreInterface) *SessionStore {
|
type SessionStore struct {
|
||||||
return &SessionStore{cache: cache.New[string](cacheStore)}
|
cache TokenCache
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewSessionStore(cacheStore TokenCache) *SessionStore {
|
||||||
|
return &SessionStore{cache: cacheStore}
|
||||||
}
|
}
|
||||||
|
|
||||||
// RegisterToken records a JWT until its exp time and rejects reuse.
|
// RegisterToken records a JWT until its exp time and rejects reuse.
|
||||||
@@ -38,20 +40,14 @@ func (s *SessionStore) RegisterToken(ctx context.Context, token string, expiresA
|
|||||||
}
|
}
|
||||||
|
|
||||||
key := usedTokenKeyPrefix + hashToken(token)
|
key := usedTokenKeyPrefix + hashToken(token)
|
||||||
_, err := s.cache.Get(ctx, key)
|
created, err := s.cache.SetNX(ctx, key, usedTokenMarker, ttl)
|
||||||
if err == nil {
|
if err != nil {
|
||||||
|
return fmt.Errorf("store used token entry: %w", err)
|
||||||
|
}
|
||||||
|
if !created {
|
||||||
return ErrTokenAlreadyUsed
|
return ErrTokenAlreadyUsed
|
||||||
}
|
}
|
||||||
|
|
||||||
var notFound *store.NotFound
|
|
||||||
if !errors.As(err, ¬Found) {
|
|
||||||
return fmt.Errorf("failed to lookup used token entry: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := s.cache.Set(ctx, key, usedTokenMarker, store.WithExpiration(ttl)); err != nil {
|
|
||||||
return fmt.Errorf("failed to store used token entry: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package auth
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"errors"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -38,6 +39,39 @@ func TestSessionStore_RegisterSameTokenTwiceIsRejected(t *testing.T) {
|
|||||||
assert.ErrorIs(t, err, ErrTokenAlreadyUsed)
|
assert.ErrorIs(t, err, ErrTokenAlreadyUsed)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestSessionStore_ConcurrentRegistrationAllowsOneCaller(t *testing.T) {
|
||||||
|
s := newTestSessionStore(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
const attempts = 100
|
||||||
|
|
||||||
|
start := make(chan struct{})
|
||||||
|
results := make(chan error, attempts)
|
||||||
|
for range attempts {
|
||||||
|
go func() {
|
||||||
|
<-start
|
||||||
|
results <- s.RegisterToken(ctx, "token", time.Now().Add(time.Hour))
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
close(start)
|
||||||
|
|
||||||
|
succeeded := 0
|
||||||
|
alreadyUsed := 0
|
||||||
|
for range attempts {
|
||||||
|
err := <-results
|
||||||
|
switch {
|
||||||
|
case err == nil:
|
||||||
|
succeeded++
|
||||||
|
case errors.Is(err, ErrTokenAlreadyUsed):
|
||||||
|
alreadyUsed++
|
||||||
|
default:
|
||||||
|
require.NoError(t, err, "concurrent registration returned an unexpected error")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.Equal(t, 1, succeeded, "exactly one concurrent caller should register the token")
|
||||||
|
assert.Equal(t, attempts-1, alreadyUsed, "every other caller should be rejected as already used")
|
||||||
|
}
|
||||||
|
|
||||||
func TestSessionStore_RegisterDifferentTokensAreIndependent(t *testing.T) {
|
func TestSessionStore_RegisterDifferentTokensAreIndependent(t *testing.T) {
|
||||||
s := newTestSessionStore(t)
|
s := newTestSessionStore(t)
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
@@ -72,6 +106,23 @@ func TestSessionStore_EntryEvictsAtTTLAndAllowsReRegistration(t *testing.T) {
|
|||||||
require.NoError(t, s.RegisterToken(ctx, token, time.Now().Add(time.Hour)))
|
require.NoError(t, s.RegisterToken(ctx, token, time.Now().Add(time.Hour)))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type failingTokenCache struct {
|
||||||
|
err error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f failingTokenCache) SetNX(context.Context, string, string, time.Duration) (bool, error) {
|
||||||
|
return false, f.err
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSessionStore_CacheErrorIsReturned(t *testing.T) {
|
||||||
|
cacheErr := errors.New("cache unavailable")
|
||||||
|
s := NewSessionStore(failingTokenCache{err: cacheErr})
|
||||||
|
|
||||||
|
err := s.RegisterToken(context.Background(), "token", time.Now().Add(time.Hour))
|
||||||
|
require.Error(t, err, "cache failure should be surfaced to the caller")
|
||||||
|
assert.ErrorIs(t, err, cacheErr, "cache error should be wrapped, not replaced")
|
||||||
|
}
|
||||||
|
|
||||||
func TestHashToken_StableAndDoesNotLeak(t *testing.T) {
|
func TestHashToken_StableAndDoesNotLeak(t *testing.T) {
|
||||||
a := hashToken("tokenA")
|
a := hashToken("tokenA")
|
||||||
b := hashToken("tokenB")
|
b := hashToken("tokenB")
|
||||||
|
|||||||
Vendored
+57
@@ -0,0 +1,57 @@
|
|||||||
|
package cache
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/eko/gocache/lib/v4/store"
|
||||||
|
gocachestore "github.com/eko/gocache/store/go_cache/v4"
|
||||||
|
gocache "github.com/patrickmn/go-cache"
|
||||||
|
)
|
||||||
|
|
||||||
|
type goCacheStore struct {
|
||||||
|
store.StoreInterface
|
||||||
|
client *gocache.Cache
|
||||||
|
mu sync.Mutex
|
||||||
|
}
|
||||||
|
|
||||||
|
func newMemoryStore(maxTimeout, cleanupInterval time.Duration) Store {
|
||||||
|
client := gocache.New(maxTimeout, cleanupInterval)
|
||||||
|
return &goCacheStore{
|
||||||
|
StoreInterface: gocachestore.NewGoCache(client),
|
||||||
|
client: client,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *goCacheStore) SetNX(_ context.Context, key, value string, ttl time.Duration) (bool, error) {
|
||||||
|
// Add only returns an error when a non-expired entry already exists.
|
||||||
|
if err := s.client.Add(key, value, ttl); err != nil {
|
||||||
|
return false, nil //nolint:nilerr
|
||||||
|
}
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetDel reads the value under key and removes it. go-cache has no native read-and-delete
|
||||||
|
// and releases its own lock between the two calls, so mu holds the pair together and no
|
||||||
|
// value is consumed twice.
|
||||||
|
//
|
||||||
|
// Writes do not take mu: a Set landing mid-pair is lost, since GetDel returns the prior
|
||||||
|
// value and deletes the new one. Callers must write a consumed key only once.
|
||||||
|
func (s *goCacheStore) GetDel(_ context.Context, key string) (string, bool, error) {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
|
||||||
|
value, found := s.client.Get(key)
|
||||||
|
if !found {
|
||||||
|
return "", false, nil
|
||||||
|
}
|
||||||
|
s.client.Delete(key)
|
||||||
|
|
||||||
|
str, ok := value.(string)
|
||||||
|
if !ok {
|
||||||
|
return "", false, fmt.Errorf("cached value is %T, not a string", value)
|
||||||
|
}
|
||||||
|
return str, true, nil
|
||||||
|
}
|
||||||
+76
@@ -0,0 +1,76 @@
|
|||||||
|
package cache_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
|
||||||
|
"github.com/netbirdio/netbird/management/server/cache"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestMemoryStore(t *testing.T) {
|
||||||
|
memStore, err := cache.NewStore(context.Background(), 100*time.Millisecond, 300*time.Millisecond, 100)
|
||||||
|
require.NoError(t, err, "couldn't create memory store")
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
key, value := "testing", "tested"
|
||||||
|
err = memStore.Set(ctx, key, value)
|
||||||
|
assert.NoError(t, err, "couldn't set testing data")
|
||||||
|
|
||||||
|
result, err := memStore.Get(ctx, key)
|
||||||
|
assert.NoError(t, err, "couldn't get testing data")
|
||||||
|
assert.Equal(t, value, result, "value returned doesn't match testing data")
|
||||||
|
|
||||||
|
created, err := memStore.SetNX(ctx, "conditional", value, 100*time.Millisecond)
|
||||||
|
require.NoError(t, err, "couldn't conditionally set testing data")
|
||||||
|
require.True(t, created, "first conditional set should create the entry")
|
||||||
|
|
||||||
|
created, err = memStore.SetNX(ctx, "conditional", value, 100*time.Millisecond)
|
||||||
|
require.NoError(t, err, "couldn't conditionally check testing data")
|
||||||
|
require.False(t, created, "second conditional set should not replace the entry")
|
||||||
|
|
||||||
|
// test expiration
|
||||||
|
time.Sleep(300 * time.Millisecond)
|
||||||
|
_, err = memStore.Get(ctx, key)
|
||||||
|
assert.Error(t, err, "value should not be found")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMemoryStoreGetDel(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
newStore := func(t *testing.T) cache.Store {
|
||||||
|
t.Helper()
|
||||||
|
memStore, err := cache.NewStore(ctx, time.Minute, time.Minute, 100)
|
||||||
|
require.NoError(t, err, "couldn't create memory store")
|
||||||
|
|
||||||
|
return memStore
|
||||||
|
}
|
||||||
|
|
||||||
|
const (
|
||||||
|
key = "consume"
|
||||||
|
value = "verifier"
|
||||||
|
)
|
||||||
|
|
||||||
|
t.Run("exactly one concurrent caller consumes the key", func(t *testing.T) {
|
||||||
|
memStore := newStore(t)
|
||||||
|
require.NoError(t, memStore.Set(ctx, key, value), "couldn't set testing data")
|
||||||
|
|
||||||
|
assertGetDelConsumedOnce(ctx, t, []cache.Store{memStore}, key, value)
|
||||||
|
assertGetDelMisses(ctx, t, memStore, key)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("missing key is not an error", func(t *testing.T) {
|
||||||
|
assertGetDelMisses(ctx, t, newStore(t), "never-set")
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("expired key is not found", func(t *testing.T) {
|
||||||
|
memStore := newStore(t)
|
||||||
|
_, err := memStore.SetNX(ctx, key, value, 50*time.Millisecond)
|
||||||
|
require.NoError(t, err, "couldn't set testing data")
|
||||||
|
|
||||||
|
time.Sleep(100 * time.Millisecond)
|
||||||
|
assertGetDelMisses(ctx, t, memStore, key)
|
||||||
|
})
|
||||||
|
}
|
||||||
Vendored
+63
@@ -0,0 +1,63 @@
|
|||||||
|
package cache
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"math"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/eko/gocache/lib/v4/store"
|
||||||
|
redisstore "github.com/eko/gocache/store/redis/v4"
|
||||||
|
"github.com/redis/go-redis/v9"
|
||||||
|
log "github.com/sirupsen/logrus"
|
||||||
|
)
|
||||||
|
|
||||||
|
type redisStore struct {
|
||||||
|
store.StoreInterface
|
||||||
|
client *redis.Client
|
||||||
|
}
|
||||||
|
|
||||||
|
func getRedisStore(ctx context.Context, redisEnvAddr string, maxConn int) (Store, error) {
|
||||||
|
options, err := redis.ParseURL(redisEnvAddr)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("parsing redis cache url: %s", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
options.MaxIdleConns = int(math.Ceil(float64(maxConn) * 0.5)) // 50% of max conns
|
||||||
|
options.MinIdleConns = int(math.Ceil(float64(maxConn) * 0.1)) // 10% of max conns
|
||||||
|
options.MaxActiveConns = maxConn
|
||||||
|
options.ConnMaxIdleTime = 30 * time.Minute
|
||||||
|
options.ConnMaxLifetime = 0
|
||||||
|
options.PoolTimeout = 10 * time.Second
|
||||||
|
redisClient := redis.NewClient(options)
|
||||||
|
subCtx, cancel := context.WithTimeout(ctx, 2*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
_, err = redisClient.Ping(subCtx).Result()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
log.WithContext(subCtx).Infof("using redis cache at %s", redisEnvAddr)
|
||||||
|
|
||||||
|
return &redisStore{
|
||||||
|
StoreInterface: redisstore.NewRedis(redisClient),
|
||||||
|
client: redisClient,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *redisStore) SetNX(ctx context.Context, key, value string, ttl time.Duration) (bool, error) {
|
||||||
|
return s.client.SetNX(ctx, key, value, ttl).Result()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *redisStore) GetDel(ctx context.Context, key string) (string, bool, error) {
|
||||||
|
value, err := s.client.GetDel(ctx, key).Result()
|
||||||
|
if errors.Is(err, redis.Nil) {
|
||||||
|
return "", false, nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return "", false, err
|
||||||
|
}
|
||||||
|
return value, true, nil
|
||||||
|
}
|
||||||
+153
@@ -0,0 +1,153 @@
|
|||||||
|
package cache_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/eko/gocache/lib/v4/store"
|
||||||
|
"github.com/redis/go-redis/v9"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
testcontainersredis "github.com/testcontainers/testcontainers-go/modules/redis"
|
||||||
|
|
||||||
|
"github.com/netbirdio/netbird/management/server/cache"
|
||||||
|
)
|
||||||
|
|
||||||
|
func startRedis(t *testing.T) string {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
redisContainer, err := testcontainersredis.Run(ctx, "redis:7")
|
||||||
|
require.NoError(t, err, "couldn't start redis container")
|
||||||
|
|
||||||
|
t.Cleanup(func() {
|
||||||
|
if err := redisContainer.Terminate(ctx); err != nil {
|
||||||
|
t.Logf("failed to terminate container: %s", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
redisURL, err := redisContainer.ConnectionString(ctx)
|
||||||
|
require.NoError(t, err, "couldn't get connection string")
|
||||||
|
|
||||||
|
t.Setenv(cache.RedisStoreEnvVar, redisURL)
|
||||||
|
return redisURL
|
||||||
|
}
|
||||||
|
|
||||||
|
func newRedisStore(t *testing.T) cache.Store {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
redisStore, err := cache.NewStore(context.Background(), 100*time.Millisecond, 300*time.Millisecond, 100)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
return redisStore
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRedisStoreConnectionFailure(t *testing.T) {
|
||||||
|
t.Setenv(cache.RedisStoreEnvVar, "redis://127.0.0.1:6379")
|
||||||
|
_, err := cache.NewStore(context.Background(), 10*time.Millisecond, 30*time.Millisecond, 100)
|
||||||
|
require.Error(t, err, "getting redis cache store should return error")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRedisStoreConnectionSuccess(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
redisURL := startRedis(t)
|
||||||
|
redisStore := newRedisStore(t)
|
||||||
|
|
||||||
|
key, value := "testing", "tested"
|
||||||
|
err := redisStore.Set(ctx, key, value, store.WithExpiration(100*time.Millisecond))
|
||||||
|
assert.NoError(t, err, "couldn't set testing data")
|
||||||
|
|
||||||
|
result, err := redisStore.Get(ctx, key)
|
||||||
|
assert.NoError(t, err, "couldn't get testing data")
|
||||||
|
assert.Equal(t, value, result, "value returned doesn't match testing data")
|
||||||
|
|
||||||
|
options, err := redis.ParseURL(redisURL)
|
||||||
|
require.NoError(t, err, "parsing redis cache url")
|
||||||
|
|
||||||
|
redisClient := redis.NewClient(options)
|
||||||
|
r, err := redisClient.Get(ctx, key).Result()
|
||||||
|
assert.NoError(t, err, "couldn't get testing data from redis")
|
||||||
|
assert.Equal(t, value, r, "value returned from redis doesn't match testing data")
|
||||||
|
|
||||||
|
// test expiration
|
||||||
|
time.Sleep(300 * time.Millisecond)
|
||||||
|
_, err = redisStore.Get(ctx, key)
|
||||||
|
assert.Error(t, err, "value should not be found")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRedisStoreSetNX(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
redisURL := startRedis(t)
|
||||||
|
redisStore, secondRedisStore := newRedisStore(t), newRedisStore(t)
|
||||||
|
|
||||||
|
const (
|
||||||
|
key = "conditional"
|
||||||
|
value = "tested"
|
||||||
|
)
|
||||||
|
|
||||||
|
start := make(chan struct{})
|
||||||
|
type setResult struct {
|
||||||
|
created bool
|
||||||
|
err error
|
||||||
|
}
|
||||||
|
results := make(chan setResult, 2)
|
||||||
|
for _, cacheStore := range []cache.Store{redisStore, secondRedisStore} {
|
||||||
|
go func() {
|
||||||
|
<-start
|
||||||
|
created, err := cacheStore.SetNX(ctx, key, value, time.Minute)
|
||||||
|
results <- setResult{created: created, err: err}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
close(start)
|
||||||
|
|
||||||
|
created := 0
|
||||||
|
for range 2 {
|
||||||
|
result := <-results
|
||||||
|
require.NoError(t, result.err, "conditional redis set failed")
|
||||||
|
if result.created {
|
||||||
|
created++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
require.Equal(t, 1, created, "expected exactly one redis client to create the entry")
|
||||||
|
|
||||||
|
options, err := redis.ParseURL(redisURL)
|
||||||
|
require.NoError(t, err, "parsing redis cache url")
|
||||||
|
|
||||||
|
ttl, err := redis.NewClient(options).PTTL(ctx, key).Result()
|
||||||
|
require.NoError(t, err, "couldn't read entry TTL")
|
||||||
|
require.Positive(t, ttl, "created entry should have a positive TTL")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRedisStoreGetDel(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
startRedis(t)
|
||||||
|
redisStore, secondRedisStore := newRedisStore(t), newRedisStore(t)
|
||||||
|
|
||||||
|
const (
|
||||||
|
key = "consume"
|
||||||
|
value = "verifier"
|
||||||
|
)
|
||||||
|
|
||||||
|
t.Run("exactly one caller across independent clients consumes the key", func(t *testing.T) {
|
||||||
|
// A generous TTL: the key is consumed explicitly, so expiry racing the
|
||||||
|
// concurrent callers would only make the test flaky on a loaded runner.
|
||||||
|
err := redisStore.Set(ctx, key, value, store.WithExpiration(time.Minute))
|
||||||
|
require.NoError(t, err, "couldn't set value to consume")
|
||||||
|
|
||||||
|
assertGetDelConsumedOnce(ctx, t, []cache.Store{redisStore, secondRedisStore}, key, value)
|
||||||
|
assertGetDelMisses(ctx, t, secondRedisStore, key)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("missing key is not an error", func(t *testing.T) {
|
||||||
|
assertGetDelMisses(ctx, t, redisStore, "never-set")
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("expired key is not found", func(t *testing.T) {
|
||||||
|
err := redisStore.Set(ctx, key, value, store.WithExpiration(50*time.Millisecond))
|
||||||
|
require.NoError(t, err, "couldn't set value to consume")
|
||||||
|
|
||||||
|
time.Sleep(100 * time.Millisecond)
|
||||||
|
assertGetDelMisses(ctx, t, redisStore, key)
|
||||||
|
})
|
||||||
|
}
|
||||||
Vendored
+11
-36
@@ -2,17 +2,10 @@ package cache
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
|
||||||
"math"
|
|
||||||
"os"
|
"os"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/eko/gocache/lib/v4/store"
|
"github.com/eko/gocache/lib/v4/store"
|
||||||
gocache_store "github.com/eko/gocache/store/go_cache/v4"
|
|
||||||
redis_store "github.com/eko/gocache/store/redis/v4"
|
|
||||||
gocache "github.com/patrickmn/go-cache"
|
|
||||||
"github.com/redis/go-redis/v9"
|
|
||||||
log "github.com/sirupsen/logrus"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// RedisStoreEnvVar is the environment variable that determines if a redis store should be used.
|
// RedisStoreEnvVar is the environment variable that determines if a redis store should be used.
|
||||||
@@ -31,15 +24,23 @@ const (
|
|||||||
DefaultStoreMaxConn = 1000
|
DefaultStoreMaxConn = 1000
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Store extends the shared cache interface with conditional and consuming operations.
|
||||||
|
type Store interface {
|
||||||
|
store.StoreInterface
|
||||||
|
// SetNX stores a value with a TTL only when the key does not exist.
|
||||||
|
SetNX(ctx context.Context, key, value string, ttl time.Duration) (bool, error)
|
||||||
|
// GetDel reads a value and removes it, so only one caller can consume a key.
|
||||||
|
GetDel(ctx context.Context, key string) (value string, found bool, err error)
|
||||||
|
}
|
||||||
|
|
||||||
// NewStore creates a new cache store with the given max timeout and cleanup interval. It checks for the environment Variable RedisStoreEnvVar
|
// NewStore creates a new cache store with the given max timeout and cleanup interval. It checks for the environment Variable RedisStoreEnvVar
|
||||||
// to determine if a redis store should be used. If the environment variable is set, it will attempt to connect to the redis store.
|
// to determine if a redis store should be used. If the environment variable is set, it will attempt to connect to the redis store.
|
||||||
func NewStore(ctx context.Context, maxTimeout, cleanupInterval time.Duration, maxConn int) (store.StoreInterface, error) {
|
func NewStore(ctx context.Context, maxTimeout, cleanupInterval time.Duration, maxConn int) (Store, error) {
|
||||||
redisAddr := GetAddrFromEnv()
|
redisAddr := GetAddrFromEnv()
|
||||||
if redisAddr != "" {
|
if redisAddr != "" {
|
||||||
return getRedisStore(ctx, redisAddr, maxConn)
|
return getRedisStore(ctx, redisAddr, maxConn)
|
||||||
}
|
}
|
||||||
goc := gocache.New(maxTimeout, cleanupInterval)
|
return newMemoryStore(maxTimeout, cleanupInterval), nil
|
||||||
return gocache_store.NewGoCache(goc), nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetAddrFromEnv returns the redis address from the environment variable RedisStoreEnvVar or its legacy counterpart.
|
// GetAddrFromEnv returns the redis address from the environment variable RedisStoreEnvVar or its legacy counterpart.
|
||||||
@@ -50,29 +51,3 @@ func GetAddrFromEnv() string {
|
|||||||
}
|
}
|
||||||
return addr
|
return addr
|
||||||
}
|
}
|
||||||
|
|
||||||
func getRedisStore(ctx context.Context, redisEnvAddr string, maxConn int) (store.StoreInterface, error) {
|
|
||||||
options, err := redis.ParseURL(redisEnvAddr)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("parsing redis cache url: %s", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
options.MaxIdleConns = int(math.Ceil(float64(maxConn) * 0.5)) // 50% of max conns
|
|
||||||
options.MinIdleConns = int(math.Ceil(float64(maxConn) * 0.1)) // 10% of max conns
|
|
||||||
options.MaxActiveConns = maxConn
|
|
||||||
options.ConnMaxIdleTime = 30 * time.Minute
|
|
||||||
options.ConnMaxLifetime = 0
|
|
||||||
options.PoolTimeout = 10 * time.Second
|
|
||||||
redisClient := redis.NewClient(options)
|
|
||||||
subCtx, cancel := context.WithTimeout(ctx, 2*time.Second)
|
|
||||||
defer cancel()
|
|
||||||
|
|
||||||
_, err = redisClient.Ping(subCtx).Result()
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
log.WithContext(subCtx).Infof("using redis cache at %s", redisEnvAddr)
|
|
||||||
|
|
||||||
return redis_store.NewRedis(redisClient), nil
|
|
||||||
}
|
|
||||||
|
|||||||
+39
-87
@@ -3,101 +3,53 @@ package cache_test
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/eko/gocache/lib/v4/store"
|
"github.com/stretchr/testify/require"
|
||||||
"github.com/redis/go-redis/v9"
|
|
||||||
testcontainersredis "github.com/testcontainers/testcontainers-go/modules/redis"
|
|
||||||
|
|
||||||
"github.com/netbirdio/netbird/management/server/cache"
|
"github.com/netbirdio/netbird/management/server/cache"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestMemoryStore(t *testing.T) {
|
func assertGetDelConsumedOnce(ctx context.Context, t *testing.T, stores []cache.Store, key, value string) {
|
||||||
memStore, err := cache.NewStore(context.Background(), 100*time.Millisecond, 300*time.Millisecond, 100)
|
t.Helper()
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("couldn't create memory store: %s", err)
|
|
||||||
}
|
|
||||||
ctx := context.Background()
|
|
||||||
key, value := "testing", "tested"
|
|
||||||
err = memStore.Set(ctx, key, value)
|
|
||||||
if err != nil {
|
|
||||||
t.Errorf("couldn't set testing data: %s", err)
|
|
||||||
}
|
|
||||||
result, err := memStore.Get(ctx, key)
|
|
||||||
if err != nil {
|
|
||||||
t.Errorf("couldn't get testing data: %s", err)
|
|
||||||
}
|
|
||||||
if value != result.(string) {
|
|
||||||
t.Errorf("value returned doesn't match testing data, got %s, expected %s", result, value)
|
|
||||||
}
|
|
||||||
// test expiration
|
|
||||||
time.Sleep(300 * time.Millisecond)
|
|
||||||
_, err = memStore.Get(ctx, key)
|
|
||||||
if err == nil {
|
|
||||||
t.Error("value should not be found")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestRedisStoreConnectionFailure(t *testing.T) {
|
const getDelAttempts = 64
|
||||||
t.Setenv(cache.RedisStoreEnvVar, "redis://127.0.0.1:6379")
|
|
||||||
_, err := cache.NewStore(context.Background(), 10*time.Millisecond, 30*time.Millisecond, 100)
|
|
||||||
if err == nil {
|
|
||||||
t.Fatal("getting redis cache store should return error")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestRedisStoreConnectionSuccess(t *testing.T) {
|
type getDelResult struct {
|
||||||
ctx := context.Background()
|
value string
|
||||||
redisContainer, err := testcontainersredis.Run(ctx, "redis:7")
|
found bool
|
||||||
if err != nil {
|
err error
|
||||||
t.Fatalf("couldn't start redis container: %s", err)
|
|
||||||
}
|
}
|
||||||
defer func() {
|
|
||||||
if err := redisContainer.Terminate(ctx); err != nil {
|
start := make(chan struct{})
|
||||||
t.Logf("failed to terminate container: %s", err)
|
results := make(chan getDelResult, getDelAttempts)
|
||||||
|
for i := range getDelAttempts {
|
||||||
|
cacheStore := stores[i%len(stores)]
|
||||||
|
go func() {
|
||||||
|
<-start
|
||||||
|
value, found, err := cacheStore.GetDel(ctx, key)
|
||||||
|
results <- getDelResult{value: value, found: found, err: err}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
close(start)
|
||||||
|
|
||||||
|
consumers := 0
|
||||||
|
for range getDelAttempts {
|
||||||
|
result := <-results
|
||||||
|
require.NoError(t, result.err, "concurrent GetDel failed")
|
||||||
|
if !result.found {
|
||||||
|
continue
|
||||||
}
|
}
|
||||||
}()
|
consumers++
|
||||||
redisURL, err := redisContainer.ConnectionString(ctx)
|
require.Equal(t, value, result.value, "consumed value doesn't match testing data")
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("couldn't get connection string: %s", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
t.Setenv(cache.RedisStoreEnvVar, redisURL)
|
|
||||||
redisStore, err := cache.NewStore(context.Background(), 100*time.Millisecond, 300*time.Millisecond, 100)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("couldn't create redis store: %s", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
key, value := "testing", "tested"
|
|
||||||
err = redisStore.Set(ctx, key, value, store.WithExpiration(100*time.Millisecond))
|
|
||||||
if err != nil {
|
|
||||||
t.Errorf("couldn't set testing data: %s", err)
|
|
||||||
}
|
|
||||||
result, err := redisStore.Get(ctx, key)
|
|
||||||
if err != nil {
|
|
||||||
t.Errorf("couldn't get testing data: %s", err)
|
|
||||||
}
|
|
||||||
if value != result.(string) {
|
|
||||||
t.Errorf("value returned doesn't match testing data, got %s, expected %s", result, value)
|
|
||||||
}
|
|
||||||
|
|
||||||
options, err := redis.ParseURL(redisURL)
|
|
||||||
if err != nil {
|
|
||||||
t.Errorf("parsing redis cache url: %s", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
redisClient := redis.NewClient(options)
|
|
||||||
r, e := redisClient.Get(ctx, key).Result()
|
|
||||||
if e != nil {
|
|
||||||
t.Errorf("couldn't get testing data from redis: %s", e)
|
|
||||||
}
|
|
||||||
if value != r {
|
|
||||||
t.Errorf("value returned from redis doesn't match testing data, got %s, expected %s", r, value)
|
|
||||||
}
|
|
||||||
// test expiration
|
|
||||||
time.Sleep(300 * time.Millisecond)
|
|
||||||
_, err = redisStore.Get(ctx, key)
|
|
||||||
if err == nil {
|
|
||||||
t.Error("value should not be found")
|
|
||||||
}
|
}
|
||||||
|
require.Equal(t, 1, consumers, "expected exactly one consumer")
|
||||||
|
}
|
||||||
|
|
||||||
|
func assertGetDelMisses(ctx context.Context, t *testing.T, cacheStore cache.Store, key string) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
value, found, err := cacheStore.GetDel(ctx, key)
|
||||||
|
require.NoError(t, err, "GetDel on a missing key should not error")
|
||||||
|
require.False(t, found, "GetDel should not find key %q, got value %q", key, value)
|
||||||
|
require.Empty(t, value, "GetDel should return an empty value when not found")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -100,9 +100,10 @@ func (h *AuthCallbackHandler) handleCallback(w http.ResponseWriter, r *http.Requ
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Group validation is performed by the proxy via ValidateSession gRPC call.
|
// GenerateSessionToken applies the service's group and account-status gates,
|
||||||
// This allows the proxy to show 403 pages directly without redirect dance.
|
// so a user without access never receives a token. The proxy re-checks the
|
||||||
|
// installed cookie against the service's allowed groups, and renders the
|
||||||
|
// denial page from the error carried back in the redirect.
|
||||||
sessionToken, err := h.proxyService.GenerateSessionToken(r.Context(), redirectURL.Hostname(), userID, auth.MethodOIDC)
|
sessionToken, err := h.proxyService.GenerateSessionToken(r.Context(), redirectURL.Hostname(), userID, auth.MethodOIDC)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.WithError(err).Error("Failed to create session token")
|
log.WithError(err).Error("Failed to create session token")
|
||||||
@@ -136,6 +137,9 @@ func sessionTokenErrorDescription(err error) string {
|
|||||||
if errors.Is(err, nbgrpc.ErrUserBlocked) {
|
if errors.Is(err, nbgrpc.ErrUserBlocked) {
|
||||||
return "Your account is blocked"
|
return "Your account is blocked"
|
||||||
}
|
}
|
||||||
|
if errors.Is(err, nbgrpc.ErrUserNotInGroup) {
|
||||||
|
return "You are not authorized to access this service"
|
||||||
|
}
|
||||||
return "Service configuration error"
|
return "Service configuration error"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10,9 +10,9 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"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.uber.org/mock/gomock"
|
||||||
|
|
||||||
"github.com/netbirdio/netbird/management/internals/controllers/network_map/controller"
|
"github.com/netbirdio/netbird/management/internals/controllers/network_map/controller"
|
||||||
"github.com/netbirdio/netbird/management/internals/controllers/network_map/update_channel"
|
"github.com/netbirdio/netbird/management/internals/controllers/network_map/update_channel"
|
||||||
@@ -34,6 +34,20 @@ import (
|
|||||||
|
|
||||||
func createManagerWithEmbeddedIdP(t testing.TB) (*DefaultAccountManager, *update_channel.PeersUpdateManager, error) {
|
func createManagerWithEmbeddedIdP(t testing.TB) (*DefaultAccountManager, *update_channel.PeersUpdateManager, error) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
return createManagerWithEmbeddedIdPMode(t, "netbird.selfhosted")
|
||||||
|
}
|
||||||
|
|
||||||
|
func createManagerWithEmbeddedIdPMode(t testing.TB, singleAccountModeDomain string) (*DefaultAccountManager, *update_channel.PeersUpdateManager, error) {
|
||||||
|
t.Helper()
|
||||||
|
return createManagerWithEmbeddedIdPModeAndSetup(t, singleAccountModeDomain, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
func createManagerWithEmbeddedIdPModeAndSetup(
|
||||||
|
t testing.TB,
|
||||||
|
singleAccountModeDomain string,
|
||||||
|
setupStore func(context.Context, store.Store) error,
|
||||||
|
) (*DefaultAccountManager, *update_channel.PeersUpdateManager, error) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
||||||
@@ -43,6 +57,11 @@ func createManagerWithEmbeddedIdP(t testing.TB) (*DefaultAccountManager, *update
|
|||||||
return nil, nil, err
|
return nil, nil, err
|
||||||
}
|
}
|
||||||
t.Cleanup(cleanUp)
|
t.Cleanup(cleanUp)
|
||||||
|
if setupStore != nil {
|
||||||
|
if err := setupStore(ctx, testStore); err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Create embedded IdP manager
|
// Create embedded IdP manager
|
||||||
embeddedConfig := &idp.EmbeddedIdPConfig{
|
embeddedConfig := &idp.EmbeddedIdPConfig{
|
||||||
@@ -93,7 +112,7 @@ func createManagerWithEmbeddedIdP(t testing.TB) (*DefaultAccountManager, *update
|
|||||||
updateManager := update_channel.NewPeersUpdateManager(metrics)
|
updateManager := update_channel.NewPeersUpdateManager(metrics)
|
||||||
requestBuffer := NewAccountRequestBuffer(ctx, testStore)
|
requestBuffer := NewAccountRequestBuffer(ctx, testStore)
|
||||||
networkMapController := controller.NewController(ctx, testStore, metrics, updateManager, requestBuffer, MockIntegratedValidator{}, settingsMockManager, "netbird.cloud", port_forwarding.NewControllerMock(), ephemeral_manager.NewEphemeralManager(testStore, peersManager), &config.Config{}, nil)
|
networkMapController := controller.NewController(ctx, testStore, metrics, updateManager, requestBuffer, MockIntegratedValidator{}, settingsMockManager, "netbird.cloud", port_forwarding.NewControllerMock(), ephemeral_manager.NewEphemeralManager(testStore, peersManager), &config.Config{}, nil)
|
||||||
manager, err := BuildManager(ctx, &config.Config{}, testStore, networkMapController, job.NewJobManager(nil, testStore, peersManager), idpManager, "", eventStore, nil, false, MockIntegratedValidator{}, metrics, port_forwarding.NewControllerMock(), settingsMockManager, permissionsManager, false, cacheStore)
|
manager, err := BuildManager(ctx, &config.Config{}, testStore, networkMapController, job.NewJobManager(nil, testStore, peersManager), idpManager, singleAccountModeDomain, eventStore, nil, false, MockIntegratedValidator{}, metrics, port_forwarding.NewControllerMock(), settingsMockManager, permissionsManager, false, cacheStore)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, nil, err
|
return nil, nil, err
|
||||||
}
|
}
|
||||||
@@ -196,6 +215,23 @@ func TestDefaultAccountManager_GetIdentityProvider_NotFound(t *testing.T) {
|
|||||||
assert.Contains(t, err.Error(), "not found")
|
assert.Contains(t, err.Error(), "not found")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestUpdateUserAuthWithSingleModeKeepsConfiguredDomain(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
manager, _, err := createManagerWithEmbeddedIdPModeAndSetup(t, "netbird.selfhosted", func(ctx context.Context, testStore store.Store) error {
|
||||||
|
// An account with no domain, as left behind by an IdP that emitted no domain claims.
|
||||||
|
return testStore.SaveAccount(ctx, newAccountWithId(ctx, "account-1", "user-1", "", "", "", false))
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.True(t, manager.singleAccountMode)
|
||||||
|
|
||||||
|
userAuth := auth.UserAuth{UserId: "user-2"}
|
||||||
|
require.NoError(t, manager.updateUserAuthWithSingleMode(ctx, &userAuth))
|
||||||
|
|
||||||
|
assert.Equal(t, "netbird.selfhosted", userAuth.Domain,
|
||||||
|
"An empty account domain must not clear the configured single account domain")
|
||||||
|
assert.Equal(t, types.PrivateCategory, userAuth.DomainCategory)
|
||||||
|
}
|
||||||
|
|
||||||
func TestDefaultAccountManager_UpdateIdentityProvider_Validation(t *testing.T) {
|
func TestDefaultAccountManager_UpdateIdentityProvider_Validation(t *testing.T) {
|
||||||
manager, _, err := createManager(t)
|
manager, _, err := createManager(t)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
|
||||||
log "github.com/sirupsen/logrus"
|
log "github.com/sirupsen/logrus"
|
||||||
|
|
||||||
@@ -25,8 +27,10 @@ type Server interface {
|
|||||||
EventStore() EventStore // may return nil
|
EventStore() EventStore // may return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
const idpSeedInfoKey = "IDP_SEED_INFO"
|
const (
|
||||||
const dryRunEnvKey = "NB_IDP_MIGRATION_DRY_RUN"
|
idpSeedInfoKey = "IDP_SEED_INFO"
|
||||||
|
dryRunEnvKey = "NB_IDP_MIGRATION_DRY_RUN"
|
||||||
|
)
|
||||||
|
|
||||||
func isDryRun() bool {
|
func isDryRun() bool {
|
||||||
return os.Getenv(dryRunEnvKey) == "true"
|
return os.Getenv(dryRunEnvKey) == "true"
|
||||||
@@ -233,3 +237,163 @@ func PopulateUserInfo(s Server, idpManager idp.Manager, dryRun bool) error {
|
|||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const DefaultSingleAccountDomain = "netbird.selfhosted"
|
||||||
|
|
||||||
|
var (
|
||||||
|
ErrMultipleAccounts = errors.New("the embedded IdP supports a single account only")
|
||||||
|
ErrUnusableDomain = errors.New("domain cannot be resolved in single account mode")
|
||||||
|
ErrDomainConflict = errors.New("requested domain conflicts with the account domain")
|
||||||
|
)
|
||||||
|
|
||||||
|
var resolvableDomainRegexp = regexp.MustCompile(`^([a-z0-9]+(-[a-z0-9]+)*\.)+[a-z]{2,}$`)
|
||||||
|
|
||||||
|
// RequireSingleAccount refuses to migrate an instance that holds more than one account.
|
||||||
|
func RequireSingleAccount(s Server) error {
|
||||||
|
accountsCounter, err := s.Store().GetAccountsCounter(context.Background())
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to count accounts: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if accountsCounter > 1 {
|
||||||
|
return errMultipleAccounts(accountsCounter)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func errMultipleAccounts(accountsCounter int64) error {
|
||||||
|
return fmt.Errorf("%w: this instance has %d accounts. Identity provider connectors are stored without "+
|
||||||
|
"an account scope, so every account would share and be able to manage the same connectors. "+
|
||||||
|
"Consolidate this instance to a single account, or keep using an external IdP, before migrating",
|
||||||
|
ErrMultipleAccounts, accountsCounter)
|
||||||
|
}
|
||||||
|
|
||||||
|
func NormalizeSingleAccountDomain(singleAccountDomain string) (string, error) {
|
||||||
|
if singleAccountDomain == "" {
|
||||||
|
singleAccountDomain = DefaultSingleAccountDomain
|
||||||
|
}
|
||||||
|
|
||||||
|
singleAccountDomain = strings.ToLower(singleAccountDomain)
|
||||||
|
if !resolvableDomainRegexp.MatchString(singleAccountDomain) {
|
||||||
|
return "", fmt.Errorf("%w: %q must contain at least one dot and only lowercase letters, digits and "+
|
||||||
|
"hyphens, otherwise users cannot join the existing account", ErrUnusableDomain, singleAccountDomain)
|
||||||
|
}
|
||||||
|
|
||||||
|
return singleAccountDomain, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// resolveAccountDomain picks the domain the account should end up with. The account keeps a usable
|
||||||
|
// domain of its own, the configured one only fills a blank. Anything else is a conflict to report.
|
||||||
|
func resolveAccountDomain(accountID, accountDomain, singleAccountDomain string, requested bool) (string, error) {
|
||||||
|
accountDomain = strings.ToLower(accountDomain)
|
||||||
|
|
||||||
|
if accountDomain == "" {
|
||||||
|
return singleAccountDomain, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if !resolvableDomainRegexp.MatchString(accountDomain) {
|
||||||
|
return "", fmt.Errorf("%w: account %s has domain %q, which must contain at least one dot and only "+
|
||||||
|
"lowercase letters, digits and hyphens. Correct the account domain before migrating",
|
||||||
|
ErrUnusableDomain, accountID, accountDomain)
|
||||||
|
}
|
||||||
|
|
||||||
|
if requested && accountDomain != singleAccountDomain {
|
||||||
|
return "", fmt.Errorf("%w: account %s already uses domain %q but %q was requested. Re-run without "+
|
||||||
|
"--single-account-mode-domain to keep %q, or correct the account domain first",
|
||||||
|
ErrDomainConflict, accountID, accountDomain, singleAccountDomain, accountDomain)
|
||||||
|
}
|
||||||
|
|
||||||
|
return accountDomain, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// EnsureSingleAccountDomain gives the remaining account the domain attributes single account mode
|
||||||
|
// resolves against, so users can still join it after the migration.
|
||||||
|
func EnsureSingleAccountDomain(s Server, singleAccountDomain string) error {
|
||||||
|
plan, err := planSingleAccountDomain(s, singleAccountDomain)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if plan.skip {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if isDryRun() {
|
||||||
|
log.Infof("[DRY RUN] would set account %s domain to %q, category to %q and mark it as the primary domain account "+
|
||||||
|
"(currently domain=%q primary=%v)", plan.accountID, plan.domain, types.PrivateCategory,
|
||||||
|
plan.currentDomain, plan.isPrimary)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.Store().UpdateAccountDomainAttributes(context.Background(), plan.accountID, plan.domain,
|
||||||
|
types.PrivateCategory, true); err != nil {
|
||||||
|
return fmt.Errorf("failed to update domain attributes of account %s: %w", plan.accountID, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Infof("account %s now resolves in single account mode with domain %q", plan.accountID, plan.domain)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CheckSingleAccountDomain reports whether EnsureSingleAccountDomain would succeed, without writing.
|
||||||
|
func CheckSingleAccountDomain(s Server, singleAccountDomain string) error {
|
||||||
|
_, err := planSingleAccountDomain(s, singleAccountDomain)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
type singleAccountDomainPlan struct {
|
||||||
|
accountID string
|
||||||
|
domain string
|
||||||
|
currentDomain string
|
||||||
|
isPrimary bool
|
||||||
|
skip bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// planSingleAccountDomain decides what the account's domain attributes should become. It reads
|
||||||
|
// only, so it can run both as a preflight and as the first half of the update.
|
||||||
|
func planSingleAccountDomain(s Server, singleAccountDomain string) (singleAccountDomainPlan, error) {
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
// An empty value means the operator did not pick a domain, so the default is only a fallback.
|
||||||
|
requested := singleAccountDomain != ""
|
||||||
|
|
||||||
|
singleAccountDomain, err := NormalizeSingleAccountDomain(singleAccountDomain)
|
||||||
|
if err != nil {
|
||||||
|
return singleAccountDomainPlan{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
accountsCounter, err := s.Store().GetAccountsCounter(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return singleAccountDomainPlan{}, fmt.Errorf("failed to count accounts: %w", err)
|
||||||
|
}
|
||||||
|
// The count is checked again here: it is read long after RequireSingleAccount, and marking an
|
||||||
|
// arbitrary account as the primary one for the domain would be wrong.
|
||||||
|
switch {
|
||||||
|
case accountsCounter == 0:
|
||||||
|
log.Info("no accounts yet, nothing to prepare for single account mode")
|
||||||
|
return singleAccountDomainPlan{skip: true}, nil
|
||||||
|
case accountsCounter > 1:
|
||||||
|
return singleAccountDomainPlan{}, errMultipleAccounts(accountsCounter)
|
||||||
|
}
|
||||||
|
|
||||||
|
accountID, err := s.Store().GetAnyAccountID(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return singleAccountDomainPlan{}, fmt.Errorf("failed to get the existing account: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
isPrimary, accountDomain, err := s.Store().IsPrimaryAccount(ctx, accountID)
|
||||||
|
if err != nil {
|
||||||
|
return singleAccountDomainPlan{}, fmt.Errorf("failed to read domain attributes of account %s: %w", accountID, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
domain, err := resolveAccountDomain(accountID, accountDomain, singleAccountDomain, requested)
|
||||||
|
if err != nil {
|
||||||
|
return singleAccountDomainPlan{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return singleAccountDomainPlan{
|
||||||
|
accountID: accountID,
|
||||||
|
domain: domain,
|
||||||
|
currentDomain: accountDomain,
|
||||||
|
isPrimary: isPrimary,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -24,6 +24,17 @@ type testStore struct {
|
|||||||
checkSchemaFunc func(checks []SchemaCheck) []SchemaError
|
checkSchemaFunc func(checks []SchemaCheck) []SchemaError
|
||||||
updateCalls []updateUserIDCall
|
updateCalls []updateUserIDCall
|
||||||
updateInfoCalls []updateUserInfoCall
|
updateInfoCalls []updateUserInfoCall
|
||||||
|
|
||||||
|
accountsCounter int64
|
||||||
|
accounts map[string]*types.Account
|
||||||
|
domainAttrCalls []domainAttrCall
|
||||||
|
}
|
||||||
|
|
||||||
|
type domainAttrCall struct {
|
||||||
|
AccountID string
|
||||||
|
Domain string
|
||||||
|
Category string
|
||||||
|
IsPrimary bool
|
||||||
}
|
}
|
||||||
|
|
||||||
type updateUserIDCall struct {
|
type updateUserIDCall struct {
|
||||||
@@ -38,6 +49,35 @@ type updateUserInfoCall struct {
|
|||||||
Name string
|
Name string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *testStore) GetAccountsCounter(context.Context) (int64, error) {
|
||||||
|
return s.accountsCounter, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *testStore) GetAnyAccountID(context.Context) (string, error) {
|
||||||
|
for id := range s.accounts {
|
||||||
|
return id, nil
|
||||||
|
}
|
||||||
|
return "", fmt.Errorf("no accounts")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *testStore) IsPrimaryAccount(_ context.Context, accountID string) (bool, string, error) {
|
||||||
|
account, ok := s.accounts[accountID]
|
||||||
|
if !ok {
|
||||||
|
return false, "", fmt.Errorf("account %s not found", accountID)
|
||||||
|
}
|
||||||
|
return account.IsDomainPrimaryAccount, account.Domain, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *testStore) UpdateAccountDomainAttributes(_ context.Context, accountID, domain, category string, isPrimaryDomain bool) error {
|
||||||
|
s.domainAttrCalls = append(s.domainAttrCalls, domainAttrCall{accountID, domain, category, isPrimaryDomain})
|
||||||
|
if account, ok := s.accounts[accountID]; ok {
|
||||||
|
account.Domain = domain
|
||||||
|
account.DomainCategory = category
|
||||||
|
account.IsDomainPrimaryAccount = isPrimaryDomain
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func (s *testStore) ListUsers(ctx context.Context) ([]*types.User, error) {
|
func (s *testStore) ListUsers(ctx context.Context) ([]*types.User, error) {
|
||||||
return s.listUsersFunc(ctx)
|
return s.listUsersFunc(ctx)
|
||||||
}
|
}
|
||||||
@@ -826,3 +866,212 @@ func TestCheckSchema_MockStore(t *testing.T) {
|
|||||||
assert.Equal(t, "email", errs[0].Column)
|
assert.Equal(t, "email", errs[0].Column)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestRequireSingleAccount(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
accounts int64
|
||||||
|
expectErr bool
|
||||||
|
}{
|
||||||
|
{name: "fresh install", accounts: 0},
|
||||||
|
{name: "single account", accounts: 1},
|
||||||
|
{name: "multiple accounts", accounts: 3, expectErr: true},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
srv := &testServer{store: &testStore{accountsCounter: tt.accounts}}
|
||||||
|
|
||||||
|
err := RequireSingleAccount(srv)
|
||||||
|
if !tt.expectErr {
|
||||||
|
require.NoError(t, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.ErrorIs(t, err, ErrMultipleAccounts)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEnsureSingleAccountDomain(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
account *types.Account
|
||||||
|
requestedDomain string
|
||||||
|
expectedDomain string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "account migrated from an IdP without domain claims",
|
||||||
|
account: &types.Account{Id: "account-1"},
|
||||||
|
expectedDomain: DefaultSingleAccountDomain,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "requested domain is applied to an account without one",
|
||||||
|
account: &types.Account{Id: "account-1"},
|
||||||
|
requestedDomain: "corp.example.com",
|
||||||
|
expectedDomain: "corp.example.com",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "account keeps its own domain",
|
||||||
|
account: &types.Account{Id: "account-1", Domain: "acme.com"},
|
||||||
|
expectedDomain: "acme.com",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "requesting the domain the account already has is not a conflict",
|
||||||
|
account: &types.Account{Id: "account-1", Domain: "acme.com"},
|
||||||
|
requestedDomain: "acme.com",
|
||||||
|
expectedDomain: "acme.com",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "already resolvable account is rewritten with the same values",
|
||||||
|
account: &types.Account{
|
||||||
|
Id: "account-1",
|
||||||
|
Domain: "acme.com",
|
||||||
|
DomainCategory: types.PrivateCategory,
|
||||||
|
IsDomainPrimaryAccount: true,
|
||||||
|
},
|
||||||
|
expectedDomain: "acme.com",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
store := &testStore{
|
||||||
|
accountsCounter: 1,
|
||||||
|
accounts: map[string]*types.Account{tt.account.Id: tt.account},
|
||||||
|
}
|
||||||
|
|
||||||
|
require.NoError(t, EnsureSingleAccountDomain(&testServer{store: store}, tt.requestedDomain))
|
||||||
|
|
||||||
|
require.Len(t, store.domainAttrCalls, 1)
|
||||||
|
assert.Equal(t, domainAttrCall{
|
||||||
|
AccountID: tt.account.Id,
|
||||||
|
Domain: tt.expectedDomain,
|
||||||
|
Category: types.PrivateCategory,
|
||||||
|
IsPrimary: true,
|
||||||
|
}, store.domainAttrCalls[0])
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEnsureSingleAccountDomainDryRun(t *testing.T) {
|
||||||
|
t.Setenv(dryRunEnvKey, "true")
|
||||||
|
|
||||||
|
store := &testStore{
|
||||||
|
accountsCounter: 1,
|
||||||
|
accounts: map[string]*types.Account{"account-1": {Id: "account-1"}},
|
||||||
|
}
|
||||||
|
|
||||||
|
require.NoError(t, EnsureSingleAccountDomain(&testServer{store: store}, ""))
|
||||||
|
assert.Empty(t, store.domainAttrCalls, "Dry run must not write anything")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEnsureSingleAccountDomainRejectsUnresolvableDomains(t *testing.T) {
|
||||||
|
t.Run("account domain that cannot resolve is reported", func(t *testing.T) {
|
||||||
|
account := &types.Account{Id: "account-1", Domain: "corp"}
|
||||||
|
store := &testStore{
|
||||||
|
accountsCounter: 1,
|
||||||
|
accounts: map[string]*types.Account{account.Id: account},
|
||||||
|
}
|
||||||
|
|
||||||
|
err := EnsureSingleAccountDomain(&testServer{store: store}, "")
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.ErrorIs(t, err, ErrUnusableDomain)
|
||||||
|
assert.Empty(t, store.domainAttrCalls, "A broken account domain must not be replaced silently")
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("requested domain conflicting with the account domain is reported", func(t *testing.T) {
|
||||||
|
account := &types.Account{Id: "account-1", Domain: "acme.com"}
|
||||||
|
store := &testStore{
|
||||||
|
accountsCounter: 1,
|
||||||
|
accounts: map[string]*types.Account{account.Id: account},
|
||||||
|
}
|
||||||
|
|
||||||
|
err := EnsureSingleAccountDomain(&testServer{store: store}, "corp.example.com")
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.ErrorIs(t, err, ErrDomainConflict)
|
||||||
|
assert.Empty(t, store.domainAttrCalls, "A conflict must not overwrite the account domain")
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("configured domain that cannot resolve is rejected", func(t *testing.T) {
|
||||||
|
store := &testStore{
|
||||||
|
accountsCounter: 1,
|
||||||
|
accounts: map[string]*types.Account{"account-1": {Id: "account-1"}},
|
||||||
|
}
|
||||||
|
|
||||||
|
err := EnsureSingleAccountDomain(&testServer{store: store}, "corp")
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.ErrorIs(t, err, ErrUnusableDomain)
|
||||||
|
assert.Empty(t, store.domainAttrCalls)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("account appearing after the preflight is rejected", func(t *testing.T) {
|
||||||
|
store := &testStore{
|
||||||
|
accountsCounter: 2,
|
||||||
|
accounts: map[string]*types.Account{
|
||||||
|
"account-1": {Id: "account-1"},
|
||||||
|
"account-2": {Id: "account-2"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
err := EnsureSingleAccountDomain(&testServer{store: store}, "")
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.ErrorIs(t, err, ErrMultipleAccounts)
|
||||||
|
assert.Empty(t, store.domainAttrCalls, "No account may be marked primary when several exist")
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("fresh install with no accounts is a no-op", func(t *testing.T) {
|
||||||
|
store := &testStore{accountsCounter: 0, accounts: map[string]*types.Account{}}
|
||||||
|
|
||||||
|
require.NoError(t, EnsureSingleAccountDomain(&testServer{store: store}, ""))
|
||||||
|
assert.Empty(t, store.domainAttrCalls)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCheckSingleAccountDomain(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
account *types.Account
|
||||||
|
requested string
|
||||||
|
expectErr error
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "usable account domain passes",
|
||||||
|
account: &types.Account{Id: "account-1", Domain: "acme.com"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "empty account domain passes",
|
||||||
|
account: &types.Account{Id: "account-1"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "unresolvable account domain fails",
|
||||||
|
account: &types.Account{Id: "account-1", Domain: "corp"},
|
||||||
|
expectErr: ErrUnusableDomain,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "conflicting request fails",
|
||||||
|
account: &types.Account{Id: "account-1", Domain: "acme.com"},
|
||||||
|
requested: "corp.example.com",
|
||||||
|
expectErr: ErrDomainConflict,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
store := &testStore{
|
||||||
|
accountsCounter: 1,
|
||||||
|
accounts: map[string]*types.Account{tt.account.Id: tt.account},
|
||||||
|
}
|
||||||
|
|
||||||
|
err := CheckSingleAccountDomain(&testServer{store: store}, tt.requested)
|
||||||
|
if tt.expectErr == nil {
|
||||||
|
require.NoError(t, err)
|
||||||
|
} else {
|
||||||
|
require.ErrorIs(t, err, tt.expectErr)
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.Empty(t, store.domainAttrCalls, "The preflight must not write anything")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -60,6 +60,20 @@ type Store interface {
|
|||||||
// CheckSchema verifies that all tables and columns required by the migration
|
// CheckSchema verifies that all tables and columns required by the migration
|
||||||
// exist in the database. Returns a list of problems; an empty slice means OK.
|
// exist in the database. Returns a list of problems; an empty slice means OK.
|
||||||
CheckSchema(checks []SchemaCheck) []SchemaError
|
CheckSchema(checks []SchemaCheck) []SchemaError
|
||||||
|
|
||||||
|
// GetAccountsCounter returns the total number of accounts in the store.
|
||||||
|
GetAccountsCounter(ctx context.Context) (int64, error)
|
||||||
|
|
||||||
|
// GetAnyAccountID returns the ID of one of the existing accounts.
|
||||||
|
GetAnyAccountID(ctx context.Context) (string, error)
|
||||||
|
|
||||||
|
// IsPrimaryAccount returns whether the account is the primary account for its domain,
|
||||||
|
// along with that domain.
|
||||||
|
IsPrimaryAccount(ctx context.Context, accountID string) (bool, string, error)
|
||||||
|
|
||||||
|
// UpdateAccountDomainAttributes sets the domain, domain category and primary
|
||||||
|
// domain flag of an account.
|
||||||
|
UpdateAccountDomainAttributes(ctx context.Context, accountID string, domain string, category string, isPrimaryDomain bool) error
|
||||||
}
|
}
|
||||||
|
|
||||||
// RequiredEventSchema lists all tables and columns that the migration tool needs
|
// RequiredEventSchema lists all tables and columns that the migration tool needs
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ type ManagementServiceServerMock struct {
|
|||||||
proto.UnimplementedManagementServiceServer
|
proto.UnimplementedManagementServiceServer
|
||||||
|
|
||||||
LoginFunc func(context.Context, *proto.EncryptedMessage) (*proto.EncryptedMessage, error)
|
LoginFunc func(context.Context, *proto.EncryptedMessage) (*proto.EncryptedMessage, error)
|
||||||
SyncFunc func(*proto.EncryptedMessage, proto.ManagementService_SyncServer)
|
SyncFunc func(*proto.EncryptedMessage, proto.ManagementService_SyncServer) error
|
||||||
GetServerKeyFunc func(context.Context, *proto.Empty) (*proto.ServerKeyResponse, error)
|
GetServerKeyFunc func(context.Context, *proto.Empty) (*proto.ServerKeyResponse, error)
|
||||||
IsHealthyFunc func(context.Context, *proto.Empty) (*proto.Empty, error)
|
IsHealthyFunc func(context.Context, *proto.Empty) (*proto.Empty, error)
|
||||||
GetDeviceAuthorizationFlowFunc func(ctx context.Context, req *proto.EncryptedMessage) (*proto.EncryptedMessage, error)
|
GetDeviceAuthorizationFlowFunc func(ctx context.Context, req *proto.EncryptedMessage) (*proto.EncryptedMessage, error)
|
||||||
@@ -30,7 +30,7 @@ func (m ManagementServiceServerMock) Login(ctx context.Context, req *proto.Encry
|
|||||||
|
|
||||||
func (m ManagementServiceServerMock) Sync(msg *proto.EncryptedMessage, sync proto.ManagementService_SyncServer) error {
|
func (m ManagementServiceServerMock) Sync(msg *proto.EncryptedMessage, sync proto.ManagementService_SyncServer) error {
|
||||||
if m.SyncFunc != nil {
|
if m.SyncFunc != nil {
|
||||||
return m.Sync(msg, sync)
|
return m.SyncFunc(msg, sync)
|
||||||
}
|
}
|
||||||
return status.Errorf(codes.Unimplemented, "method Sync not implemented")
|
return status.Errorf(codes.Unimplemented, "method Sync not implemented")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1337,6 +1337,10 @@ func (am *DefaultAccountManager) deleteRegularUser(ctx context.Context, accountI
|
|||||||
return fmt.Errorf("failed to get user to delete: %w", err)
|
return fmt.Errorf("failed to get user to delete: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if targetUser.Role == types.UserRoleOwner && targetUser.Id != initiatorUserID {
|
||||||
|
return status.NewOwnerDeletePermissionError()
|
||||||
|
}
|
||||||
|
|
||||||
settings, err = transaction.GetAccountSettings(ctx, store.LockingStrengthNone, accountID)
|
settings, err = transaction.GetAccountSettings(ctx, store.LockingStrengthNone, accountID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to get account settings: %w", err)
|
return fmt.Errorf("failed to get account settings: %w", err)
|
||||||
|
|||||||
@@ -942,6 +942,49 @@ func TestUser_DeleteUser_regularUser(t *testing.T) {
|
|||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestUser_deleteRegularUser_RejectsOwner(t *testing.T) {
|
||||||
|
s, cleanup, err := store.NewTestStoreFromSQL(context.Background(), "", t.TempDir())
|
||||||
|
require.NoError(t, err)
|
||||||
|
t.Cleanup(cleanup)
|
||||||
|
|
||||||
|
account := newAccountWithId(context.Background(), mockAccountID, mockUserID, "", "", "", false)
|
||||||
|
account.Users[mockTargetUserId] = &types.User{
|
||||||
|
Id: mockTargetUserId,
|
||||||
|
Issued: types.UserIssuedAPI,
|
||||||
|
Role: types.UserRoleOwner,
|
||||||
|
}
|
||||||
|
require.NoError(t, s.SaveAccount(context.Background(), account))
|
||||||
|
|
||||||
|
am := DefaultAccountManager{Store: s}
|
||||||
|
|
||||||
|
_, err = am.deleteRegularUser(context.Background(), mockAccountID, mockUserID, &types.UserInfo{ID: mockTargetUserId})
|
||||||
|
assert.EqualError(t, err, status.NewOwnerDeletePermissionError().Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUser_deleteRegularUser_InitiatorOwnerDeletesThemself(t *testing.T) {
|
||||||
|
s, cleanup, err := store.NewTestStoreFromSQL(context.Background(), "", t.TempDir())
|
||||||
|
require.NoError(t, err)
|
||||||
|
t.Cleanup(cleanup)
|
||||||
|
|
||||||
|
account := newAccountWithId(context.Background(), mockAccountID, mockUserID, "", "", "", false)
|
||||||
|
require.NoError(t, s.SaveAccount(context.Background(), account))
|
||||||
|
|
||||||
|
networkMapControllerMock := network_map.NewMockController(gomock.NewController(t))
|
||||||
|
networkMapControllerMock.EXPECT().OnPeersDeleted(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return(nil)
|
||||||
|
|
||||||
|
am := DefaultAccountManager{
|
||||||
|
Store: s,
|
||||||
|
eventStore: &activity.InMemoryEventStore{},
|
||||||
|
networkMapController: networkMapControllerMock,
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = am.deleteRegularUser(context.Background(), mockAccountID, mockUserID, &types.UserInfo{ID: mockUserID})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
_, err = s.GetUserByUserID(context.Background(), store.LockingStrengthNone, mockUserID)
|
||||||
|
assert.Equal(t, status.NewUserNotFoundError(mockUserID), err)
|
||||||
|
}
|
||||||
|
|
||||||
func TestUser_DeleteUser_RegularUsers(t *testing.T) {
|
func TestUser_DeleteUser_RegularUsers(t *testing.T) {
|
||||||
store, cleanup, err := store.NewTestStoreFromSQL(context.Background(), "", t.TempDir())
|
store, cleanup, err := store.NewTestStoreFromSQL(context.Background(), "", t.TempDir())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -59,6 +59,11 @@ type DomainConfig struct {
|
|||||||
IPRestrictions *restrict.Filter
|
IPRestrictions *restrict.Filter
|
||||||
// Private routes the domain through ValidateTunnelPeer; failure → 403.
|
// Private routes the domain through ValidateTunnelPeer; failure → 403.
|
||||||
Private bool
|
Private bool
|
||||||
|
// AllowedGroups holds the group ids that may reach the service through an
|
||||||
|
// OIDC identity. When non-empty, a session cookie is honoured only if its
|
||||||
|
// groups claim intersects this set. Empty means group membership does not
|
||||||
|
// restrict access.
|
||||||
|
AllowedGroups map[string]struct{}
|
||||||
}
|
}
|
||||||
|
|
||||||
type validationResult struct {
|
type validationResult struct {
|
||||||
@@ -316,6 +321,9 @@ func (mw *Middleware) handleOAuthCallbackError(w http.ResponseWriter, r *http.Re
|
|||||||
|
|
||||||
// forwardWithSessionCookie checks for a valid session cookie and, if found,
|
// forwardWithSessionCookie checks for a valid session cookie and, if found,
|
||||||
// sets the user identity on the request context and forwards to the next handler.
|
// sets the user identity on the request context and forwards to the next handler.
|
||||||
|
// A signature-valid cookie is not on its own a grant: an OIDC session must also
|
||||||
|
// carry a group the service allows, so a token cannot be replayed past the
|
||||||
|
// group check that gated the login it came from.
|
||||||
func (mw *Middleware) forwardWithSessionCookie(w http.ResponseWriter, r *http.Request, host string, config DomainConfig, next http.Handler) bool {
|
func (mw *Middleware) forwardWithSessionCookie(w http.ResponseWriter, r *http.Request, host string, config DomainConfig, next http.Handler) bool {
|
||||||
cookie, err := r.Cookie(auth.SessionCookieName)
|
cookie, err := r.Cookie(auth.SessionCookieName)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -335,6 +343,14 @@ func (mw *Middleware) forwardWithSessionCookie(w http.ResponseWriter, r *http.Re
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if !sessionGroupsAllowed(config.AllowedGroups, auth.Method(method), groups) {
|
||||||
|
mw.logger.WithFields(log.Fields{
|
||||||
|
"host": host,
|
||||||
|
"user_id": userID,
|
||||||
|
}).Debug("session cookie rejected: groups claim does not intersect the service's allowed groups")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
if cd := proxy.CapturedDataFromContext(r.Context()); cd != nil {
|
if cd := proxy.CapturedDataFromContext(r.Context()); cd != nil {
|
||||||
cd.SetUserID(userID)
|
cd.SetUserID(userID)
|
||||||
cd.SetUserEmail(email)
|
cd.SetUserEmail(email)
|
||||||
@@ -625,7 +641,8 @@ func wasCredentialSubmitted(r *http.Request, method auth.Method) bool {
|
|||||||
|
|
||||||
// AddDomain registers authentication schemes for the given domain. With schemes a valid session public key is required.
|
// AddDomain registers authentication schemes for the given domain. With schemes a valid session public key is required.
|
||||||
// private=true forces ValidateTunnelPeer enforcement (403 on failure) regardless of the schemes list.
|
// private=true forces ValidateTunnelPeer enforcement (403 on failure) regardless of the schemes list.
|
||||||
func (mw *Middleware) AddDomain(domain string, schemes []Scheme, publicKeyB64 string, expiration time.Duration, accountID types.AccountID, serviceID types.ServiceID, ipRestrictions *restrict.Filter, private bool) error {
|
// allowedGroups restricts OIDC sessions to the given group ids; empty means unrestricted.
|
||||||
|
func (mw *Middleware) AddDomain(domain string, schemes []Scheme, publicKeyB64 string, expiration time.Duration, accountID types.AccountID, serviceID types.ServiceID, ipRestrictions *restrict.Filter, private bool, allowedGroups []string) error {
|
||||||
if len(schemes) == 0 {
|
if len(schemes) == 0 {
|
||||||
mw.domainsMux.Lock()
|
mw.domainsMux.Lock()
|
||||||
defer mw.domainsMux.Unlock()
|
defer mw.domainsMux.Unlock()
|
||||||
@@ -634,6 +651,7 @@ func (mw *Middleware) AddDomain(domain string, schemes []Scheme, publicKeyB64 st
|
|||||||
ServiceID: serviceID,
|
ServiceID: serviceID,
|
||||||
IPRestrictions: ipRestrictions,
|
IPRestrictions: ipRestrictions,
|
||||||
Private: private,
|
Private: private,
|
||||||
|
AllowedGroups: groupSet(allowedGroups),
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -656,6 +674,7 @@ func (mw *Middleware) AddDomain(domain string, schemes []Scheme, publicKeyB64 st
|
|||||||
ServiceID: serviceID,
|
ServiceID: serviceID,
|
||||||
IPRestrictions: ipRestrictions,
|
IPRestrictions: ipRestrictions,
|
||||||
Private: private,
|
Private: private,
|
||||||
|
AllowedGroups: groupSet(allowedGroups),
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -707,6 +726,50 @@ func (mw *Middleware) validateSessionToken(ctx context.Context, host, token stri
|
|||||||
return &validationResult{UserID: userID, UserEmail: email, Valid: true, Groups: groups, GroupNames: groupNames}, nil
|
return &validationResult{UserID: userID, UserEmail: email, Valid: true, Groups: groups, GroupNames: groupNames}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// groupSet builds the lookup set the cookie path consults, returning nil for an
|
||||||
|
// empty list so callers can test membership restriction with len().
|
||||||
|
func groupSet(groups []string) map[string]struct{} {
|
||||||
|
if len(groups) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
set := make(map[string]struct{}, len(groups))
|
||||||
|
for _, g := range groups {
|
||||||
|
if g != "" {
|
||||||
|
set[g] = struct{}{}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(set) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return set
|
||||||
|
}
|
||||||
|
|
||||||
|
// sessionGroupsAllowed reports whether a session token's groups claim satisfies
|
||||||
|
// the service's allowed groups. Only OIDC sessions are gated: password, PIN and
|
||||||
|
// header credentials carry no group identity and are authorised by the secret
|
||||||
|
// itself, which mirrors how management validates them. A token minted before the
|
||||||
|
// groups claim existed carries none and is therefore denied on a group-restricted
|
||||||
|
// service, which sends the user back through login for a fresh decision. A method
|
||||||
|
// this build doesn't know carries no such argument, so it is denied.
|
||||||
|
func sessionGroupsAllowed(allowed map[string]struct{}, method auth.Method, groups []string) bool {
|
||||||
|
if len(allowed) == 0 {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
switch method {
|
||||||
|
case auth.MethodPassword, auth.MethodPIN, auth.MethodHeader:
|
||||||
|
return true
|
||||||
|
case auth.MethodOIDC:
|
||||||
|
for _, g := range groups {
|
||||||
|
if _, ok := allowed[g]; ok {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// stripSessionTokenParam returns the request URI with the session_token query
|
// stripSessionTokenParam returns the request URI with the session_token query
|
||||||
// parameter removed so it doesn't linger in the browser's address bar or history.
|
// parameter removed so it doesn't linger in the browser's address bar or history.
|
||||||
func stripSessionTokenParam(u *url.URL) string {
|
func stripSessionTokenParam(u *url.URL) string {
|
||||||
|
|||||||
@@ -66,7 +66,7 @@ func TestAddDomain_ValidKey(t *testing.T) {
|
|||||||
kp := generateTestKeyPair(t)
|
kp := generateTestKeyPair(t)
|
||||||
|
|
||||||
scheme := &stubScheme{method: auth.MethodPIN, promptID: "pin"}
|
scheme := &stubScheme{method: auth.MethodPIN, promptID: "pin"}
|
||||||
err := mw.AddDomain("example.com", []Scheme{scheme}, kp.PublicKey, time.Hour, "", "", nil, false)
|
err := mw.AddDomain("example.com", []Scheme{scheme}, kp.PublicKey, time.Hour, "", "", nil, false, nil)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
mw.domainsMux.RLock()
|
mw.domainsMux.RLock()
|
||||||
@@ -83,7 +83,7 @@ func TestAddDomain_EmptyKey(t *testing.T) {
|
|||||||
mw := NewMiddleware(log.StandardLogger(), nil, nil)
|
mw := NewMiddleware(log.StandardLogger(), nil, nil)
|
||||||
|
|
||||||
scheme := &stubScheme{method: auth.MethodPIN, promptID: "pin"}
|
scheme := &stubScheme{method: auth.MethodPIN, promptID: "pin"}
|
||||||
err := mw.AddDomain("example.com", []Scheme{scheme}, "", time.Hour, "", "", nil, false)
|
err := mw.AddDomain("example.com", []Scheme{scheme}, "", time.Hour, "", "", nil, false, nil)
|
||||||
require.Error(t, err)
|
require.Error(t, err)
|
||||||
assert.Contains(t, err.Error(), "invalid session public key size")
|
assert.Contains(t, err.Error(), "invalid session public key size")
|
||||||
|
|
||||||
@@ -97,7 +97,7 @@ func TestAddDomain_InvalidBase64(t *testing.T) {
|
|||||||
mw := NewMiddleware(log.StandardLogger(), nil, nil)
|
mw := NewMiddleware(log.StandardLogger(), nil, nil)
|
||||||
|
|
||||||
scheme := &stubScheme{method: auth.MethodPIN, promptID: "pin"}
|
scheme := &stubScheme{method: auth.MethodPIN, promptID: "pin"}
|
||||||
err := mw.AddDomain("example.com", []Scheme{scheme}, "not-valid-base64!!!", time.Hour, "", "", nil, false)
|
err := mw.AddDomain("example.com", []Scheme{scheme}, "not-valid-base64!!!", time.Hour, "", "", nil, false, nil)
|
||||||
require.Error(t, err)
|
require.Error(t, err)
|
||||||
assert.Contains(t, err.Error(), "decode session public key")
|
assert.Contains(t, err.Error(), "decode session public key")
|
||||||
|
|
||||||
@@ -112,7 +112,7 @@ func TestAddDomain_WrongKeySize(t *testing.T) {
|
|||||||
|
|
||||||
shortKey := base64.StdEncoding.EncodeToString([]byte("tooshort"))
|
shortKey := base64.StdEncoding.EncodeToString([]byte("tooshort"))
|
||||||
scheme := &stubScheme{method: auth.MethodPIN, promptID: "pin"}
|
scheme := &stubScheme{method: auth.MethodPIN, promptID: "pin"}
|
||||||
err := mw.AddDomain("example.com", []Scheme{scheme}, shortKey, time.Hour, "", "", nil, false)
|
err := mw.AddDomain("example.com", []Scheme{scheme}, shortKey, time.Hour, "", "", nil, false, nil)
|
||||||
require.Error(t, err)
|
require.Error(t, err)
|
||||||
assert.Contains(t, err.Error(), "invalid session public key size")
|
assert.Contains(t, err.Error(), "invalid session public key size")
|
||||||
|
|
||||||
@@ -125,7 +125,7 @@ func TestAddDomain_WrongKeySize(t *testing.T) {
|
|||||||
func TestAddDomain_NoSchemes_NoKeyRequired(t *testing.T) {
|
func TestAddDomain_NoSchemes_NoKeyRequired(t *testing.T) {
|
||||||
mw := NewMiddleware(log.StandardLogger(), nil, nil)
|
mw := NewMiddleware(log.StandardLogger(), nil, nil)
|
||||||
|
|
||||||
err := mw.AddDomain("example.com", nil, "", time.Hour, "", "", nil, false)
|
err := mw.AddDomain("example.com", nil, "", time.Hour, "", "", nil, false, nil)
|
||||||
require.NoError(t, err, "domains with no auth schemes should not require a key")
|
require.NoError(t, err, "domains with no auth schemes should not require a key")
|
||||||
|
|
||||||
mw.domainsMux.RLock()
|
mw.domainsMux.RLock()
|
||||||
@@ -141,8 +141,8 @@ func TestAddDomain_OverwritesPreviousConfig(t *testing.T) {
|
|||||||
|
|
||||||
scheme := &stubScheme{method: auth.MethodPIN, promptID: "pin"}
|
scheme := &stubScheme{method: auth.MethodPIN, promptID: "pin"}
|
||||||
|
|
||||||
require.NoError(t, mw.AddDomain("example.com", []Scheme{scheme}, kp1.PublicKey, time.Hour, "", "", nil, false))
|
require.NoError(t, mw.AddDomain("example.com", []Scheme{scheme}, kp1.PublicKey, time.Hour, "", "", nil, false, nil))
|
||||||
require.NoError(t, mw.AddDomain("example.com", []Scheme{scheme}, kp2.PublicKey, 2*time.Hour, "", "", nil, false))
|
require.NoError(t, mw.AddDomain("example.com", []Scheme{scheme}, kp2.PublicKey, 2*time.Hour, "", "", nil, false, nil))
|
||||||
|
|
||||||
mw.domainsMux.RLock()
|
mw.domainsMux.RLock()
|
||||||
config := mw.domains["example.com"]
|
config := mw.domains["example.com"]
|
||||||
@@ -158,7 +158,7 @@ func TestRemoveDomain(t *testing.T) {
|
|||||||
kp := generateTestKeyPair(t)
|
kp := generateTestKeyPair(t)
|
||||||
|
|
||||||
scheme := &stubScheme{method: auth.MethodPIN, promptID: "pin"}
|
scheme := &stubScheme{method: auth.MethodPIN, promptID: "pin"}
|
||||||
require.NoError(t, mw.AddDomain("example.com", []Scheme{scheme}, kp.PublicKey, time.Hour, "", "", nil, false))
|
require.NoError(t, mw.AddDomain("example.com", []Scheme{scheme}, kp.PublicKey, time.Hour, "", "", nil, false, nil))
|
||||||
|
|
||||||
mw.RemoveDomain("example.com")
|
mw.RemoveDomain("example.com")
|
||||||
|
|
||||||
@@ -182,7 +182,7 @@ func TestProtect_UnknownDomainPassesThrough(t *testing.T) {
|
|||||||
|
|
||||||
func TestProtect_DomainWithNoSchemesPassesThrough(t *testing.T) {
|
func TestProtect_DomainWithNoSchemesPassesThrough(t *testing.T) {
|
||||||
mw := NewMiddleware(log.StandardLogger(), nil, nil)
|
mw := NewMiddleware(log.StandardLogger(), nil, nil)
|
||||||
require.NoError(t, mw.AddDomain("example.com", nil, "", time.Hour, "", "", nil, false))
|
require.NoError(t, mw.AddDomain("example.com", nil, "", time.Hour, "", "", nil, false, nil))
|
||||||
|
|
||||||
handler := mw.Protect(newPassthroughHandler())
|
handler := mw.Protect(newPassthroughHandler())
|
||||||
|
|
||||||
@@ -199,7 +199,7 @@ func TestProtect_UnauthenticatedRequestIsBlocked(t *testing.T) {
|
|||||||
kp := generateTestKeyPair(t)
|
kp := generateTestKeyPair(t)
|
||||||
|
|
||||||
scheme := &stubScheme{method: auth.MethodPIN, promptID: "pin"}
|
scheme := &stubScheme{method: auth.MethodPIN, promptID: "pin"}
|
||||||
require.NoError(t, mw.AddDomain("example.com", []Scheme{scheme}, kp.PublicKey, time.Hour, "", "", nil, false))
|
require.NoError(t, mw.AddDomain("example.com", []Scheme{scheme}, kp.PublicKey, time.Hour, "", "", nil, false, nil))
|
||||||
|
|
||||||
var backendCalled bool
|
var backendCalled bool
|
||||||
backend := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
backend := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||||
@@ -220,7 +220,7 @@ func TestProtect_HostWithPortIsMatched(t *testing.T) {
|
|||||||
kp := generateTestKeyPair(t)
|
kp := generateTestKeyPair(t)
|
||||||
|
|
||||||
scheme := &stubScheme{method: auth.MethodPIN, promptID: "pin"}
|
scheme := &stubScheme{method: auth.MethodPIN, promptID: "pin"}
|
||||||
require.NoError(t, mw.AddDomain("example.com", []Scheme{scheme}, kp.PublicKey, time.Hour, "", "", nil, false))
|
require.NoError(t, mw.AddDomain("example.com", []Scheme{scheme}, kp.PublicKey, time.Hour, "", "", nil, false, nil))
|
||||||
|
|
||||||
var backendCalled bool
|
var backendCalled bool
|
||||||
backend := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
backend := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||||
@@ -241,7 +241,7 @@ func TestProtect_ValidSessionCookiePassesThrough(t *testing.T) {
|
|||||||
kp := generateTestKeyPair(t)
|
kp := generateTestKeyPair(t)
|
||||||
|
|
||||||
scheme := &stubScheme{method: auth.MethodPIN, promptID: "pin"}
|
scheme := &stubScheme{method: auth.MethodPIN, promptID: "pin"}
|
||||||
require.NoError(t, mw.AddDomain("example.com", []Scheme{scheme}, kp.PublicKey, time.Hour, "", "", nil, false))
|
require.NoError(t, mw.AddDomain("example.com", []Scheme{scheme}, kp.PublicKey, time.Hour, "", "", nil, false, nil))
|
||||||
|
|
||||||
token, err := sessionkey.SignToken(kp.PrivateKey, "test-user", "", "example.com", auth.MethodPIN, nil, nil, time.Hour)
|
token, err := sessionkey.SignToken(kp.PrivateKey, "test-user", "", "example.com", auth.MethodPIN, nil, nil, time.Hour)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
@@ -274,7 +274,7 @@ func TestProtect_SessionCookieGroupsPropagate(t *testing.T) {
|
|||||||
kp := generateTestKeyPair(t)
|
kp := generateTestKeyPair(t)
|
||||||
|
|
||||||
scheme := &stubScheme{method: auth.MethodPIN, promptID: "pin"}
|
scheme := &stubScheme{method: auth.MethodPIN, promptID: "pin"}
|
||||||
require.NoError(t, mw.AddDomain("example.com", []Scheme{scheme}, kp.PublicKey, time.Hour, "", "", nil, false))
|
require.NoError(t, mw.AddDomain("example.com", []Scheme{scheme}, kp.PublicKey, time.Hour, "", "", nil, false, nil))
|
||||||
|
|
||||||
groups := []string{"engineering", "sre"}
|
groups := []string{"engineering", "sre"}
|
||||||
token, err := sessionkey.SignToken(kp.PrivateKey, "test-user", "", "example.com", auth.MethodPIN, groups, nil, time.Hour)
|
token, err := sessionkey.SignToken(kp.PrivateKey, "test-user", "", "example.com", auth.MethodPIN, groups, nil, time.Hour)
|
||||||
@@ -339,7 +339,7 @@ func TestProtect_PrivateService_TunnelPeerGroupsPropagate(t *testing.T) {
|
|||||||
kp := generateTestKeyPair(t)
|
kp := generateTestKeyPair(t)
|
||||||
|
|
||||||
// Private service: no operator schemes — auth gates solely on the tunnel peer.
|
// Private service: no operator schemes — auth gates solely on the tunnel peer.
|
||||||
require.NoError(t, mw.AddDomain("agent.example.com", nil, kp.PublicKey, time.Hour, "acct-1", "svc-1", nil, true))
|
require.NoError(t, mw.AddDomain("agent.example.com", nil, kp.PublicKey, time.Hour, "acct-1", "svc-1", nil, true, nil))
|
||||||
|
|
||||||
cd := proxy.NewCapturedData("")
|
cd := proxy.NewCapturedData("")
|
||||||
cd.SetClientIP(netip.MustParseAddr("100.90.1.14")) // CGNAT tunnel source
|
cd.SetClientIP(netip.MustParseAddr("100.90.1.14")) // CGNAT tunnel source
|
||||||
@@ -379,7 +379,7 @@ func TestProtect_PrivateService_TunnelPeerDenied(t *testing.T) {
|
|||||||
}}
|
}}
|
||||||
mw := NewMiddleware(log.StandardLogger(), validator, nil)
|
mw := NewMiddleware(log.StandardLogger(), validator, nil)
|
||||||
kp := generateTestKeyPair(t)
|
kp := generateTestKeyPair(t)
|
||||||
require.NoError(t, mw.AddDomain("agent.example.com", nil, kp.PublicKey, time.Hour, "acct-1", "svc-1", nil, true))
|
require.NoError(t, mw.AddDomain("agent.example.com", nil, kp.PublicKey, time.Hour, "acct-1", "svc-1", nil, true, nil))
|
||||||
|
|
||||||
cd := proxy.NewCapturedData("")
|
cd := proxy.NewCapturedData("")
|
||||||
cd.SetClientIP(netip.MustParseAddr("100.90.1.14"))
|
cd.SetClientIP(netip.MustParseAddr("100.90.1.14"))
|
||||||
@@ -407,7 +407,7 @@ func TestProtect_ExpiredSessionCookieIsRejected(t *testing.T) {
|
|||||||
kp := generateTestKeyPair(t)
|
kp := generateTestKeyPair(t)
|
||||||
|
|
||||||
scheme := &stubScheme{method: auth.MethodPIN, promptID: "pin"}
|
scheme := &stubScheme{method: auth.MethodPIN, promptID: "pin"}
|
||||||
require.NoError(t, mw.AddDomain("example.com", []Scheme{scheme}, kp.PublicKey, time.Hour, "", "", nil, false))
|
require.NoError(t, mw.AddDomain("example.com", []Scheme{scheme}, kp.PublicKey, time.Hour, "", "", nil, false, nil))
|
||||||
|
|
||||||
// Sign a token that expired 1 second ago.
|
// Sign a token that expired 1 second ago.
|
||||||
token, err := sessionkey.SignToken(kp.PrivateKey, "test-user", "", "example.com", auth.MethodPIN, nil, nil, -time.Second)
|
token, err := sessionkey.SignToken(kp.PrivateKey, "test-user", "", "example.com", auth.MethodPIN, nil, nil, -time.Second)
|
||||||
@@ -433,7 +433,7 @@ func TestProtect_WrongDomainCookieIsRejected(t *testing.T) {
|
|||||||
kp := generateTestKeyPair(t)
|
kp := generateTestKeyPair(t)
|
||||||
|
|
||||||
scheme := &stubScheme{method: auth.MethodPIN, promptID: "pin"}
|
scheme := &stubScheme{method: auth.MethodPIN, promptID: "pin"}
|
||||||
require.NoError(t, mw.AddDomain("example.com", []Scheme{scheme}, kp.PublicKey, time.Hour, "", "", nil, false))
|
require.NoError(t, mw.AddDomain("example.com", []Scheme{scheme}, kp.PublicKey, time.Hour, "", "", nil, false, nil))
|
||||||
|
|
||||||
// Token signed for a different domain audience.
|
// Token signed for a different domain audience.
|
||||||
token, err := sessionkey.SignToken(kp.PrivateKey, "test-user", "", "other.com", auth.MethodPIN, nil, nil, time.Hour)
|
token, err := sessionkey.SignToken(kp.PrivateKey, "test-user", "", "other.com", auth.MethodPIN, nil, nil, time.Hour)
|
||||||
@@ -460,7 +460,7 @@ func TestProtect_WrongKeyCookieIsRejected(t *testing.T) {
|
|||||||
kp2 := generateTestKeyPair(t)
|
kp2 := generateTestKeyPair(t)
|
||||||
|
|
||||||
scheme := &stubScheme{method: auth.MethodPIN, promptID: "pin"}
|
scheme := &stubScheme{method: auth.MethodPIN, promptID: "pin"}
|
||||||
require.NoError(t, mw.AddDomain("example.com", []Scheme{scheme}, kp1.PublicKey, time.Hour, "", "", nil, false))
|
require.NoError(t, mw.AddDomain("example.com", []Scheme{scheme}, kp1.PublicKey, time.Hour, "", "", nil, false, nil))
|
||||||
|
|
||||||
// Token signed with a different private key.
|
// Token signed with a different private key.
|
||||||
token, err := sessionkey.SignToken(kp2.PrivateKey, "test-user", "", "example.com", auth.MethodPIN, nil, nil, time.Hour)
|
token, err := sessionkey.SignToken(kp2.PrivateKey, "test-user", "", "example.com", auth.MethodPIN, nil, nil, time.Hour)
|
||||||
@@ -497,7 +497,7 @@ func TestProtect_SchemeAuthRedirectsWithCookie(t *testing.T) {
|
|||||||
return "", "pin", nil
|
return "", "pin", nil
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
require.NoError(t, mw.AddDomain("example.com", []Scheme{scheme}, kp.PublicKey, time.Hour, "", "", nil, false))
|
require.NoError(t, mw.AddDomain("example.com", []Scheme{scheme}, kp.PublicKey, time.Hour, "", "", nil, false, nil))
|
||||||
|
|
||||||
var backendCalled bool
|
var backendCalled bool
|
||||||
backend := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
backend := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||||
@@ -550,7 +550,7 @@ func TestProtect_FailedAuthDoesNotSetCookie(t *testing.T) {
|
|||||||
return "", "pin", nil
|
return "", "pin", nil
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
require.NoError(t, mw.AddDomain("example.com", []Scheme{scheme}, kp.PublicKey, time.Hour, "", "", nil, false))
|
require.NoError(t, mw.AddDomain("example.com", []Scheme{scheme}, kp.PublicKey, time.Hour, "", "", nil, false, nil))
|
||||||
|
|
||||||
handler := mw.Protect(newPassthroughHandler())
|
handler := mw.Protect(newPassthroughHandler())
|
||||||
|
|
||||||
@@ -586,7 +586,7 @@ func TestProtect_MultipleSchemes(t *testing.T) {
|
|||||||
return "", "password", nil
|
return "", "password", nil
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
require.NoError(t, mw.AddDomain("example.com", []Scheme{pinScheme, passwordScheme}, kp.PublicKey, time.Hour, "", "", nil, false))
|
require.NoError(t, mw.AddDomain("example.com", []Scheme{pinScheme, passwordScheme}, kp.PublicKey, time.Hour, "", "", nil, false, nil))
|
||||||
|
|
||||||
var backendCalled bool
|
var backendCalled bool
|
||||||
backend := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
backend := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||||
@@ -616,7 +616,7 @@ func TestProtect_InvalidTokenFromSchemeReturns400(t *testing.T) {
|
|||||||
return "invalid-jwt-token", "", nil
|
return "invalid-jwt-token", "", nil
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
require.NoError(t, mw.AddDomain("example.com", []Scheme{scheme}, kp.PublicKey, time.Hour, "", "", nil, false))
|
require.NoError(t, mw.AddDomain("example.com", []Scheme{scheme}, kp.PublicKey, time.Hour, "", "", nil, false, nil))
|
||||||
|
|
||||||
handler := mw.Protect(newPassthroughHandler())
|
handler := mw.Protect(newPassthroughHandler())
|
||||||
|
|
||||||
@@ -640,7 +640,7 @@ func TestAddDomain_RandomBytes32NotEd25519(t *testing.T) {
|
|||||||
key := base64.StdEncoding.EncodeToString(randomBytes)
|
key := base64.StdEncoding.EncodeToString(randomBytes)
|
||||||
scheme := &stubScheme{method: auth.MethodPIN, promptID: "pin"}
|
scheme := &stubScheme{method: auth.MethodPIN, promptID: "pin"}
|
||||||
|
|
||||||
err = mw.AddDomain("example.com", []Scheme{scheme}, key, time.Hour, "", "", nil, false)
|
err = mw.AddDomain("example.com", []Scheme{scheme}, key, time.Hour, "", "", nil, false, nil)
|
||||||
require.NoError(t, err, "any 32-byte key should be accepted at registration time")
|
require.NoError(t, err, "any 32-byte key should be accepted at registration time")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -649,10 +649,10 @@ func TestAddDomain_InvalidKeyDoesNotCorruptExistingConfig(t *testing.T) {
|
|||||||
kp := generateTestKeyPair(t)
|
kp := generateTestKeyPair(t)
|
||||||
|
|
||||||
scheme := &stubScheme{method: auth.MethodPIN, promptID: "pin"}
|
scheme := &stubScheme{method: auth.MethodPIN, promptID: "pin"}
|
||||||
require.NoError(t, mw.AddDomain("example.com", []Scheme{scheme}, kp.PublicKey, time.Hour, "", "", nil, false))
|
require.NoError(t, mw.AddDomain("example.com", []Scheme{scheme}, kp.PublicKey, time.Hour, "", "", nil, false, nil))
|
||||||
|
|
||||||
// Attempt to overwrite with an invalid key.
|
// Attempt to overwrite with an invalid key.
|
||||||
err := mw.AddDomain("example.com", []Scheme{scheme}, "bad", time.Hour, "", "", nil, false)
|
err := mw.AddDomain("example.com", []Scheme{scheme}, "bad", time.Hour, "", "", nil, false, nil)
|
||||||
require.Error(t, err)
|
require.Error(t, err)
|
||||||
|
|
||||||
// The original valid config should still be intact.
|
// The original valid config should still be intact.
|
||||||
@@ -676,7 +676,7 @@ func TestProtect_FailedPinAuthCapturesAuthMethod(t *testing.T) {
|
|||||||
return "", "pin", nil
|
return "", "pin", nil
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
require.NoError(t, mw.AddDomain("example.com", []Scheme{scheme}, kp.PublicKey, time.Hour, "", "", nil, false))
|
require.NoError(t, mw.AddDomain("example.com", []Scheme{scheme}, kp.PublicKey, time.Hour, "", "", nil, false, nil))
|
||||||
|
|
||||||
capturedData := proxy.NewCapturedData("")
|
capturedData := proxy.NewCapturedData("")
|
||||||
handler := mw.Protect(newPassthroughHandler())
|
handler := mw.Protect(newPassthroughHandler())
|
||||||
@@ -703,7 +703,7 @@ func TestProtect_FailedPasswordAuthCapturesAuthMethod(t *testing.T) {
|
|||||||
return "", "password", nil
|
return "", "password", nil
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
require.NoError(t, mw.AddDomain("example.com", []Scheme{scheme}, kp.PublicKey, time.Hour, "", "", nil, false))
|
require.NoError(t, mw.AddDomain("example.com", []Scheme{scheme}, kp.PublicKey, time.Hour, "", "", nil, false, nil))
|
||||||
|
|
||||||
capturedData := proxy.NewCapturedData("")
|
capturedData := proxy.NewCapturedData("")
|
||||||
handler := mw.Protect(newPassthroughHandler())
|
handler := mw.Protect(newPassthroughHandler())
|
||||||
@@ -730,7 +730,7 @@ func TestProtect_NoCredentialsDoesNotCaptureAuthMethod(t *testing.T) {
|
|||||||
return "", "pin", nil
|
return "", "pin", nil
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
require.NoError(t, mw.AddDomain("example.com", []Scheme{scheme}, kp.PublicKey, time.Hour, "", "", nil, false))
|
require.NoError(t, mw.AddDomain("example.com", []Scheme{scheme}, kp.PublicKey, time.Hour, "", "", nil, false, nil))
|
||||||
|
|
||||||
capturedData := proxy.NewCapturedData("")
|
capturedData := proxy.NewCapturedData("")
|
||||||
handler := mw.Protect(newPassthroughHandler())
|
handler := mw.Protect(newPassthroughHandler())
|
||||||
@@ -818,7 +818,7 @@ func TestCheckIPRestrictions_UnparseableAddress(t *testing.T) {
|
|||||||
mw := NewMiddleware(log.StandardLogger(), nil, nil)
|
mw := NewMiddleware(log.StandardLogger(), nil, nil)
|
||||||
|
|
||||||
err := mw.AddDomain("example.com", nil, "", 0, "acc1", "svc1",
|
err := mw.AddDomain("example.com", nil, "", 0, "acc1", "svc1",
|
||||||
restrict.ParseFilter(restrict.FilterConfig{AllowedCIDRs: []string{"10.0.0.0/8"}}), false)
|
restrict.ParseFilter(restrict.FilterConfig{AllowedCIDRs: []string{"10.0.0.0/8"}}), false, nil)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
handler := mw.Protect(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
handler := mw.Protect(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
@@ -854,7 +854,7 @@ func TestCheckIPRestrictions_UsesCapturedDataClientIP(t *testing.T) {
|
|||||||
mw := NewMiddleware(log.StandardLogger(), nil, nil)
|
mw := NewMiddleware(log.StandardLogger(), nil, nil)
|
||||||
|
|
||||||
err := mw.AddDomain("example.com", nil, "", 0, "acc1", "svc1",
|
err := mw.AddDomain("example.com", nil, "", 0, "acc1", "svc1",
|
||||||
restrict.ParseFilter(restrict.FilterConfig{AllowedCIDRs: []string{"203.0.113.0/24"}}), false)
|
restrict.ParseFilter(restrict.FilterConfig{AllowedCIDRs: []string{"203.0.113.0/24"}}), false, nil)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
handler := mw.Protect(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
handler := mw.Protect(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
@@ -895,7 +895,7 @@ func TestCheckIPRestrictions_NilGeoWithCountryRules(t *testing.T) {
|
|||||||
mw := NewMiddleware(log.StandardLogger(), nil, nil)
|
mw := NewMiddleware(log.StandardLogger(), nil, nil)
|
||||||
|
|
||||||
err := mw.AddDomain("example.com", nil, "", 0, "acc1", "svc1",
|
err := mw.AddDomain("example.com", nil, "", 0, "acc1", "svc1",
|
||||||
restrict.ParseFilter(restrict.FilterConfig{AllowedCountries: []string{"US"}}), false)
|
restrict.ParseFilter(restrict.FilterConfig{AllowedCountries: []string{"US"}}), false, nil)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
handler := mw.Protect(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
handler := mw.Protect(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
@@ -922,7 +922,7 @@ func TestCheckIPRestrictions_OverlayOriginSkipsCountryRules(t *testing.T) {
|
|||||||
restrict.ParseFilter(restrict.FilterConfig{
|
restrict.ParseFilter(restrict.FilterConfig{
|
||||||
AllowedCIDRs: []string{"100.64.0.0/10"},
|
AllowedCIDRs: []string{"100.64.0.0/10"},
|
||||||
AllowedCountries: []string{"US"},
|
AllowedCountries: []string{"US"},
|
||||||
}), false)
|
}), false, nil)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
handler := mw.Protect(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
handler := mw.Protect(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||||
@@ -956,7 +956,7 @@ func TestCheckIPRestrictions_OverlayOriginRespectsCIDR(t *testing.T) {
|
|||||||
mw := NewMiddleware(log.StandardLogger(), nil, nil)
|
mw := NewMiddleware(log.StandardLogger(), nil, nil)
|
||||||
|
|
||||||
err := mw.AddDomain("example.com", nil, "", 0, "acc1", "svc1",
|
err := mw.AddDomain("example.com", nil, "", 0, "acc1", "svc1",
|
||||||
restrict.ParseFilter(restrict.FilterConfig{AllowedCIDRs: []string{"100.64.0.0/16"}}), false)
|
restrict.ParseFilter(restrict.FilterConfig{AllowedCIDRs: []string{"100.64.0.0/16"}}), false, nil)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
handler := mw.Protect(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
handler := mw.Protect(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||||
@@ -984,7 +984,7 @@ func TestProtect_OIDCOnlyRedirectsDirectly(t *testing.T) {
|
|||||||
return "", oidcURL, nil
|
return "", oidcURL, nil
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
require.NoError(t, mw.AddDomain("example.com", []Scheme{scheme}, kp.PublicKey, time.Hour, "", "", nil, false))
|
require.NoError(t, mw.AddDomain("example.com", []Scheme{scheme}, kp.PublicKey, time.Hour, "", "", nil, false, nil))
|
||||||
|
|
||||||
handler := mw.Protect(newPassthroughHandler())
|
handler := mw.Protect(newPassthroughHandler())
|
||||||
|
|
||||||
@@ -1013,7 +1013,7 @@ func TestProtect_OIDCWithOtherMethodShowsLoginPage(t *testing.T) {
|
|||||||
return "", "pin", nil
|
return "", "pin", nil
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
require.NoError(t, mw.AddDomain("example.com", []Scheme{oidcScheme, pinScheme}, kp.PublicKey, time.Hour, "", "", nil, false))
|
require.NoError(t, mw.AddDomain("example.com", []Scheme{oidcScheme, pinScheme}, kp.PublicKey, time.Hour, "", "", nil, false, nil))
|
||||||
|
|
||||||
handler := mw.Protect(newPassthroughHandler())
|
handler := mw.Protect(newPassthroughHandler())
|
||||||
|
|
||||||
@@ -1043,7 +1043,7 @@ func TestProtect_HeaderAuth_ForwardsOnSuccess(t *testing.T) {
|
|||||||
kp := generateTestKeyPair(t)
|
kp := generateTestKeyPair(t)
|
||||||
|
|
||||||
hdr := newHeaderScheme(t, "X-API-Key", "secret-key")
|
hdr := newHeaderScheme(t, "X-API-Key", "secret-key")
|
||||||
require.NoError(t, mw.AddDomain("example.com", []Scheme{hdr}, kp.PublicKey, time.Hour, "acc1", "svc1", nil, false))
|
require.NoError(t, mw.AddDomain("example.com", []Scheme{hdr}, kp.PublicKey, time.Hour, "acc1", "svc1", nil, false, nil))
|
||||||
|
|
||||||
var backendCalled bool
|
var backendCalled bool
|
||||||
capturedData := proxy.NewCapturedData("")
|
capturedData := proxy.NewCapturedData("")
|
||||||
@@ -1079,7 +1079,7 @@ func TestProtect_HeaderAuth_MissingHeaderFallsThrough(t *testing.T) {
|
|||||||
hdr := newHeaderScheme(t, "X-API-Key", "secret-key")
|
hdr := newHeaderScheme(t, "X-API-Key", "secret-key")
|
||||||
// Also add a PIN scheme so we can verify fallthrough behavior.
|
// Also add a PIN scheme so we can verify fallthrough behavior.
|
||||||
pinScheme := &stubScheme{method: auth.MethodPIN, promptID: "pin"}
|
pinScheme := &stubScheme{method: auth.MethodPIN, promptID: "pin"}
|
||||||
require.NoError(t, mw.AddDomain("example.com", []Scheme{hdr, pinScheme}, kp.PublicKey, time.Hour, "acc1", "svc1", nil, false))
|
require.NoError(t, mw.AddDomain("example.com", []Scheme{hdr, pinScheme}, kp.PublicKey, time.Hour, "acc1", "svc1", nil, false, nil))
|
||||||
|
|
||||||
handler := mw.Protect(newPassthroughHandler())
|
handler := mw.Protect(newPassthroughHandler())
|
||||||
|
|
||||||
@@ -1096,7 +1096,7 @@ func TestProtect_HeaderAuth_WrongValueReturns401(t *testing.T) {
|
|||||||
kp := generateTestKeyPair(t)
|
kp := generateTestKeyPair(t)
|
||||||
|
|
||||||
hdr := newHeaderScheme(t, "X-API-Key", "secret-key")
|
hdr := newHeaderScheme(t, "X-API-Key", "secret-key")
|
||||||
require.NoError(t, mw.AddDomain("example.com", []Scheme{hdr}, kp.PublicKey, time.Hour, "acc1", "svc1", nil, false))
|
require.NoError(t, mw.AddDomain("example.com", []Scheme{hdr}, kp.PublicKey, time.Hour, "acc1", "svc1", nil, false, nil))
|
||||||
|
|
||||||
capturedData := proxy.NewCapturedData("")
|
capturedData := proxy.NewCapturedData("")
|
||||||
handler := mw.Protect(newPassthroughHandler())
|
handler := mw.Protect(newPassthroughHandler())
|
||||||
@@ -1137,7 +1137,7 @@ func TestProtect_HeaderAuth_MatchesAnyConfiguredHeader(t *testing.T) {
|
|||||||
if tt.matchedLast {
|
if tt.matchedLast {
|
||||||
schemes = []Scheme{authz, apiKey}
|
schemes = []Scheme{authz, apiKey}
|
||||||
}
|
}
|
||||||
require.NoError(t, mw.AddDomain("example.com", schemes, kp.PublicKey, time.Hour, "acc1", "svc1", nil, false))
|
require.NoError(t, mw.AddDomain("example.com", schemes, kp.PublicKey, time.Hour, "acc1", "svc1", nil, false, nil))
|
||||||
|
|
||||||
var backendCalled bool
|
var backendCalled bool
|
||||||
handler := mw.Protect(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
handler := mw.Protect(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||||
@@ -1166,7 +1166,7 @@ func TestProtect_HeaderAuth_RejectsWhenEveryPresentedHeaderFails(t *testing.T) {
|
|||||||
|
|
||||||
authz := newHeaderScheme(t, "Authorization", "Bearer proxy-secret")
|
authz := newHeaderScheme(t, "Authorization", "Bearer proxy-secret")
|
||||||
apiKey := newHeaderScheme(t, "X-Api-Key", "secret-key")
|
apiKey := newHeaderScheme(t, "X-Api-Key", "secret-key")
|
||||||
require.NoError(t, mw.AddDomain("example.com", []Scheme{authz, apiKey}, kp.PublicKey, time.Hour, "acc1", "svc1", nil, false))
|
require.NoError(t, mw.AddDomain("example.com", []Scheme{authz, apiKey}, kp.PublicKey, time.Hour, "acc1", "svc1", nil, false, nil))
|
||||||
|
|
||||||
var backendCalled bool
|
var backendCalled bool
|
||||||
handler := mw.Protect(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
handler := mw.Protect(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||||
@@ -1209,7 +1209,7 @@ func TestProtect_HeaderAuth_ReportsUndecodableHash(t *testing.T) {
|
|||||||
kp := generateTestKeyPair(t)
|
kp := generateTestKeyPair(t)
|
||||||
|
|
||||||
require.NoError(t, mw.AddDomain("example.com", []Scheme{NewHeader("X-Api-Key", tt.hashes)},
|
require.NoError(t, mw.AddDomain("example.com", []Scheme{NewHeader("X-Api-Key", tt.hashes)},
|
||||||
kp.PublicKey, time.Hour, "acc1", "svc1", nil, false))
|
kp.PublicKey, time.Hour, "acc1", "svc1", nil, false, nil))
|
||||||
|
|
||||||
handler := mw.Protect(newPassthroughHandler())
|
handler := mw.Protect(newPassthroughHandler())
|
||||||
|
|
||||||
@@ -1245,7 +1245,7 @@ func TestProtect_HeaderAuth_NoHashesFailsClosed(t *testing.T) {
|
|||||||
kp := generateTestKeyPair(t)
|
kp := generateTestKeyPair(t)
|
||||||
|
|
||||||
hdr := NewHeader("X-API-Key", nil)
|
hdr := NewHeader("X-API-Key", nil)
|
||||||
require.NoError(t, mw.AddDomain("example.com", []Scheme{hdr}, kp.PublicKey, time.Hour, "acc1", "svc1", nil, false))
|
require.NoError(t, mw.AddDomain("example.com", []Scheme{hdr}, kp.PublicKey, time.Hour, "acc1", "svc1", nil, false, nil))
|
||||||
|
|
||||||
var backendCalled bool
|
var backendCalled bool
|
||||||
handler := mw.Protect(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
handler := mw.Protect(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||||
@@ -1270,7 +1270,7 @@ func TestProtect_HeaderAuth_SubsequentRequestRequiresHeader(t *testing.T) {
|
|||||||
kp := generateTestKeyPair(t)
|
kp := generateTestKeyPair(t)
|
||||||
|
|
||||||
hdr := newHeaderScheme(t, "X-API-Key", "secret-key")
|
hdr := newHeaderScheme(t, "X-API-Key", "secret-key")
|
||||||
require.NoError(t, mw.AddDomain("example.com", []Scheme{hdr}, kp.PublicKey, time.Hour, "acc1", "svc1", nil, false))
|
require.NoError(t, mw.AddDomain("example.com", []Scheme{hdr}, kp.PublicKey, time.Hour, "acc1", "svc1", nil, false, nil))
|
||||||
|
|
||||||
var backendCalls int
|
var backendCalls int
|
||||||
handler := mw.Protect(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
handler := mw.Protect(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||||
@@ -1309,7 +1309,7 @@ func TestProtect_HeaderAuth_LegacySessionCookieIsIgnored(t *testing.T) {
|
|||||||
kp := generateTestKeyPair(t)
|
kp := generateTestKeyPair(t)
|
||||||
|
|
||||||
hdr := newHeaderScheme(t, "X-API-Key", "secret-key")
|
hdr := newHeaderScheme(t, "X-API-Key", "secret-key")
|
||||||
require.NoError(t, mw.AddDomain("example.com", []Scheme{hdr}, kp.PublicKey, time.Hour, "acc1", "svc1", nil, false))
|
require.NoError(t, mw.AddDomain("example.com", []Scheme{hdr}, kp.PublicKey, time.Hour, "acc1", "svc1", nil, false, nil))
|
||||||
|
|
||||||
// A token management would have minted for header auth before the upgrade.
|
// A token management would have minted for header auth before the upgrade.
|
||||||
legacyToken, err := sessionkey.SignToken(kp.PrivateKey, auth.HeaderUserID, "", "example.com", auth.MethodHeader, nil, nil, time.Hour)
|
legacyToken, err := sessionkey.SignToken(kp.PrivateKey, auth.HeaderUserID, "", "example.com", auth.MethodHeader, nil, nil, time.Hour)
|
||||||
@@ -1351,7 +1351,7 @@ func TestProtect_HeaderAuth_RepeatedValueIsMemoized(t *testing.T) {
|
|||||||
kp := generateTestKeyPair(t)
|
kp := generateTestKeyPair(t)
|
||||||
|
|
||||||
hdr := newHeaderScheme(t, "X-API-Key", "key-a", "key-b")
|
hdr := newHeaderScheme(t, "X-API-Key", "key-a", "key-b")
|
||||||
require.NoError(t, mw.AddDomain("example.com", []Scheme{hdr}, kp.PublicKey, time.Hour, "acc1", "svc1", nil, false))
|
require.NoError(t, mw.AddDomain("example.com", []Scheme{hdr}, kp.PublicKey, time.Hour, "acc1", "svc1", nil, false, nil))
|
||||||
|
|
||||||
handler := mw.Protect(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
handler := mw.Protect(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||||
w.WriteHeader(http.StatusOK)
|
w.WriteHeader(http.StatusOK)
|
||||||
@@ -1385,7 +1385,7 @@ func TestProtect_HeaderAuth_MultipleValuesSameHeader(t *testing.T) {
|
|||||||
kp := generateTestKeyPair(t)
|
kp := generateTestKeyPair(t)
|
||||||
|
|
||||||
hdr := newHeaderScheme(t, "Authorization", "Bearer token-a", "Bearer token-b")
|
hdr := newHeaderScheme(t, "Authorization", "Bearer token-a", "Bearer token-b")
|
||||||
require.NoError(t, mw.AddDomain("example.com", []Scheme{hdr}, kp.PublicKey, time.Hour, "acc1", "svc1", nil, false))
|
require.NoError(t, mw.AddDomain("example.com", []Scheme{hdr}, kp.PublicKey, time.Hour, "acc1", "svc1", nil, false, nil))
|
||||||
|
|
||||||
var backendCalled bool
|
var backendCalled bool
|
||||||
handler := mw.Protect(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
handler := mw.Protect(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||||
@@ -1443,7 +1443,7 @@ func TestProtect_OIDCOnPlainHTTP_BlockedWith400(t *testing.T) {
|
|||||||
return "", "https://idp.example.com/authorize", nil
|
return "", "https://idp.example.com/authorize", nil
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
require.NoError(t, mw.AddDomain("example.com", []Scheme{scheme}, kp.PublicKey, time.Hour, "", "", nil, false))
|
require.NoError(t, mw.AddDomain("example.com", []Scheme{scheme}, kp.PublicKey, time.Hour, "", "", nil, false, nil))
|
||||||
|
|
||||||
handler := mw.Protect(newPassthroughHandler())
|
handler := mw.Protect(newPassthroughHandler())
|
||||||
|
|
||||||
@@ -1467,7 +1467,7 @@ func TestProtect_OIDCOverTLS_NotBlocked(t *testing.T) {
|
|||||||
return "", "https://idp.example.com/authorize", nil
|
return "", "https://idp.example.com/authorize", nil
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
require.NoError(t, mw.AddDomain("example.com", []Scheme{scheme}, kp.PublicKey, time.Hour, "", "", nil, false))
|
require.NoError(t, mw.AddDomain("example.com", []Scheme{scheme}, kp.PublicKey, time.Hour, "", "", nil, false, nil))
|
||||||
|
|
||||||
handler := mw.Protect(newPassthroughHandler())
|
handler := mw.Protect(newPassthroughHandler())
|
||||||
|
|
||||||
@@ -1487,7 +1487,7 @@ func TestProtect_NonOIDCSchemes_PlainHTTP_NotBlocked(t *testing.T) {
|
|||||||
kp := generateTestKeyPair(t)
|
kp := generateTestKeyPair(t)
|
||||||
|
|
||||||
scheme := &stubScheme{method: auth.MethodPIN, promptID: "pin"}
|
scheme := &stubScheme{method: auth.MethodPIN, promptID: "pin"}
|
||||||
require.NoError(t, mw.AddDomain("example.com", []Scheme{scheme}, kp.PublicKey, time.Hour, "", "", nil, false))
|
require.NoError(t, mw.AddDomain("example.com", []Scheme{scheme}, kp.PublicKey, time.Hour, "", "", nil, false, nil))
|
||||||
|
|
||||||
handler := mw.Protect(newPassthroughHandler())
|
handler := mw.Protect(newPassthroughHandler())
|
||||||
|
|
||||||
@@ -1517,7 +1517,7 @@ func TestProtect_TunnelPeerFastPath_RequiresInboundMarker(t *testing.T) {
|
|||||||
kp := generateTestKeyPair(t)
|
kp := generateTestKeyPair(t)
|
||||||
|
|
||||||
scheme := &stubScheme{method: auth.MethodPIN, promptID: "pin"}
|
scheme := &stubScheme{method: auth.MethodPIN, promptID: "pin"}
|
||||||
require.NoError(t, mw.AddDomain("example.com", []Scheme{scheme}, kp.PublicKey, time.Hour, "", "", nil, false))
|
require.NoError(t, mw.AddDomain("example.com", []Scheme{scheme}, kp.PublicKey, time.Hour, "", "", nil, false, nil))
|
||||||
|
|
||||||
handler := mw.Protect(newPassthroughHandler())
|
handler := mw.Protect(newPassthroughHandler())
|
||||||
|
|
||||||
@@ -1552,7 +1552,7 @@ func TestProtect_TunnelPeerFastPath_TakesPathWithInboundMarker(t *testing.T) {
|
|||||||
kp := generateTestKeyPair(t)
|
kp := generateTestKeyPair(t)
|
||||||
|
|
||||||
scheme := &stubScheme{method: auth.MethodPIN, promptID: "pin"}
|
scheme := &stubScheme{method: auth.MethodPIN, promptID: "pin"}
|
||||||
require.NoError(t, mw.AddDomain("example.com", []Scheme{scheme}, kp.PublicKey, time.Hour, "", "", nil, false))
|
require.NoError(t, mw.AddDomain("example.com", []Scheme{scheme}, kp.PublicKey, time.Hour, "", "", nil, false, nil))
|
||||||
|
|
||||||
handler := mw.Protect(newPassthroughHandler())
|
handler := mw.Protect(newPassthroughHandler())
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,167 @@
|
|||||||
|
package auth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/tls"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
log "github.com/sirupsen/logrus"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
"google.golang.org/grpc"
|
||||||
|
|
||||||
|
"github.com/netbirdio/netbird/management/internals/modules/reverseproxy/sessionkey"
|
||||||
|
"github.com/netbirdio/netbird/proxy/auth"
|
||||||
|
"github.com/netbirdio/netbird/proxy/internal/proxy"
|
||||||
|
"github.com/netbirdio/netbird/shared/management/proto"
|
||||||
|
)
|
||||||
|
|
||||||
|
// denyingSessionValidator mimics management for a user who completed OIDC login
|
||||||
|
// but is outside the service's distribution groups: ValidateSession denies.
|
||||||
|
type denyingSessionValidator struct {
|
||||||
|
calls int
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *denyingSessionValidator) ValidateSession(context.Context, *proto.ValidateSessionRequest, ...grpc.CallOption) (*proto.ValidateSessionResponse, error) {
|
||||||
|
d.calls++
|
||||||
|
return &proto.ValidateSessionResponse{Valid: false, UserId: "user-1", DeniedReason: "not_in_group"}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *denyingSessionValidator) ValidateTunnelPeer(context.Context, *proto.ValidateTunnelPeerRequest, ...grpc.CallOption) (*proto.ValidateTunnelPeerResponse, error) {
|
||||||
|
return &proto.ValidateTunnelPeerResponse{Valid: false}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestProtect_SelfInstalledCookieCannotBypassGroupCheck is the regression guard
|
||||||
|
// for the group-authorisation bypass: a user denied at login still holds the raw
|
||||||
|
// session token from the ?session_token= redirect, so pasting it into the
|
||||||
|
// nb_session cookie must not buy access. The cookie path validated only the JWT
|
||||||
|
// signature, which turned the token management had already refused into a bearer
|
||||||
|
// credential for the service.
|
||||||
|
func TestProtect_SelfInstalledCookieCannotBypassGroupCheck(t *testing.T) {
|
||||||
|
validator := &denyingSessionValidator{}
|
||||||
|
mw := NewMiddleware(log.StandardLogger(), validator, nil)
|
||||||
|
kp := generateTestKeyPair(t)
|
||||||
|
|
||||||
|
oidc := &stubScheme{method: auth.MethodOIDC, authFn: func(r *http.Request) (string, string, error) {
|
||||||
|
return r.URL.Query().Get("session_token"), "https://idp.example/authorize", nil
|
||||||
|
}}
|
||||||
|
require.NoError(t, mw.AddDomain("example.com", []Scheme{oidc}, kp.PublicKey, time.Hour, "acct-1", "svc-1", nil, false, []string{"grp-allowed"}))
|
||||||
|
|
||||||
|
// The token a denied user gets to see: validly signed for this service and
|
||||||
|
// domain, but carrying no group the service allows.
|
||||||
|
token, err := sessionkey.SignToken(kp.PrivateKey, "user-1", "john.doe@example.com", "example.com", auth.MethodOIDC, nil, nil, time.Hour)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
backendHits := 0
|
||||||
|
handler := mw.Protect(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
backendHits++
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
}))
|
||||||
|
|
||||||
|
t.Run("token in the callback URL is denied", func(t *testing.T) {
|
||||||
|
rec := serveWithCookie(t, handler, "https://example.com/?session_token="+token, nil)
|
||||||
|
|
||||||
|
assert.Equal(t, http.StatusForbidden, rec.Code, "group check must deny the login")
|
||||||
|
assert.Empty(t, rec.Result().Cookies(), "a denied login must not install a session cookie")
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("same token pasted into the session cookie is denied", func(t *testing.T) {
|
||||||
|
rec := serveWithCookie(t, handler, "https://example.com/", &http.Cookie{Name: auth.SessionCookieName, Value: token})
|
||||||
|
|
||||||
|
assert.NotEqual(t, http.StatusOK, rec.Code, "a self-installed cookie must not reach the backend")
|
||||||
|
assert.Equal(t, 0, backendHits, "backend must never be reached without an allowed group")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestProtect_SessionCookieWithAllowedGroupPassesThrough is the positive half of
|
||||||
|
// the group gate: a member of an allowed group keeps the cookie fast-path, with
|
||||||
|
// no management round-trip.
|
||||||
|
func TestProtect_SessionCookieWithAllowedGroupPassesThrough(t *testing.T) {
|
||||||
|
validator := &denyingSessionValidator{}
|
||||||
|
mw := NewMiddleware(log.StandardLogger(), validator, nil)
|
||||||
|
kp := generateTestKeyPair(t)
|
||||||
|
|
||||||
|
oidc := &stubScheme{method: auth.MethodOIDC}
|
||||||
|
require.NoError(t, mw.AddDomain("example.com", []Scheme{oidc}, kp.PublicKey, time.Hour, "acct-1", "svc-1", nil, false, []string{"grp-other", "grp-allowed"}))
|
||||||
|
|
||||||
|
token, err := sessionkey.SignToken(kp.PrivateKey, "user-2", "jane@example.com", "example.com", auth.MethodOIDC,
|
||||||
|
[]string{"grp-unrelated", "grp-allowed"}, []string{"Unrelated", "Allowed"}, time.Hour)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
handler := mw.Protect(newPassthroughHandler())
|
||||||
|
rec := serveWithCookie(t, handler, "https://example.com/", &http.Cookie{Name: auth.SessionCookieName, Value: token})
|
||||||
|
|
||||||
|
assert.Equal(t, http.StatusOK, rec.Code, "a cookie carrying an allowed group must pass through")
|
||||||
|
assert.Equal(t, 0, validator.calls, "the cookie fast-path must not call management")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestProtect_NonOIDCSessionCookieIgnoresGroupRestriction locks the scope of the
|
||||||
|
// gate: PIN, password and header credentials carry no group identity and are
|
||||||
|
// authorised by the secret itself, exactly as management validates them.
|
||||||
|
func TestProtect_NonOIDCSessionCookieIgnoresGroupRestriction(t *testing.T) {
|
||||||
|
mw := NewMiddleware(log.StandardLogger(), nil, nil)
|
||||||
|
kp := generateTestKeyPair(t)
|
||||||
|
|
||||||
|
scheme := &stubScheme{method: auth.MethodPIN, promptID: "pin"}
|
||||||
|
require.NoError(t, mw.AddDomain("example.com", []Scheme{scheme}, kp.PublicKey, time.Hour, "acct-1", "svc-1", nil, false, []string{"grp-allowed"}))
|
||||||
|
|
||||||
|
token, err := sessionkey.SignToken(kp.PrivateKey, "pin-user", "", "example.com", auth.MethodPIN, nil, nil, time.Hour)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
handler := mw.Protect(newPassthroughHandler())
|
||||||
|
rec := serveWithCookie(t, handler, "https://example.com/", &http.Cookie{Name: auth.SessionCookieName, Value: token})
|
||||||
|
|
||||||
|
assert.Equal(t, http.StatusOK, rec.Code, "a PIN session must not be gated on OIDC group membership")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSessionGroupsAllowed(t *testing.T) {
|
||||||
|
allowed := groupSet([]string{"a", "b"})
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
allowed map[string]struct{}
|
||||||
|
method auth.Method
|
||||||
|
groups []string
|
||||||
|
want bool
|
||||||
|
}{
|
||||||
|
{"unrestricted service allows a groupless token", nil, auth.MethodOIDC, nil, true},
|
||||||
|
{"restricted service allows an intersecting token", allowed, auth.MethodOIDC, []string{"c", "b"}, true},
|
||||||
|
{"restricted service denies a disjoint token", allowed, auth.MethodOIDC, []string{"c"}, false},
|
||||||
|
{"restricted service denies a groupless token", allowed, auth.MethodOIDC, nil, false},
|
||||||
|
{"restricted service ignores a pin token", allowed, auth.MethodPIN, nil, true},
|
||||||
|
{"restricted service ignores a password token", allowed, auth.MethodPassword, nil, true},
|
||||||
|
{"restricted service ignores a header token", allowed, auth.MethodHeader, nil, true},
|
||||||
|
{"restricted service denies an unknown method", allowed, auth.Method("totp"), []string{"a"}, false},
|
||||||
|
{"restricted service denies a token with no method", allowed, auth.Method(""), []string{"a"}, false},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range tests {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
assert.Equal(t, tc.want, sessionGroupsAllowed(tc.allowed, tc.method, tc.groups))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGroupSetDropsEmptyEntries(t *testing.T) {
|
||||||
|
assert.Nil(t, groupSet(nil), "no groups means unrestricted")
|
||||||
|
assert.Nil(t, groupSet([]string{"", ""}), "blank ids must not restrict access to nothing reachable")
|
||||||
|
assert.Equal(t, map[string]struct{}{"a": {}}, groupSet([]string{"a", ""}))
|
||||||
|
}
|
||||||
|
|
||||||
|
// serveWithCookie drives the middleware over TLS with captured data attached,
|
||||||
|
// optionally carrying a session cookie.
|
||||||
|
func serveWithCookie(t *testing.T, handler http.Handler, url string, cookie *http.Cookie) *httptest.ResponseRecorder {
|
||||||
|
t.Helper()
|
||||||
|
req := httptest.NewRequest(http.MethodGet, url, nil)
|
||||||
|
req.TLS = &tls.ConnectionState{}
|
||||||
|
if cookie != nil {
|
||||||
|
req.AddCookie(cookie)
|
||||||
|
}
|
||||||
|
req = req.WithContext(proxy.WithCapturedData(req.Context(), proxy.NewCapturedData("")))
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
handler.ServeHTTP(rec, req)
|
||||||
|
return rec
|
||||||
|
}
|
||||||
@@ -44,7 +44,7 @@ func (s *stubSessionValidator) ValidateTunnelPeer(_ context.Context, in *proto.V
|
|||||||
func newTunnelMiddleware(t *testing.T, validator SessionValidator) *Middleware {
|
func newTunnelMiddleware(t *testing.T, validator SessionValidator) *Middleware {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
mw := NewMiddleware(log.New(), validator, nil)
|
mw := NewMiddleware(log.New(), validator, nil)
|
||||||
require.NoError(t, mw.AddDomain("svc.example", nil, "", 0, "acct-1", "svc-1", nil, false))
|
require.NoError(t, mw.AddDomain("svc.example", nil, "", 0, "acct-1", "svc-1", nil, false, nil))
|
||||||
return mw
|
return mw
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -235,8 +235,8 @@ func TestForwardWithTunnelPeer_RoutesAccountIDIntoCacheKey(t *testing.T) {
|
|||||||
}
|
}
|
||||||
mw := NewMiddleware(log.New(), validator, nil)
|
mw := NewMiddleware(log.New(), validator, nil)
|
||||||
|
|
||||||
require.NoError(t, mw.AddDomain("svc-a.example", nil, "", 0, "acct-a", "svc-a", nil, false))
|
require.NoError(t, mw.AddDomain("svc-a.example", nil, "", 0, "acct-a", "svc-a", nil, false, nil))
|
||||||
require.NoError(t, mw.AddDomain("svc-b.example", nil, "", 0, "acct-b", "svc-b", nil, false))
|
require.NoError(t, mw.AddDomain("svc-b.example", nil, "", 0, "acct-b", "svc-b", nil, false, nil))
|
||||||
|
|
||||||
// The fast-path requires the inbound-listener marker on the context.
|
// The fast-path requires the inbound-listener marker on the context.
|
||||||
// The peerstore lookup itself is account-agnostic at this level
|
// The peerstore lookup itself is account-agnostic at this level
|
||||||
@@ -299,7 +299,7 @@ func TestForwardWithTunnelPeer_LocalLookupShortCircuitDoesNotPopulateCache(t *te
|
|||||||
|
|
||||||
func TestPrivateService_FailsClosedOnTunnelPeerFailure(t *testing.T) {
|
func TestPrivateService_FailsClosedOnTunnelPeerFailure(t *testing.T) {
|
||||||
mw := NewMiddleware(log.New(), nil, nil)
|
mw := NewMiddleware(log.New(), nil, nil)
|
||||||
require.NoError(t, mw.AddDomain("private.svc", nil, "", 0, "acct-1", "svc-1", nil, true))
|
require.NoError(t, mw.AddDomain("private.svc", nil, "", 0, "acct-1", "svc-1", nil, true, nil))
|
||||||
|
|
||||||
called := false
|
called := false
|
||||||
handler := mw.Protect(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
handler := mw.Protect(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||||
@@ -328,7 +328,7 @@ func TestPrivateService_ForwardsOnTunnelPeerSuccess(t *testing.T) {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
mw := NewMiddleware(log.New(), validator, nil)
|
mw := NewMiddleware(log.New(), validator, nil)
|
||||||
require.NoError(t, mw.AddDomain("private.svc", nil, "", 0, "acct-1", "svc-1", nil, true))
|
require.NoError(t, mw.AddDomain("private.svc", nil, "", 0, "acct-1", "svc-1", nil, true, nil))
|
||||||
|
|
||||||
called := false
|
called := false
|
||||||
handler := mw.Protect(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
handler := mw.Protect(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
|||||||
@@ -571,6 +571,7 @@ func TestIntegration_ProxyConnection_ReconnectDoesNotDuplicateState(t *testing.T
|
|||||||
proxytypes.ServiceID(mapping.GetId()),
|
proxytypes.ServiceID(mapping.GetId()),
|
||||||
nil,
|
nil,
|
||||||
mapping.GetPrivate(),
|
mapping.GetPrivate(),
|
||||||
|
mapping.GetAuth().GetAllowedGroupIds(),
|
||||||
)
|
)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -2069,7 +2069,7 @@ func (s *Server) updateMapping(ctx context.Context, mapping *proto.ProxyMapping)
|
|||||||
s.warnIfGeoUnavailable(mapping.GetDomain(), mapping.GetAccessRestrictions())
|
s.warnIfGeoUnavailable(mapping.GetDomain(), mapping.GetAccessRestrictions())
|
||||||
|
|
||||||
maxSessionAge := time.Duration(mapping.GetAuth().GetMaxSessionAgeSeconds()) * time.Second
|
maxSessionAge := time.Duration(mapping.GetAuth().GetMaxSessionAgeSeconds()) * time.Second
|
||||||
if err := s.auth.AddDomain(mapping.GetDomain(), schemes, mapping.GetAuth().GetSessionKey(), maxSessionAge, accountID, svcID, ipRestrictions, mapping.GetPrivate()); err != nil {
|
if err := s.auth.AddDomain(mapping.GetDomain(), schemes, mapping.GetAuth().GetSessionKey(), maxSessionAge, accountID, svcID, ipRestrictions, mapping.GetPrivate(), mapping.GetAuth().GetAllowedGroupIds()); err != nil {
|
||||||
return fmt.Errorf("auth setup for domain %s: %w", mapping.GetDomain(), err)
|
return fmt.Errorf("auth setup for domain %s: %w", mapping.GetDomain(), err)
|
||||||
}
|
}
|
||||||
m := s.protoToMapping(ctx, mapping)
|
m := s.protoToMapping(ctx, mapping)
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import (
|
|||||||
// Client is the interface for the management service client.
|
// Client is the interface for the management service client.
|
||||||
type Client interface {
|
type Client interface {
|
||||||
io.Closer
|
io.Closer
|
||||||
Sync(ctx context.Context, sysInfo *system.Info, msgHandler func(msg *proto.SyncResponse) error) error
|
Sync(ctx context.Context, getInfo func(ctx context.Context) *system.Info, msgHandler func(msg *proto.SyncResponse) error) error
|
||||||
Job(ctx context.Context, msgHandler func(msg *proto.JobRequest) *proto.JobResponse) error
|
Job(ctx context.Context, msgHandler func(msg *proto.JobRequest) *proto.JobResponse) error
|
||||||
Register(setupKey string, jwtToken string, sysInfo *system.Info, sshKey []byte, dnsLabels domain.List) (*proto.LoginResponse, error)
|
Register(setupKey string, jwtToken string, sysInfo *system.Info, sshKey []byte, dnsLabels domain.List) (*proto.LoginResponse, error)
|
||||||
Login(sysInfo *system.Info, sshKey []byte, dnsLabels domain.List) (*proto.LoginResponse, error)
|
Login(sysInfo *system.Info, sshKey []byte, dnsLabels domain.List) (*proto.LoginResponse, error)
|
||||||
|
|||||||
@@ -2,9 +2,11 @@ package client
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"fmt"
|
||||||
"net"
|
"net"
|
||||||
"os"
|
"os"
|
||||||
"sync"
|
"sync"
|
||||||
|
"sync/atomic"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -305,7 +307,7 @@ func TestClient_Sync(t *testing.T) {
|
|||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
go func() {
|
go func() {
|
||||||
err = client.Sync(ctx, info, func(msg *mgmtProto.SyncResponse) error {
|
err = client.Sync(ctx, func(context.Context) *system.Info { return info }, func(msg *mgmtProto.SyncResponse) error {
|
||||||
ch <- msg
|
ch <- msg
|
||||||
return nil
|
return nil
|
||||||
})
|
})
|
||||||
@@ -397,6 +399,75 @@ func wgKeyFromBytes(raw []byte) string {
|
|||||||
return k.String()
|
return k.String()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestClient_SyncGathersInfoOnEveryConnect(t *testing.T) {
|
||||||
|
s, lis, mgmtMockServer, serverKey := startMockManagement(t)
|
||||||
|
defer s.GracefulStop()
|
||||||
|
|
||||||
|
testKey, err := wgtypes.GenerateKey()
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
hostnames := make(chan string, 2)
|
||||||
|
mgmtMockServer.SyncFunc = func(msg *mgmtProto.EncryptedMessage, _ mgmtProto.ManagementService_SyncServer) error {
|
||||||
|
peerKey, err := wgtypes.ParseKey(msg.GetWgPubKey())
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("invalid peer key: %v", err)
|
||||||
|
return status.Error(codes.InvalidArgument, err.Error())
|
||||||
|
}
|
||||||
|
syncReq := &mgmtProto.SyncRequest{}
|
||||||
|
if err := encryption.DecryptMessage(peerKey, serverKey, msg.Body, syncReq); err != nil {
|
||||||
|
t.Errorf("decrypt sync request: %v", err)
|
||||||
|
return status.Error(codes.InvalidArgument, err.Error())
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case hostnames <- syncReq.GetMeta().GetHostname():
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
// Returning closes the stream, so the client reconnects and gathers again.
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
client, err := NewClient(ctx, lis.Addr().String(), testKey, false)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
var gathers atomic.Int32
|
||||||
|
done := make(chan struct{})
|
||||||
|
go func() {
|
||||||
|
defer close(done)
|
||||||
|
_ = client.Sync(ctx, func(ctx context.Context) *system.Info {
|
||||||
|
info := system.GetInfo(ctx)
|
||||||
|
info.Hostname = fmt.Sprintf("host-%d", gathers.Add(1))
|
||||||
|
return info
|
||||||
|
}, func(*mgmtProto.SyncResponse) error { return nil })
|
||||||
|
}()
|
||||||
|
|
||||||
|
// A connect attempt can fail before it reaches the server, so the sequence
|
||||||
|
// numbers seen here may skip. What matters is that the reconnect carries a
|
||||||
|
// newly gathered info instead of the one sent on the previous stream.
|
||||||
|
var seen []int
|
||||||
|
for len(seen) < 2 {
|
||||||
|
select {
|
||||||
|
case got := <-hostnames:
|
||||||
|
var n int
|
||||||
|
_, err := fmt.Sscanf(got, "host-%d", &n)
|
||||||
|
require.NoError(t, err, "hostname should carry the gather sequence number")
|
||||||
|
seen = append(seen, n)
|
||||||
|
case <-time.After(10 * time.Second):
|
||||||
|
t.Fatalf("timeout waiting for the second sync request, got %v", seen)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert.Greater(t, seen[1], seen[0], "the reconnect should carry a newly gathered info")
|
||||||
|
|
||||||
|
cancel()
|
||||||
|
select {
|
||||||
|
case <-done:
|
||||||
|
case <-time.After(10 * time.Second):
|
||||||
|
t.Fatal("timeout waiting for Sync to return after cancel")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func Test_SystemMetaDataFromClient(t *testing.T) {
|
func Test_SystemMetaDataFromClient(t *testing.T) {
|
||||||
s, lis, mgmtMockServer, serverKey := startMockManagement(t)
|
s, lis, mgmtMockServer, serverKey := startMockManagement(t)
|
||||||
defer s.GracefulStop()
|
defer s.GracefulStop()
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user