Merge branch 'main' into poc/certificate-posture

This commit is contained in:
pascal
2026-09-11 17:13:05 +02:00
107 changed files with 4703 additions and 1178 deletions
+7 -2
View File
@@ -287,10 +287,15 @@ jobs:
image_refs=() image_refs=()
tag_and_push() { tag_and_push() {
local src="$1" img_name tag dst local src="$1" img_name tag dst variant=""
img_name="${src%%:*}" img_name="${src%%:*}"
# Client variants share a repository, so keep their tag suffixes.
case "$src" in
*-rootless-ubi-amd64) variant="-rootless-ubi" ;;
*-rootless-amd64) variant="-rootless" ;;
esac
for tag in $(resolve_tags); do for tag in $(resolve_tags); do
dst="${img_name}:${tag}" dst="${img_name}:${tag}${variant}"
echo "Tagging ${src} -> ${dst}" echo "Tagging ${src} -> ${dst}"
docker tag "$src" "$dst" docker tag "$src" "$dst"
docker push "$dst" docker push "$dst"
+37
View File
@@ -289,6 +289,43 @@ dockers_v2:
"org.opencontainers.image.revision": "{{.FullCommit}}" "org.opencontainers.image.revision": "{{.FullCommit}}"
"org.opencontainers.image.source": "{{.GitURL}}" "org.opencontainers.image.source": "{{.GitURL}}"
"maintainer": "dev@netbird.io" "maintainer": "dev@netbird.io"
- id: netbird-rootless-ubi
disable: "{{ .Env.SKIP_DOCKER_PUSH }}"
ids:
- netbird
images:
- netbirdio/netbird
- ghcr.io/netbirdio/netbird
tags:
- "{{ .Version }}-rootless-ubi"
- "{{ if eq .Env.SKIP_PUBLISH \"false\" }}rootless-ubi-latest{{ end }}"
dockerfile: client/Dockerfile-rootless.ubi
extra_files:
- client/netbird-entrypoint.sh
platforms:
- linux/amd64
- linux/arm64
build_args:
VERSION: "{{ .Version }}"
RELEASE: "{{ .Timestamp }}"
hooks:
pre:
- cmd: 'sh client/collect-licenses.sh "{{ .ContextDir }}/licenses" amd64 arm64'
env:
- GOOS=linux
- CGO_ENABLED=0
labels:
"org.opencontainers.image.created": "{{.Date}}"
"org.opencontainers.image.version": "{{.Version}}"
"org.opencontainers.image.revision": "{{.FullCommit}}"
"org.opencontainers.image.source": "{{.GitURL}}"
annotations:
"org.opencontainers.image.created": "{{.Date}}"
"org.opencontainers.image.title": "{{.ProjectName}}"
"org.opencontainers.image.version": "{{.Version}}"
"org.opencontainers.image.revision": "{{.FullCommit}}"
"org.opencontainers.image.source": "{{.GitURL}}"
"maintainer": "dev@netbird.io"
- id: relay - id: relay
disable: "{{ .Env.SKIP_DOCKER_PUSH }}" disable: "{{ .Env.SKIP_DOCKER_PUSH }}"
ids: ids:
+45
View File
@@ -0,0 +1,45 @@
FROM registry.access.redhat.com/ubi9/ubi-minimal@sha256:7fbeae18dc9476399f565e68255f602a3374ea8614ba3d14843565131a13ff93
ARG TARGETPLATFORM
ARG NETBIRD_BINARY=$TARGETPLATFORM/netbird
ARG VERSION=dev
ARG RELEASE=1
LABEL name="netbird-rootless" \
maintainer="NetBird <dev@netbird.io>" \
vendor="NetBird GmbH" \
version="${VERSION}" \
release="${RELEASE}" \
summary="NetBird Rootless Client" \
description="NetBird connects devices through an encrypted overlay using userspace networking without a TUN device or network administration capabilities."
RUN microdnf install -y bash ca-certificates && microdnf clean all
COPY --chmod=0555 client/netbird-entrypoint.sh /usr/local/bin/netbird-entrypoint.sh
COPY --chmod=0555 ${NETBIRD_BINARY} /usr/local/bin/netbird
COPY licenses/ /licenses/
# Only application storage is group-writable for arbitrary non-root UIDs.
# Runtime-created credentials keep the client's restrictive file modes.
RUN mkdir -p /var/lib/netbird && \
chown 1000:0 /var/lib/netbird && \
chmod 0770 /var/lib/netbird && \
chmod -R a+rX /licenses
WORKDIR /var/lib/netbird
USER 1000:0
ENV \
HOME="/var/lib/netbird" \
NETBIRD_BIN="/usr/local/bin/netbird" \
NB_USE_NETSTACK_MODE="true" \
NB_ENABLE_NETSTACK_LOCAL_FORWARDING="true" \
NB_CONFIG="/var/lib/netbird/config.json" \
NB_STATE_DIR="/var/lib/netbird" \
NB_DAEMON_ADDR="unix:///var/lib/netbird/netbird.sock" \
NB_LOG_FILE="console,/var/lib/netbird/client.log" \
NB_DISABLE_DNS="true" \
NB_ENABLE_CAPTURE="false" \
NB_ENTRYPOINT_SERVICE_TIMEOUT="30"
STOPSIGNAL SIGTERM
ENTRYPOINT ["/usr/local/bin/netbird-entrypoint.sh"]
+12
View File
@@ -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()
} }
+52
View File
@@ -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
View File
@@ -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
+3 -3
View File
@@ -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)
} }
+19
View File
@@ -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)
}
+59 -9
View File
@@ -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
} }
+12 -13
View File
@@ -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")
} }
} }
+6
View File
@@ -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) {
+6
View File
@@ -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
+5
View File
@@ -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)
+77
View File
@@ -0,0 +1,77 @@
#!/bin/sh
set -eu
if [ "$#" -lt 2 ]; then
printf '%s\n' "usage: $0 OUTPUT_DIRECTORY GOARCH..." >&2
exit 2
fi
repo_root=$(CDPATH= cd -- "$(dirname "$0")/.." && pwd)
output_name=$(basename "$1")
if [ -z "$output_name" ] || [ "$output_name" = "." ] ||
[ "$output_name" = ".." ] || [ "$output_name" = "/" ]; then
printf '%s\n' "OUTPUT_DIRECTORY must name a directory" >&2
exit 2
fi
output_parent=$(CDPATH= cd -- "$(dirname "$1")" && pwd)
output="$output_parent/$output_name"
shift
modules=$(mktemp "${TMPDIR:-/tmp}/netbird-client-licenses.modules.XXXXXX")
sorted_modules=$(mktemp "${TMPDIR:-/tmp}/netbird-client-licenses.sorted.XXXXXX")
trap 'rm -f "$modules" "$sorted_modules"' EXIT HUP INT TERM
if [ -e "$output" ] || [ -L "$output" ]; then
printf 'output directory already exists: %s\n' "$output" >&2
exit 1
fi
mkdir "$output"
mkdir "$output/third_party"
cp "$repo_root/LICENSE" "$output/BSD-3-Clause.txt"
cd "$repo_root"
for arch in "$@"; do
GOOS=${GOOS:-linux} GOARCH="$arch" CGO_ENABLED=${CGO_ENABLED:-0} \
go list -deps -f '{{with .Module}}{{if .Replace}}{{.Replace.Path}}{{"\t"}}{{.Replace.Version}}{{"\t"}}{{.Replace.Dir}}{{else}}{{.Path}}{{"\t"}}{{.Version}}{{"\t"}}{{.Dir}}{{end}}{{end}}' -tags load_wgnt_from_rsrc ./client >>"$modules"
done
LC_ALL=C sort -u "$modules" >"$sorted_modules"
goroot=$(go env GOROOT)
for term in LICENSE PATENTS; do
if [ ! -f "$goroot/$term" ]; then
printf 'missing Go standard-library term: %s\n' "$goroot/$term" >&2
exit 1
fi
cp "$goroot/$term" "$output/Go-$term"
done
while IFS=' ' read -r module version module_dir; do
[ -n "$module" ] || continue
[ "$module" = "github.com/netbirdio/netbird" ] && continue
if [ -z "$version" ] || [ ! -d "$module_dir" ]; then
printf 'cannot collect terms for module %s at version %s\n' "$module" "$version" >&2
exit 1
fi
destination="$output/third_party/$module/$version"
mkdir -p "$destination"
printf 'module: %s\nversion: %s\n' "$module" "$version" >"$destination/MODULE"
found=false
for term in \
"$module_dir"/LICENSE* "$module_dir"/License* "$module_dir"/license* \
"$module_dir"/LICENCE* "$module_dir"/Licence* "$module_dir"/licence* \
"$module_dir"/COPYING* "$module_dir"/Copying* "$module_dir"/copying* \
"$module_dir"/NOTICE* "$module_dir"/Notice* "$module_dir"/notice* \
"$module_dir"/PATENTS* "$module_dir"/Patents* "$module_dir"/patents*; do
[ -f "$term" ] || continue
cp "$term" "$destination/"
found=true
done
if [ "$found" = false ]; then
printf 'no root license terms found for module %s at %s\n' "$module" "$module_dir" >&2
exit 1
fi
done <"$sorted_modules"
+5
View File
@@ -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
+136 -87
View File
@@ -6,6 +6,7 @@ import (
"net" "net"
"net/netip" "net/netip"
"runtime" "runtime"
"slices"
"strconv" "strconv"
"sync" "sync"
"time" "time"
@@ -17,17 +18,20 @@ import (
nberrors "github.com/netbirdio/netbird/client/errors" nberrors "github.com/netbirdio/netbird/client/errors"
firewall "github.com/netbirdio/netbird/client/firewall/manager" firewall "github.com/netbirdio/netbird/client/firewall/manager"
"github.com/netbirdio/netbird/client/internal/ebpf"
ebpfMgr "github.com/netbirdio/netbird/client/internal/ebpf/manager"
) )
const ( const (
customPort = 5053 customPort = 5053
// randomPortAttempts bounds the search for a port free on both protocols.
randomPortAttempts = 5
) )
var ( var (
defaultIP = netip.MustParseAddr("127.0.0.1") defaultIP = netip.MustParseAddr("127.0.0.1")
customIP = netip.MustParseAddr("127.0.0.153") customIP = netip.MustParseAddr("127.0.0.153")
// dnatProtocols are the protocols the port 53 redirect covers.
dnatProtocols = []firewall.Protocol{firewall.ProtocolUDP, firewall.ProtocolTCP}
) )
type serviceViaListener struct { type serviceViaListener struct {
@@ -40,9 +44,20 @@ type serviceViaListener struct {
listenPort uint16 listenPort uint16
listenerIsRunning bool listenerIsRunning bool
listenerFlagLock sync.Mutex listenerFlagLock sync.Mutex
ebpfService ebpfMgr.Manager
firewall Firewall firewall Firewall
tcpDNATConfigured bool // dnatRules holds the port 53 redirects that are installed and not yet
// removed, so a removal that fails can be retried.
dnatRules []dnatRule
}
// dnatRule is a port 53 redirect as it was installed. The target is kept with
// the rule because the listener can come back on a different address or port,
// and a retried removal has to name the address and port the rule was added
// with, not the ones in use now.
type dnatRule struct {
protocol firewall.Protocol
ip netip.Addr
port uint16
} }
func newServiceViaListener(wgIface WGIface, customAddr *netip.AddrPort, fw Firewall) *serviceViaListener { func newServiceViaListener(wgIface WGIface, customAddr *netip.AddrPort, fw Firewall) *serviceViaListener {
@@ -112,34 +127,93 @@ func (s *serviceViaListener) Listen() error {
} }
}() }()
// When eBPF redirects UDP port 53 to our listen port, TCP still needs if s.listenPort != DefaultPort {
// a DNAT rule because eBPF only handles UDP. s.setupDNAT()
if s.ebpfService != nil && s.firewall != nil && s.listenPort != DefaultPort {
if err := s.firewall.AddOutputDNAT(s.listenIP, firewall.ProtocolTCP, DefaultPort, s.listenPort); err != nil {
log.Warnf("failed to add DNS TCP DNAT rule, TCP DNS on port 53 will not work: %v", err)
} else {
s.tcpDNATConfigured = true
log.Infof("added DNS TCP DNAT rule: %s:%d -> %s:%d", s.listenIP, DefaultPort, s.listenIP, s.listenPort)
}
} }
return nil return nil
} }
// setupDNAT redirects port 53 to the port the DNS server actually listens on.
// Both protocols must be redirected or none: RuntimePort reports port 53 only
// while the full redirect is in place, so a half-configured redirect would
// advertise a resolver that answers over one protocol.
func (s *serviceViaListener) setupDNAT() {
if s.firewall == nil {
log.Errorf("no firewall manager available to redirect DNS port %d to %d, "+
"clients pointed at %s will not reach the resolver", DefaultPort, s.listenPort, s.listenIP)
return
}
// Clear whatever an earlier removal left behind first. Those rules can point
// at an address or port this listener no longer uses, and they are matched
// before anything added now, so adding a redirect on top of one would keep
// sending port 53 traffic to the previous listener while reporting the
// redirect as complete. The rules stay recorded for a later attempt.
if err := s.removeDNAT(); err != nil {
log.Errorf("failed to remove stale DNS DNAT rules, leaving port %d redirected to the previous listener: %v",
DefaultPort, err)
return
}
for _, proto := range dnatProtocols {
if err := s.firewall.AddOutputDNAT(s.listenIP, proto, DefaultPort, s.listenPort); err != nil {
log.Errorf("failed to add DNS %s DNAT rule, DNS on port %d will not work: %v",
proto, DefaultPort, err)
if err := s.removeDNAT(); err != nil {
log.Warnf("failed to roll back DNS DNAT rules, retrying on stop: %v", err)
}
return
}
s.dnatRules = append(s.dnatRules, dnatRule{protocol: proto, ip: s.listenIP, port: s.listenPort})
}
log.Infof("added DNS DNAT rules: %s:%d -> %s:%d (UDP + TCP)", s.listenIP, DefaultPort, s.listenIP, s.listenPort)
}
// removeDNAT removes every installed port 53 redirect. A rule whose removal
// fails stays recorded so a later setup or Stop retries it, rather than leaving
// port 53 pointing at a resolver that is no longer listening.
func (s *serviceViaListener) removeDNAT() error {
if s.firewall == nil {
return nil
}
var merr *multierror.Error
var remaining []dnatRule
for _, rule := range s.dnatRules {
if err := s.firewall.RemoveOutputDNAT(rule.ip, rule.protocol, DefaultPort, rule.port); err != nil {
merr = multierror.Append(merr, fmt.Errorf("remove DNS %s DNAT rule for %s:%d: %w",
rule.protocol, rule.ip, rule.port, err))
remaining = append(remaining, rule)
}
}
s.dnatRules = remaining
return nberrors.FormatErrorOrNil(merr)
}
func (s *serviceViaListener) Stop() error { func (s *serviceViaListener) Stop() error {
s.listenerFlagLock.Lock() s.listenerFlagLock.Lock()
defer s.listenerFlagLock.Unlock() defer s.listenerFlagLock.Unlock()
var merr *multierror.Error
// Redirects are removed even when the listener is already stopped, so that
// a removal which failed earlier is retried instead of leaving port 53
// pointing at a resolver that no longer listens.
if err := s.removeDNAT(); err != nil {
merr = multierror.Append(merr, err)
}
if !s.listenerIsRunning { if !s.listenerIsRunning {
return nil return nberrors.FormatErrorOrNil(merr)
} }
s.listenerIsRunning = false s.listenerIsRunning = false
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel() defer cancel()
var merr *multierror.Error
if err := s.server.ShutdownContext(ctx); err != nil { if err := s.server.ShutdownContext(ctx); err != nil {
merr = multierror.Append(merr, fmt.Errorf("stop DNS UDP server: %w", err)) merr = multierror.Append(merr, fmt.Errorf("stop DNS UDP server: %w", err))
} }
@@ -148,19 +222,6 @@ func (s *serviceViaListener) Stop() error {
merr = multierror.Append(merr, fmt.Errorf("stop DNS TCP server: %w", err)) merr = multierror.Append(merr, fmt.Errorf("stop DNS TCP server: %w", err))
} }
if s.tcpDNATConfigured && s.firewall != nil {
if err := s.firewall.RemoveOutputDNAT(s.listenIP, firewall.ProtocolTCP, DefaultPort, s.listenPort); err != nil {
merr = multierror.Append(merr, fmt.Errorf("remove DNS TCP DNAT rule: %w", err))
}
s.tcpDNATConfigured = false
}
if s.ebpfService != nil {
if err := s.ebpfService.FreeDNSFwd(); err != nil {
merr = multierror.Append(merr, fmt.Errorf("stop traffic forwarder: %w", err))
}
}
return nberrors.FormatErrorOrNil(merr) return nberrors.FormatErrorOrNil(merr)
} }
@@ -177,11 +238,23 @@ func (s *serviceViaListener) RuntimePort() int {
s.listenerFlagLock.Lock() s.listenerFlagLock.Lock()
defer s.listenerFlagLock.Unlock() defer s.listenerFlagLock.Unlock()
if s.ebpfService != nil { if s.redirectInstalled() {
return DefaultPort return DefaultPort
} else {
return int(s.listenPort)
} }
return int(s.listenPort)
}
// redirectInstalled reports whether every protocol is redirected from port 53
// to the address and port the listener currently serves. Rules left over from
// an earlier listener do not count.
func (s *serviceViaListener) redirectInstalled() bool {
for _, proto := range dnatProtocols {
current := dnatRule{protocol: proto, ip: s.listenIP, port: s.listenPort}
if !slices.Contains(s.dnatRules, current) {
return false
}
}
return true
} }
func (s *serviceViaListener) RuntimeIP() netip.Addr { func (s *serviceViaListener) RuntimeIP() netip.Addr {
@@ -190,30 +263,29 @@ func (s *serviceViaListener) RuntimeIP() netip.Addr {
// evalListenAddress figures out the listen address for the DNS server. // evalListenAddress figures out the listen address for the DNS server.
// IPv4-only: all peers have a v4 overlay address, and DNS config points to v4. // IPv4-only: all peers have a v4 overlay address, and DNS config points to v4.
// First checks port 53 on WG interface or lo, then tries eBPF on a random port, // Prefers port 53 on the overlay interface or lo, so no redirect is needed at
// then falls back to port 5053. // all; when it is taken it falls back to port 5053 and then to a random free
// port, both of which need the port 53 redirect set up by setupDNAT.
func (s *serviceViaListener) evalListenAddress() (netip.Addr, uint16, error) { func (s *serviceViaListener) evalListenAddress() (netip.Addr, uint16, error) {
if s.customAddr != nil { if s.customAddr != nil {
return s.customAddr.Addr(), s.customAddr.Port(), nil return s.customAddr.Addr(), s.customAddr.Port(), nil
} }
ip, ok := s.testFreePort(DefaultPort) if ip, ok := s.testFreePort(DefaultPort); ok {
if ok {
return ip, DefaultPort, nil return ip, DefaultPort, nil
} }
ebpfSrv, port, ok := s.tryToUseeBPF() if ip, ok := s.testFreePort(customPort); ok {
if ok {
s.ebpfService = ebpfSrv
return s.wgInterface.Address().IP, port, nil
}
ip, ok = s.testFreePort(customPort)
if ok {
return ip, customPort, nil return ip, customPort, nil
} }
return netip.Addr{}, 0, fmt.Errorf("failed to find a free port for DNS server") ip := s.wgInterface.Address().IP
port, err := s.randomFreePort(ip)
if err != nil {
return netip.Addr{}, 0, fmt.Errorf("find a free port for DNS server: %w", err)
}
return ip, port, nil
} }
func (s *serviceViaListener) testFreePort(port int) (netip.Addr, bool) { func (s *serviceViaListener) testFreePort(port int) (netip.Addr, bool) {
@@ -260,48 +332,25 @@ func (s *serviceViaListener) tryToBind(ip netip.Addr, port int) bool {
return true return true
} }
// tryToUseeBPF decides whether to apply eBPF program to capture DNS traffic on port 53. // randomFreePort returns a port that is free on ip for both UDP and TCP, since
// This is needed because on some operating systems if we start a DNS server not on a default port 53, // the DNS server binds both. The probe listeners are closed again, so the port
// the domain name resolution won't work. So, in case we are running on Linux and picked a free // is only likely, not guaranteed, to still be free when the server binds it.
// port we should fall back to the eBPF solution that will capture traffic on port 53 and forward func (s *serviceViaListener) randomFreePort(ip netip.Addr) (uint16, error) {
// it to a local DNS server running on the chosen port. for range randomPortAttempts {
func (s *serviceViaListener) tryToUseeBPF() (ebpfMgr.Manager, uint16, bool) { probeListener, err := net.ListenUDP("udp4", &net.UDPAddr{})
if runtime.GOOS != "linux" { if err != nil {
return nil, 0, false return 0, fmt.Errorf("bind random port: %w", err)
}
port := uint16(probeListener.LocalAddr().(*net.UDPAddr).Port)
if err := probeListener.Close(); err != nil {
return 0, fmt.Errorf("free up probed port: %w", err)
}
if s.tryToBind(ip, int(port)) {
return port, nil
}
} }
port, err := s.generateFreePort() //nolint:staticcheck,unused return 0, fmt.Errorf("no port free for UDP and TCP on %s after %d attempts", ip, randomPortAttempts)
if err != nil {
log.Warnf("failed to generate a free port for eBPF DNS forwarder server: %s", err)
return nil, 0, false
}
ebpfSrv := ebpf.GetEbpfManagerInstance()
err = ebpfSrv.LoadDNSFwd(s.wgInterface.Address().IP, int(port))
if err != nil {
log.Warnf("failed to load DNS forwarder eBPF program, error: %s", err)
return nil, 0, false
}
return ebpfSrv, port, true
}
func (s *serviceViaListener) generateFreePort() (uint16, error) {
ok := s.tryToBind(s.wgInterface.Address().IP, customPort)
if ok {
return customPort, nil
}
probeListener, err := net.ListenUDP("udp4", &net.UDPAddr{})
if err != nil {
log.Debugf("failed to bind random port for DNS: %s", err)
return 0, err
}
port := uint16(probeListener.LocalAddr().(*net.UDPAddr).Port)
if err = probeListener.Close(); err != nil {
log.Debugf("failed to free up DNS port: %s", err)
return 0, err
}
return port, nil
} }
@@ -1,6 +1,7 @@
package dns package dns
import ( import (
"errors"
"fmt" "fmt"
"net" "net"
"net/netip" "net/netip"
@@ -10,6 +11,8 @@ import (
"github.com/miekg/dns" "github.com/miekg/dns"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
firewall "github.com/netbirdio/netbird/client/firewall/manager"
) )
func TestServiceViaListener_TCPAndUDP(t *testing.T) { func TestServiceViaListener_TCPAndUDP(t *testing.T) {
@@ -84,3 +87,133 @@ func TestServiceViaListener_TCPAndUDP(t *testing.T) {
require.NotEmpty(t, tcpResp.Answer) require.NotEmpty(t, tcpResp.Answer)
assert.Contains(t, tcpResp.Answer[0].String(), "192.0.2.1", "TCP response should contain expected IP") assert.Contains(t, tcpResp.Answer[0].String(), "192.0.2.1", "TCP response should contain expected IP")
} }
type dnatCall struct {
rule dnatRule
added bool
}
// fakeFirewall records DNAT calls and fails the ones named in addErrs/removeErrs.
type fakeFirewall struct {
calls []dnatCall
addErrs map[firewall.Protocol]error
removeErrs map[firewall.Protocol]error
}
func (f *fakeFirewall) AddOutputDNAT(ip netip.Addr, protocol firewall.Protocol, _, translatedPort uint16) error {
if err := f.addErrs[protocol]; err != nil {
return err
}
f.calls = append(f.calls, dnatCall{rule: dnatRule{protocol: protocol, ip: ip, port: translatedPort}, added: true})
return nil
}
func (f *fakeFirewall) RemoveOutputDNAT(ip netip.Addr, protocol firewall.Protocol, _, translatedPort uint16) error {
if err := f.removeErrs[protocol]; err != nil {
return err
}
f.calls = append(f.calls, dnatCall{rule: dnatRule{protocol: protocol, ip: ip, port: translatedPort}})
return nil
}
func newDNATTestService(fw Firewall) *serviceViaListener {
return &serviceViaListener{
listenIP: netip.MustParseAddr("100.64.0.1"),
listenPort: customPort,
firewall: fw,
}
}
func TestSetupDNAT_BothProtocols(t *testing.T) {
svc := newDNATTestService(&fakeFirewall{})
svc.setupDNAT()
assert.Len(t, svc.dnatRules, len(dnatProtocols))
assert.Equal(t, DefaultPort, svc.RuntimePort(), "port 53 is advertised once both redirects are installed")
}
func TestSetupDNAT_RollsBackPartialRedirect(t *testing.T) {
fw := &fakeFirewall{addErrs: map[firewall.Protocol]error{firewall.ProtocolTCP: errors.New("nftables busy")}}
svc := newDNATTestService(fw)
svc.setupDNAT()
assert.Empty(t, svc.dnatRules, "the UDP redirect installed before the failure must be rolled back")
assert.Equal(t, int(svc.listenPort), svc.RuntimePort(), "an incomplete redirect must not advertise port 53")
udp := dnatRule{protocol: firewall.ProtocolUDP, ip: svc.listenIP, port: svc.listenPort}
assert.Contains(t, fw.calls, dnatCall{rule: udp}, "UDP removal should have been attempted")
}
// A rollback that fails must keep the rule recorded, so port 53 is not left
// redirected to a resolver that no longer listens.
func TestStop_RetriesFailedDNATRemoval(t *testing.T) {
fw := &fakeFirewall{
addErrs: map[firewall.Protocol]error{firewall.ProtocolTCP: errors.New("nftables busy")},
removeErrs: map[firewall.Protocol]error{firewall.ProtocolUDP: errors.New("nftables busy")},
}
svc := newDNATTestService(fw)
svc.setupDNAT()
udp := dnatRule{protocol: firewall.ProtocolUDP, ip: svc.listenIP, port: svc.listenPort}
require.Equal(t, []dnatRule{udp}, svc.dnatRules, "a failed rollback keeps the rule for a later retry")
require.Error(t, svc.Stop(), "the failing removal should be reported")
require.Equal(t, []dnatRule{udp}, svc.dnatRules)
delete(fw.removeErrs, firewall.ProtocolUDP)
require.NoError(t, svc.Stop(), "a later stop retries the removal")
assert.Empty(t, svc.dnatRules)
}
// A stale rule that cannot be removed is matched before anything added now, so
// no new redirect may be installed on top of it and port 53 must not be
// advertised as reaching this listener.
func TestSetupDNAT_AbortsWhileStaleRuleRemains(t *testing.T) {
fw := &fakeFirewall{removeErrs: map[firewall.Protocol]error{firewall.ProtocolUDP: errors.New("nftables busy")}}
svc := newDNATTestService(fw)
stalePort := svc.listenPort
svc.setupDNAT()
require.Error(t, svc.Stop())
staleUDP := dnatRule{protocol: firewall.ProtocolUDP, ip: svc.listenIP, port: stalePort}
require.Equal(t, []dnatRule{staleUDP}, svc.dnatRules)
svc.listenPort = stalePort + 1
fw.calls = nil
svc.setupDNAT()
assert.Equal(t, []dnatRule{staleUDP}, svc.dnatRules, "the stale rule stays recorded for a later attempt")
for _, call := range fw.calls {
assert.False(t, call.added, "no redirect may be installed while a stale one is still in place")
}
assert.Equal(t, int(svc.listenPort), svc.RuntimePort(), "port 53 must not be advertised")
}
// A rule left behind by a failed removal must be removed with the address and
// port it was installed with, even when the listener has since moved to another
// port, and it must not count towards the redirect the new listener advertises.
func TestSetupDNAT_ClearsStaleRuleAfterPortChange(t *testing.T) {
fw := &fakeFirewall{removeErrs: map[firewall.Protocol]error{firewall.ProtocolUDP: errors.New("nftables busy")}}
svc := newDNATTestService(fw)
stalePort := svc.listenPort
svc.setupDNAT()
require.Error(t, svc.Stop())
staleUDP := dnatRule{protocol: firewall.ProtocolUDP, ip: svc.listenIP, port: stalePort}
require.Equal(t, []dnatRule{staleUDP}, svc.dnatRules)
delete(fw.removeErrs, firewall.ProtocolUDP)
svc.listenPort = stalePort + 1
fw.calls = nil
svc.setupDNAT()
assert.Contains(t, fw.calls, dnatCall{rule: staleUDP}, "the stale rule must be removed with its original port")
assert.Len(t, svc.dnatRules, len(dnatProtocols))
assert.Equal(t, DefaultPort, svc.RuntimePort(), "the new listener is fully redirected")
for _, rule := range svc.dnatRules {
assert.Equal(t, svc.listenPort, rule.port, "only rules for the current listener remain")
}
}
+28 -8
View File
@@ -1,5 +1,5 @@
// Code generated by bpf2go; DO NOT EDIT. // Code generated by bpf2go; DO NOT EDIT.
//go:build arm64be || armbe || mips || mips64 || mips64p32 || ppc64 || s390 || s390x || sparc || sparc64 //go:build mips || mips64 || ppc64 || s390x
package ebpf package ebpf
@@ -47,9 +47,10 @@ func loadBpfObjects(obj interface{}, opts *ebpf.CollectionOptions) error {
type bpfSpecs struct { type bpfSpecs struct {
bpfProgramSpecs bpfProgramSpecs
bpfMapSpecs bpfMapSpecs
bpfVariableSpecs
} }
// bpfSpecs contains programs before they are loaded into the kernel. // bpfProgramSpecs contains programs before they are loaded into the kernel.
// //
// It can be passed ebpf.CollectionSpec.Assign. // It can be passed ebpf.CollectionSpec.Assign.
type bpfProgramSpecs struct { type bpfProgramSpecs struct {
@@ -61,17 +62,28 @@ type bpfProgramSpecs struct {
// It can be passed ebpf.CollectionSpec.Assign. // It can be passed ebpf.CollectionSpec.Assign.
type bpfMapSpecs struct { type bpfMapSpecs struct {
NbFeatures *ebpf.MapSpec `ebpf:"nb_features"` NbFeatures *ebpf.MapSpec `ebpf:"nb_features"`
NbMapDnsIp *ebpf.MapSpec `ebpf:"nb_map_dns_ip"`
NbMapDnsPort *ebpf.MapSpec `ebpf:"nb_map_dns_port"`
NbWgProxySettingsMap *ebpf.MapSpec `ebpf:"nb_wg_proxy_settings_map"` NbWgProxySettingsMap *ebpf.MapSpec `ebpf:"nb_wg_proxy_settings_map"`
} }
// bpfVariableSpecs contains global variables before they are loaded into the kernel.
//
// It can be passed ebpf.CollectionSpec.Assign.
type bpfVariableSpecs struct {
FlagFeatureWgProxy *ebpf.VariableSpec `ebpf:"flag_feature_wg_proxy"`
MapKeyFeatures *ebpf.VariableSpec `ebpf:"map_key_features"`
MapKeyProxyPort *ebpf.VariableSpec `ebpf:"map_key_proxy_port"`
MapKeyWgPort *ebpf.VariableSpec `ebpf:"map_key_wg_port"`
ProxyPort *ebpf.VariableSpec `ebpf:"proxy_port"`
WgPort *ebpf.VariableSpec `ebpf:"wg_port"`
}
// bpfObjects contains all objects after they have been loaded into the kernel. // bpfObjects contains all objects after they have been loaded into the kernel.
// //
// It can be passed to loadBpfObjects or ebpf.CollectionSpec.LoadAndAssign. // It can be passed to loadBpfObjects or ebpf.CollectionSpec.LoadAndAssign.
type bpfObjects struct { type bpfObjects struct {
bpfPrograms bpfPrograms
bpfMaps bpfMaps
bpfVariables
} }
func (o *bpfObjects) Close() error { func (o *bpfObjects) Close() error {
@@ -86,20 +98,28 @@ func (o *bpfObjects) Close() error {
// It can be passed to loadBpfObjects or ebpf.CollectionSpec.LoadAndAssign. // It can be passed to loadBpfObjects or ebpf.CollectionSpec.LoadAndAssign.
type bpfMaps struct { type bpfMaps struct {
NbFeatures *ebpf.Map `ebpf:"nb_features"` NbFeatures *ebpf.Map `ebpf:"nb_features"`
NbMapDnsIp *ebpf.Map `ebpf:"nb_map_dns_ip"`
NbMapDnsPort *ebpf.Map `ebpf:"nb_map_dns_port"`
NbWgProxySettingsMap *ebpf.Map `ebpf:"nb_wg_proxy_settings_map"` NbWgProxySettingsMap *ebpf.Map `ebpf:"nb_wg_proxy_settings_map"`
} }
func (m *bpfMaps) Close() error { func (m *bpfMaps) Close() error {
return _BpfClose( return _BpfClose(
m.NbFeatures, m.NbFeatures,
m.NbMapDnsIp,
m.NbMapDnsPort,
m.NbWgProxySettingsMap, m.NbWgProxySettingsMap,
) )
} }
// bpfVariables contains all global variables after they have been loaded into the kernel.
//
// It can be passed to loadBpfObjects or ebpf.CollectionSpec.LoadAndAssign.
type bpfVariables struct {
FlagFeatureWgProxy *ebpf.Variable `ebpf:"flag_feature_wg_proxy"`
MapKeyFeatures *ebpf.Variable `ebpf:"map_key_features"`
MapKeyProxyPort *ebpf.Variable `ebpf:"map_key_proxy_port"`
MapKeyWgPort *ebpf.Variable `ebpf:"map_key_wg_port"`
ProxyPort *ebpf.Variable `ebpf:"proxy_port"`
WgPort *ebpf.Variable `ebpf:"wg_port"`
}
// bpfPrograms contains all programs after they have been loaded into the kernel. // bpfPrograms contains all programs after they have been loaded into the kernel.
// //
// It can be passed to loadBpfObjects or ebpf.CollectionSpec.LoadAndAssign. // It can be passed to loadBpfObjects or ebpf.CollectionSpec.LoadAndAssign.
Binary file not shown.
+28 -8
View File
@@ -1,5 +1,5 @@
// Code generated by bpf2go; DO NOT EDIT. // Code generated by bpf2go; DO NOT EDIT.
//go:build 386 || amd64 || amd64p32 || arm || arm64 || loong64 || mips64le || mips64p32le || mipsle || ppc64le || riscv64 //go:build 386 || amd64 || arm || arm64 || loong64 || mips64le || mipsle || ppc64le || riscv64 || wasm
package ebpf package ebpf
@@ -47,9 +47,10 @@ func loadBpfObjects(obj interface{}, opts *ebpf.CollectionOptions) error {
type bpfSpecs struct { type bpfSpecs struct {
bpfProgramSpecs bpfProgramSpecs
bpfMapSpecs bpfMapSpecs
bpfVariableSpecs
} }
// bpfSpecs contains programs before they are loaded into the kernel. // bpfProgramSpecs contains programs before they are loaded into the kernel.
// //
// It can be passed ebpf.CollectionSpec.Assign. // It can be passed ebpf.CollectionSpec.Assign.
type bpfProgramSpecs struct { type bpfProgramSpecs struct {
@@ -61,17 +62,28 @@ type bpfProgramSpecs struct {
// It can be passed ebpf.CollectionSpec.Assign. // It can be passed ebpf.CollectionSpec.Assign.
type bpfMapSpecs struct { type bpfMapSpecs struct {
NbFeatures *ebpf.MapSpec `ebpf:"nb_features"` NbFeatures *ebpf.MapSpec `ebpf:"nb_features"`
NbMapDnsIp *ebpf.MapSpec `ebpf:"nb_map_dns_ip"`
NbMapDnsPort *ebpf.MapSpec `ebpf:"nb_map_dns_port"`
NbWgProxySettingsMap *ebpf.MapSpec `ebpf:"nb_wg_proxy_settings_map"` NbWgProxySettingsMap *ebpf.MapSpec `ebpf:"nb_wg_proxy_settings_map"`
} }
// bpfVariableSpecs contains global variables before they are loaded into the kernel.
//
// It can be passed ebpf.CollectionSpec.Assign.
type bpfVariableSpecs struct {
FlagFeatureWgProxy *ebpf.VariableSpec `ebpf:"flag_feature_wg_proxy"`
MapKeyFeatures *ebpf.VariableSpec `ebpf:"map_key_features"`
MapKeyProxyPort *ebpf.VariableSpec `ebpf:"map_key_proxy_port"`
MapKeyWgPort *ebpf.VariableSpec `ebpf:"map_key_wg_port"`
ProxyPort *ebpf.VariableSpec `ebpf:"proxy_port"`
WgPort *ebpf.VariableSpec `ebpf:"wg_port"`
}
// bpfObjects contains all objects after they have been loaded into the kernel. // bpfObjects contains all objects after they have been loaded into the kernel.
// //
// It can be passed to loadBpfObjects or ebpf.CollectionSpec.LoadAndAssign. // It can be passed to loadBpfObjects or ebpf.CollectionSpec.LoadAndAssign.
type bpfObjects struct { type bpfObjects struct {
bpfPrograms bpfPrograms
bpfMaps bpfMaps
bpfVariables
} }
func (o *bpfObjects) Close() error { func (o *bpfObjects) Close() error {
@@ -86,20 +98,28 @@ func (o *bpfObjects) Close() error {
// It can be passed to loadBpfObjects or ebpf.CollectionSpec.LoadAndAssign. // It can be passed to loadBpfObjects or ebpf.CollectionSpec.LoadAndAssign.
type bpfMaps struct { type bpfMaps struct {
NbFeatures *ebpf.Map `ebpf:"nb_features"` NbFeatures *ebpf.Map `ebpf:"nb_features"`
NbMapDnsIp *ebpf.Map `ebpf:"nb_map_dns_ip"`
NbMapDnsPort *ebpf.Map `ebpf:"nb_map_dns_port"`
NbWgProxySettingsMap *ebpf.Map `ebpf:"nb_wg_proxy_settings_map"` NbWgProxySettingsMap *ebpf.Map `ebpf:"nb_wg_proxy_settings_map"`
} }
func (m *bpfMaps) Close() error { func (m *bpfMaps) Close() error {
return _BpfClose( return _BpfClose(
m.NbFeatures, m.NbFeatures,
m.NbMapDnsIp,
m.NbMapDnsPort,
m.NbWgProxySettingsMap, m.NbWgProxySettingsMap,
) )
} }
// bpfVariables contains all global variables after they have been loaded into the kernel.
//
// It can be passed to loadBpfObjects or ebpf.CollectionSpec.LoadAndAssign.
type bpfVariables struct {
FlagFeatureWgProxy *ebpf.Variable `ebpf:"flag_feature_wg_proxy"`
MapKeyFeatures *ebpf.Variable `ebpf:"map_key_features"`
MapKeyProxyPort *ebpf.Variable `ebpf:"map_key_proxy_port"`
MapKeyWgPort *ebpf.Variable `ebpf:"map_key_wg_port"`
ProxyPort *ebpf.Variable `ebpf:"proxy_port"`
WgPort *ebpf.Variable `ebpf:"wg_port"`
}
// bpfPrograms contains all programs after they have been loaded into the kernel. // bpfPrograms contains all programs after they have been loaded into the kernel.
// //
// It can be passed to loadBpfObjects or ebpf.CollectionSpec.LoadAndAssign. // It can be passed to loadBpfObjects or ebpf.CollectionSpec.LoadAndAssign.
Binary file not shown.
@@ -1,52 +0,0 @@
package ebpf
import (
"encoding/binary"
"fmt"
"net/netip"
log "github.com/sirupsen/logrus"
)
const (
mapKeyDNSIP uint32 = 0
mapKeyDNSPort uint32 = 1
)
func (tf *GeneralManager) LoadDNSFwd(ip netip.Addr, dnsPort int) error {
log.Debugf("load eBPF DNS forwarder, watching addr: %s:53, redirect to port: %d", ip, dnsPort)
tf.lock.Lock()
defer tf.lock.Unlock()
err := tf.loadXdp()
if err != nil {
return err
}
if !ip.Is4() {
return fmt.Errorf("eBPF DNS forwarder only supports IPv4, got %s", ip)
}
ip4 := ip.As4()
err = tf.bpfObjs.NbMapDnsIp.Put(mapKeyDNSIP, binary.BigEndian.Uint32(ip4[:]))
if err != nil {
return err
}
err = tf.bpfObjs.NbMapDnsPort.Put(mapKeyDNSPort, uint16(dnsPort))
if err != nil {
return err
}
tf.setFeatureFlag(featureFlagDnsForwarder)
err = tf.bpfObjs.NbFeatures.Put(mapKeyFeatures, tf.featureFlags)
if err != nil {
return err
}
return nil
}
func (tf *GeneralManager) FreeDNSFwd() error {
log.Debugf("free ebpf DNS forwarder")
return tf.unsetFeatureFlag(featureFlagDnsForwarder)
}
+3 -4
View File
@@ -15,8 +15,7 @@ import (
const ( const (
mapKeyFeatures uint32 = 0 mapKeyFeatures uint32 = 0
featureFlagWGProxy = 0b00000001 featureFlagWGProxy = 0b00000001
featureFlagDnsForwarder = 0b00000010
) )
var ( var (
@@ -28,9 +27,9 @@ var (
// GeneralManager is used to load multiple eBPF programs with a custom check (if then) done in prog.c // GeneralManager is used to load multiple eBPF programs with a custom check (if then) done in prog.c
// The manager simply adds a feature (byte) of each program to a map that is shared between the userspace and kernel. // The manager simply adds a feature (byte) of each program to a map that is shared between the userspace and kernel.
// When packet arrives, the C code checks for each feature (if it is set) and executes each enabled program (e.g., dns_fwd.c and wg_proxy.c). // When packet arrives, the C code checks for each feature (if it is set) and executes each enabled program (e.g., wg_proxy.c).
// //
//go:generate go run github.com/cilium/ebpf/cmd/bpf2go -cc clang-14 bpf src/prog.c -- -I /usr/x86_64-linux-gnu/include //go:generate go run github.com/cilium/ebpf/cmd/bpf2go -cc clang-14 bpf src/prog.c -- -I /usr/x86_64-linux-gnu/include -include src/bpf_map_def.h
type GeneralManager struct { type GeneralManager struct {
lock sync.Mutex lock sync.Mutex
link link.Link link link.Link
@@ -7,33 +7,24 @@ import (
func TestManager_setFeatureFlag(t *testing.T) { func TestManager_setFeatureFlag(t *testing.T) {
mgr := GeneralManager{} mgr := GeneralManager{}
mgr.setFeatureFlag(featureFlagWGProxy) mgr.setFeatureFlag(featureFlagWGProxy)
if mgr.featureFlags != 1 { if mgr.featureFlags != featureFlagWGProxy {
t.Errorf("invalid feature state") t.Errorf("invalid feature state")
} }
mgr.setFeatureFlag(featureFlagDnsForwarder) mgr.setFeatureFlag(featureFlagWGProxy)
if mgr.featureFlags != 3 { if mgr.featureFlags != featureFlagWGProxy {
t.Errorf("invalid feature state") t.Errorf("setting a flag twice must be idempotent, got: %d", mgr.featureFlags)
} }
} }
func TestManager_unsetFeatureFlag(t *testing.T) { func TestManager_unsetFeatureFlag(t *testing.T) {
mgr := GeneralManager{} mgr := GeneralManager{}
mgr.setFeatureFlag(featureFlagWGProxy) mgr.setFeatureFlag(featureFlagWGProxy)
mgr.setFeatureFlag(featureFlagDnsForwarder)
err := mgr.unsetFeatureFlag(featureFlagWGProxy) err := mgr.unsetFeatureFlag(featureFlagWGProxy)
if err != nil { if err != nil {
t.Errorf("unexpected error: %s", err) t.Errorf("unexpected error: %s", err)
} }
if mgr.featureFlags != 2 {
t.Errorf("invalid feature state, expected: %d, got: %d", 2, mgr.featureFlags)
}
err = mgr.unsetFeatureFlag(featureFlagDnsForwarder)
if err != nil {
t.Errorf("unexpected error: %s", err)
}
if mgr.featureFlags != 0 { if mgr.featureFlags != 0 {
t.Errorf("invalid feature state, expected: %d, got: %d", 0, mgr.featureFlags) t.Errorf("invalid feature state, expected: %d, got: %d", 0, mgr.featureFlags)
} }
@@ -0,0 +1,16 @@
// libbpf 1.0 removed struct bpf_map_def, but the programs here keep the legacy
// map definitions: they load on kernels built without BTF, which BTF-style
// (SEC(".maps")) definitions do not. Define the struct ourselves so the
// programs compile against current libbpf headers.
#ifndef NB_BPF_MAP_DEF_H
#define NB_BPF_MAP_DEF_H
struct bpf_map_def {
unsigned int type;
unsigned int key_size;
unsigned int value_size;
unsigned int max_entries;
unsigned int map_flags;
};
#endif
-67
View File
@@ -1,67 +0,0 @@
const __u32 map_key_dns_ip = 0;
const __u32 map_key_dns_port = 1;
struct bpf_map_def SEC("maps") nb_map_dns_ip = {
.type = BPF_MAP_TYPE_ARRAY,
.key_size = sizeof(__u32),
.value_size = sizeof(__u32),
.max_entries = 10,
};
struct bpf_map_def SEC("maps") nb_map_dns_port = {
.type = BPF_MAP_TYPE_ARRAY,
.key_size = sizeof(__u32),
.value_size = sizeof(__u16),
.max_entries = 10,
};
__be32 dns_ip = 0;
__be16 dns_port = 0;
// 13568 is 53 in big endian
__be16 GENERAL_DNS_PORT = 13568;
bool read_settings() {
__u16 *port_value;
__u32 *ip_value;
// read dns ip
ip_value = bpf_map_lookup_elem(&nb_map_dns_ip, &map_key_dns_ip);
if(!ip_value) {
return false;
}
dns_ip = htonl(*ip_value);
// read dns port
port_value = bpf_map_lookup_elem(&nb_map_dns_port, &map_key_dns_port);
if (!port_value) {
return false;
}
dns_port = htons(*port_value);
return true;
}
int xdp_dns_fwd(struct iphdr *ip, struct udphdr *udp) {
if (dns_port == 0) {
if(!read_settings()){
return XDP_PASS;
}
// bpf_printk("dns port: %d", ntohs(dns_port));
// bpf_printk("dns ip: %d", ntohl(dns_ip));
}
if (udp->dest == GENERAL_DNS_PORT && ip->daddr == dns_ip) {
udp->dest = dns_port;
// Clear the now-stale checksum; zero means "not computed" for IPv4.
udp->check = 0;
return XDP_PASS;
}
if (udp->source == dns_port && ip->saddr == dns_ip) {
udp->source = GENERAL_DNS_PORT;
udp->check = 0;
return XDP_PASS;
}
return XDP_PASS;
}
-6
View File
@@ -5,11 +5,9 @@
#include <netinet/in.h> #include <netinet/in.h>
#include <linux/bpf.h> #include <linux/bpf.h>
#include <bpf/bpf_helpers.h> #include <bpf/bpf_helpers.h>
#include "dns_fwd.c"
#include "wg_proxy.c" #include "wg_proxy.c"
const __u16 flag_feature_wg_proxy = 0b01; const __u16 flag_feature_wg_proxy = 0b01;
const __u16 flag_feature_dns_fwd = 0b10;
const __u32 map_key_features = 0; const __u32 map_key_features = 0;
struct bpf_map_def SEC("maps") nb_features = { struct bpf_map_def SEC("maps") nb_features = {
@@ -48,10 +46,6 @@ int nb_xdp_prog(struct xdp_md *ctx) {
return XDP_PASS; return XDP_PASS;
} }
if (*features & flag_feature_dns_fwd) {
xdp_dns_fwd(ip, udp);
}
if (*features & flag_feature_wg_proxy) { if (*features & flag_feature_wg_proxy) {
xdp_wg_proxy(ip, udp); xdp_wg_proxy(ip, udp);
} }
+14 -4
View File
@@ -1,8 +1,18 @@
# DNS forwarder # XDP programs
The agent attach the XDP program to the lo device. We can not use fake address in eBPF because the `prog.c` is attached to the `lo` device and dispatches to the features enabled in the
traffic does not appear in the eBPF program. The program capture the traffic on wg_ip:53 and `nb_features` map. The only feature is the WireGuard proxy (`wg_proxy.c`): it rewrites
overwrite in it the destination port to 5053. loopback UDP sent from the WireGuard listen port so it reaches the userspace relay proxy
port instead, and swaps the peer endpoint port into the source so the proxy can tell
peers apart.
Maps use the legacy `struct bpf_map_def` form, defined in `bpf_map_def.h` because libbpf
1.0 removed it. They load on kernels built without BTF, which BTF-style (`SEC(".maps")`)
definitions do not.
Regenerate the objects with `go generate ./client/internal/ebpf/ebpf/`; it needs
`clang-14`. Loading a regenerated object needs root, attaching it needs `bpf_link`
(kernel >= 5.7), and only one XDP program can own `lo` at a time.
# Debug # Debug
+1 -5
View File
@@ -1,11 +1,7 @@
package manager package manager
import "net/netip" // Manager is used to load multiple eBPF programs. E.g., the WireGuard proxy
// Manager is used to load multiple eBPF programs. E.g., current DNS programs and WireGuard proxy
type Manager interface { type Manager interface {
LoadDNSFwd(ip netip.Addr, dnsPort int) error
FreeDNSFwd() error
LoadWgProxy(proxyPort, wgPort int) error LoadWgProxy(proxyPort, wgPort int) error
FreeWGProxy() error FreeWGProxy() error
} }
+21 -7
View File
@@ -14,12 +14,14 @@ import (
"sort" "sort"
"strings" "strings"
"sync" "sync"
"sync/atomic"
"time" "time"
"github.com/hashicorp/go-multierror" "github.com/hashicorp/go-multierror"
"github.com/pion/ice/v4" "github.com/pion/ice/v4"
"github.com/pion/stun/v3" "github.com/pion/stun/v3"
log "github.com/sirupsen/logrus" log "github.com/sirupsen/logrus"
wgdevice "golang.zx2c4.com/wireguard/device"
"golang.zx2c4.com/wireguard/tun/netstack" "golang.zx2c4.com/wireguard/tun/netstack"
"golang.zx2c4.com/wireguard/wgctrl/wgtypes" "golang.zx2c4.com/wireguard/wgctrl/wgtypes"
@@ -237,6 +239,12 @@ type Engine struct {
wgInterface WGIface wgInterface WGIface
// wgDevice is a lock-free handle on the WireGuard device behind
// wgInterface. Reaching the device through wgInterface requires
// syncMsgMux, which handleSync holds while it adds and removes peers;
// SetPerformance must stay reachable exactly when that work is stuck.
wgDevice atomic.Pointer[wgdevice.Device]
udpMux *udpmux.UniversalUDPMuxDefault udpMux *udpmux.UniversalUDPMuxDefault
// networkSerial is the latest CurrentSerial (state ID) of the network sent by the Management service // networkSerial is the latest CurrentSerial (state ID) of the network sent by the Management service
@@ -652,6 +660,7 @@ func (e *Engine) Start(netbirdConfig *mgmProto.NetbirdConfig, mgmtURL *url.URL)
log.Errorf("failed to pull up wgInterface [%s]: %s", e.wgInterface.Name(), err.Error()) log.Errorf("failed to pull up wgInterface [%s]: %s", e.wgInterface.Name(), err.Error())
return fmt.Errorf("up wg interface: %w", err) return fmt.Errorf("up wg interface: %w", err)
} }
e.wgDevice.Store(e.wgInterface.GetWGDevice())
// Set up notrack rules immediately after proxy is listening to prevent // Set up notrack rules immediately after proxy is listening to prevent
// conntrack entries from being created before the rules are in place // conntrack entries from being created before the rules are in place
@@ -2155,6 +2164,10 @@ func (e *Engine) close() {
log.Debugf("removing Netbird interface %s", e.config.WgIfaceName) log.Debugf("removing Netbird interface %s", e.config.WgIfaceName)
if e.wgInterface != nil { if e.wgInterface != nil {
// Drop the handle before the close starts: a retune that loads it
// afterwards would touch a device on its way out and report success
// for an engine that is already gone.
e.wgDevice.Store(nil)
if err := e.wgInterface.Close(); err != nil { if err := e.wgInterface.Close(); err != nil {
log.Errorf("failed closing Netbird interface %s %v", e.config.WgIfaceName, err) log.Errorf("failed closing Netbird interface %s %v", e.config.WgIfaceName, err)
} }
@@ -2314,15 +2327,16 @@ type Performance struct {
} }
// SetPerformance applies the given tuning to this engine's live Device. // SetPerformance applies the given tuning to this engine's live Device.
//
// It deliberately does not take syncMsgMux. Raising the buffer pool cap is the
// recovery path for a device whose pool is exhausted, and an exhausted pool
// blocks peer removal inside handleSync, which holds syncMsgMux for as long as
// it stays blocked. Taking the lock here would make the retune unreachable in
// the one situation that needs it.
func (e *Engine) SetPerformance(t Performance) error { func (e *Engine) SetPerformance(t Performance) error {
e.syncMsgMux.Lock() dev := e.wgDevice.Load()
defer e.syncMsgMux.Unlock()
if e.wgInterface == nil {
return fmt.Errorf("wg interface not initialized")
}
dev := e.wgInterface.GetWGDevice()
if dev == nil { if dev == nil {
return fmt.Errorf("wg device not initialized") return errors.New("wg device not initialized")
} }
if t.PreallocatedBuffersPerPool != nil { if t.PreallocatedBuffersPerPool != nil {
dev.SetPreallocatedBuffersPerPool(*t.PreallocatedBuffersPerPool) dev.SetPreallocatedBuffersPerPool(*t.PreallocatedBuffersPerPool)
+4 -4
View File
@@ -116,7 +116,7 @@ func (h *Handshaker) Listen(ctx context.Context) {
for { for {
select { select {
case remoteOfferAnswer := <-h.remoteOffersCh: case remoteOfferAnswer := <-h.remoteOffersCh:
h.log.Infof("received offer, running version %s, remote WireGuard listen port %d, session id: %s, remote ICE supported: %t", remoteOfferAnswer.Version, remoteOfferAnswer.WgListenPort, remoteOfferAnswer.SessionIDString(), remoteOfferAnswer.hasICECredentials()) h.log.Infof("received offer, running version %s, remote WireGuard listen port %d, session id: %s, remote ICE supported: %t, relay server: %s, relay IP: %s", remoteOfferAnswer.Version, remoteOfferAnswer.WgListenPort, remoteOfferAnswer.SessionIDString(), remoteOfferAnswer.hasICECredentials(), remoteOfferAnswer.RelaySrvAddress, remoteOfferAnswer.RelaySrvIP)
// Record signaling received for reconnection attempts // Record signaling received for reconnection attempts
if h.metricsStages != nil { if h.metricsStages != nil {
@@ -138,7 +138,7 @@ func (h *Handshaker) Listen(ctx context.Context) {
continue continue
} }
case remoteOfferAnswer := <-h.remoteAnswerCh: case remoteOfferAnswer := <-h.remoteAnswerCh:
h.log.Infof("received answer, running version %s, remote WireGuard listen port %d, session id: %s, remote ICE supported: %t", remoteOfferAnswer.Version, remoteOfferAnswer.WgListenPort, remoteOfferAnswer.SessionIDString(), remoteOfferAnswer.hasICECredentials()) h.log.Infof("received answer, running version %s, remote WireGuard listen port %d, session id: %s, remote ICE supported: %t, relay server: %s, relay IP: %s", remoteOfferAnswer.Version, remoteOfferAnswer.WgListenPort, remoteOfferAnswer.SessionIDString(), remoteOfferAnswer.hasICECredentials(), remoteOfferAnswer.RelaySrvAddress, remoteOfferAnswer.RelaySrvIP)
// Record signaling received for reconnection attempts // Record signaling received for reconnection attempts
if h.metricsStages != nil { if h.metricsStages != nil {
@@ -209,14 +209,14 @@ func (h *Handshaker) sendOffer() error {
} }
offer := h.buildOfferAnswer() offer := h.buildOfferAnswer()
h.log.Debugf("sending offer with serial: %s", offer.SessionIDString()) h.log.Debugf("sending offer with serial: %s, relay server: %s, relay IP: %s", offer.SessionIDString(), offer.RelaySrvAddress, offer.RelaySrvIP)
return h.signaler.SignalOffer(offer, h.config.Key) return h.signaler.SignalOffer(offer, h.config.Key)
} }
func (h *Handshaker) sendAnswer() error { func (h *Handshaker) sendAnswer() error {
answer := h.buildOfferAnswer() answer := h.buildOfferAnswer()
h.log.Debugf("sending answer with serial: %s", answer.SessionIDString()) h.log.Debugf("sending answer with serial: %s, relay server: %s, relay IP: %s", answer.SessionIDString(), answer.RelaySrvAddress, answer.RelaySrvIP)
return h.signaler.SignalAnswer(answer, h.config.Key) return h.signaler.SignalAnswer(answer, h.config.Key)
} }
+22 -12
View File
@@ -58,10 +58,6 @@ var DefaultInterfaceBlacklist = []string{
"Tailscale", "tailscale", "docker", "veth", "br-", "lo", "Tailscale", "tailscale", "docker", "veth", "br-", "lo",
} }
// loadMDMPolicy is the package-level indirection used by apply() to read the
// active MDM policy. Tests override this to inject a fake policy.
var loadMDMPolicy = mdm.LoadPolicy
// ConfigInput carries configuration changes to the client // ConfigInput carries configuration changes to the client
type ConfigInput struct { type ConfigInput struct {
ManagementURL string ManagementURL string
@@ -202,14 +198,26 @@ type Config struct {
MTU uint16 MTU uint16
// policy is the MDM policy that produced the currently-set values for // policy is the MDM policy that produced the currently-set values
// any MDM-enforced fields. Set by applyMDMPolicy at the tail of apply() // for any MDM-enforced fields. Set by ApplyMDMPolicy on every
// and reset on every apply() invocation. Never persisted to disk. // invocation. Never persisted to disk. Callers query enforcement
// Callers query enforcement state via Policy() and the mdm.Policy API // state via Policy() and the mdm.Policy API (HasKey, ManagedKeys,
// (HasKey, ManagedKeys, IsEmpty). // IsEmpty).
policy *mdm.Policy `json:"-"` policy *mdm.Policy `json:"-"`
} }
// ApplyMDMPolicy overlays the supplied MDM Policy on top of the current
// Config values and records it as Policy(). The overlay is not reversible:
// an empty Policy only clears the enforcement metadata, so resolve the base
// Config again (from disk or JSON) before applying a changed policy, the way
// the lifecycle owners do on every load.
func (config *Config) ApplyMDMPolicy(policy *mdm.Policy) {
if config == nil {
return
}
config.applyMDMPolicy(policy)
}
// Policy returns the MDM policy applied to this Config. Returns a non-nil // Policy returns the MDM policy applied to this Config. Returns a non-nil
// empty Policy when MDM enforcement is inactive; callers can always invoke // empty Policy when MDM enforcement is inactive; callers can always invoke
// HasKey / ManagedKeys / IsEmpty without a nil check. // HasKey / ManagedKeys / IsEmpty without a nil check.
@@ -712,9 +720,11 @@ func (config *Config) apply(input ConfigInput) (updated bool, err error) {
updated = true updated = true
} }
// MDM is the last override layer: any key present in the policy // Initialise the MDM overlay to "no enforcement" so Config.Policy()
// supersedes defaults, on-disk config, env vars and CLI input. // never returns a stale or nil policy on a freshly applied Config.
config.applyMDMPolicy(loadMDMPolicy()) // Lifecycle owners that want to enforce a real MDM policy invoke
// Config.ApplyMDMPolicy(loader.Load()) after this returns.
config.applyMDMPolicy(mdm.NewPolicy(nil))
return updated, nil return updated, nil
} }
@@ -0,0 +1,52 @@
package profilemanager
import (
"errors"
"fmt"
"github.com/netbirdio/netbird/client/mdm"
)
// ErrMDMManagedFields marks a config change rejected because it diverges from
// MDM-enforced values.
var ErrMDMManagedFields = errors.New("fields managed by MDM cannot be modified")
// MDMConflicts returns the names of MDM-managed keys whose requested value in
// the ConfigInput differs from the policy-enforced value; a field set to the
// enforced value is a no-op echo, not a conflict.
func MDMConflicts(input ConfigInput, policy *mdm.Policy) []string {
pskGot := input.PreSharedKey
if isPreSharedKeyHidden(pskGot) {
pskGot = nil
}
var port *int64
if input.WireguardPort != nil {
v := int64(*input.WireguardPort)
port = &v
}
return mdm.ResolveConflicts(policy, []mdm.ConflictCheck{
mdm.ConflictURL(mdm.KeyManagementURL, input.ManagementURL),
mdm.ConflictStringPtr(mdm.KeyPreSharedKey, pskGot),
mdm.ConflictBool(mdm.KeyRosenpassEnabled, input.RosenpassEnabled),
mdm.ConflictBool(mdm.KeyRosenpassPermissive, input.RosenpassPermissive),
mdm.ConflictBool(mdm.KeyDisableAutoConnect, input.DisableAutoConnect),
mdm.ConflictBool(mdm.KeyAllowServerSSH, input.ServerSSHAllowed),
mdm.ConflictBool(mdm.KeyRemoteJobsAllowed, input.RemoteJobsAllowed),
mdm.ConflictBool(mdm.KeyDisableClientRoutes, input.DisableClientRoutes),
mdm.ConflictBool(mdm.KeyDisableServerRoutes, input.DisableServerRoutes),
mdm.ConflictBool(mdm.KeyBlockInbound, input.BlockInbound),
mdm.ConflictInt64(mdm.KeyWireguardPort, port),
mdm.ConflictBool(mdm.KeyEnableLocalMetrics, input.LocalMetricsEnabled),
mdm.ConflictStringPtr(mdm.KeyLocalMetricsAddress, input.LocalMetricsAddress),
})
}
// CheckMDMConflicts returns an ErrMDMManagedFields-wrapped error naming the
// conflicting keys, or nil when the input does not fight the policy.
func CheckMDMConflicts(input ConfigInput, policy *mdm.Policy) error {
conflicts := MDMConflicts(input, policy)
if len(conflicts) == 0 {
return nil
}
return fmt.Errorf("%w: %v", ErrMDMManagedFields, conflicts)
}
+141 -68
View File
@@ -10,24 +10,58 @@ import (
"github.com/netbirdio/netbird/client/mdm" "github.com/netbirdio/netbird/client/mdm"
) )
// withMDMPolicy temporarily overrides the package-level loadMDMPolicy hook so // fakeFetcher implements mdm.PolicyFetcher returning a pre-set policy
// apply() observes the supplied Policy. The original loader is restored at // map. Test helper used to construct a Loader without touching the OS
// test cleanup. // or any package-level state.
func withMDMPolicy(t *testing.T, policy *mdm.Policy) { type fakeFetcher struct{ values map[string]any }
func (f *fakeFetcher) Fetch() map[string]any { return f.values }
// loaderFor builds an mdm.Loader whose loadPlatform returns the
// supplied Policy's underlying values.
func loaderFor(policy *mdm.Policy) *mdm.Loader {
if policy == nil || policy.IsEmpty() {
return mdm.NewLoader(&fakeFetcher{values: nil})
}
values := make(map[string]any)
for _, k := range policy.ManagedKeys() {
if v, ok := policy.GetString(k); ok {
values[k] = v
continue
}
if v, ok := policy.GetInt(k); ok {
values[k] = v
continue
}
if v, ok := policy.GetBool(k); ok {
values[k] = v
continue
}
if v, ok := policy.GetStringSlice(k); ok {
values[k] = v
}
}
return mdm.NewLoader(&fakeFetcher{values: values})
}
// configWithMDM is the test convenience that builds a Config via
// UpdateOrCreateConfig and overlays the supplied MDM policy on top —
// mirrors the production pattern (Server.getConfig / Client.applyMDMOverlay)
// where the Loader lives outside Config and the apply step is driven
// by the lifecycle owner.
func configWithMDM(t *testing.T, input ConfigInput, policy *mdm.Policy) *Config {
t.Helper() t.Helper()
prev := loadMDMPolicy cfg, err := UpdateOrCreateConfig(input)
loadMDMPolicy = func() *mdm.Policy { return policy } require.NoError(t, err)
t.Cleanup(func() { loadMDMPolicy = prev }) require.NotNil(t, cfg)
cfg.ApplyMDMPolicy(loaderFor(policy).Load())
return cfg
} }
func TestApply_MDMEmpty_NoEnforcement(t *testing.T) { func TestApply_MDMEmpty_NoEnforcement(t *testing.T) {
withMDMPolicy(t, mdm.NewPolicy(nil)) cfg := configWithMDM(t, ConfigInput{
cfg, err := UpdateOrCreateConfig(ConfigInput{
ConfigPath: filepath.Join(t.TempDir(), "config.json"), ConfigPath: filepath.Join(t.TempDir(), "config.json"),
}) }, mdm.NewPolicy(nil))
require.NoError(t, err)
require.NotNil(t, cfg)
assert.True(t, cfg.Policy().IsEmpty(), "no MDM source ⇒ empty Policy") assert.True(t, cfg.Policy().IsEmpty(), "no MDM source ⇒ empty Policy")
assert.False(t, cfg.Policy().HasKey(mdm.KeyManagementURL)) assert.False(t, cfg.Policy().HasKey(mdm.KeyManagementURL))
@@ -39,18 +73,15 @@ func TestApply_MDMEmpty_NoEnforcement(t *testing.T) {
func TestApply_MDMOnly_OverridesDefaults(t *testing.T) { func TestApply_MDMOnly_OverridesDefaults(t *testing.T) {
const mdmURL = "https://corp.mdm.example.com:443" const mdmURL = "https://corp.mdm.example.com:443"
withMDMPolicy(t, mdm.NewPolicy(map[string]any{
cfg := configWithMDM(t, ConfigInput{
ConfigPath: filepath.Join(t.TempDir(), "config.json"),
}, mdm.NewPolicy(map[string]any{
mdm.KeyManagementURL: mdmURL, mdm.KeyManagementURL: mdmURL,
mdm.KeyDisableClientRoutes: true, mdm.KeyDisableClientRoutes: true,
mdm.KeyBlockInbound: true, mdm.KeyBlockInbound: true,
})) }))
cfg, err := UpdateOrCreateConfig(ConfigInput{
ConfigPath: filepath.Join(t.TempDir(), "config.json"),
})
require.NoError(t, err)
require.NotNil(t, cfg)
assert.Equal(t, mdmURL, cfg.ManagementURL.String()) assert.Equal(t, mdmURL, cfg.ManagementURL.String())
assert.True(t, cfg.DisableClientRoutes) assert.True(t, cfg.DisableClientRoutes)
assert.True(t, cfg.BlockInbound) assert.True(t, cfg.BlockInbound)
@@ -65,16 +96,12 @@ func TestApply_MDMBeatsCLIInput(t *testing.T) {
const mdmURL = "https://mdm.example.com:443" const mdmURL = "https://mdm.example.com:443"
const cliURL = "https://cli.example.com:443" const cliURL = "https://cli.example.com:443"
withMDMPolicy(t, mdm.NewPolicy(map[string]any{ cfg := configWithMDM(t, ConfigInput{
mdm.KeyManagementURL: mdmURL,
}))
cfg, err := UpdateOrCreateConfig(ConfigInput{
ConfigPath: filepath.Join(t.TempDir(), "config.json"), ConfigPath: filepath.Join(t.TempDir(), "config.json"),
ManagementURL: cliURL, ManagementURL: cliURL,
}) }, mdm.NewPolicy(map[string]any{
require.NoError(t, err) mdm.KeyManagementURL: mdmURL,
require.NotNil(t, cfg) }))
// MDM wins over CLI-supplied management URL. // MDM wins over CLI-supplied management URL.
assert.Equal(t, mdmURL, cfg.ManagementURL.String()) assert.Equal(t, mdmURL, cfg.ManagementURL.String())
@@ -82,16 +109,12 @@ func TestApply_MDMBeatsCLIInput(t *testing.T) {
} }
func TestApply_MDMInvalidURL_KeepsPreviousValue(t *testing.T) { func TestApply_MDMInvalidURL_KeepsPreviousValue(t *testing.T) {
withMDMPolicy(t, mdm.NewPolicy(map[string]any{ cfg := configWithMDM(t, ConfigInput{
ConfigPath: filepath.Join(t.TempDir(), "config.json"),
}, mdm.NewPolicy(map[string]any{
mdm.KeyManagementURL: "not-a-url", mdm.KeyManagementURL: "not-a-url",
})) }))
cfg, err := UpdateOrCreateConfig(ConfigInput{
ConfigPath: filepath.Join(t.TempDir(), "config.json"),
})
require.NoError(t, err)
require.NotNil(t, cfg)
// Invalid MDM URL is logged and skipped: default URL stays in place // Invalid MDM URL is logged and skipped: default URL stays in place
// to keep the client functional. // to keep the client functional.
assert.Equal(t, DefaultManagementURL, cfg.ManagementURL.String()) assert.Equal(t, DefaultManagementURL, cfg.ManagementURL.String())
@@ -106,24 +129,20 @@ func TestApply_MDMBoolKeysOverrideOnDiskValue(t *testing.T) {
tmp := filepath.Join(t.TempDir(), "config.json") tmp := filepath.Join(t.TempDir(), "config.json")
// Seed without MDM. // Seed without MDM.
withMDMPolicy(t, mdm.NewPolicy(nil)) configWithMDM(t, ConfigInput{
_, err := UpdateOrCreateConfig(ConfigInput{
ConfigPath: tmp, ConfigPath: tmp,
DisableClientRoutes: boolPtr(false), DisableClientRoutes: boolPtr(false),
RosenpassEnabled: boolPtr(false), RosenpassEnabled: boolPtr(false),
}) }, mdm.NewPolicy(nil))
require.NoError(t, err)
// Now enable MDM enforcement for these keys. // Now enable MDM enforcement for these keys.
withMDMPolicy(t, mdm.NewPolicy(map[string]any{ cfg := configWithMDM(t, ConfigInput{
ConfigPath: tmp,
}, mdm.NewPolicy(map[string]any{
mdm.KeyDisableClientRoutes: true, mdm.KeyDisableClientRoutes: true,
mdm.KeyRosenpassEnabled: true, mdm.KeyRosenpassEnabled: true,
})) }))
cfg, err := UpdateOrCreateConfig(ConfigInput{ConfigPath: tmp})
require.NoError(t, err)
require.NotNil(t, cfg)
assert.True(t, cfg.DisableClientRoutes, "MDM override should flip on-disk false to true") assert.True(t, cfg.DisableClientRoutes, "MDM override should flip on-disk false to true")
assert.True(t, cfg.RosenpassEnabled) assert.True(t, cfg.RosenpassEnabled)
assert.True(t, cfg.Policy().HasKey(mdm.KeyDisableClientRoutes)) assert.True(t, cfg.Policy().HasKey(mdm.KeyDisableClientRoutes))
@@ -134,22 +153,19 @@ func TestApply_MDMLocalMetrics(t *testing.T) {
tmp := filepath.Join(t.TempDir(), "config.json") tmp := filepath.Join(t.TempDir(), "config.json")
// Seed without MDM. // Seed without MDM.
withMDMPolicy(t, mdm.NewPolicy(nil)) configWithMDM(t, ConfigInput{
_, err := UpdateOrCreateConfig(ConfigInput{
ConfigPath: tmp, ConfigPath: tmp,
LocalMetricsEnabled: boolPtr(false), LocalMetricsEnabled: boolPtr(false),
}) }, mdm.NewPolicy(nil))
require.NoError(t, err)
withMDMPolicy(t, mdm.NewPolicy(map[string]any{ // Now enable MDM enforcement for these keys.
cfg := configWithMDM(t, ConfigInput{
ConfigPath: tmp,
}, mdm.NewPolicy(map[string]any{
mdm.KeyEnableLocalMetrics: true, mdm.KeyEnableLocalMetrics: true,
mdm.KeyLocalMetricsAddress: "127.0.0.1:9292", mdm.KeyLocalMetricsAddress: "127.0.0.1:9292",
})) }))
cfg, err := UpdateOrCreateConfig(ConfigInput{ConfigPath: tmp})
require.NoError(t, err)
require.NotNil(t, cfg)
assert.True(t, cfg.LocalMetricsEnabled, "MDM override should flip on-disk false to true") assert.True(t, cfg.LocalMetricsEnabled, "MDM override should flip on-disk false to true")
assert.Equal(t, "127.0.0.1:9292", cfg.LocalMetricsAddress) assert.Equal(t, "127.0.0.1:9292", cfg.LocalMetricsAddress)
assert.True(t, cfg.Policy().HasKey(mdm.KeyEnableLocalMetrics)) assert.True(t, cfg.Policy().HasKey(mdm.KeyEnableLocalMetrics))
@@ -171,16 +187,12 @@ func TestApply_MDMLazyConnection(t *testing.T) {
} }
for _, c := range cases { for _, c := range cases {
t.Run(c.name, func(t *testing.T) { t.Run(c.name, func(t *testing.T) {
withMDMPolicy(t, mdm.NewPolicy(map[string]any{ cfg := configWithMDM(t, ConfigInput{
ConfigPath: filepath.Join(t.TempDir(), "config.json"),
}, mdm.NewPolicy(map[string]any{
mdm.KeyLazyConnection: c.raw, mdm.KeyLazyConnection: c.raw,
})) }))
cfg, err := UpdateOrCreateConfig(ConfigInput{
ConfigPath: filepath.Join(t.TempDir(), "config.json"),
})
require.NoError(t, err)
require.NotNil(t, cfg)
assert.Equal(t, c.want, cfg.LazyConnection) assert.Equal(t, c.want, cfg.LazyConnection)
assert.True(t, cfg.Policy().HasKey(mdm.KeyLazyConnection)) assert.True(t, cfg.Policy().HasKey(mdm.KeyLazyConnection))
}) })
@@ -188,22 +200,83 @@ func TestApply_MDMLazyConnection(t *testing.T) {
} }
func TestApply_MDMPreSharedKeyRedactionSentinelRejected(t *testing.T) { func TestApply_MDMPreSharedKeyRedactionSentinelRejected(t *testing.T) {
const maskSentinel = "**********" const maskSentinel = mdm.PreSharedKeyRedactedSentinel
withMDMPolicy(t, mdm.NewPolicy(map[string]any{ cfg := configWithMDM(t, ConfigInput{
ConfigPath: filepath.Join(t.TempDir(), "config.json"),
}, mdm.NewPolicy(map[string]any{
mdm.KeyPreSharedKey: maskSentinel, mdm.KeyPreSharedKey: maskSentinel,
})) }))
cfg, err := UpdateOrCreateConfig(ConfigInput{
ConfigPath: filepath.Join(t.TempDir(), "config.json"),
})
require.NoError(t, err)
require.NotNil(t, cfg)
// Mask sentinel must not be persisted as the actual PSK. // Mask sentinel must not be persisted as the actual PSK.
assert.NotEqual(t, maskSentinel, cfg.PreSharedKey) assert.NotEqual(t, maskSentinel, cfg.PreSharedKey)
// Key still marked managed so user writes are still rejected. // Key still marked managed so user writes are still rejected.
assert.True(t, cfg.Policy().HasKey(mdm.KeyPreSharedKey)) assert.True(t, cfg.Policy().HasKey(mdm.KeyPreSharedKey))
} }
func TestMDMConflicts_PreSharedKey(t *testing.T) {
policy := mdm.NewPolicy(map[string]any{
mdm.KeyPreSharedKey: "mdm-enforced-psk",
})
empty := ""
sentinel := mdm.PreSharedKeyRedactedSentinel
same := "mdm-enforced-psk"
other := "user-psk"
tests := []struct {
name string
psk *string
want []string
}{
{name: "unset", psk: nil, want: nil},
{name: "explicit empty", psk: &empty, want: []string{mdm.KeyPreSharedKey}},
{name: "sentinel echo", psk: &sentinel, want: nil},
{name: "same value", psk: &same, want: nil},
{name: "divergent", psk: &other, want: []string{mdm.KeyPreSharedKey}},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
assert.Equal(t, tc.want, MDMConflicts(ConfigInput{PreSharedKey: tc.psk}, policy))
})
}
}
func TestMDMConflicts_RemoteJobsAndLocalMetrics(t *testing.T) {
policy := mdm.NewPolicy(map[string]any{
mdm.KeyRemoteJobsAllowed: false,
mdm.KeyEnableLocalMetrics: true,
mdm.KeyLocalMetricsAddress: "127.0.0.1:9999",
})
sameAddr := "127.0.0.1:9999"
otherAddr := "0.0.0.0:9999"
emptyAddr := ""
tests := []struct {
name string
input ConfigInput
want []string
}{
{name: "unset", input: ConfigInput{}, want: nil},
{name: "echo", input: ConfigInput{
RemoteJobsAllowed: boolPtr(false),
LocalMetricsEnabled: boolPtr(true),
LocalMetricsAddress: &sameAddr,
}, want: nil},
{name: "remote jobs divergent", input: ConfigInput{RemoteJobsAllowed: boolPtr(true)}, want: []string{mdm.KeyRemoteJobsAllowed}},
{name: "metrics disabled", input: ConfigInput{LocalMetricsEnabled: boolPtr(false)}, want: []string{mdm.KeyEnableLocalMetrics}},
{name: "metrics address divergent", input: ConfigInput{LocalMetricsAddress: &otherAddr}, want: []string{mdm.KeyLocalMetricsAddress}},
{name: "metrics address explicit empty", input: ConfigInput{LocalMetricsAddress: &emptyAddr}, want: []string{mdm.KeyLocalMetricsAddress}},
{name: "all divergent", input: ConfigInput{
RemoteJobsAllowed: boolPtr(true),
LocalMetricsEnabled: boolPtr(false),
LocalMetricsAddress: &otherAddr,
}, want: []string{mdm.KeyRemoteJobsAllowed, mdm.KeyEnableLocalMetrics, mdm.KeyLocalMetricsAddress}},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
assert.Equal(t, tc.want, MDMConflicts(tc.input, policy))
})
}
}
func boolPtr(b bool) *bool { return &b } func boolPtr(b bool) *bool { return &b }
@@ -6,6 +6,7 @@ import (
"os/user" "os/user"
"path/filepath" "path/filepath"
"runtime" "runtime"
"strconv"
log "github.com/sirupsen/logrus" log "github.com/sirupsen/logrus"
) )
@@ -13,17 +14,21 @@ import (
const envSudoUser = "SUDO_USER" const envSudoUser = "SUDO_USER"
var ( var (
geteuid = os.Geteuid currentUser = user.Current
lookupUser = user.Lookup getegid = os.Getegid
geteuid = os.Geteuid
lookupUser = user.Lookup
) )
// InvokingUser returns the user a CLI invocation acts for. Under sudo that is // InvokingUser returns the user a CLI invocation acts for. Under sudo that is
// the user who ran sudo, not root: privileged flags force commands through // the user who ran sudo, not root: privileged flags force commands through
// sudo, and resolving profiles as root would silently switch the daemon to // sudo, and resolving profiles as root would silently switch the daemon to
// root's (default) profile instead of the invoking user's. Privilege decisions // root's (default) profile instead of the invoking user's. An unmapped positive
// are not made here — those stay on the kernel credentials of the daemon // process UID uses its numeric kernel identity; root, sudo lookup failures, and
// connection, which SUDO_USER (a plain environment variable) can never // unavailable platform identities still fail closed. Privilege decisions stay
// influence; a forged value only selects a profile root could select anyway. // on the kernel credentials of the daemon connection, which SUDO_USER (a plain
// environment variable) can never influence; a forged value only selects a
// profile root could select anyway.
func InvokingUser() (*user.User, error) { func InvokingUser() (*user.User, error) {
if u, ok := sudoInvokingUser(); ok { if u, ok := sudoInvokingUser(); ok {
return u, nil return u, nil
@@ -35,7 +40,23 @@ func InvokingUser() (*user.User, error) {
if sudoActive() { if sudoActive() {
return nil, fmt.Errorf("resolve sudo invoking user %q: refusing to fall back to root", os.Getenv(envSudoUser)) return nil, fmt.Errorf("resolve sudo invoking user %q: refusing to fall back to root", os.Getenv(envSudoUser))
} }
return user.Current() u, err := currentUser()
if err == nil {
return u, nil
}
uid := geteuid()
if uid <= 0 {
return nil, err
}
log.Debugf("current user lookup for UID %d: %v; using numeric UID", uid, err)
uidString := strconv.Itoa(uid)
return &user.User{
Username: uidString,
Uid: uidString,
Gid: strconv.Itoa(getegid()),
}, nil
} }
// IsPlainRoot reports that the process runs as root with no usable sudo // IsPlainRoot reports that the process runs as root with no usable sudo
@@ -2,6 +2,7 @@ package profilemanager
import ( import (
"errors" "errors"
"fmt"
"io/fs" "io/fs"
"os" "os"
"os/user" "os/user"
@@ -21,7 +22,51 @@ func TestInvokingUserFallsBackToProcessUser(t *testing.T) {
current, err := user.Current() current, err := user.Current()
require.NoError(t, err) require.NoError(t, err)
assert.Equal(t, current.Username, got.Username) assert.Equal(t, current.Username, got.Username, "invoking user should match the process user without sudo")
}
func TestInvokingUserFailsClosedWithoutPositiveUID(t *testing.T) {
for _, uid := range []int{0, -1} {
t.Run(fmt.Sprintf("UID%d", uid), func(t *testing.T) {
t.Setenv(envSudoUser, "")
lookupErr := errors.New("current user unavailable")
fakeUnmappedUser(t, uid, 0, lookupErr)
got, err := InvokingUser()
require.ErrorIs(t, err, lookupErr)
assert.Nil(t, got, "root or unavailable UID must not become a synthetic identity")
})
}
}
func TestProfileFilePathUsesNumericIdentityForUnmappedNonRoot(t *testing.T) {
t.Setenv(envSudoUser, "")
fakeUnmappedUser(t, 1001230000, 0, errors.New("user: unknown userid 1001230000"))
profilesRoot := t.TempDir()
origDir := DefaultConfigPathDir
origOverride := ConfigDirOverride
DefaultConfigPathDir = profilesRoot
ConfigDirOverride = ""
t.Cleanup(func() {
DefaultConfigPathDir = origDir
ConfigDirOverride = origOverride
})
profileID := ID("0123456789abcdef0123456789abcdef")
got, err := (&Profile{ID: profileID}).FilePath()
require.NoError(t, err)
assert.Equal(t,
filepath.Join(profilesRoot, "1001230000", profileID.String()+".json"),
got,
"profile path should use the numeric UID namespace",
)
entries, err := os.ReadDir(profilesRoot)
require.NoError(t, err)
require.Len(t, entries, 1, "only the numeric UID directory should be created")
assert.Equal(t, "1001230000", entries[0].Name(), "profile namespace should be numeric")
assert.True(t, entries[0].IsDir(), "profile namespace should be a directory")
} }
func TestSudoInvokingUserInactiveWithoutSudoContext(t *testing.T) { func TestSudoInvokingUserInactiveWithoutSudoContext(t *testing.T) {
@@ -60,6 +105,13 @@ func TestInvokingUserFailsClosedWhenSudoLookupFails(t *testing.T) {
fakeSudo(t, filepath.Join("/home", "misha")) fakeSudo(t, filepath.Join("/home", "misha"))
lookupUser = func(string) (*user.User, error) { return nil, errors.New("nss unavailable") } lookupUser = func(string) (*user.User, error) { return nil, errors.New("nss unavailable") }
origCurrentUser := currentUser
currentUser = func() (*user.User, error) {
t.Fatal("currentUser must not be called after a sudo lookup failure")
return nil, errors.New("currentUser called unexpectedly")
}
t.Cleanup(func() { currentUser = origCurrentUser })
got, err := InvokingUser() got, err := InvokingUser()
require.Error(t, err) require.Error(t, err)
assert.Nil(t, got, "must not resolve to the root process user") assert.Nil(t, got, "must not resolve to the root process user")
@@ -215,6 +267,22 @@ func fakeSudo(t *testing.T, home string) {
}) })
} }
func fakeUnmappedUser(t *testing.T, uid, gid int, lookupErr error) {
t.Helper()
origCurrentUser := currentUser
origEuid := geteuid
origEgid := getegid
currentUser = func() (*user.User, error) { return nil, lookupErr }
geteuid = func() int { return uid }
getegid = func() int { return gid }
t.Cleanup(func() {
currentUser = origCurrentUser
geteuid = origEuid
getegid = origEgid
})
}
func assertNoEntries(t *testing.T, root string) { func assertNoEntries(t *testing.T, root string) {
t.Helper() t.Helper()
err := filepath.WalkDir(root, func(path string, _ fs.DirEntry, err error) error { err := filepath.WalkDir(root, func(path string, _ fs.DirEntry, err error) error {
+44 -63
View File
@@ -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 {
+53 -50
View File
@@ -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)
}
+66
View File
@@ -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)
}
+47 -9
View File
@@ -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)
+12 -13
View File
@@ -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")
} }
} }
+6
View File
@@ -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) {
+34
View File
@@ -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
}
+116
View File
@@ -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()
}
+40
View File
@@ -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, "")}))
}
+34
View File
@@ -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
View File
@@ -119,16 +119,46 @@ func NewPolicy(values map[string]any) *Policy {
return &Policy{values: values} return &Policy{values: values}
} }
// LoadPolicy reads the platform-native MDM configuration. Returns an // PolicyFetcher supplies the managed configuration to a Loader. Mobile
// empty (but non-nil) Policy when no source is present, the source is // platforms (Android / iOS) implement it to push the OS-managed values
// empty, or the platform is unsupported. // into the Go runtime. On every platform a non-nil fetcher takes
// precedence over the native source, which is the test seam for the
// registry / plist loaders; a nil fetcher leaves the native source in
// charge, or disables MDM enforcement where there is none.
type PolicyFetcher interface {
Fetch() map[string]any
}
// Loader is the DI-friendly entry point for reading the active MDM
// policy. Construct one at the daemon's lifecycle owner (Server on
// desktop, gomobile-exposed bridge on mobile) and pass it to anything
// that needs to read MDM state (the reload ticker, profilemanager's
// Config). Each callsite has the Loader handed in instead of looking
// up package-level state.
type Loader struct {
fetcher PolicyFetcher
}
// NewLoader constructs a Loader. A non-nil fetcher takes precedence over
// the platform-native source; production desktop callers pass nil so the
// registry / plist stays authoritative.
func NewLoader(f PolicyFetcher) *Loader {
return &Loader{fetcher: f}
}
// Load reads the platform-native MDM configuration and returns a
// Policy. Returns an empty (but non-nil) Policy when no source is
// present, the source is empty, or the platform is unsupported.
// //
// Diagnostic logging differentiates the three states: // Diagnostic logging differentiates the three states:
// - source absent / unsupported platform: trace log only // - source absent / unsupported platform: trace log only
// - source present, zero keys: info "MDM enrolled (no managed keys)" // - source present, zero keys: info "MDM enrolled (no managed keys)"
// - source present, N keys: info "MDM enrolled with N managed keys: [...]" // - source present, N keys: info "MDM enrolled with N managed keys: [...]"
func LoadPolicy() *Policy { func (l *Loader) Load() *Policy {
values, err := loadPlatformPolicy() if l == nil {
return &Policy{values: map[string]any{}}
}
values, err := l.loadPlatform()
if err != nil { if err != nil {
log.Tracef("MDM policy load: %v", err) log.Tracef("MDM policy load: %v", err)
return &Policy{values: map[string]any{}} return &Policy{values: map[string]any{}}
@@ -205,6 +235,8 @@ func (p *Policy) GetBool(key string) (bool, bool) {
return t != 0, true return t != 0, true
case int64: case int64:
return t != 0, true return t != 0, true
case float64:
return t != 0, true
} }
return false, false return false, false
} }
@@ -270,7 +302,7 @@ func (p *Policy) GetStringSlice(key string) ([]string, bool) {
} }
// sortedKeys returns the keys of m as a deterministic, lexicographically // sortedKeys returns the keys of m as a deterministic, lexicographically
// sorted slice. Used internally by Policy.ManagedKeys and LoadPolicy's // sorted slice. Used internally by Policy.ManagedKeys and Loader.Load's
// diagnostic log line so callers see a stable key order across runs // diagnostic log line so callers see a stable key order across runs
// regardless of Go's randomised map iteration. // regardless of Go's randomised map iteration.
func sortedKeys(m map[string]any) []string { func sortedKeys(m map[string]any) []string {
+11 -4
View File
@@ -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)
+10 -9
View File
@@ -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
} }
+12 -8
View File
@@ -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
} }
+26 -5
View File
@@ -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())
+11 -4
View File
@@ -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)
+91
View File
@@ -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"`
DisableAutoConnect bool `json:"disableAutoConnect"`
DisableAutostart bool `json:"disableAutostart"`
BlockInbound bool `json:"blockInbound"`
DisableMetricsCollection bool `json:"disableMetricsCollection"`
SplitTunnelMode bool `json:"splitTunnelMode"`
SplitTunnelApps bool `json:"splitTunnelApps"`
RemoteJobsAllowed bool `json:"allowRemoteJobs"`
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)
r.MDM.RemoteJobsAllowed = policy.HasKey(KeyRemoteJobsAllowed)
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
View File
@@ -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
View File
@@ -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")
+42
View File
@@ -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 {
+83
View File
@@ -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))
}
+36 -187
View File
@@ -3,7 +3,6 @@ package server
import ( import (
"context" "context"
"fmt" "fmt"
"net/url"
"time" "time"
log "github.com/sirupsen/logrus" log "github.com/sirupsen/logrus"
@@ -14,28 +13,6 @@ import (
"github.com/netbirdio/netbird/client/proto" "github.com/netbirdio/netbird/client/proto"
) )
// preSharedKeyRedactedSentinel is the value GetConfig returns in place
// of an actual PSK, so a UI that round-trips the field back to the
// daemon (via SetConfig / Login) can be distinguished from a deliberate
// override. Any incoming PSK that equals this sentinel is treated as
// a no-op echo, never as a conflict with the policy.
const preSharedKeyRedactedSentinel = "**********"
// loadMDMPolicy is the indirection used by server handlers to read the
// active MDM policy. Tests override this to inject a fake policy.
var loadMDMPolicy = mdm.LoadPolicy
// conflictCheck is a value-aware comparison between a single field in
// the incoming request and the corresponding MDM-enforced value. It
// runs only when the field was actually set in the request (presence
// already filtered upstream); ok=true reports the policy value, ok=false
// means the policy is silent on the key — both are treated as conflicts
// to be safe (an MDM key declared as managed must hold a value).
type conflictCheck struct {
key string
check func(*mdm.Policy) (match bool)
}
// onMDMPolicyChange is invoked by the MDM reload ticker every time the // onMDMPolicyChange is invoked by the MDM reload ticker every time the
// OS-native managed-config store reports a diff vs the last observation. // OS-native managed-config store reports a diff vs the last observation.
// //
@@ -168,126 +145,6 @@ func (s *Server) restartEngineForMDMLocked() error {
return nil return nil
} }
// conflictBool builds a conflictCheck for a boolean MDM key. If p is nil
// the field is treated as matching (no override requested); otherwise the
// check returns true only when the policy contains the key and its
// boolean value equals *p.
func conflictBool(key string, p *bool) conflictCheck {
return conflictCheck{
key: key,
check: func(pol *mdm.Policy) bool {
if p == nil {
return true // absent → match by definition
}
want, ok := pol.GetBool(key)
return ok && want == *p
},
}
}
func canonicalURL(s string) string {
u, err := url.ParseRequestURI(s)
if err != nil {
return s
}
if u.Port() == "" {
switch u.Scheme {
case "https":
u.Host += ":443"
case "http":
u.Host += ":80"
}
}
return u.String()
}
// conflictURL is conflictString for URL-typed keys: both sides are
// normalized via canonicalURL before comparison.
func conflictURL(key, got string) conflictCheck {
return conflictCheck{
key: key,
check: func(pol *mdm.Policy) bool {
if got == "" {
return true
}
want, ok := pol.GetString(key)
return ok && canonicalURL(want) == canonicalURL(got)
},
}
}
// conflictString builds a conflictCheck for a string MDM key. An empty
// `got` is treated as "field not set" (no override requested); otherwise
// the check returns true only when the policy contains the key and its
// value equals got.
func conflictString(key, got string) conflictCheck {
return conflictCheck{
key: key,
check: func(pol *mdm.Policy) bool {
if got == "" {
return true
}
want, ok := pol.GetString(key)
return ok && want == got
},
}
}
// conflictStringPtr is conflictString for optional proto fields, where an
// explicit empty value is still a request to change the setting. If p is
// nil the field is treated as matching (no override requested); otherwise
// the check returns true only when the policy contains the key and its
// value equals *p.
func conflictStringPtr(key string, p *string) conflictCheck {
return conflictCheck{
key: key,
check: func(pol *mdm.Policy) bool {
if p == nil {
return true
}
want, ok := pol.GetString(key)
return ok && want == *p
},
}
}
// conflictInt64 builds a conflictCheck for an integer MDM key. If p is
// nil the field is treated as matching; otherwise the check returns
// true only when the policy contains the key and its int value equals *p.
func conflictInt64(key string, p *int64) conflictCheck {
return conflictCheck{
key: key,
check: func(pol *mdm.Policy) bool {
if p == nil {
return true
}
want, ok := pol.GetInt(key)
return ok && want == *p
},
}
}
// resolveConflicts walks the per-field checks against the active MDM
// policy and returns the names of keys whose requested value diverges
// from the policy-enforced value. Keys not present in the policy are
// skipped silently (the gate fires only for keys the admin has
// actually pushed). Returns nil for an empty policy.
func resolveConflicts(policy *mdm.Policy, checks []conflictCheck) []string {
if policy.IsEmpty() {
return nil
}
var conflicts []string
for _, c := range checks {
if !policy.HasKey(c.key) {
continue
}
if !c.check(policy) {
conflicts = append(conflicts, c.key)
}
}
return conflicts
}
// mdmManagedFieldConflicts returns the names of MDM-managed keys whose // mdmManagedFieldConflicts returns the names of MDM-managed keys whose
// requested value in the SetConfigRequest differs from the MDM-enforced // requested value in the SetConfigRequest differs from the MDM-enforced
// value. A field set to the same value the policy already enforces is // value. A field set to the same value the policy already enforces is
@@ -301,27 +158,25 @@ func mdmManagedFieldConflicts(msg *proto.SetConfigRequest, policy *mdm.Policy) [
return nil return nil
} }
// PSK round-trip echo: collapse the sentinel to empty so the pskGot := msg.OptionalPreSharedKey
// shared check treats it as "field not set". if pskGot != nil && *pskGot == mdm.PreSharedKeyRedactedSentinel {
pskGot := "" pskGot = nil
if msg.OptionalPreSharedKey != nil && *msg.OptionalPreSharedKey != preSharedKeyRedactedSentinel {
pskGot = *msg.OptionalPreSharedKey
} }
return resolveConflicts(policy, []conflictCheck{ return mdm.ResolveConflicts(policy, []mdm.ConflictCheck{
conflictURL(mdm.KeyManagementURL, msg.ManagementUrl), mdm.ConflictURL(mdm.KeyManagementURL, msg.ManagementUrl),
conflictString(mdm.KeyPreSharedKey, pskGot), mdm.ConflictStringPtr(mdm.KeyPreSharedKey, pskGot),
conflictBool(mdm.KeyRosenpassEnabled, msg.RosenpassEnabled), mdm.ConflictBool(mdm.KeyRosenpassEnabled, msg.RosenpassEnabled),
conflictBool(mdm.KeyRosenpassPermissive, msg.RosenpassPermissive), mdm.ConflictBool(mdm.KeyRosenpassPermissive, msg.RosenpassPermissive),
conflictBool(mdm.KeyDisableAutoConnect, msg.DisableAutoConnect), mdm.ConflictBool(mdm.KeyDisableAutoConnect, msg.DisableAutoConnect),
conflictBool(mdm.KeyAllowServerSSH, msg.ServerSSHAllowed), mdm.ConflictBool(mdm.KeyAllowServerSSH, msg.ServerSSHAllowed),
conflictBool(mdm.KeyRemoteJobsAllowed, msg.RemoteJobsAllowed), mdm.ConflictBool(mdm.KeyRemoteJobsAllowed, msg.RemoteJobsAllowed),
conflictBool(mdm.KeyDisableClientRoutes, msg.DisableClientRoutes), mdm.ConflictBool(mdm.KeyDisableClientRoutes, msg.DisableClientRoutes),
conflictBool(mdm.KeyDisableServerRoutes, msg.DisableServerRoutes), mdm.ConflictBool(mdm.KeyDisableServerRoutes, msg.DisableServerRoutes),
conflictBool(mdm.KeyBlockInbound, msg.BlockInbound), mdm.ConflictBool(mdm.KeyBlockInbound, msg.BlockInbound),
conflictInt64(mdm.KeyWireguardPort, msg.WireguardPort), mdm.ConflictInt64(mdm.KeyWireguardPort, msg.WireguardPort),
conflictBool(mdm.KeyEnableLocalMetrics, msg.EnableLocalMetrics), mdm.ConflictBool(mdm.KeyEnableLocalMetrics, msg.EnableLocalMetrics),
conflictStringPtr(mdm.KeyLocalMetricsAddress, msg.LocalMetricsAddress), mdm.ConflictStringPtr(mdm.KeyLocalMetricsAddress, msg.LocalMetricsAddress),
}) })
} }
@@ -424,34 +279,28 @@ func loginRequestMDMConflicts(msg *proto.LoginRequest, policy *mdm.Policy) []str
return nil return nil
} }
// Collapse the two PSK fields + the redaction sentinel down to a pskGot := msg.OptionalPreSharedKey
// single "got" string the shared check can compare against the if pskGot == nil && msg.PreSharedKey != "" { //nolint:staticcheck // SA1019: legacy proto field still accepted by Login
// policy: OptionalPreSharedKey wins if set; PreSharedKey (deprecated) pskGot = &msg.PreSharedKey //nolint:staticcheck // SA1019
// is the fallback; sentinel echo is treated as "field not set".
pskGot := ""
if msg.OptionalPreSharedKey != nil {
pskGot = *msg.OptionalPreSharedKey
} else if msg.PreSharedKey != "" { //nolint:staticcheck // SA1019: legacy proto field still accepted by Login
pskGot = msg.PreSharedKey //nolint:staticcheck // SA1019
} }
if pskGot == preSharedKeyRedactedSentinel { if pskGot != nil && *pskGot == mdm.PreSharedKeyRedactedSentinel {
pskGot = "" pskGot = nil
} }
return resolveConflicts(policy, []conflictCheck{ return mdm.ResolveConflicts(policy, []mdm.ConflictCheck{
conflictURL(mdm.KeyManagementURL, msg.ManagementUrl), mdm.ConflictURL(mdm.KeyManagementURL, msg.ManagementUrl),
conflictString(mdm.KeyPreSharedKey, pskGot), mdm.ConflictStringPtr(mdm.KeyPreSharedKey, pskGot),
conflictBool(mdm.KeyRosenpassEnabled, msg.RosenpassEnabled), mdm.ConflictBool(mdm.KeyRosenpassEnabled, msg.RosenpassEnabled),
conflictBool(mdm.KeyRosenpassPermissive, msg.RosenpassPermissive), mdm.ConflictBool(mdm.KeyRosenpassPermissive, msg.RosenpassPermissive),
conflictBool(mdm.KeyDisableAutoConnect, msg.DisableAutoConnect), mdm.ConflictBool(mdm.KeyDisableAutoConnect, msg.DisableAutoConnect),
conflictBool(mdm.KeyAllowServerSSH, msg.ServerSSHAllowed), mdm.ConflictBool(mdm.KeyAllowServerSSH, msg.ServerSSHAllowed),
conflictBool(mdm.KeyRemoteJobsAllowed, msg.RemoteJobsAllowed), mdm.ConflictBool(mdm.KeyRemoteJobsAllowed, msg.RemoteJobsAllowed),
conflictBool(mdm.KeyDisableClientRoutes, msg.DisableClientRoutes), mdm.ConflictBool(mdm.KeyDisableClientRoutes, msg.DisableClientRoutes),
conflictBool(mdm.KeyDisableServerRoutes, msg.DisableServerRoutes), mdm.ConflictBool(mdm.KeyDisableServerRoutes, msg.DisableServerRoutes),
conflictBool(mdm.KeyBlockInbound, msg.BlockInbound), mdm.ConflictBool(mdm.KeyBlockInbound, msg.BlockInbound),
conflictInt64(mdm.KeyWireguardPort, msg.WireguardPort), mdm.ConflictInt64(mdm.KeyWireguardPort, msg.WireguardPort),
conflictBool(mdm.KeyEnableLocalMetrics, msg.EnableLocalMetrics), mdm.ConflictBool(mdm.KeyEnableLocalMetrics, msg.EnableLocalMetrics),
conflictStringPtr(mdm.KeyLocalMetricsAddress, msg.LocalMetricsAddress), mdm.ConflictStringPtr(mdm.KeyLocalMetricsAddress, msg.LocalMetricsAddress),
}) })
} }
+32 -3
View File
@@ -138,6 +138,15 @@ type Server struct {
// stopped by the rootCtx cancellation. // stopped by the rootCtx cancellation.
mdmTicker *mdm.Ticker mdmTicker *mdm.Ticker
// mdmLoader is the daemon-owned source of the active MDM policy.
// Constructed once during Server.Start (with a nil PolicyFetcher on
// desktop — the build-tagged Loader.loadPlatform reads the OS
// registry / plist directly) and injected into every consumer:
// mdmTicker for its periodic reload, the SetConfig / Login MDM
// gates for conflict detection, and every Config produced via
// getConfig() so its apply() picks up the same overlay.
mdmLoader *mdm.Loader
updateManager *updater.Manager updateManager *updater.Manager
jwtCache *jwtCache jwtCache *jwtCache
@@ -246,8 +255,14 @@ func (s *Server) Start() error {
// Runs re-resolves Config (re-running profilemanager.Config.apply which // Runs re-resolves Config (re-running profilemanager.Config.apply which
// applies the freshly-read MDM policy as the last layer) and brings // applies the freshly-read MDM policy as the last layer) and brings
// the engine back with the new values. // the engine back with the new values.
if s.mdmLoader == nil {
// Desktop builds pass a nil PolicyFetcher: the Loader's
// build-tagged loadPlatform reads the OS source directly
// (registry on Windows, plist on macOS, no-op elsewhere).
s.mdmLoader = mdm.NewLoader(nil)
}
if s.mdmTicker == nil { if s.mdmTicker == nil {
s.mdmTicker = mdm.NewTicker(mdm.DefaultReloadInterval) s.mdmTicker = mdm.NewTicker(mdm.DefaultReloadInterval, s.mdmLoader)
go s.mdmTicker.Run(s.rootCtx, s.onMDMPolicyChange) go s.mdmTicker.Run(s.rootCtx, s.onMDMPolicyChange)
} }
@@ -493,7 +508,7 @@ func (s *Server) SetConfig(callerCtx context.Context, msg *proto.SetConfigReques
// by the active MDM policy. The error carries an MDMManagedFields- // by the active MDM policy. The error carries an MDMManagedFields-
// Violation detail listing the offending key names. Non-conflicting // Violation detail listing the offending key names. Non-conflicting
// fields in the same request are not applied either. // fields in the same request are not applied either.
policy := loadMDMPolicy() policy := s.mdmLoader.Load()
if err := rejectMDMManagedFieldConflicts(mdmManagedFieldConflicts(msg, policy)); err != nil { if err := rejectMDMManagedFieldConflicts(mdmManagedFieldConflicts(msg, policy)); err != nil {
return nil, err return nil, err
} }
@@ -636,7 +651,7 @@ func (s *Server) Login(callerCtx context.Context, msg *proto.LoginRequest) (*pro
if s.checkUpdateSettingsDisabled() { if s.checkUpdateSettingsDisabled() {
return nil, gstatus.Errorf(codes.Unavailable, errUpdateSettingsDisabled) return nil, gstatus.Errorf(codes.Unavailable, errUpdateSettingsDisabled)
} }
policy := loadMDMPolicy() policy := s.mdmLoader.Load()
if err := rejectMDMManagedFieldConflicts(loginRequestMDMConflicts(msg, policy)); err != nil { if err := rejectMDMManagedFieldConflicts(loginRequestMDMConflicts(msg, policy)); err != nil {
return nil, err return nil, err
} }
@@ -1487,6 +1502,12 @@ func (s *Server) getConfig(activeProf *profilemanager.ActiveProfileState) (*prof
return nil, false, fmt.Errorf("failed to get config: %w", err) return nil, false, fmt.Errorf("failed to get config: %w", err)
} }
// Apply the daemon-owned MDM policy on top of the just-resolved
// Config. profilemanager's apply() initialises the policy to
// empty — the Loader lives outside Config, so this overlay step
// is driven externally here.
config.ApplyMDMPolicy(s.mdmLoader.Load())
return config, configExisted, nil return config, configExisted, nil
} }
@@ -1543,6 +1564,9 @@ func (s *Server) logoutFromProfile(ctx context.Context, profile *profilemanager.
if err != nil { if err != nil {
return fmt.Errorf("profile '%s' not found", profile.ID) return fmt.Errorf("profile '%s' not found", profile.ID)
} }
// Honour any MDM-enforced ManagementURL when issuing the logout
// RPC: the user-stored value may have been overridden by policy.
config.ApplyMDMPolicy(s.mdmLoader.Load())
return s.sendLogoutRequestWithConfig(ctx, config) return s.sendLogoutRequestWithConfig(ctx, config)
} }
@@ -2177,6 +2201,11 @@ func (s *Server) GetConfig(ctx context.Context, req *proto.GetConfigRequest) (*p
log.Errorf("failed to get active profile config: %v", err) log.Errorf("failed to get active profile config: %v", err)
return nil, fmt.Errorf("failed to get active profile config: %w", err) return nil, fmt.Errorf("failed to get active profile config: %w", err)
} }
// Overlay the active MDM policy so the response's MDMManagedFields
// list reflects what the GUI / CLI must render as read-only.
// profilemanager.GetConfig itself returns a Config without the
// overlay (Loader lives outside profilemanager).
cfg.ApplyMDMPolicy(s.mdmLoader.Load())
managementURL := cfg.ManagementURL managementURL := cfg.ManagementURL
adminURL := cfg.AdminURL adminURL := cfg.AdminURL
+112 -30
View File
@@ -16,14 +16,40 @@ import (
"github.com/netbirdio/netbird/client/proto" "github.com/netbirdio/netbird/client/proto"
) )
// withMDMPolicy temporarily overrides the server-package loadMDMPolicy hook // fakeMDMFetcher implements mdm.PolicyFetcher returning a pre-set
// so SetConfig observes the supplied Policy. Restores the original loader // policy map. Tests build one per Server instance to inject a
// at test cleanup. // scripted MDM overlay via a Loader rather than via package-level state.
func withMDMPolicy(t *testing.T, policy *mdm.Policy) { type fakeMDMFetcher struct{ values map[string]any }
func (f *fakeMDMFetcher) Fetch() map[string]any { return f.values }
// withMDMPolicy installs an mdm.Loader on the given Server whose
// loadPlatform returns the supplied Policy's underlying values. Use
// after setupServerWithProfile to inject the scripted policy the
// SetConfig / Login MDM gates will observe.
func withMDMPolicy(t *testing.T, s *Server, policy *mdm.Policy) {
t.Helper() t.Helper()
prev := loadMDMPolicy values := map[string]any{}
loadMDMPolicy = func() *mdm.Policy { return policy } if policy != nil {
t.Cleanup(func() { loadMDMPolicy = prev }) for _, k := range policy.ManagedKeys() {
if v, ok := policy.GetString(k); ok {
values[k] = v
continue
}
if v, ok := policy.GetInt(k); ok {
values[k] = v
continue
}
if v, ok := policy.GetBool(k); ok {
values[k] = v
continue
}
if v, ok := policy.GetStringSlice(k); ok {
values[k] = v
}
}
}
s.mdmLoader = mdm.NewLoader(&fakeMDMFetcher{values: values})
} }
// setupServerWithProfile mirrors the boilerplate of TestSetConfig_AllFieldsSaved: // setupServerWithProfile mirrors the boilerplate of TestSetConfig_AllFieldsSaved:
@@ -93,12 +119,11 @@ func extractViolation(t *testing.T, err error) *proto.MDMManagedFieldsViolation
} }
func TestSetConfig_MDMReject_SingleField(t *testing.T) { func TestSetConfig_MDMReject_SingleField(t *testing.T) {
withMDMPolicy(t, mdm.NewPolicy(map[string]any{ s, ctx, profName, username, _ := setupServerWithProfile(t)
withMDMPolicy(t, s, mdm.NewPolicy(map[string]any{
mdm.KeyManagementURL: "https://mdm.example.com:443", mdm.KeyManagementURL: "https://mdm.example.com:443",
})) }))
s, ctx, profName, username, _ := setupServerWithProfile(t)
_, err := s.SetConfig(ctx, &proto.SetConfigRequest{ _, err := s.SetConfig(ctx, &proto.SetConfigRequest{
ProfileName: profName, ProfileName: profName,
Username: username, Username: username,
@@ -110,14 +135,13 @@ func TestSetConfig_MDMReject_SingleField(t *testing.T) {
} }
func TestSetConfig_MDMReject_MultipleFields(t *testing.T) { func TestSetConfig_MDMReject_MultipleFields(t *testing.T) {
withMDMPolicy(t, mdm.NewPolicy(map[string]any{ s, ctx, profName, username, _ := setupServerWithProfile(t)
withMDMPolicy(t, s, mdm.NewPolicy(map[string]any{
mdm.KeyManagementURL: "https://mdm.example.com:443", mdm.KeyManagementURL: "https://mdm.example.com:443",
mdm.KeyBlockInbound: true, mdm.KeyBlockInbound: true,
mdm.KeyRosenpassEnabled: true, mdm.KeyRosenpassEnabled: true,
})) }))
s, ctx, profName, username, _ := setupServerWithProfile(t)
blockInbound := false blockInbound := false
rosenpassEnabled := false rosenpassEnabled := false
_, err := s.SetConfig(ctx, &proto.SetConfigRequest{ _, err := s.SetConfig(ctx, &proto.SetConfigRequest{
@@ -137,13 +161,12 @@ func TestSetConfig_MDMReject_MultipleFields(t *testing.T) {
} }
func TestSetConfig_MDMReject_LocalMetrics(t *testing.T) { func TestSetConfig_MDMReject_LocalMetrics(t *testing.T) {
withMDMPolicy(t, mdm.NewPolicy(map[string]any{ s, ctx, profName, username, _ := setupServerWithProfile(t)
withMDMPolicy(t, s, mdm.NewPolicy(map[string]any{
mdm.KeyEnableLocalMetrics: true, mdm.KeyEnableLocalMetrics: true,
mdm.KeyLocalMetricsAddress: "127.0.0.1:9191", mdm.KeyLocalMetricsAddress: "127.0.0.1:9191",
})) }))
s, ctx, profName, username, _ := setupServerWithProfile(t)
enabled := false enabled := false
addr := "0.0.0.0:9999" addr := "0.0.0.0:9999"
_, err := s.SetConfig(ctx, &proto.SetConfigRequest{ _, err := s.SetConfig(ctx, &proto.SetConfigRequest{
@@ -164,12 +187,11 @@ func TestSetConfig_MDMReject_LocalMetrics(t *testing.T) {
// (the manager falls back to the default), so presence must be honored // (the manager falls back to the default), so presence must be honored
// rather than collapsed to "field not set". // rather than collapsed to "field not set".
func TestSetConfig_MDMReject_LocalMetricsEmptyAddress(t *testing.T) { func TestSetConfig_MDMReject_LocalMetricsEmptyAddress(t *testing.T) {
withMDMPolicy(t, mdm.NewPolicy(map[string]any{ s, ctx, profName, username, _ := setupServerWithProfile(t)
withMDMPolicy(t, s, mdm.NewPolicy(map[string]any{
mdm.KeyLocalMetricsAddress: "127.0.0.1:9999", mdm.KeyLocalMetricsAddress: "127.0.0.1:9999",
})) }))
s, ctx, profName, username, _ := setupServerWithProfile(t)
addr := "" addr := ""
_, err := s.SetConfig(ctx, &proto.SetConfigRequest{ _, err := s.SetConfig(ctx, &proto.SetConfigRequest{
ProfileName: profName, ProfileName: profName,
@@ -181,17 +203,80 @@ func TestSetConfig_MDMReject_LocalMetricsEmptyAddress(t *testing.T) {
assert.ElementsMatch(t, []string{mdm.KeyLocalMetricsAddress}, v.GetFields()) assert.ElementsMatch(t, []string{mdm.KeyLocalMetricsAddress}, v.GetFields())
} }
func TestSetConfig_MDMReject_EmptyPreSharedKey(t *testing.T) {
s, ctx, profName, username, _ := setupServerWithProfile(t)
withMDMPolicy(t, s, mdm.NewPolicy(map[string]any{
mdm.KeyPreSharedKey: "mdm-enforced-psk",
}))
psk := ""
_, err := s.SetConfig(ctx, &proto.SetConfigRequest{
ProfileName: profName,
Username: username,
OptionalPreSharedKey: &psk,
})
v := extractViolation(t, err)
assert.ElementsMatch(t, []string{mdm.KeyPreSharedKey}, v.GetFields())
}
func TestSetConfig_MDMAllow_PreSharedKeySentinelEcho(t *testing.T) {
s, ctx, profName, username, _ := setupServerWithProfile(t)
withMDMPolicy(t, s, mdm.NewPolicy(map[string]any{
mdm.KeyPreSharedKey: "mdm-enforced-psk",
}))
psk := mdm.PreSharedKeyRedactedSentinel
resp, err := s.SetConfig(ctx, &proto.SetConfigRequest{
ProfileName: profName,
Username: username,
OptionalPreSharedKey: &psk,
})
require.NoError(t, err)
require.NotNil(t, resp)
}
func TestLoginRequestMDMConflicts_PreSharedKey(t *testing.T) {
policy := mdm.NewPolicy(map[string]any{
mdm.KeyPreSharedKey: "mdm-enforced-psk",
})
empty := ""
sentinel := mdm.PreSharedKeyRedactedSentinel
same := "mdm-enforced-psk"
other := "user-psk"
tests := []struct {
name string
msg *proto.LoginRequest
want []string
}{
{name: "unset", msg: &proto.LoginRequest{}, want: nil},
{name: "optional empty", msg: &proto.LoginRequest{OptionalPreSharedKey: &empty}, want: []string{mdm.KeyPreSharedKey}},
{name: "optional sentinel echo", msg: &proto.LoginRequest{OptionalPreSharedKey: &sentinel}, want: nil},
{name: "optional same value", msg: &proto.LoginRequest{OptionalPreSharedKey: &same}, want: nil},
{name: "optional divergent", msg: &proto.LoginRequest{OptionalPreSharedKey: &other}, want: []string{mdm.KeyPreSharedKey}},
{name: "legacy empty is unset", msg: &proto.LoginRequest{PreSharedKey: ""}, want: nil}, //nolint:staticcheck // SA1019: legacy proto field still accepted by Login
{name: "legacy sentinel echo", msg: &proto.LoginRequest{PreSharedKey: sentinel}, want: nil}, //nolint:staticcheck // SA1019: legacy proto field still accepted by Login
{name: "legacy divergent", msg: &proto.LoginRequest{PreSharedKey: other}, want: []string{mdm.KeyPreSharedKey}}, //nolint:staticcheck // SA1019: legacy proto field still accepted by Login
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
assert.Equal(t, tc.want, loginRequestMDMConflicts(tc.msg, policy))
})
}
}
func TestSetConfig_MDMReject_AllOrNothing(t *testing.T) { func TestSetConfig_MDMReject_AllOrNothing(t *testing.T) {
// MDM enforces ManagementURL only; user request touches both the // MDM enforces ManagementURL only; user request touches both the
// enforced field AND a non-enforced field (RosenpassEnabled). // enforced field AND a non-enforced field (RosenpassEnabled).
// The whole request must be rejected — non-conflicting fields are not // The whole request must be rejected — non-conflicting fields are not
// applied either. // applied either.
withMDMPolicy(t, mdm.NewPolicy(map[string]any{ s, ctx, profName, username, cfgPath := setupServerWithProfile(t)
withMDMPolicy(t, s, mdm.NewPolicy(map[string]any{
mdm.KeyManagementURL: "https://mdm.example.com:443", mdm.KeyManagementURL: "https://mdm.example.com:443",
})) }))
s, ctx, profName, username, cfgPath := setupServerWithProfile(t)
rosenpassEnabled := true rosenpassEnabled := true
_, err := s.SetConfig(ctx, &proto.SetConfigRequest{ _, err := s.SetConfig(ctx, &proto.SetConfigRequest{
ProfileName: profName, ProfileName: profName,
@@ -213,12 +298,11 @@ func TestSetConfig_MDMReject_AllOrNothing(t *testing.T) {
func TestSetConfig_MDMAllow_NonManagedFields(t *testing.T) { func TestSetConfig_MDMAllow_NonManagedFields(t *testing.T) {
// MDM enforces ManagementURL but the user only writes RosenpassEnabled. // MDM enforces ManagementURL but the user only writes RosenpassEnabled.
// Request must succeed. // Request must succeed.
withMDMPolicy(t, mdm.NewPolicy(map[string]any{ s, ctx, profName, username, _ := setupServerWithProfile(t)
withMDMPolicy(t, s, mdm.NewPolicy(map[string]any{
mdm.KeyManagementURL: "https://mdm.example.com:443", mdm.KeyManagementURL: "https://mdm.example.com:443",
})) }))
s, ctx, profName, username, _ := setupServerWithProfile(t)
rosenpassEnabled := true rosenpassEnabled := true
resp, err := s.SetConfig(ctx, &proto.SetConfigRequest{ resp, err := s.SetConfig(ctx, &proto.SetConfigRequest{
ProfileName: profName, ProfileName: profName,
@@ -247,12 +331,11 @@ func TestSetConfig_MDMAllow_ManagementURLPortNormalized(t *testing.T) {
for _, tc := range tests { for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) { t.Run(tc.name, func(t *testing.T) {
withMDMPolicy(t, mdm.NewPolicy(map[string]any{ s, ctx, profName, username, _ := setupServerWithProfile(t)
withMDMPolicy(t, s, mdm.NewPolicy(map[string]any{
mdm.KeyManagementURL: tc.mdmURL, mdm.KeyManagementURL: tc.mdmURL,
})) }))
s, ctx, profName, username, _ := setupServerWithProfile(t)
rosenpassEnabled := true rosenpassEnabled := true
resp, err := s.SetConfig(ctx, &proto.SetConfigRequest{ resp, err := s.SetConfig(ctx, &proto.SetConfigRequest{
ProfileName: profName, ProfileName: profName,
@@ -269,9 +352,8 @@ func TestSetConfig_MDMAllow_ManagementURLPortNormalized(t *testing.T) {
func TestSetConfig_MDMEmpty_NoEnforcement(t *testing.T) { func TestSetConfig_MDMEmpty_NoEnforcement(t *testing.T) {
// No MDM policy active: any field can be written. // No MDM policy active: any field can be written.
withMDMPolicy(t, mdm.NewPolicy(nil))
s, ctx, profName, username, _ := setupServerWithProfile(t) s, ctx, profName, username, _ := setupServerWithProfile(t)
withMDMPolicy(t, s, mdm.NewPolicy(nil))
resp, err := s.SetConfig(ctx, &proto.SetConfigRequest{ resp, err := s.SetConfig(ctx, &proto.SetConfigRequest{
ProfileName: profName, ProfileName: profName,
+18 -15
View File
@@ -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)
}
+90
View File
@@ -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
}
+1 -1
View File
@@ -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 {
+8 -30
View File
@@ -11,37 +11,18 @@ import (
"github.com/netbirdio/netbird/client/internal/daemonaddr" "github.com/netbirdio/netbird/client/internal/daemonaddr"
"github.com/netbirdio/netbird/client/internal/ipcauth" "github.com/netbirdio/netbird/client/internal/ipcauth"
"github.com/netbirdio/netbird/client/mdm"
"github.com/netbirdio/netbird/client/proto" "github.com/netbirdio/netbird/client/proto"
) )
type MDMFields struct { // MDMFields is the shared per-key MDM enforcement snapshot; see mdm.Fields.
ManagementURL string `json:"managementURL"` type MDMFields = mdm.Fields
PreSharedKey bool `json:"preSharedKey"`
WireguardPort bool `json:"wireguardPort"`
RosenpassEnabled bool `json:"rosenpassEnabled"`
RosenpassPermissive bool `json:"rosenpassPermissive"`
DisableClientRoutes bool `json:"disableClientRoutes"`
DisableServerRoutes bool `json:"disableServerRoutes"`
AllowServerSSH *bool `json:"allowServerSSH"`
DisableAutoConnect bool `json:"disableAutoConnect"`
DisableAutostart bool `json:"disableAutostart"`
BlockInbound bool `json:"blockInbound"`
DisableMetricsCollection bool `json:"disableMetricsCollection"`
SplitTunnelMode bool `json:"splitTunnelMode"`
SplitTunnelApps bool `json:"splitTunnelApps"`
DisableAdvancedView bool `json:"disableAdvancedView"`
}
type Features struct { // Features is the shared feature-gate snapshot; see mdm.Features.
DisableProfiles bool `json:"disableProfiles"` type Features = mdm.Features
DisableNetworks bool `json:"disableNetworks"`
DisableUpdateSettings bool `json:"disableUpdateSettings"`
}
type Restrictions struct { // Restrictions is the shared UI enforcement snapshot; see mdm.Restrictions.
MDM MDMFields `json:"mdm"` type Restrictions = mdm.Restrictions
Features Features `json:"features"`
}
// Privilege tells the frontend whether this process may perform the changes the // Privilege tells the frontend whether this process may perform the changes the
// daemon restricts to root/administrator, whether it can ask the operating // daemon restricts to root/administrator, whether it can ask the operating
@@ -383,7 +364,7 @@ func (s *Settings) GetRestrictions(ctx context.Context) (Restrictions, error) {
}, },
} }
applyMDMRestrictions(&r.MDM, cfgResp) applyMDMRestrictions(&r.MDM, cfgResp)
r.MDM.DisableAdvancedView = featResp.GetDisableAdvancedView() r.MDM.DisableAdvancedView = featResp.DisableAdvancedView
return r, nil return r, nil
} }
@@ -411,9 +392,6 @@ func applyMDMRestrictions(mdm *MDMFields, cfgResp *proto.GetConfigResponse) {
if v.Field(i).Kind() != reflect.Bool { if v.Field(i).Kind() != reflect.Bool {
continue continue
} }
if t.Field(i).Name == "DisableAdvancedView" {
continue
}
if _, ok := set[t.Field(i).Tag.Get("json")]; ok { if _, ok := set[t.Field(i).Tag.Get("json")]; ok {
v.Field(i).SetBool(true) v.Field(i).SetBool(true)
} }
+6 -5
View File
@@ -205,7 +205,7 @@ func createAllServers(ctx context.Context, cfg *CombinedConfig) (*serverInstance
metricsServer: metricsServer, metricsServer: metricsServer,
} }
_, tlsSupport, err := handleTLSConfig(cfg) tlsConfig, tlsSupport, err := handleTLSConfig(cfg)
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to setup TLS config: %w", err) return nil, fmt.Errorf("failed to setup TLS config: %w", err)
} }
@@ -214,7 +214,7 @@ func createAllServers(ctx context.Context, cfg *CombinedConfig) (*serverInstance
return nil, err return nil, err
} }
if err := servers.createManagementServer(ctx, cfg); err != nil { if err := servers.createManagementServer(ctx, cfg, tlsConfig); err != nil {
return nil, err return nil, err
} }
@@ -264,7 +264,7 @@ func (s *serverInstances) createRelayServer(cfg *CombinedConfig, tlsSupport bool
return nil return nil
} }
func (s *serverInstances) createManagementServer(ctx context.Context, cfg *CombinedConfig) error { func (s *serverInstances) createManagementServer(ctx context.Context, cfg *CombinedConfig, tlsConfig *tls.Config) error {
if !cfg.Management.Enabled { if !cfg.Management.Enabled {
return nil return nil
} }
@@ -297,7 +297,7 @@ func (s *serverInstances) createManagementServer(ctx context.Context, cfg *Combi
LogConfigInfo(mgmtConfig) LogConfigInfo(mgmtConfig)
s.mgmtSrv, err = createManagementServer(cfg, mgmtConfig) s.mgmtSrv, err = createManagementServer(cfg, mgmtConfig, tlsConfig)
if err != nil { if err != nil {
cleanupSTUNListeners(s.stunListeners) cleanupSTUNListeners(s.stunListeners)
return fmt.Errorf("failed to create management server: %w", err) return fmt.Errorf("failed to create management server: %w", err)
@@ -513,7 +513,7 @@ func handleTLSConfig(cfg *CombinedConfig) (*tls.Config, bool, error) {
return nil, false, nil return nil, false, nil
} }
func createManagementServer(cfg *CombinedConfig, mgmtConfig *nbconfig.Config) (mgmtServer.Server, error) { func createManagementServer(cfg *CombinedConfig, mgmtConfig *nbconfig.Config, tlsConfig *tls.Config) (mgmtServer.Server, error) {
mgmt := cfg.Management mgmt := cfg.Management
// Extract port from listen address // Extract port from listen address
@@ -542,6 +542,7 @@ func createManagementServer(cfg *CombinedConfig, mgmtConfig *nbconfig.Config) (m
AutoResolveDomains: true, AutoResolveDomains: true,
MgmtPort: mgmtPort, MgmtPort: mgmtPort,
MgmtMetricsPort: cfg.Server.MetricsPort, MgmtMetricsPort: cfg.Server.MetricsPort,
TLSConfig: tlsConfig,
DisableMetrics: mgmt.DisableAnonymousMetrics, DisableMetrics: mgmt.DisableAnonymousMetrics,
DisableGeoliteUpdate: mgmt.DisableGeoliteUpdate, DisableGeoliteUpdate: mgmt.DisableGeoliteUpdate,
// Always enable user deletion from IDP in combined server (embedded IdP is always enabled) // Always enable user deletion from IDP in combined server (embedded IdP is always enabled)
@@ -808,8 +808,9 @@ server:
# Trust X-Forwarded-* only from the Traefik container's static address. Both # Trust X-Forwarded-* only from the Traefik container's static address. Both
# keys must stay in step with the ipv4_address pinned in docker-compose.yml: # keys must stay in step with the ipv4_address pinned in docker-compose.yml:
# trustedPeers decides whether forwarded headers are read at all, and leaving # trustedPeers decides whether forwarded headers are read at all. Leaving it
# it unset falls back to 0.0.0.0/0. # unset trusts nothing and records Traefik's own address as every peer's
# connection IP.
reverseProxy: reverseProxy:
trustedPeers: trustedPeers:
- "${TRAEFIK_IP}/32" - "${TRAEFIK_IP}/32"
+29
View File
@@ -153,6 +153,7 @@ check_domain_resolves() {
# NETBIRD_TRAEFIK_CERTRESOLVER external-Traefik cert resolver (type 1) # NETBIRD_TRAEFIK_CERTRESOLVER external-Traefik cert resolver (type 1)
# NETBIRD_BIND_LOCALHOST_ONLY true/false (default true, types 2-5) # NETBIRD_BIND_LOCALHOST_ONLY true/false (default true, types 2-5)
# NETBIRD_EXTERNAL_PROXY_NETWORK docker network to join (types 2-4) # NETBIRD_EXTERNAL_PROXY_NETWORK docker network to join (types 2-4)
# NETBIRD_TRUSTED_PEERS reverse proxy address management sees (default: built-in Traefik's IP, empty for types 1-5)
# NETBIRD_NON_INTERACTIVE true forces unattended mode even with a TTY # NETBIRD_NON_INTERACTIVE true forces unattended mode even with a TTY
# tty_available succeeds only when we may prompt: never when the operator has # tty_available succeeds only when we may prompt: never when the operator has
@@ -459,6 +460,8 @@ initialize_default_values() {
MANAGEMENT_HOST_PORT="8081" # Combined server port (management + signal + relay) MANAGEMENT_HOST_PORT="8081" # Combined server port (management + signal + relay)
BIND_LOCALHOST_ONLY="true" BIND_LOCALHOST_ONLY="true"
EXTERNAL_PROXY_NETWORK="" EXTERNAL_PROXY_NETWORK=""
TRUSTED_PEERS="" # Address the reverse proxy connects to management from
# Traefik static IP within the internal bridge network # Traefik static IP within the internal bridge network
TRAEFIK_IP="172.30.0.10" TRAEFIK_IP="172.30.0.10"
@@ -519,6 +522,7 @@ apply_agent_network_preset() {
REVERSE_PROXY_TYPE="0" REVERSE_PROXY_TYPE="0"
ENABLE_PROXY="true" ENABLE_PROXY="true"
ENABLE_CROWDSEC="false" ENABLE_CROWDSEC="false"
TRUSTED_PEERS="${NETBIRD_TRUSTED_PEERS:-$TRAEFIK_IP/32}"
TRAEFIK_ACME_EMAIL=$(resolve NETBIRD_LETSENCRYPT_EMAIL required read_traefik_acme_email) TRAEFIK_ACME_EMAIL=$(resolve NETBIRD_LETSENCRYPT_EMAIL required read_traefik_acme_email)
@@ -573,6 +577,21 @@ configure_reverse_proxy() {
4) EXTERNAL_PROXY_NETWORK=$(resolve NETBIRD_EXTERNAL_PROXY_NETWORK "" read_proxy_docker_network "Caddy") ;; 4) EXTERNAL_PROXY_NETWORK=$(resolve NETBIRD_EXTERNAL_PROXY_NETWORK "" read_proxy_docker_network "Caddy") ;;
*) ;; # No network prompt for other options *) ;; # No network prompt for other options
esac esac
# Only the bundled Traefik has an address we know at render time. External proxies
# must supply the address their proxy reaches management from.
if [[ "$REVERSE_PROXY_TYPE" == "0" ]]; then
TRUSTED_PEERS="${NETBIRD_TRUSTED_PEERS:-$TRAEFIK_IP/32}"
else
TRUSTED_PEERS="${NETBIRD_TRUSTED_PEERS:-}"
if [[ -z "$TRUSTED_PEERS" ]]; then
echo "" > /dev/stderr
echo "Note: reverseProxy.trustedPeers is unset, so NetBird will use the address your" > /dev/stderr
echo "proxy connects from as each peer's connection IP. To record real client IPs," > /dev/stderr
echo "set NETBIRD_TRUSTED_PEERS to your proxy's address (e.g. 172.20.0.5/32) and re-run." > /dev/stderr
echo "" > /dev/stderr
fi
fi
return 0 return 0
} }
@@ -1033,6 +1052,7 @@ server:
reverseProxy: reverseProxy:
trustedHTTPProxies: trustedHTTPProxies:
- "$TRAEFIK_IP/32" - "$TRAEFIK_IP/32"
$(render_trusted_peers)
store: store:
engine: "sqlite" engine: "sqlite"
@@ -1041,6 +1061,12 @@ EOF
return 0 return 0
} }
render_trusted_peers() {
if [[ -n "$TRUSTED_PEERS" ]]; then
printf ' trustedPeers:\n - "%s"' "$TRUSTED_PEERS"
fi
}
render_dashboard_env() { render_dashboard_env() {
cat <<EOF cat <<EOF
# Endpoints # Endpoints
@@ -1453,6 +1479,9 @@ location ~ ^/(relay|ws-proxy/) {
# Native gRPC (signal + management) # Native gRPC (signal + management)
location ~ ^/(signalexchange\.SignalExchange|management\.ManagementService)/ { location ~ ^/(signalexchange\.SignalExchange|management\.ManagementService)/ {
grpc_pass grpc://${server_addr}; grpc_pass grpc://${server_addr};
# Overwrite rather than pass through: without this the client's own
# x-forwarded-for metadata reaches NetBird as the connection IP.
grpc_set_header X-Forwarded-For \$proxy_add_x_forwarded_for;
grpc_read_timeout 1d; grpc_read_timeout 1d;
grpc_send_timeout 1d; grpc_send_timeout 1d;
grpc_socket_keepalive on; grpc_socket_keepalive on;
+1 -3
View File
@@ -34,9 +34,7 @@
"ReverseProxy": { "ReverseProxy": {
"TrustedHTTPProxies": [], "TrustedHTTPProxies": [],
"TrustedHTTPProxiesCount": 0, "TrustedHTTPProxiesCount": 0,
"TrustedPeers": [ "TrustedPeers": []
"0.0.0.0/0"
]
}, },
"DisableDefaultPolicy": $NETBIRD_MGMT_DISABLE_DEFAULT_POLICY, "DisableDefaultPolicy": $NETBIRD_MGMT_DISABLE_DEFAULT_POLICY,
"Datadir": "", "Datadir": "",
+9
View File
@@ -54,6 +54,15 @@ func Execute() error {
return rootCmd.Execute() return rootCmd.Execute()
} }
// Customize hands the fully built root command to fn so an embedding binary
// can extend or adjust the command tree — most commonly attaching its own
// subcommands next to (or under) the built-in ones — before calling Execute.
// The root command is constructed in this package's init, so Customize may be
// called from the embedding binary's main at any point before Execute.
func Customize(fn func(root *cobra.Command)) {
fn(rootCmd)
}
func init() { func init() {
mgmtCmd.Flags().IntVar(&mgmtPort, "port", 80, "server port to listen on (defaults to 443 if TLS is enabled, 80 otherwise") mgmtCmd.Flags().IntVar(&mgmtPort, "port", 80, "server port to listen on (defaults to 443 if TLS is enabled, 80 otherwise")
mgmtCmd.Flags().BoolVar(&disableLegacyManagementPort, "disable-legacy-port", false, "disabling the old legacy port (33073)") mgmtCmd.Flags().BoolVar(&disableLegacyManagementPort, "disable-legacy-port", false, "disabling the old legacy port (33073)")
+42
View File
@@ -0,0 +1,42 @@
package cmd
import (
"testing"
"github.com/spf13/cobra"
)
// TestCustomize verifies an embedding binary can extend the command tree: a
// top-level command attached through the hook, and a subcommand attached under
// the built-in admin group, are both resolvable exactly as Execute would
// resolve them.
func TestCustomize(t *testing.T) {
topLevel := &cobra.Command{Use: "some-extra", RunE: func(*cobra.Command, []string) error { return nil }}
nested := &cobra.Command{Use: "cluster", RunE: func(*cobra.Command, []string) error { return nil }}
Customize(func(root *cobra.Command) {
root.AddCommand(topLevel)
for _, c := range root.Commands() {
if c.Name() == "admin" {
c.AddCommand(nested)
return
}
}
t.Fatal("admin command not found in the root tree")
})
t.Cleanup(func() {
rootCmd.RemoveCommand(topLevel)
for _, c := range rootCmd.Commands() {
if c.Name() == "admin" {
c.RemoveCommand(nested)
}
}
})
if found, _, err := rootCmd.Find([]string{"some-extra"}); err != nil || found != topLevel {
t.Fatalf("top-level command not resolvable: found=%v err=%v", found, err)
}
if found, _, err := rootCmd.Find([]string{"admin", "cluster"}); err != nil || found != nested {
t.Fatalf("nested admin subcommand not resolvable: found=%v err=%v", found, err)
}
}
@@ -66,8 +66,8 @@ func TestExtractClusterFromFreeDomain(t *testing.T) {
func TestExtractClusterFromCustomDomains(t *testing.T) { func TestExtractClusterFromCustomDomains(t *testing.T) {
customDomains := []*domain.Domain{ customDomains := []*domain.Domain{
{Domain: "example.com", TargetCluster: "eu1.proxy.netbird.io"}, {Domain: "example.com", TargetCluster: "eu1.proxy.netbird.io", Validated: true},
{Domain: "proxy.corp.io", TargetCluster: "us1.proxy.netbird.io"}, {Domain: "proxy.corp.io", TargetCluster: "us1.proxy.netbird.io", Validated: true},
} }
tests := []struct { tests := []struct {
@@ -120,19 +120,49 @@ func TestExtractClusterFromCustomDomains(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) {
cluster, ok := extractClusterFromCustomDomains(tc.domain, customDomains) cluster, match := extractClusterFromCustomDomains(tc.domain, customDomains)
assert.Equal(t, tc.wantOK, ok) if !tc.wantOK {
if ok { assert.Equal(t, customDomainNoMatch, match, "unrelated domain should not match any custom domain")
assert.Equal(t, tc.wantVal, cluster) return
} }
assert.Equal(t, customDomainValidated, match, "validated custom domain should resolve a cluster")
assert.Equal(t, tc.wantVal, cluster)
}) })
} }
} }
// An unvalidated row must never yield a cluster: the account has not shown it
// controls the name, so no service may be bound to it.
func TestExtractClusterFromCustomDomains_UnvalidatedDomainRefused(t *testing.T) {
customDomains := []*domain.Domain{
{Domain: "example.com", TargetCluster: "eu1.proxy.netbird.io", Validated: false},
}
for _, serviceDomain := range []string{"example.com", "app.example.com"} {
t.Run(serviceDomain, func(t *testing.T) {
cluster, match := extractClusterFromCustomDomains(serviceDomain, customDomains)
assert.Equal(t, customDomainUnvalidated, match, "unvalidated row must be reported as such")
assert.Empty(t, cluster, "unvalidated row must not resolve a cluster")
})
}
}
// A more specific unvalidated row must not shadow a validated parent domain.
func TestExtractClusterFromCustomDomains_ValidatedParentWinsOverUnvalidatedChild(t *testing.T) {
customDomains := []*domain.Domain{
{Domain: "example.com", TargetCluster: "cluster-generic", Validated: true},
{Domain: "app.example.com", TargetCluster: "cluster-app", Validated: false},
}
cluster, match := extractClusterFromCustomDomains("app.example.com", customDomains)
assert.Equal(t, customDomainValidated, match)
assert.Equal(t, "cluster-generic", cluster, "validated parent domain should provide the cluster")
}
func TestExtractClusterFromCustomDomains_OverlappingDomains(t *testing.T) { func TestExtractClusterFromCustomDomains_OverlappingDomains(t *testing.T) {
customDomains := []*domain.Domain{ customDomains := []*domain.Domain{
{Domain: "example.com", TargetCluster: "cluster-generic"}, {Domain: "example.com", TargetCluster: "cluster-generic", Validated: true},
{Domain: "app.example.com", TargetCluster: "cluster-app"}, {Domain: "app.example.com", TargetCluster: "cluster-app", Validated: true},
} }
tests := []struct { tests := []struct {
@@ -164,8 +194,8 @@ func TestExtractClusterFromCustomDomains_OverlappingDomains(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) {
cluster, ok := extractClusterFromCustomDomains(tc.domain, customDomains) cluster, match := extractClusterFromCustomDomains(tc.domain, customDomains)
assert.True(t, ok) assert.Equal(t, customDomainValidated, match)
assert.Equal(t, tc.wantVal, cluster) assert.Equal(t, tc.wantVal, cluster)
}) })
} }
@@ -26,6 +26,7 @@ type store interface {
GetAgentNetworkSettings(ctx context.Context, lockStrength nbstore.LockingStrength, accountID string) (*agentnetworkTypes.Settings, error) GetAgentNetworkSettings(ctx context.Context, lockStrength nbstore.LockingStrength, accountID string) (*agentnetworkTypes.Settings, error)
GetCustomDomain(ctx context.Context, accountID string, domainID string) (*domain.Domain, error) GetCustomDomain(ctx context.Context, accountID string, domainID string) (*domain.Domain, error)
GetCustomDomainByName(ctx context.Context, domainName string) (*domain.Domain, error)
ListFreeDomains(ctx context.Context, accountID string) ([]string, error) ListFreeDomains(ctx context.Context, accountID string) ([]string, error)
ListCustomDomains(ctx context.Context, accountID string) ([]*domain.Domain, error) ListCustomDomains(ctx context.Context, accountID string) ([]*domain.Domain, error)
CreateCustomDomain(ctx context.Context, accountID string, domainName string, targetCluster string, validated bool) (*domain.Domain, error) CreateCustomDomain(ctx context.Context, accountID string, domainName string, targetCluster string, validated bool) (*domain.Domain, error)
@@ -150,6 +151,10 @@ func (m Manager) CreateDomain(ctx context.Context, accountID, userID, domainName
return nil, fmt.Errorf("target cluster %s is not available", targetCluster) return nil, fmt.Errorf("target cluster %s is not available", targetCluster)
} }
if err := m.checkDomainAvailable(ctx, domainName); err != nil {
return nil, err
}
// Attempt an initial validation against the specified cluster only // Attempt an initial validation against the specified cluster only
var validated bool var validated bool
if m.validator.IsValid(ctx, domainName, []string{targetCluster}) { if m.validator.IsValid(ctx, domainName, []string{targetCluster}) {
@@ -166,6 +171,23 @@ func (m Manager) CreateDomain(ctx context.Context, accountID, userID, domainName
return d, nil return d, nil
} }
// checkDomainAvailable reports whether the domain is free to claim. The unique
// index on the column is the real guard; this turns the violation into a
// conflict the caller can act on instead of a database error, and says nothing
// about which account holds the domain.
func (m Manager) checkDomainAvailable(ctx context.Context, domainName string) error {
_, err := m.store.GetCustomDomainByName(ctx, domainName)
if err == nil {
return status.Errorf(status.AlreadyExists, "domain %s is already registered", domainName)
}
if sErr, ok := status.FromError(err); ok && sErr.Type() == status.NotFound {
return nil
}
return fmt.Errorf("look up domain: %w", err)
}
func (m Manager) DeleteDomain(ctx context.Context, accountID, userID, domainID string) error { func (m Manager) DeleteDomain(ctx context.Context, accountID, userID, domainID string) error {
ok, ctx, err := m.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Services, operations.Delete) ok, ctx, err := m.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Services, operations.Delete)
if err != nil { if err != nil {
@@ -203,7 +225,9 @@ func (m Manager) ValidateDomain(ctx context.Context, accountID, userID, domainID
log.WithFields(log.Fields{ log.WithFields(log.Fields{
"accountID": accountID, "accountID": accountID,
"domainID": domainID, "domainID": domainID,
}).WithError(err).Error("validate domain") "userID": userID,
}).Error("validate domain: permission denied")
return
} }
log.WithFields(log.Fields{ log.WithFields(log.Fields{
@@ -298,9 +322,12 @@ func (m Manager) DeriveClusterFromDomain(ctx context.Context, accountID, domain
return "", fmt.Errorf("list custom domains: %w", err) return "", fmt.Errorf("list custom domains: %w", err)
} }
targetCluster, valid := extractClusterFromCustomDomains(domain, customDomains) targetCluster, match := extractClusterFromCustomDomains(domain, customDomains)
if valid { switch match {
case customDomainValidated:
return targetCluster, nil return targetCluster, nil
case customDomainUnvalidated:
return "", status.Errorf(status.PreconditionFailed, "domain %s is not validated", domain)
} }
return "", fmt.Errorf("domain %s does not match any available proxy cluster", domain) return "", fmt.Errorf("domain %s does not match any available proxy cluster", domain)
@@ -363,19 +390,46 @@ func (m Manager) reservedGatewayAddress(ctx context.Context, accountID string) (
return settings.ProxyAddress, nil return settings.ProxyAddress, nil
} }
func extractClusterFromCustomDomains(serviceDomain string, customDomains []*domain.Domain) (string, bool) { // customDomainMatch describes how a service domain relates to the account's
// custom domain rows.
type customDomainMatch int
const (
customDomainNoMatch customDomainMatch = iota
customDomainUnvalidated
customDomainValidated
)
// extractClusterFromCustomDomains finds the longest custom domain covering the
// service domain and reports its target cluster. Only a validated row yields a
// cluster: until the CNAME check has passed the account has not shown it
// controls the name, so no traffic may be routed for it.
func extractClusterFromCustomDomains(serviceDomain string, customDomains []*domain.Domain) (string, customDomainMatch) {
bestCluster := "" bestCluster := ""
bestLen := -1 bestLen := -1
matched := false
for _, cd := range customDomains { for _, cd := range customDomains {
if serviceDomain != cd.Domain && !strings.HasSuffix(serviceDomain, "."+cd.Domain) { if serviceDomain != cd.Domain && !strings.HasSuffix(serviceDomain, "."+cd.Domain) {
continue continue
} }
matched = true
if !cd.Validated {
continue
}
if l := len(cd.Domain); l > bestLen { if l := len(cd.Domain); l > bestLen {
bestLen = l bestLen = l
bestCluster = cd.TargetCluster bestCluster = cd.TargetCluster
} }
} }
return bestCluster, bestLen >= 0
switch {
case bestLen >= 0:
return bestCluster, customDomainValidated
case matched:
return "", customDomainUnvalidated
default:
return "", customDomainNoMatch
}
} }
// ExtractClusterFromFreeDomain extracts the cluster address from a free domain. // ExtractClusterFromFreeDomain extracts the cluster address from a free domain.
@@ -0,0 +1,326 @@
package manager
import (
"context"
"fmt"
"sync"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.opentelemetry.io/otel/metric/noop"
"github.com/netbirdio/netbird/management/internals/modules/reverseproxy/domain"
proxymanager "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/proxy/manager"
"github.com/netbirdio/netbird/management/server/activity"
"github.com/netbirdio/netbird/management/server/mock_server"
"github.com/netbirdio/netbird/management/server/permissions"
nbstore "github.com/netbirdio/netbird/management/server/store"
"github.com/netbirdio/netbird/management/server/types"
"github.com/netbirdio/netbird/shared/management/status"
)
const (
testCluster = "eu.proxy.test"
accountA = "account-a"
accountAUser = "account-a-admin"
accountB = "account-b"
accountBUser = "account-b-admin"
accountAMember = "account-a-member"
)
// stubResolver answers CNAME lookups from a table the test controls, so a
// domain can point at the cluster or nowhere without touching a real resolver.
type stubResolver struct {
mu sync.Mutex
cnames map[string]string
}
func (r *stubResolver) LookupCNAME(_ context.Context, host string) (string, error) {
r.mu.Lock()
defer r.mu.Unlock()
cname, ok := r.cnames[host]
if !ok {
return "", fmt.Errorf("lookup %s: no such host", host)
}
return cname + ".", nil
}
func (r *stubResolver) set(host, cname string) {
r.mu.Lock()
defer r.mu.Unlock()
r.cnames[host] = cname
}
type domainTestEnv struct {
manager Manager
store nbstore.Store
resolver *stubResolver
}
// setupDomainTest builds the domain manager on a real SQLite store with two
// accounts and one active public proxy cluster.
func setupDomainTest(t *testing.T) *domainTestEnv {
t.Helper()
ctx := context.Background()
testStore, cleanup, err := nbstore.NewTestStoreFromSQL(ctx, "", t.TempDir())
require.NoError(t, err)
t.Cleanup(cleanup)
for accountID, userID := range map[string]string{accountA: accountAUser, accountB: accountBUser} {
users := map[string]*types.User{
userID: {
Id: userID,
AccountID: accountID,
Role: types.UserRoleAdmin,
},
}
if accountID == accountA {
// A real member of the account whose role denies Services:Create, so
// permission denial is exercised as ok=false rather than as a lookup
// error for a user who is not in the account at all.
users[accountAMember] = &types.User{
Id: accountAMember,
AccountID: accountID,
Role: types.UserRoleUser,
}
}
require.NoError(t, testStore.SaveAccount(ctx, &types.Account{
Id: accountID,
CreatedBy: userID,
Settings: &types.Settings{},
Users: users,
}))
}
proxyMgr, err := proxymanager.NewManager(testStore, noop.NewMeterProvider().Meter(""))
require.NoError(t, err)
_, err = proxyMgr.Connect(ctx, "proxy-1", "session-1", testCluster, "127.0.0.1", nil, nil)
require.NoError(t, err)
resolver := &stubResolver{cnames: make(map[string]string)}
mgr := Manager{
store: testStore,
proxyManager: proxyMgr,
validator: domain.Validator{Resolver: resolver},
permissionsManager: permissions.NewManager(testStore),
accountManager: &mock_server.MockAccountManager{
StoreEventFunc: func(context.Context, string, string, string, activity.ActivityDescriber, map[string]any) {},
},
}
return &domainTestEnv{manager: mgr, store: testStore, resolver: resolver}
}
// storedDomain reads a domain row back through the store so assertions are made
// on what was persisted rather than on the value the manager returned.
func storedDomain(t *testing.T, s nbstore.Store, accountID, domainName string) *domain.Domain {
t.Helper()
domains, err := s.ListCustomDomains(context.Background(), accountID)
require.NoError(t, err)
for _, d := range domains {
if d.Domain == domainName {
return d
}
}
return nil
}
// A domain whose CNAME check fails is stored unvalidated and must not resolve a
// cluster, which is what service creation gates on.
func TestCreateDomain_FailedLookupIsNotServable(t *testing.T) {
ctx := context.Background()
env := setupDomainTest(t)
created, err := env.manager.CreateDomain(ctx, accountA, accountAUser, "apps.example.com", testCluster)
require.NoError(t, err)
assert.False(t, created.Validated, "a domain whose CNAME lookup fails must not be created validated")
stored := storedDomain(t, env.store, accountA, "apps.example.com")
require.NotNil(t, stored, "domain row should exist")
assert.False(t, stored.Validated, "persisted row must be unvalidated")
cluster, err := env.manager.DeriveClusterFromDomain(ctx, accountA, "apps.example.com")
require.Error(t, err, "an unvalidated domain must not resolve a cluster")
assert.Empty(t, cluster)
assert.Contains(t, err.Error(), "not validated", "error should tell the caller what to fix")
sErr, ok := status.FromError(err)
require.True(t, ok, "error should be a typed status error")
assert.Equal(t, status.PreconditionFailed, sErr.Type())
_, err = env.manager.DeriveClusterFromDomain(ctx, accountA, "sub.apps.example.com")
assert.Error(t, err, "subdomains of an unvalidated custom domain are not servable either")
}
// A second account claiming a registered domain gets a clean conflict, not a
// database error surfaced as a 500.
func TestCreateDomain_DuplicateIsAConflict(t *testing.T) {
ctx := context.Background()
env := setupDomainTest(t)
_, err := env.manager.CreateDomain(ctx, accountA, accountAUser, "shared.example.com", testCluster)
require.NoError(t, err)
_, err = env.manager.CreateDomain(ctx, accountB, accountBUser, "shared.example.com", testCluster)
require.Error(t, err)
sErr, ok := status.FromError(err)
require.True(t, ok, "conflict must be a typed status error, not a raw database error")
assert.Equal(t, status.AlreadyExists, sErr.Type(), "conflict should map to 409, not 500")
assert.NotContains(t, sErr.Message, accountA, "the response must not reveal the holding account")
assert.Nil(t, storedDomain(t, env.store, accountB, "shared.example.com"), "no row should be written on conflict")
}
// The same account re-adding one of its own domains is a conflict too.
func TestCreateDomain_SameAccountDuplicateIsAConflict(t *testing.T) {
ctx := context.Background()
env := setupDomainTest(t)
_, err := env.manager.CreateDomain(ctx, accountA, accountAUser, "dup.example.com", testCluster)
require.NoError(t, err)
_, err = env.manager.CreateDomain(ctx, accountA, accountAUser, "dup.example.com", testCluster)
require.Error(t, err)
sErr, ok := status.FromError(err)
require.True(t, ok)
assert.Equal(t, status.AlreadyExists, sErr.Type())
}
// The negative control: a validated domain still derives its cluster, for the
// bare name and for subdomains, exactly as before.
func TestCreateDomain_ValidatedDomainDerivesCluster(t *testing.T) {
ctx := context.Background()
env := setupDomainTest(t)
env.resolver.set("validation.valid.example.com", testCluster)
created, err := env.manager.CreateDomain(ctx, accountA, accountAUser, "valid.example.com", testCluster)
require.NoError(t, err)
require.True(t, created.Validated, "a matching CNAME should validate on create")
cluster, err := env.manager.DeriveClusterFromDomain(ctx, accountA, "valid.example.com")
require.NoError(t, err)
assert.Equal(t, testCluster, cluster)
cluster, err = env.manager.DeriveClusterFromDomain(ctx, accountA, "app.valid.example.com")
require.NoError(t, err)
assert.Equal(t, testCluster, cluster, "subdomains of a validated custom domain resolve too")
}
// Validating a domain flips the gate: the same lookup that failed before now
// resolves a cluster.
func TestValidateDomain_UnlocksClusterDerivation(t *testing.T) {
ctx := context.Background()
env := setupDomainTest(t)
created, err := env.manager.CreateDomain(ctx, accountA, accountAUser, "later.example.com", testCluster)
require.NoError(t, err)
require.False(t, created.Validated)
_, err = env.manager.DeriveClusterFromDomain(ctx, accountA, "later.example.com")
require.Error(t, err)
env.resolver.set("validation.later.example.com", testCluster)
env.manager.ValidateDomain(ctx, accountA, accountAUser, created.ID)
require.True(t, storedDomain(t, env.store, accountA, "later.example.com").Validated)
cluster, err := env.manager.DeriveClusterFromDomain(ctx, accountA, "later.example.com")
require.NoError(t, err)
assert.Equal(t, testCluster, cluster)
}
// Free cluster domains are unaffected by the custom domain gate.
func TestDeriveClusterFromDomain_FreeDomainUnaffected(t *testing.T) {
ctx := context.Background()
env := setupDomainTest(t)
cluster, err := env.manager.DeriveClusterFromDomain(ctx, accountA, "myapp.abc123."+testCluster)
require.NoError(t, err)
assert.Equal(t, testCluster, cluster)
}
// The manager pre-check exists to turn a conflict into a 409, but the unique
// index on the column is what actually guarantees the domain is claimed once.
//
// Two requests can clear the pre-check concurrently and race to the insert.
// Inserting twice through the store reaches the same code path the loser of
// that race takes, without the nondeterminism of driving it from goroutines,
// and the loser must still see a conflict rather than an internal error.
func TestStore_DuplicateDomainRejectedByIndexAsConflict(t *testing.T) {
ctx := context.Background()
env := setupDomainTest(t)
_, err := env.store.CreateCustomDomain(ctx, accountA, "indexed.example.com", testCluster, false)
require.NoError(t, err)
_, err = env.store.CreateCustomDomain(ctx, accountB, "indexed.example.com", testCluster, false)
require.Error(t, err, "the unique index must reject the same domain in a second account")
sErr, ok := status.FromError(err)
require.True(t, ok, "the losing insert must return a typed status error")
assert.Equal(t, status.AlreadyExists, sErr.Type(), "a lost race is a 409, not a 500")
}
// Validation is what decides whether a domain routes traffic, so a caller
// without permission to it must not be able to flip the flag. The check logged
// the denial and then carried on, which was inert while nothing read Validated
// and is not once cluster derivation gates on it.
func TestValidateDomain_PermissionDeniedDoesNotValidate(t *testing.T) {
ctx := context.Background()
env := setupDomainTest(t)
created, err := env.manager.CreateDomain(ctx, accountA, accountAUser, "guarded.example.com", testCluster)
require.NoError(t, err)
require.False(t, created.Validated)
// The CNAME is in place, so the only thing standing between this caller and
// a validated domain is the permission check.
env.resolver.set("validation.guarded.example.com", testCluster)
env.manager.ValidateDomain(ctx, accountA, accountAMember, created.ID)
stored := storedDomain(t, env.store, accountA, "guarded.example.com")
require.NotNil(t, stored)
assert.False(t, stored.Validated, "a caller without permission must not validate the domain")
_, err = env.manager.DeriveClusterFromDomain(ctx, accountA, "guarded.example.com")
assert.Error(t, err, "the domain must still be unservable")
}
// Validation runs asynchronously, so it can finish after the domain was
// deleted and then write a stale row back. gorm's Save falls back to an insert
// when an update affects no rows, which would resurrect the domain as
// validated; UpdateCustomDomain avoids that by selecting explicit columns.
// This pins that behaviour, since dropping the Select would reintroduce it.
func TestUpdateCustomDomain_DoesNotResurrectDeletedDomain(t *testing.T) {
ctx := context.Background()
env := setupDomainTest(t)
created, err := env.manager.CreateDomain(ctx, accountA, accountAUser, "racy.example.com", testCluster)
require.NoError(t, err)
stale := storedDomain(t, env.store, accountA, "racy.example.com")
require.NotNil(t, stale)
require.NoError(t, env.manager.DeleteDomain(ctx, accountA, accountAUser, created.ID))
require.Nil(t, storedDomain(t, env.store, accountA, "racy.example.com"), "the domain should be gone")
// What an in-flight validation would write once its CNAME check succeeded.
// The write has to succeed for the assertion below to mean anything: a
// rejected write would leave the domain absent for the wrong reason.
stale.Validated = true
_, err = env.store.UpdateCustomDomain(ctx, accountA, stale)
require.NoError(t, err, "the update itself must succeed, so absence is not just a failed write")
assert.Nil(t, storedDomain(t, env.store, accountA, "racy.example.com"),
"a late validation write must not recreate a deleted domain")
}
@@ -184,6 +184,10 @@ func (s *stubStore) GetCustomDomain(context.Context, string, string) (*domain.Do
panic("not used in allow-list tests") panic("not used in allow-list tests")
} }
func (s *stubStore) GetCustomDomainByName(context.Context, string) (*domain.Domain, error) {
panic("not used in allow-list tests")
}
func (s *stubStore) ListFreeDomains(context.Context, string) ([]string, error) { func (s *stubStore) ListFreeDomains(context.Context, string) ([]string, error) {
panic("not used in allow-list tests") panic("not used in allow-list tests")
} }
@@ -0,0 +1,127 @@
package manager
import (
"context"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.opentelemetry.io/otel/metric/noop"
domainmanager "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/domain/manager"
proxymanager "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/proxy/manager"
rpservice "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/service"
"github.com/netbirdio/netbird/management/server/activity"
"github.com/netbirdio/netbird/management/server/mock_server"
"github.com/netbirdio/netbird/management/server/permissions"
"github.com/netbirdio/netbird/management/server/store"
"github.com/netbirdio/netbird/shared/management/status"
)
const validationTestCluster = "eu.proxy.test"
// withRealDomainManager swaps the stub cluster deriver for the real domain
// manager backed by the same store, so service creation is gated by the actual
// domain rows rather than by a test double that always agrees.
func withRealDomainManager(t *testing.T, mgr *Manager, testStore store.Store) {
t.Helper()
ctx := context.Background()
proxyMgr, err := proxymanager.NewManager(testStore, noop.NewMeterProvider().Meter(""))
require.NoError(t, err)
_, err = proxyMgr.Connect(ctx, "proxy-1", "session-1", validationTestCluster, "127.0.0.1", nil, nil)
require.NoError(t, err)
accountMgr := &mock_server.MockAccountManager{
StoreEventFunc: func(context.Context, string, string, string, activity.ActivityDescriber, map[string]any) {},
}
mgr.clusterDeriver = domainmanager.NewManager(testStore, proxyMgr, permissions.NewManager(testStore), accountMgr)
}
func newTestService(domain string) *rpservice.Service {
return &rpservice.Service{
Name: "test-service",
Domain: domain,
Enabled: true,
Mode: rpservice.ModeHTTP,
Targets: []*rpservice.Target{{
Host: "10.0.0.1",
Port: 8080,
Protocol: "http",
TargetId: testPeerID,
TargetType: "peer",
Enabled: true,
}},
}
}
// A service must not bind to a domain the account has not validated, and
// nothing may be persisted for the attempt.
func TestCreateService_RefusesUnvalidatedDomain(t *testing.T) {
ctx := context.Background()
mgr, testStore := setupIntegrationTest(t)
withRealDomainManager(t, mgr, testStore)
_, err := testStore.CreateCustomDomain(ctx, testAccountID, "unproven.example.com", validationTestCluster, false)
require.NoError(t, err)
_, err = mgr.CreateService(ctx, testAccountID, testUserID, newTestService("unproven.example.com"))
require.Error(t, err, "an unvalidated domain must not bind a service")
assert.Contains(t, err.Error(), "not validated", "the API error should name the actual problem")
sErr, ok := status.FromError(err)
require.True(t, ok, "error should be a typed status error")
assert.Equal(t, status.PreconditionFailed, sErr.Type())
services, err := testStore.GetAccountServices(ctx, store.LockingStrengthNone, testAccountID)
require.NoError(t, err)
assert.Empty(t, services, "no service row should be written for a refused domain")
}
// The negative control: a validated domain still binds a service and derives
// its cluster exactly as before.
func TestCreateService_ValidatedDomainBindsService(t *testing.T) {
ctx := context.Background()
mgr, testStore := setupIntegrationTest(t)
withRealDomainManager(t, mgr, testStore)
_, err := testStore.CreateCustomDomain(ctx, testAccountID, "proven.example.com", validationTestCluster, true)
require.NoError(t, err)
created, err := mgr.CreateService(ctx, testAccountID, testUserID, newTestService("app.proven.example.com"))
require.NoError(t, err)
assert.Equal(t, validationTestCluster, created.ProxyCluster, "service should bind to the domain's target cluster")
services, err := testStore.GetAccountServices(ctx, store.LockingStrengthNone, testAccountID)
require.NoError(t, err)
require.Len(t, services, 1, "the service should be persisted")
assert.Equal(t, "app.proven.example.com", services[0].Domain)
}
// An update must not be a way around the creation gate: moving a live service
// onto an unvalidated domain has to fail rather than silently keep the old
// cluster and start serving the new hostname.
func TestUpdateService_RefusesMoveToUnvalidatedDomain(t *testing.T) {
ctx := context.Background()
mgr, testStore := setupIntegrationTest(t)
withRealDomainManager(t, mgr, testStore)
_, err := testStore.CreateCustomDomain(ctx, testAccountID, "proven.example.com", validationTestCluster, true)
require.NoError(t, err)
_, err = testStore.CreateCustomDomain(ctx, testAccountID, "unproven.example.com", validationTestCluster, false)
require.NoError(t, err)
created, err := mgr.CreateService(ctx, testAccountID, testUserID, newTestService("app.proven.example.com"))
require.NoError(t, err)
moved := *created
moved.Domain = "app.unproven.example.com"
_, err = mgr.UpdateService(ctx, testAccountID, testUserID, &moved)
require.Error(t, err, "moving to an unvalidated domain must fail")
assert.Contains(t, err.Error(), "not validated")
stored, err := testStore.GetServiceByID(ctx, store.LockingStrengthNone, testAccountID, created.ID)
require.NoError(t, err)
assert.Equal(t, "app.proven.example.com", stored.Domain, "the service must keep its original domain")
}
@@ -606,16 +606,19 @@ func (m *Manager) resolveEffectiveCluster(ctx context.Context, accountID string,
return existing.ProxyCluster, nil return existing.ProxyCluster, nil
} }
if m.clusterDeriver != nil { if m.clusterDeriver == nil {
derived, err := m.clusterDeriver.DeriveClusterFromDomain(ctx, accountID, svc.Domain) return existing.ProxyCluster, nil
if err != nil {
log.WithError(err).Warnf("could not derive cluster from domain %s", svc.Domain)
} else {
return derived, nil
}
} }
return existing.ProxyCluster, nil // Falling back to the old cluster here would let an update move a service
// onto a domain the account has not validated, bypassing the check that
// creation makes.
derived, err := m.clusterDeriver.DeriveClusterFromDomain(ctx, accountID, svc.Domain)
if err != nil {
return "", status.Errorf(status.PreconditionFailed, "could not derive cluster from domain %s: %v", svc.Domain, err)
}
return derived, nil
} }
func (m *Manager) executeServiceUpdate(ctx context.Context, transaction store.Store, accountID string, service *service.Service, updateInfo *serviceUpdateInfo, customPorts *bool, effectiveCluster string) error { func (m *Manager) executeServiceUpdate(ctx context.Context, transaction store.Store, accountID string, service *service.Service, updateInfo *serviceUpdateInfo, customPorts *bool, effectiveCluster string) error {
+36 -20
View File
@@ -31,6 +31,7 @@ import (
rpservice "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/service" rpservice "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/service"
networkmapdb "github.com/netbirdio/netbird/management/internals/network_map_db" networkmapdb "github.com/netbirdio/netbird/management/internals/network_map_db"
networkmapdbfactory "github.com/netbirdio/netbird/management/internals/network_map_db/factory" networkmapdbfactory "github.com/netbirdio/netbird/management/internals/network_map_db/factory"
nbconfig "github.com/netbirdio/netbird/management/internals/server/config"
nbgrpc "github.com/netbirdio/netbird/management/internals/shared/grpc" nbgrpc "github.com/netbirdio/netbird/management/internals/shared/grpc"
"github.com/netbirdio/netbird/management/server/activity" "github.com/netbirdio/netbird/management/server/activity"
activitystore "github.com/netbirdio/netbird/management/server/activity/store" activitystore "github.com/netbirdio/netbird/management/server/activity/store"
@@ -111,7 +112,8 @@ func (s *BaseServer) NetworkMapStore() *networkmapdb.NetworkMapDBStoreImpl {
s.Config.StoreConfig.Engine, s.Config.StoreConfig.Engine,
s.Config.Datadir, s.Config.Datadir,
s.IntegratedValidator(), s.IntegratedValidator(),
s.SettingsManager()) s.SettingsManager(),
)
// networkmap db store supports postgres and sqlite backends only // networkmap db store supports postgres and sqlite backends only
// for other backends a fallback is used, so NotSupportedStoreEngineError // for other backends a fallback is used, so NotSupportedStoreEngineError
// is not a fatal error // is not a fatal error
@@ -180,24 +182,7 @@ func (s *BaseServer) RateLimiter() *middleware.APIRateLimiter {
func (s *BaseServer) GRPCServer() *grpc.Server { func (s *BaseServer) GRPCServer() *grpc.Server {
return Create(s, func() *grpc.Server { return Create(s, func() *grpc.Server {
trustedPeers := s.Config.ReverseProxy.TrustedPeers realipOpts := realIPOptions(s.Config.ReverseProxy)
defaultTrustedPeers := []netip.Prefix{netip.MustParsePrefix("0.0.0.0/0"), netip.MustParsePrefix("::/0")}
if len(trustedPeers) == 0 || slices.Equal[[]netip.Prefix](trustedPeers, defaultTrustedPeers) {
log.WithContext(context.Background()).Warn("TrustedPeers are configured to default value '0.0.0.0/0', '::/0'. This allows connection IP spoofing.")
trustedPeers = defaultTrustedPeers
}
trustedHTTPProxies := s.Config.ReverseProxy.TrustedHTTPProxies
trustedProxiesCount := s.Config.ReverseProxy.TrustedHTTPProxiesCount
if len(trustedHTTPProxies) > 0 && trustedProxiesCount > 0 {
log.WithContext(context.Background()).Warn("TrustedHTTPProxies and TrustedHTTPProxiesCount both are configured. " +
"This is not recommended way to extract X-Forwarded-For. Consider using one of these options.")
}
realipOpts := []realip.Option{
realip.WithTrustedPeers(trustedPeers),
realip.WithTrustedProxies(trustedHTTPProxies),
realip.WithTrustedProxiesCount(trustedProxiesCount),
realip.WithHeaders([]string{realip.XForwardedFor, realip.XRealIp}),
}
proxyUnary, proxyStream, proxyAuthClose := nbgrpc.NewProxyAuthInterceptors(s.Store()) proxyUnary, proxyStream, proxyAuthClose := nbgrpc.NewProxyAuthInterceptors(s.Store())
s.proxyAuthClose = proxyAuthClose s.proxyAuthClose = proxyAuthClose
gRPCOpts := []grpc.ServerOption{ gRPCOpts := []grpc.ServerOption{
@@ -333,7 +318,7 @@ func (s *BaseServer) AccessLogsManager() accesslogs.Manager {
}) })
} }
func loadTLSConfig(certFile string, certKey string) (*tls.Config, error) { func loadTLSConfig(certFile, certKey string) (*tls.Config, error) {
// Load server's certificate and private key // Load server's certificate and private key
serverCert, err := tls.LoadX509KeyPair(certFile, certKey) serverCert, err := tls.LoadX509KeyPair(certFile, certKey)
if err != nil { if err != nil {
@@ -380,3 +365,34 @@ func streamInterceptor(
wrapped.WrappedContext = context.WithValue(ctx, nbContext.RequestIDKey, reqID) wrapped.WrappedContext = context.WithValue(ctx, nbContext.RequestIDKey, reqID)
return handler(srv, wrapped) return handler(srv, wrapped)
} }
// realIPOptions builds the real-IP middleware options from the reverse proxy config.
//
// TrustedPeers controls which transport peers are allowed to supply forwarded-IP
// headers. If empty, forwarded headers are ignored and the transport peer address
// is used directly. Operators terminating connections at a reverse proxy should
// configure TrustedPeers with that proxy's address or network.
//
// Only X-Forwarded-For is trusted. X-Real-IP contains a single client-supplied
// address with no proxy chain to validate, and none of the reverse proxies we ship
// use it on the gRPC path.
func realIPOptions(cfg nbconfig.ReverseProxy) []realip.Option {
if idx := slices.IndexFunc(cfg.TrustedPeers, func(p netip.Prefix) bool { return p.Bits() == 0 }); idx >= 0 {
log.WithContext(context.Background()).Warnf("TrustedPeers contains the default route %s, which trusts "+
"X-Forwarded-For from every client and allows connection IP spoofing. Set TrustedPeers to the address "+
"of your reverse proxy, or leave it empty to use the connection's source address.", cfg.TrustedPeers[idx])
}
if cfg.TrustedHTTPProxiesCount > 0 {
log.WithContext(context.Background()).Warn(
"TrustedHTTPProxiesCount skips X-Forwarded-For entries by position before TrustedHTTPProxies filters by address. " +
"An incorrect count may skip the real client IP and produce an incorrect source address.",
)
}
return []realip.Option{
realip.WithTrustedPeers(cfg.TrustedPeers),
realip.WithTrustedProxies(cfg.TrustedHTTPProxies),
realip.WithTrustedProxiesCount(cfg.TrustedHTTPProxiesCount),
realip.WithHeaders([]string{realip.XForwardedFor}),
}
}
+171
View File
@@ -0,0 +1,171 @@
package server
import (
"context"
"io"
"net"
"net/netip"
"testing"
"time"
"github.com/grpc-ecosystem/go-grpc-middleware/v2/interceptors/realip"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials/insecure"
"google.golang.org/grpc/metadata"
"google.golang.org/protobuf/types/known/emptypb"
nbconfig "github.com/netbirdio/netbird/management/internals/server/config"
)
const (
realIPProbeMethod = "/netbird.test.RealIPProbe/Probe"
realIPProbeStreamMethod = "/netbird.test.RealIPProbe/ProbeStream"
)
// realIPProbe records the real IP the middleware derived for each call.
type realIPProbe struct {
got chan string
}
func (p *realIPProbe) record(ctx context.Context) {
addr, _ := realip.FromContext(ctx)
p.got <- addr.String()
}
func (p *realIPProbe) wait(t *testing.T) string {
t.Helper()
select {
case got := <-p.got:
return got
case <-time.After(5 * time.Second):
t.Fatal("timed out waiting for probe")
return ""
}
}
func startProbeServer(t *testing.T, cfg nbconfig.ReverseProxy) (*grpc.ClientConn, *realIPProbe) {
t.Helper()
listener, err := net.Listen("tcp", "127.0.0.1:0")
require.NoError(t, err)
probe := &realIPProbe{got: make(chan string, 1)}
opts := realIPOptions(cfg)
srv := grpc.NewServer(
grpc.ChainUnaryInterceptor(realip.UnaryServerInterceptorOpts(opts...)),
grpc.ChainStreamInterceptor(realip.StreamServerInterceptorOpts(opts...)),
)
srv.RegisterService(&grpc.ServiceDesc{
ServiceName: "netbird.test.RealIPProbe",
HandlerType: (*any)(nil),
Methods: []grpc.MethodDesc{{
MethodName: "Probe",
Handler: func(_ any, ctx context.Context, dec func(any) error, interceptor grpc.UnaryServerInterceptor) (any, error) {
req := new(emptypb.Empty)
if err := dec(req); err != nil {
return nil, err
}
handler := func(ctx context.Context, _ any) (any, error) {
probe.record(ctx)
return &emptypb.Empty{}, nil
}
if interceptor == nil {
return handler(ctx, req)
}
return interceptor(ctx, req, &grpc.UnaryServerInfo{FullMethod: realIPProbeMethod}, handler)
},
}},
Streams: []grpc.StreamDesc{{
StreamName: "ProbeStream",
ServerStreams: true,
Handler: func(_ any, stream grpc.ServerStream) error {
probe.record(stream.Context())
return nil
},
}},
}, probe)
go func() { _ = srv.Serve(listener) }()
t.Cleanup(srv.Stop)
conn, err := grpc.NewClient(listener.Addr().String(), grpc.WithTransportCredentials(insecure.NewCredentials()))
require.NoError(t, err)
t.Cleanup(func() { _ = conn.Close() })
return conn, probe
}
func callUnary(t *testing.T, conn *grpc.ClientConn, probe *realIPProbe, kv ...string) string {
t.Helper()
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
ctx = metadata.AppendToOutgoingContext(ctx, kv...)
require.NoError(t, conn.Invoke(ctx, realIPProbeMethod, &emptypb.Empty{}, &emptypb.Empty{}))
return probe.wait(t)
}
func callStream(t *testing.T, conn *grpc.ClientConn, probe *realIPProbe, kv ...string) string {
t.Helper()
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
ctx = metadata.AppendToOutgoingContext(ctx, kv...)
desc := &grpc.StreamDesc{StreamName: "ProbeStream", ServerStreams: true}
stream, err := conn.NewStream(ctx, desc, realIPProbeStreamMethod)
require.NoError(t, err)
require.NoError(t, stream.CloseSend())
require.ErrorIs(t, stream.RecvMsg(&emptypb.Empty{}), io.EOF)
return probe.wait(t)
}
func assertRealIP(t *testing.T, cfg nbconfig.ReverseProxy, want string, kv ...string) {
t.Helper()
conn, probe := startProbeServer(t, cfg)
t.Run("unary", func(t *testing.T) {
assert.Equal(t, want, callUnary(t, conn, probe, kv...))
})
t.Run("stream", func(t *testing.T) {
assert.Equal(t, want, callStream(t, conn, probe, kv...))
})
}
func TestRealIPDefaultIgnoresClientForwardedHeaders(t *testing.T) {
assertRealIP(t, nbconfig.ReverseProxy{}, "127.0.0.1",
realip.XForwardedFor, "203.0.113.44",
realip.XRealIp, "203.0.113.44",
)
}
func TestRealIPUntrustedPeerIgnoresForwardedHeaders(t *testing.T) {
cfg := nbconfig.ReverseProxy{TrustedPeers: []netip.Prefix{netip.MustParsePrefix("10.9.8.7/32")}}
assertRealIP(t, cfg, "127.0.0.1",
realip.XForwardedFor, "203.0.113.44",
realip.XRealIp, "203.0.113.44",
)
}
func TestRealIPTrustedPeerHonoursForwardedHeaders(t *testing.T) {
cfg := nbconfig.ReverseProxy{TrustedPeers: []netip.Prefix{netip.MustParsePrefix("127.0.0.1/32")}}
assertRealIP(t, cfg, "203.0.113.44",
realip.XForwardedFor, "203.0.113.44",
realip.XRealIp, "203.0.113.44",
)
}
func TestRealIPIgnoresXRealIPWhenProxyCountIsSet(t *testing.T) {
cfg := nbconfig.ReverseProxy{
TrustedPeers: []netip.Prefix{netip.MustParsePrefix("127.0.0.1/32")},
TrustedHTTPProxiesCount: 1,
}
assertRealIP(t, cfg, "127.0.0.1", realip.XRealIp, "203.0.113.44")
}
+33 -17
View File
@@ -74,6 +74,7 @@ type BaseServer struct {
grpcExtensions []GRPCExtension grpcExtensions []GRPCExtension
listener net.Listener listener net.Listener
tlsConfig *tls.Config
certManager *autocert.Manager certManager *autocert.Manager
update *version.Update update *version.Update
@@ -94,6 +95,7 @@ type Config struct {
DisableGeoliteUpdate bool DisableGeoliteUpdate bool
UserDeleteFromIDPEnabled bool UserDeleteFromIDPEnabled bool
AutoResolveDomains bool AutoResolveDomains bool
TLSConfig *tls.Config
} }
// NewServer initializes and configures a new Server instance // NewServer initializes and configures a new Server instance
@@ -110,6 +112,7 @@ func NewServer(cfg *Config) *BaseServer {
disableLegacyManagementPort: cfg.DisableLegacyManagementPort, disableLegacyManagementPort: cfg.DisableLegacyManagementPort,
mgmtMetricsPort: cfg.MgmtMetricsPort, mgmtMetricsPort: cfg.MgmtMetricsPort,
autoResolveDomains: cfg.AutoResolveDomains, autoResolveDomains: cfg.AutoResolveDomains,
tlsConfig: cfg.TLSConfig,
} }
s.container[ContainerKeyBaseServer] = s s.container[ContainerKeyBaseServer] = s
@@ -139,21 +142,9 @@ func (s *BaseServer) Start(ctx context.Context) error {
} }
s.EphemeralManager().LoadInitialPeers(srvCtx) s.EphemeralManager().LoadInitialPeers(srvCtx)
var tlsConfig *tls.Config tlsEnabled, err := s.setupTLS(srvCtx)
tlsEnabled := false if err != nil {
if s.Config.HttpConfig.LetsEncryptDomain != "" { return err
s.certManager, err = encryption.CreateCertManager(s.Config.Datadir, s.Config.HttpConfig.LetsEncryptDomain)
if err != nil {
return fmt.Errorf("failed creating LetsEncrypt cert manager: %v", err)
}
tlsEnabled = true
} else if s.Config.HttpConfig.CertFile != "" && s.Config.HttpConfig.CertKey != "" {
tlsConfig, err = loadTLSConfig(s.Config.HttpConfig.CertFile, s.Config.HttpConfig.CertKey)
if err != nil {
log.WithContext(srvCtx).Errorf("cannot load TLS credentials: %v", err)
return err
}
tlsEnabled = true
} }
installationID, err := getInstallationID(srvCtx, s.Store()) installationID, err := getInstallationID(srvCtx, s.Store())
@@ -215,8 +206,8 @@ func (s *BaseServer) Start(ctx context.Context) error {
log.WithContext(ctx).Infof("running HTTP server (LetsEncrypt challenge handler): %s", cml.Addr().String()) log.WithContext(ctx).Infof("running HTTP server (LetsEncrypt challenge handler): %s", cml.Addr().String())
s.serveHTTP(ctx, cml, s.certManager.HTTPHandler(nil)) s.serveHTTP(ctx, cml, s.certManager.HTTPHandler(nil))
} }
case tlsConfig != nil: case s.tlsConfig != nil:
s.listener, err = tls.Listen("tcp", fmt.Sprintf(":%d", s.mgmtPort), tlsConfig) s.listener, err = tls.Listen("tcp", fmt.Sprintf(":%d", s.mgmtPort), s.tlsConfig)
if err != nil { if err != nil {
return fmt.Errorf("failed creating TLS listener on port %d: %v", s.mgmtPort, err) return fmt.Errorf("failed creating TLS listener on port %d: %v", s.mgmtPort, err)
} }
@@ -240,6 +231,31 @@ func (s *BaseServer) Start(ctx context.Context) error {
return nil return nil
} }
// setupTLS resolves the listener's TLS source: an injected config wins over the HttpConfig certificate settings
func (s *BaseServer) setupTLS(ctx context.Context) (bool, error) {
switch {
case s.tlsConfig != nil:
return true, nil
case s.Config.HttpConfig.LetsEncryptDomain != "":
certManager, err := encryption.CreateCertManager(s.Config.Datadir, s.Config.HttpConfig.LetsEncryptDomain)
if err != nil {
return false, fmt.Errorf("failed creating LetsEncrypt cert manager: %v", err)
}
s.certManager = certManager
return true, nil
case s.Config.HttpConfig.CertFile != "" && s.Config.HttpConfig.CertKey != "":
tlsConfig, err := loadTLSConfig(s.Config.HttpConfig.CertFile, s.Config.HttpConfig.CertKey)
if err != nil {
log.WithContext(ctx).Errorf("cannot load TLS credentials: %v", err)
return false, err
}
s.tlsConfig = tlsConfig
return true, nil
default:
return false, nil
}
}
// Stop attempts a graceful shutdown, waiting up to 5 seconds for active connections to finish // Stop attempts a graceful shutdown, waiting up to 5 seconds for active connections to finish
func (s *BaseServer) Stop() error { func (s *BaseServer) Stop() error {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
+5 -2
View File
@@ -719,8 +719,10 @@ func (am *DefaultAccountManager) schedulePeerLoginExpiration(ctx context.Context
log.WithContext(ctx).Tracef("peer login expiration job for account %s is already scheduled", accountID) log.WithContext(ctx).Tracef("peer login expiration job for account %s is already scheduled", accountID)
return return
} }
// The job outlives the request that arms it, so it must not inherit the request's cancellation.
jobCtx := context.WithoutCancel(ctx)
if nextRun, ok := am.getNextPeerExpiration(ctx, accountID); ok { if nextRun, ok := am.getNextPeerExpiration(ctx, accountID); ok {
go am.peerLoginExpiry.Schedule(ctx, nextRun, accountID, am.peerLoginExpirationJob(ctx, accountID)) go am.peerLoginExpiry.Schedule(jobCtx, nextRun, accountID, am.peerLoginExpirationJob(jobCtx, accountID))
} }
} }
@@ -752,8 +754,9 @@ func (am *DefaultAccountManager) peerInactivityExpirationJob(ctx context.Context
// checkAndSchedulePeerInactivityExpiration periodically checks for inactive peers to end their sessions // checkAndSchedulePeerInactivityExpiration periodically checks for inactive peers to end their sessions
func (am *DefaultAccountManager) checkAndSchedulePeerInactivityExpiration(ctx context.Context, accountID string) { func (am *DefaultAccountManager) checkAndSchedulePeerInactivityExpiration(ctx context.Context, accountID string) {
am.peerInactivityExpiry.Cancel(ctx, []string{accountID}) am.peerInactivityExpiry.Cancel(ctx, []string{accountID})
jobCtx := context.WithoutCancel(ctx)
if nextRun, ok := am.getNextInactivePeerExpiration(ctx, accountID); ok { if nextRun, ok := am.getNextInactivePeerExpiration(ctx, accountID); ok {
go am.peerInactivityExpiry.Schedule(ctx, nextRun, accountID, am.peerInactivityExpirationJob(ctx, accountID)) go am.peerInactivityExpiry.Schedule(jobCtx, nextRun, accountID, am.peerInactivityExpirationJob(jobCtx, accountID))
} }
} }
+176 -3
View File
@@ -1920,6 +1920,154 @@ func TestDefaultAccountManager_MarkPeerConnected_PeerLoginExpiration(t *testing.
} }
} }
func TestDefaultAccountManager_SchedulePeerLoginExpiration_IncludesOfflinePeers(t *testing.T) {
manager, updateManager, err := createManager(t)
require.NoError(t, err, "unable to create account manager")
accountID, err := manager.GetAccountIDByUserID(context.Background(), auth.UserAuth{UserId: userID})
require.NoError(t, err, "unable to create an account")
connectedKey, offlineKey := addExpiringPeers(t, manager)
_, err = manager.UpdateAccountSettings(context.Background(), accountID, userID, &types.Settings{
PeerLoginExpiration: time.Hour,
PeerLoginExpirationEnabled: true,
Extra: &types.ExtraSettings{},
})
require.NoError(t, err, "expecting to update account settings successfully but got error")
manager.peerLoginExpiry.CancelAll(context.Background())
// The connected peer logged in just now, so a job computed from connected peers alone
// would be armed for an hour. The offline peer's login expires in two seconds; a
// reconnect of that peer must not have to wait for the connected peer's tick.
now := time.Now().UTC()
setPeerLogin(t, manager, accountID, connectedKey, true, now)
setPeerLogin(t, manager, accountID, offlineKey, false, now.Add(-time.Hour+2*time.Second))
offlinePeer, err := manager.Store.GetPeerByPeerPubKey(context.Background(), store.LockingStrengthNone, offlineKey)
require.NoError(t, err)
updateManager.CreateChannel(context.Background(), offlinePeer.ID)
manager.peerLoginExpiry = NewDefaultScheduler()
t.Cleanup(func() { manager.peerLoginExpiry.CancelAll(context.Background()) })
manager.schedulePeerLoginExpiration(context.Background(), accountID)
// The flag is committed per peer before the disconnect fans out, so wait for both.
require.Eventually(t, func() bool {
peer, err := manager.Store.GetPeerByPeerPubKey(context.Background(), store.LockingStrengthNone, offlineKey)
return err == nil && peer.Status.LoginExpired && !updateManager.HasChannel(offlinePeer.ID)
}, 10*time.Second, 100*time.Millisecond, "offline peer should be expired and disconnected at its own deadline")
connectedPeer, err := manager.Store.GetPeerByPeerPubKey(context.Background(), store.LockingStrengthNone, connectedKey)
require.NoError(t, err)
assert.False(t, connectedPeer.Status.LoginExpired, "connected peer with a fresh login must not expire")
}
func TestDefaultAccountManager_SchedulePeerLoginExpiration_DetachesRequestContext(t *testing.T) {
manager, _, err := createManager(t)
require.NoError(t, err, "unable to create account manager")
accountID, err := manager.GetAccountIDByUserID(context.Background(), auth.UserAuth{UserId: userID})
require.NoError(t, err, "unable to create an account")
connectedKey, _ := addExpiringPeers(t, manager)
setPeerLogin(t, manager, accountID, connectedKey, true, time.Now().UTC())
scheduled := make(chan context.Context, 1)
manager.peerLoginExpiry = &MockScheduler{
IsSchedulerRunningFunc: func(string) bool { return false },
ScheduleFunc: func(ctx context.Context, _ time.Duration, _ string, _ func() (time.Duration, bool)) {
scheduled <- ctx
},
}
requestCtx, cancel := context.WithCancel(context.Background())
manager.schedulePeerLoginExpiration(requestCtx, accountID)
cancel()
select {
case jobCtx := <-scheduled:
assert.NoError(t, jobCtx.Err(), "the expiration job must outlive the request that armed it")
case <-time.After(time.Second):
t.Fatal("timeout while waiting for the job to be scheduled")
}
}
func TestDefaultAccountManager_ExpireAndUpdatePeers_SkipsPeerThatLoggedInAgain(t *testing.T) {
manager, updateManager, err := createManager(t)
require.NoError(t, err, "unable to create account manager")
accountID, err := manager.GetAccountIDByUserID(context.Background(), auth.UserAuth{UserId: userID})
require.NoError(t, err, "unable to create an account")
reloggedKey, staleKey := addExpiringPeers(t, manager)
_, err = manager.UpdateAccountSettings(context.Background(), accountID, userID, &types.Settings{
PeerLoginExpiration: time.Hour,
PeerLoginExpirationEnabled: true,
Extra: &types.ExtraSettings{},
})
require.NoError(t, err, "expecting to update account settings successfully but got error")
manager.peerLoginExpiry.CancelAll(context.Background())
expiredLogin := time.Now().UTC().Add(-2 * time.Hour)
setPeerLogin(t, manager, accountID, reloggedKey, true, expiredLogin)
setPeerLogin(t, manager, accountID, staleKey, true, expiredLogin)
expiredPeers, err := manager.getExpiredPeers(context.Background(), accountID)
require.NoError(t, err)
require.Len(t, expiredPeers, 2, "both peers should be due for expiration")
// The job holds the candidate list while one peer completes a fresh login, which
// moves its deadline into the future and must win over the stale candidate entry.
setPeerLogin(t, manager, accountID, reloggedKey, true, time.Now().UTC())
reloggedPeer, err := manager.Store.GetPeerByPeerPubKey(context.Background(), store.LockingStrengthNone, reloggedKey)
require.NoError(t, err)
stalePeer, err := manager.Store.GetPeerByPeerPubKey(context.Background(), store.LockingStrengthNone, staleKey)
require.NoError(t, err)
updateManager.CreateChannel(context.Background(), reloggedPeer.ID)
updateManager.CreateChannel(context.Background(), stalePeer.ID)
err = manager.expireAndUpdatePeers(context.Background(), accountID, expiredPeers, peerExpirationSessionExpired)
require.NoError(t, err)
reloggedPeer, err = manager.Store.GetPeerByPeerPubKey(context.Background(), store.LockingStrengthNone, reloggedKey)
require.NoError(t, err)
assert.False(t, reloggedPeer.Status.LoginExpired, "a peer that logged in again must not be flagged from the stale candidate list")
assert.True(t, reloggedPeer.Status.Connected, "the re-logged peer must keep its connected status")
assert.True(t, updateManager.HasChannel(reloggedPeer.ID), "the re-logged peer's update channel must stay open")
stalePeer, err = manager.Store.GetPeerByPeerPubKey(context.Background(), store.LockingStrengthNone, staleKey)
require.NoError(t, err)
assert.True(t, stalePeer.Status.LoginExpired, "a peer that is still due must be flagged")
assert.False(t, updateManager.HasChannel(stalePeer.ID), "the expired peer's update channel must be closed")
}
// addExpiringPeers registers two SSO peers with login expiration enabled and returns their public keys.
func addExpiringPeers(t *testing.T, manager *DefaultAccountManager) (string, string) {
t.Helper()
keys := make([]string, 0, 2)
for _, hostname := range []string{"connected-peer", "offline-peer"} {
key, err := wgtypes.GenerateKey()
require.NoError(t, err, "unable to generate WireGuard key")
_, _, _, _, err = manager.AddPeer(context.Background(), "", "", userID, &nbpeer.Peer{
Key: key.PublicKey().String(),
Meta: nbpeer.PeerSystemMeta{Hostname: hostname},
LoginExpirationEnabled: true,
}, false)
require.NoError(t, err, "unable to add peer")
keys = append(keys, key.PublicKey().String())
}
return keys[0], keys[1]
}
func setPeerLogin(t *testing.T, manager *DefaultAccountManager, accountID, peerKey string, connected bool, lastLogin time.Time) {
t.Helper()
peer, err := manager.Store.GetPeerByPeerPubKey(context.Background(), store.LockingStrengthNone, peerKey)
require.NoError(t, err)
peer.Status.Connected = connected
peer.LastLogin = &lastLogin
require.NoError(t, manager.Store.SavePeer(context.Background(), accountID, peer))
}
func TestDefaultAccountManager_MarkPeerDisconnected_SchedulesInactivityExpiration(t *testing.T) { func TestDefaultAccountManager_MarkPeerDisconnected_SchedulesInactivityExpiration(t *testing.T) {
manager, _, err := createManager(t) manager, _, err := createManager(t)
require.NoError(t, err, "unable to create account manager") require.NoError(t, err, "unable to create account manager")
@@ -2702,7 +2850,7 @@ func TestAccount_GetNextPeerExpiration(t *testing.T) {
expectedNextExpiration: time.Duration(0), expectedNextExpiration: time.Duration(0),
}, },
{ {
name: "No connected peers, no expiration", name: "Offline peer with expiration, return expiration",
peers: map[string]*nbpeer.Peer{ peers: map[string]*nbpeer.Peer{
"peer-1": { "peer-1": {
Status: &nbpeer.PeerStatus{ Status: &nbpeer.PeerStatus{
@@ -2721,8 +2869,33 @@ func TestAccount_GetNextPeerExpiration(t *testing.T) {
}, },
expiration: time.Second, expiration: time.Second,
expirationEnabled: false, expirationEnabled: false,
expectedNextRun: false, expectedNextRun: true,
expectedNextExpiration: time.Duration(0), expectedNextExpiration: time.Second,
},
{
name: "Offline peer with the earliest deadline defines the next run",
peers: map[string]*nbpeer.Peer{
"peer-1": {
Status: &nbpeer.PeerStatus{
Connected: true,
},
LoginExpirationEnabled: true,
LastLogin: util.ToPtr(time.Now().UTC()),
UserID: userID,
},
"peer-2": {
Status: &nbpeer.PeerStatus{
Connected: false,
},
LoginExpirationEnabled: true,
LastLogin: util.ToPtr(time.Now().UTC().Add(-50 * time.Minute)),
UserID: userID,
},
},
expiration: time.Hour,
expirationEnabled: true,
expectedNextRun: true,
expectedNextExpiration: 10 * time.Minute,
}, },
{ {
name: "Connected peers with disabled expiration, no expiration", name: "Connected peers with disabled expiration, no expiration",
+25 -5
View File
@@ -101,10 +101,8 @@ func (am *DefaultAccountManager) CreateGroup(ctx context.Context, accountID, use
return status.Errorf(status.Internal, "failed to create group: %v", err) return status.Errorf(status.Internal, "failed to create group: %v", err)
} }
for _, peerID := range newGroup.Peers { if err = syncGroupMembership(ctx, transaction, accountID, newGroup.ID, newGroup.Peers, nil); err != nil {
if err := transaction.AddPeerToGroup(ctx, accountID, peerID, newGroup.ID); err != nil { return err
return status.Errorf(status.Internal, "failed to add peer %s to group %s: %v", peerID, newGroup.ID, err)
}
} }
snap, err = affectedpeers.Load(ctx, transaction, accountID, change) snap, err = affectedpeers.Load(ctx, transaction, accountID, change)
@@ -200,6 +198,9 @@ func (am *DefaultAccountManager) UpdateGroup(ctx context.Context, accountID, use
// syncGroupMembership applies the peer membership delta for a group within a transaction. // syncGroupMembership applies the peer membership delta for a group within a transaction.
func syncGroupMembership(ctx context.Context, transaction store.Store, accountID, groupID string, peersToAdd, peersToRemove []string) error { func syncGroupMembership(ctx context.Context, transaction store.Store, accountID, groupID string, peersToAdd, peersToRemove []string) error {
if err := validateGroupPeers(ctx, transaction, accountID, peersToAdd); err != nil {
return err
}
for _, peerID := range peersToAdd { for _, peerID := range peersToAdd {
if err := transaction.AddPeerToGroup(ctx, accountID, peerID, groupID); err != nil { if err := transaction.AddPeerToGroup(ctx, accountID, peerID, groupID); err != nil {
return status.Errorf(status.Internal, "failed to add peer %s to group %s: %v", peerID, groupID, err) return status.Errorf(status.Internal, "failed to add peer %s to group %s: %v", peerID, groupID, err)
@@ -213,6 +214,25 @@ func syncGroupMembership(ctx context.Context, transaction store.Store, accountID
return nil return nil
} }
func validateGroupPeers(ctx context.Context, transaction store.Store, accountID string, peerIDs []string) error {
if len(peerIDs) == 0 {
return nil
}
peers, err := transaction.GetPeersByIDs(ctx, store.LockingStrengthNone, accountID, peerIDs)
if err != nil {
return err
}
for _, peerID := range peerIDs {
if _, ok := peers[peerID]; !ok {
return status.Errorf(status.InvalidArgument, "peer with ID %s not found", peerID)
}
}
return nil
}
// CreateGroups adds new groups to the account. // CreateGroups adds new groups to the account.
// Note: This function does not acquire the global lock. // Note: This function does not acquire the global lock.
// It is the caller's responsibility to ensure proper locking is in place before invoking this method. // It is the caller's responsibility to ensure proper locking is in place before invoking this method.
@@ -540,7 +560,7 @@ func (am *DefaultAccountManager) GroupAddPeer(ctx context.Context, accountID, gr
change := affectedpeers.Change{OutputPeerIDs: []string{peerID}, LinkGroups: []string{groupID}} change := affectedpeers.Change{OutputPeerIDs: []string{peerID}, LinkGroups: []string{groupID}}
err := am.Store.ExecuteInTransaction(ctx, func(transaction store.Store) error { err := am.Store.ExecuteInTransaction(ctx, func(transaction store.Store) error {
if err := transaction.AddPeerToGroup(ctx, accountID, peerID, groupID); err != nil { if err := syncGroupMembership(ctx, transaction, accountID, groupID, []string{peerID}, nil); err != nil {
return err return err
} }
+80 -1
View File
@@ -11,10 +11,10 @@ import (
"testing" "testing"
"time" "time"
"go.uber.org/mock/gomock"
"github.com/google/uuid" "github.com/google/uuid"
"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"
"golang.org/x/exp/maps" "golang.org/x/exp/maps"
nbdns "github.com/netbirdio/netbird/dns" nbdns "github.com/netbirdio/netbird/dns"
@@ -1236,3 +1236,82 @@ func Test_IncrementNetworkSerial(t *testing.T) {
assert.Equal(t, totalPeers, int(account.Network.Serial), "Expected %d serial increases in account %s, got %d", totalPeers, accountID, account.Network.Serial) assert.Equal(t, totalPeers, int(account.Network.Serial), "Expected %d serial increases in account %s, got %d", totalPeers, accountID, account.Network.Serial)
} }
func TestDefaultAccountManager_GroupPeersMustBelongToAccount(t *testing.T) {
manager, _, account, peer1, _, _ := setupNetworkMapTest(t)
otherAccount, err := createAccount(manager, "other_account", "other_user", "")
require.NoError(t, err)
foreignPeer := &peer2.Peer{
ID: "foreign-peer",
AccountID: otherAccount.Id,
Key: "foreign-key",
DNSLabel: "foreign-peer",
IP: uint32ToIP(1),
}
require.NoError(t, manager.Store.AddPeerToAccount(context.Background(), foreignPeer))
assertRejected := func(t *testing.T, err error) {
t.Helper()
require.Error(t, err)
s, ok := status.FromError(err)
require.True(t, ok, "expected status error, got %v", err)
assert.Equal(t, status.InvalidArgument, s.Type(), "peer outside the account should be rejected as invalid argument")
}
t.Run("create rejects foreign peer", func(t *testing.T) {
err := manager.CreateGroup(context.Background(), account.Id, userID, &types.Group{
Name: "foreign",
Issued: types.GroupIssuedAPI,
Peers: []string{peer1.ID, foreignPeer.ID},
})
assertRejected(t, err)
_, err = manager.Store.GetGroupByName(context.Background(), store.LockingStrengthNone, account.Id, "foreign")
assert.Error(t, err, "rejected create must not persist the group")
})
t.Run("update rejects foreign and unknown peers", func(t *testing.T) {
group := &types.Group{ID: "own", Name: "own", Issued: types.GroupIssuedAPI, Peers: []string{peer1.ID}}
require.NoError(t, manager.CreateGroup(context.Background(), account.Id, userID, group))
group.Peers = []string{peer1.ID, foreignPeer.ID}
assertRejected(t, manager.UpdateGroup(context.Background(), account.Id, userID, group))
group.Peers = []string{peer1.ID, "does-not-exist"}
assertRejected(t, manager.UpdateGroup(context.Background(), account.Id, userID, group))
stored, err := manager.Store.GetGroupByID(context.Background(), store.LockingStrengthNone, account.Id, group.ID)
require.NoError(t, err)
assert.Equal(t, []string{peer1.ID}, stored.Peers, "rejected updates must not change membership")
})
t.Run("update tolerates and drops pre-existing dangling members", func(t *testing.T) {
group := &types.Group{ID: "polluted", Name: "polluted", Issued: types.GroupIssuedAPI, Peers: []string{peer1.ID}}
require.NoError(t, manager.CreateGroup(context.Background(), account.Id, userID, group))
require.NoError(t, manager.Store.AddPeerToGroup(context.Background(), account.Id, foreignPeer.ID, group.ID))
group.Peers = []string{peer1.ID, foreignPeer.ID}
assert.NoError(t, manager.UpdateGroup(context.Background(), account.Id, userID, group), "keeping an existing member must not be rejected")
group.Peers = []string{peer1.ID}
require.NoError(t, manager.UpdateGroup(context.Background(), account.Id, userID, group))
stored, err := manager.Store.GetGroupByID(context.Background(), store.LockingStrengthNone, account.Id, group.ID)
require.NoError(t, err)
assert.Equal(t, []string{peer1.ID}, stored.Peers, "dangling member should be removed once omitted")
})
t.Run("direct add rejects foreign and unknown peers", func(t *testing.T) {
group := &types.Group{ID: "direct", Name: "direct", Issued: types.GroupIssuedAPI, Peers: []string{peer1.ID}}
require.NoError(t, manager.CreateGroup(context.Background(), account.Id, userID, group))
assertRejected(t, manager.GroupAddPeer(context.Background(), account.Id, group.ID, foreignPeer.ID))
assertRejected(t, manager.GroupAddPeer(context.Background(), account.Id, group.ID, "does-not-exist"))
stored, err := manager.Store.GetGroupByID(context.Background(), store.LockingStrengthNone, account.Id, group.ID)
require.NoError(t, err)
assert.Equal(t, []string{peer1.ID}, stored.Peers, "rejected direct adds must not change membership")
})
}
+10 -6
View File
@@ -1494,9 +1494,12 @@ func checkAuth(ctx context.Context, loginUserID string, peer *nbpeer.Peer) error
func peerLoginExpired(ctx context.Context, peer *nbpeer.Peer, settings *types.Settings) bool { func peerLoginExpired(ctx context.Context, peer *nbpeer.Peer, settings *types.Settings) bool {
expired, expiresIn := peer.LoginExpired(settings.PeerLoginExpiration) expired, expiresIn := peer.LoginExpired(settings.PeerLoginExpiration)
expired = settings.PeerLoginExpirationEnabled && expired if settings.PeerLoginExpirationEnabled && expired {
if expired || peer.Status.LoginExpired { log.WithContext(ctx).Debugf("peer's %s login expired %v ago", peer.ID, -expiresIn)
log.WithContext(ctx).Debugf("peer's %s login expired %v ago", peer.ID, expiresIn) return true
}
if peer.Status.LoginExpired {
log.WithContext(ctx).Debugf("peer's %s login is marked as expired", peer.ID)
return true return true
} }
return false return false
@@ -1643,7 +1646,9 @@ func (am *DefaultAccountManager) UpdateAccountPeer(ctx context.Context, accountI
// getNextPeerExpiration returns the minimum duration in which the next peer of the account will expire if it was found. // getNextPeerExpiration returns the minimum duration in which the next peer of the account will expire if it was found.
// If there is no peer that expires this function returns false and a duration of 0. // If there is no peer that expires this function returns false and a duration of 0.
// This function only considers peers that haven't been expired yet and that are connected. // This function only considers peers that haven't been expired yet. Offline peers count too:
// a running job is never re-armed on connect, so a peer that reconnects with an old login
// must already be part of the scheduled run.
func (am *DefaultAccountManager) getNextPeerExpiration(ctx context.Context, accountID string) (time.Duration, bool) { func (am *DefaultAccountManager) getNextPeerExpiration(ctx context.Context, accountID string) (time.Duration, bool) {
peersWithExpiry, err := am.Store.GetAccountPeersWithExpiration(ctx, store.LockingStrengthNone, accountID) peersWithExpiry, err := am.Store.GetAccountPeersWithExpiration(ctx, store.LockingStrengthNone, accountID)
if err != nil { if err != nil {
@@ -1663,8 +1668,7 @@ func (am *DefaultAccountManager) getNextPeerExpiration(ctx context.Context, acco
var nextExpiry *time.Duration var nextExpiry *time.Duration
for _, peer := range peersWithExpiry { for _, peer := range peersWithExpiry {
// consider only connected peers because others will require login on connecting to the management server if peer.Status.LoginExpired {
if peer.Status.LoginExpired || !peer.Status.Connected {
continue continue
} }
_, duration := peer.LoginExpired(settings.PeerLoginExpiration) _, duration := peer.LoginExpired(settings.PeerLoginExpiration)
+7 -2
View File
@@ -117,6 +117,7 @@ func (wm *DefaultScheduler) Schedule(ctx context.Context, in time.Duration, ID s
} }
ticker := time.NewTicker(in) ticker := time.NewTicker(in)
period := in
wm.jobs[ID] = cancel wm.jobs[ID] = cancel
log.WithContext(ctx).Debugf("scheduled a job %s to run in %s. There are %d total jobs scheduled.", ID, in.String(), len(wm.jobs)) log.WithContext(ctx).Debugf("scheduled a job %s to run in %s. There are %d total jobs scheduled.", ID, in.String(), len(wm.jobs))
@@ -136,14 +137,18 @@ func (wm *DefaultScheduler) Schedule(ctx context.Context, in time.Duration, ID s
if !reschedule { if !reschedule {
wm.mu.Lock() wm.mu.Lock()
defer wm.mu.Unlock() defer wm.mu.Unlock()
delete(wm.jobs, ID) // A Cancel during job() may have registered a replacement under this ID.
if current, ok := wm.jobs[ID]; ok && current == cancel {
delete(wm.jobs, ID)
}
log.WithContext(ctx).Debugf("job %s is not scheduled to run again", ID) log.WithContext(ctx).Debugf("job %s is not scheduled to run again", ID)
ticker.Stop() ticker.Stop()
return return
} }
// we need this comparison to avoid resetting the ticker with the same duration and missing the current elapsesed time // we need this comparison to avoid resetting the ticker with the same duration and missing the current elapsesed time
if runIn != in { if runIn != period {
ticker.Reset(runIn) ticker.Reset(runIn)
period = runIn
} }
case <-cancel: case <-cancel:
log.WithContext(ctx).Debugf("job %s was canceled, stopping timer", ID) log.WithContext(ctx).Debugf("job %s was canceled, stopping timer", ID)
+89
View File
@@ -6,10 +6,12 @@ import (
"math/rand" "math/rand"
"runtime" "runtime"
"sync" "sync"
"sync/atomic"
"testing" "testing"
"time" "time"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
) )
func TestScheduler_Performance(t *testing.T) { func TestScheduler_Performance(t *testing.T) {
@@ -150,3 +152,90 @@ func TestScheduler_Schedule(t *testing.T) {
scheduler.cancel(context.Background(), jobID) scheduler.cancel(context.Background(), jobID)
} }
func TestScheduler_Schedule_ResetsTickerAfterReturningInitialInterval(t *testing.T) {
jobID := "test-scheduler-job-2"
scheduler := NewDefaultScheduler()
defer scheduler.Cancel(context.Background(), []string{jobID})
initial := 30 * time.Millisecond
stretched := 400 * time.Millisecond
runs := make(chan time.Time, 3)
count := 0
// The first run stretches the period; the second returns the initial interval again,
// which must shrink the period back instead of keeping the stretched one.
job := func() (nextRunIn time.Duration, reschedule bool) {
count++
runs <- time.Now()
switch count {
case 1:
return stretched, true
case 2:
return initial, true
default:
return 0, false
}
}
scheduler.Schedule(context.Background(), initial, jobID, job)
var stamps []time.Time
for len(stamps) < 3 {
select {
case ts := <-runs:
stamps = append(stamps, ts)
case <-time.After(2 * time.Second):
t.Fatalf("timed out after %d runs", len(stamps))
}
}
assert.Less(t, stamps[2].Sub(stamps[1]), stretched/2, "returning the initial interval must reset the stretched ticker")
}
func TestScheduler_Schedule_StaleCompletionKeepsReplacement(t *testing.T) {
jobID := "test-scheduler-job-3"
scheduler := NewDefaultScheduler()
defer scheduler.Cancel(context.Background(), []string{jobID})
started := make(chan struct{})
release := make(chan struct{})
staleJob := func() (nextRunIn time.Duration, reschedule bool) {
close(started)
<-release
return 0, false
}
scheduler.Schedule(context.Background(), 10*time.Millisecond, jobID, staleJob)
select {
case <-started:
case <-time.After(time.Second):
t.Fatal("timed out waiting for the first job to start")
}
// Cancel the job while it is still executing and register a replacement under the
// same ID, as the expiration paths do on a settings change.
scheduler.Cancel(context.Background(), []string{jobID})
var replacementRuns atomic.Int32
scheduler.Schedule(context.Background(), 20*time.Millisecond, jobID, func() (nextRunIn time.Duration, reschedule bool) {
replacementRuns.Add(1)
return 20 * time.Millisecond, true
})
require.True(t, scheduler.IsSchedulerRunning(jobID), "replacement must be registered")
// The stale job now completes without rescheduling; its cleanup must leave the
// replacement's entry in place.
close(release)
assert.Never(t, func() bool { return !scheduler.IsSchedulerRunning(jobID) }, 200*time.Millisecond, 10*time.Millisecond,
"stale completion must not drop the replacement job")
var duplicateRuns atomic.Int32
scheduler.Schedule(context.Background(), 10*time.Millisecond, jobID, func() (nextRunIn time.Duration, reschedule bool) {
duplicateRuns.Add(1)
return 10 * time.Millisecond, true
})
assert.Never(t, func() bool { return duplicateRuns.Load() > 0 }, 100*time.Millisecond, 10*time.Millisecond,
"a duplicate schedule must be refused while the replacement is registered")
scheduler.Cancel(context.Background(), []string{jobID})
assert.False(t, scheduler.IsSchedulerRunning(jobID), "cancel must find and remove the replacement")
runsAfterCancel := replacementRuns.Load()
assert.Never(t, func() bool { return replacementRuns.Load() > runsAfterCancel+1 }, 150*time.Millisecond, 10*time.Millisecond,
"the replacement must stop after cancel")
}
+31 -2
View File
@@ -3476,7 +3476,7 @@ func (s *SqlStore) GetPeerGroups(ctx context.Context, lockStrength LockingStreng
var groups []*types.Group var groups []*types.Group
query := tx. query := tx.
Joins("JOIN group_peers ON group_peers.group_id = groups.id"). Joins("JOIN group_peers ON group_peers.group_id = groups.id").
Where("group_peers.peer_id = ?", peerId). Where("groups.account_id = ? AND group_peers.peer_id = ?", accountId, peerId).
Preload(clause.Associations). Preload(clause.Associations).
Find(&groups) Find(&groups)
@@ -5056,7 +5056,7 @@ func (s *SqlStore) GetPeersByGroupIDs(ctx context.Context, accountID string, gro
Select("DISTINCT peer_id"). Select("DISTINCT peer_id").
Where("account_id = ? AND group_id IN ?", accountID, groupIDs) Where("account_id = ? AND group_id IN ?", accountID, groupIDs)
result := s.db.Where("id IN (?)", peerIDsSubquery).Find(&peers) result := s.db.Where("account_id = ? AND id IN (?)", accountID, peerIDsSubquery).Find(&peers)
if result.Error != nil { if result.Error != nil {
log.WithContext(ctx).Errorf("failed to get peers by group IDs: %s", result.Error) log.WithContext(ctx).Errorf("failed to get peers by group IDs: %s", result.Error)
return nil, status.Errorf(status.Internal, "failed to get peers by group IDs") return nil, status.Errorf(status.Internal, "failed to get peers by group IDs")
@@ -5689,6 +5689,23 @@ func (s *SqlStore) ListCustomDomains(ctx context.Context, accountID string) ([]*
return domains, nil return domains, nil
} }
// GetCustomDomainByName returns the custom domain row holding the given name,
// regardless of which account owns it.
func (s *SqlStore) GetCustomDomainByName(ctx context.Context, domainName string) (*domain.Domain, error) {
customDomain := &domain.Domain{}
result := s.db.Take(customDomain, "domain = ?", domainName)
if result.Error != nil {
if errors.Is(result.Error, gorm.ErrRecordNotFound) {
return nil, status.Errorf(status.NotFound, "custom domain %s not found", domainName)
}
log.WithContext(ctx).Errorf("failed to get custom domain by name from store: %v", result.Error)
return nil, status.Errorf(status.Internal, "failed to get custom domain from store")
}
return customDomain, nil
}
func (s *SqlStore) CreateCustomDomain(ctx context.Context, accountID string, domainName string, targetCluster string, validated bool) (*domain.Domain, error) { func (s *SqlStore) CreateCustomDomain(ctx context.Context, accountID string, domainName string, targetCluster string, validated bool) (*domain.Domain, error) {
newDomain := &domain.Domain{ newDomain := &domain.Domain{
ID: xid.New().String(), // Generate our own ID because gorm doesn't always configure the database to handle this for us. ID: xid.New().String(), // Generate our own ID because gorm doesn't always configure the database to handle this for us.
@@ -5700,6 +5717,18 @@ func (s *SqlStore) CreateCustomDomain(ctx context.Context, accountID string, dom
} }
result := s.db.Create(newDomain) result := s.db.Create(newDomain)
if result.Error != nil { if result.Error != nil {
// The unique index is the last guard when two requests clear the
// manager's availability check at the same time. The one that loses the
// insert is a conflict, not an internal failure.
var count int64
if err := s.db.Model(&domain.Domain{}).Where("domain = ?", domainName).Count(&count).Error; err == nil && count > 0 {
// The insert error is logged even on this path: the name being taken
// is what the caller has to act on, but if the insert also failed for
// an unrelated reason the operator still needs to see it.
log.WithContext(ctx).Warnf("create reverse proxy custom domain %s rejected, name already registered: %v", domainName, result.Error)
return nil, status.Errorf(status.AlreadyExists, "domain %s is already registered", domainName)
}
log.WithContext(ctx).Errorf("failed to create reverse proxy custom domain to store: %v", result.Error) log.WithContext(ctx).Errorf("failed to create reverse proxy custom domain to store: %v", result.Error)
return nil, status.Errorf(status.Internal, "failed to create reverse proxy custom domain to store") return nil, status.Errorf(status.Internal, "failed to create reverse proxy custom domain to store")
} }
+14
View File
@@ -2844,6 +2844,14 @@ func TestSqlStore_GetPeerGroups(t *testing.T) {
groups, err = store.GetPeerGroups(context.Background(), LockingStrengthNone, accountID, peerID) groups, err = store.GetPeerGroups(context.Background(), LockingStrengthNone, accountID, peerID)
require.NoError(t, err) require.NoError(t, err)
assert.Len(t, groups, 2) assert.Len(t, groups, 2)
foreignPeerID := "foreign-peer"
err = store.AddPeerToGroup(context.Background(), accountID, foreignPeerID, "cfefqs706sqkneg59g4h")
require.NoError(t, err)
groups, err = store.GetPeerGroups(context.Background(), LockingStrengthNone, "other-account", foreignPeerID)
require.NoError(t, err)
assert.Empty(t, groups, "groups of another account must not be returned")
} }
func TestSqlStore_GetAccountPeers(t *testing.T) { func TestSqlStore_GetAccountPeers(t *testing.T) {
@@ -4039,9 +4047,15 @@ func TestSqlStore_GetPeersByGroupIDs(t *testing.T) {
} }
require.NoError(t, store.CreateGroups(ctx, accountID, groups)) require.NoError(t, store.CreateGroups(ctx, accountID, groups))
otherAccount := newAccountWithId(ctx, "other-account", "other-user", "")
require.NoError(t, store.SaveAccount(ctx, otherAccount))
foreignPeer := &nbpeer.Peer{ID: "foreign-peer", AccountID: otherAccount.Id}
require.NoError(t, store.AddPeerToAccount(ctx, foreignPeer))
require.NoError(t, store.AddPeerToGroup(ctx, accountID, peer1, group1ID)) require.NoError(t, store.AddPeerToGroup(ctx, accountID, peer1, group1ID))
require.NoError(t, store.AddPeerToGroup(ctx, accountID, peer2, group1ID)) require.NoError(t, store.AddPeerToGroup(ctx, accountID, peer2, group1ID))
require.NoError(t, store.AddPeerToGroup(ctx, accountID, peer1, group2ID)) require.NoError(t, store.AddPeerToGroup(ctx, accountID, peer1, group2ID))
require.NoError(t, store.AddPeerToGroup(ctx, accountID, foreignPeer.ID, group1ID))
peers, err := store.GetPeersByGroupIDs(ctx, accountID, tt.groupIDs) peers, err := store.GetPeersByGroupIDs(ctx, accountID, tt.groupIDs)
require.NoError(t, err) require.NoError(t, err)
+1
View File
@@ -302,6 +302,7 @@ type Store interface {
GetCustomDomain(ctx context.Context, accountID string, domainID string) (*domain.Domain, error) GetCustomDomain(ctx context.Context, accountID string, domainID string) (*domain.Domain, error)
ListFreeDomains(ctx context.Context, accountID string) ([]string, error) ListFreeDomains(ctx context.Context, accountID string) ([]string, error)
ListCustomDomains(ctx context.Context, accountID string) ([]*domain.Domain, error) ListCustomDomains(ctx context.Context, accountID string) ([]*domain.Domain, error)
GetCustomDomainByName(ctx context.Context, domainName string) (*domain.Domain, error)
CreateCustomDomain(ctx context.Context, accountID string, domainName string, targetCluster string, validated bool) (*domain.Domain, error) CreateCustomDomain(ctx context.Context, accountID string, domainName string, targetCluster string, validated bool) (*domain.Domain, error)
UpdateCustomDomain(ctx context.Context, accountID string, d *domain.Domain) (*domain.Domain, error) UpdateCustomDomain(ctx context.Context, accountID string, d *domain.Domain) (*domain.Domain, error)
DeleteCustomDomain(ctx context.Context, accountID string, domainID string) error DeleteCustomDomain(ctx context.Context, accountID string, domainID string) error
+15
View File
@@ -1941,6 +1941,21 @@ func (mr *MockStoreMockRecorder) GetCustomDomain(ctx, accountID, domainID any) *
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetCustomDomain", reflect.TypeOf((*MockStore)(nil).GetCustomDomain), ctx, accountID, domainID) return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetCustomDomain", reflect.TypeOf((*MockStore)(nil).GetCustomDomain), ctx, accountID, domainID)
} }
// GetCustomDomainByName mocks base method.
func (m *MockStore) GetCustomDomainByName(ctx context.Context, domainName string) (*domain.Domain, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "GetCustomDomainByName", ctx, domainName)
ret0, _ := ret[0].(*domain.Domain)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// GetCustomDomainByName indicates an expected call of GetCustomDomainByName.
func (mr *MockStoreMockRecorder) GetCustomDomainByName(ctx, domainName any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetCustomDomainByName", reflect.TypeOf((*MockStore)(nil).GetCustomDomainByName), ctx, domainName)
}
// GetCustomDomainsCounts mocks base method. // GetCustomDomainsCounts mocks base method.
func (m *MockStore) GetCustomDomainsCounts(ctx context.Context) (int64, int64, error) { func (m *MockStore) GetCustomDomainsCounts(ctx context.Context) (int64, int64, error) {
m.ctrl.T.Helper() m.ctrl.T.Helper()
+2 -3
View File
@@ -404,7 +404,7 @@ func (a *Account) GetExpiredPeers() []*nbpeer.Peer {
// GetNextPeerExpiration returns the minimum duration in which the next peer of the account will expire if it was found. // GetNextPeerExpiration returns the minimum duration in which the next peer of the account will expire if it was found.
// If there is no peer that expires this function returns false and a duration of 0. // If there is no peer that expires this function returns false and a duration of 0.
// This function only considers peers that haven't been expired yet and that are connected. // This function only considers peers that haven't been expired yet, whether connected or not.
func (a *Account) GetNextPeerExpiration() (time.Duration, bool) { func (a *Account) GetNextPeerExpiration() (time.Duration, bool) {
peersWithExpiry := a.GetPeersWithExpiration() peersWithExpiry := a.GetPeersWithExpiration()
if len(peersWithExpiry) == 0 { if len(peersWithExpiry) == 0 {
@@ -412,8 +412,7 @@ func (a *Account) GetNextPeerExpiration() (time.Duration, bool) {
} }
var nextExpiry *time.Duration var nextExpiry *time.Duration
for _, peer := range peersWithExpiry { for _, peer := range peersWithExpiry {
// consider only connected peers because others will require login on connecting to the management server if peer.Status.LoginExpired {
if peer.Status.LoginExpired || !peer.Status.Connected {
continue continue
} }
_, duration := peer.LoginExpired(a.Settings.PeerLoginExpiration) _, duration := peer.LoginExpired(a.Settings.PeerLoginExpiration)
+65 -16
View File
@@ -1177,28 +1177,35 @@ func (am *DefaultAccountManager) expireAndUpdatePeers(ctx context.Context, accou
dnsDomain := am.networkMapController.GetDNSDomain(settings) dnsDomain := am.networkMapController.GetDNSDomain(settings)
var peerIDs []string var peerIDs []string
for _, peer := range peers { defer func() {
if len(peerIDs) == 0 {
return
}
// this will trigger peer disconnect from the management service
log.Debugf("Expiring %d peers for account %s", len(peerIDs), accountID)
am.networkMapController.DisconnectPeers(ctx, accountID, peerIDs)
}()
for _, candidate := range peers {
// nolint:staticcheck // nolint:staticcheck
ctx = context.WithValue(ctx, nbcontext.PeerIDKey, peer.Key) peerCtx := context.WithValue(ctx, nbcontext.PeerIDKey, candidate.Key)
if peer.UserID == "" { if candidate.UserID == "" {
// we do not want to expire peers that are added via setup key // we do not want to expire peers that are added via setup key
continue continue
} }
if peer.Status.LoginExpired { peer, err := am.expirePeerIfStillDue(peerCtx, accountID, candidate.ID, settings, reason)
if err != nil {
return err
}
if peer == nil {
continue continue
} }
peerIDs = append(peerIDs, peer.ID) peerIDs = append(peerIDs, peer.ID)
peer.MarkLoginExpired(true)
if err := am.Store.SavePeerStatus(ctx, accountID, peer.ID, *peer.Status); err != nil {
return err
}
meta := peer.EventMeta(dnsDomain) meta := peer.EventMeta(dnsDomain)
meta["reason"] = string(reason) meta["reason"] = string(reason)
am.StoreEvent( am.StoreEvent(
ctx, peerCtx,
peer.UserID, peer.ID, accountID, peer.UserID, peer.ID, accountID,
activity.PeerLoginExpired, meta, activity.PeerLoginExpired, meta,
) )
@@ -1215,15 +1222,53 @@ func (am *DefaultAccountManager) expireAndUpdatePeers(ctx context.Context, accou
if err != nil { if err != nil {
return fmt.Errorf("notify network map controller of peer update: %w", err) return fmt.Errorf("notify network map controller of peer update: %w", err)
} }
if len(peerIDs) != 0 {
// this will trigger peer disconnect from the management service
log.Debugf("Expiring %d peers for account %s", len(peerIDs), accountID)
am.networkMapController.DisconnectPeers(ctx, accountID, peerIDs)
}
return nil return nil
} }
// expirePeerIfStillDue flags the peer as login-expired and returns its fresh copy, or nil
// when it no longer qualifies. The candidate list is read without a lock, so a login that
// landed in between would otherwise be overwritten with a stale expired status.
func (am *DefaultAccountManager) expirePeerIfStillDue(ctx context.Context, accountID, peerID string, settings *types.Settings, reason peerExpirationReason) (*nbpeer.Peer, error) {
var expired *nbpeer.Peer
err := am.Store.ExecuteInTransaction(ctx, func(transaction store.Store) error {
peer, err := transaction.GetPeerByID(ctx, store.LockingStrengthUpdate, accountID, peerID)
if err != nil {
if s, ok := status.FromError(err); ok && s.Type() == status.NotFound {
return nil
}
return err
}
if peer.Status.LoginExpired || !peerExpirationDue(peer, settings, reason) {
return nil
}
peer.MarkLoginExpired(true)
if err := transaction.SavePeerStatus(ctx, accountID, peer.ID, *peer.Status); err != nil {
return err
}
expired = peer
return nil
})
if err != nil {
return nil, err
}
return expired, nil
}
// peerExpirationDue re-evaluates a time-based expiry against the peer's current state.
// Administrative reasons expire the peer unconditionally.
func peerExpirationDue(peer *nbpeer.Peer, settings *types.Settings, reason peerExpirationReason) bool {
switch reason {
case peerExpirationSessionExpired:
expired, _ := peer.LoginExpired(settings.PeerLoginExpiration)
return settings.PeerLoginExpirationEnabled && expired
case peerExpirationInactivity:
expired, _ := peer.SessionExpired(settings.PeerInactivityExpiration)
return settings.PeerInactivityExpirationEnabled && expired
default:
return true
}
}
func (am *DefaultAccountManager) deleteUserFromIDP(ctx context.Context, targetUserID, accountID string) error { func (am *DefaultAccountManager) deleteUserFromIDP(ctx context.Context, targetUserID, accountID string) error {
if am.userDeleteFromIDPEnabled { if am.userDeleteFromIDPEnabled {
log.WithContext(ctx).Debugf("user %s deleted from IdP", targetUserID) log.WithContext(ctx).Debugf("user %s deleted from IdP", targetUserID)
@@ -1337,6 +1382,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)
+43
View File
@@ -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 {
+12 -4
View File
@@ -2,12 +2,14 @@ package acme
import ( import (
"context" "context"
"fmt"
"path/filepath" "path/filepath"
log "github.com/sirupsen/logrus" log "github.com/sirupsen/logrus"
"github.com/netbirdio/netbird/proxy/internal/flock" "github.com/netbirdio/netbird/proxy/internal/flock"
"github.com/netbirdio/netbird/proxy/internal/k8s" "github.com/netbirdio/netbird/proxy/internal/k8s"
"github.com/netbirdio/netbird/shared/management/domain"
) )
// certLocker provides distributed mutual exclusion for certificate operations. // certLocker provides distributed mutual exclusion for certificate operations.
@@ -74,9 +76,15 @@ func newFlockLocker(certDir string, logger *log.Logger) *flockLocker {
return &flockLocker{certDir: certDir, logger: logger} return &flockLocker{certDir: certDir, logger: logger}
} }
// Lock acquires an advisory file lock for the given domain. // Lock acquires an advisory file lock for the given domain. The domain must
func (l *flockLocker) Lock(ctx context.Context, domain string) (func(), error) { // be a valid hostname so the lock file always resolves to a direct child of
lockPath := filepath.Join(l.certDir, domain+".lock") // certDir; anything else is rejected before touching the filesystem.
func (l *flockLocker) Lock(ctx context.Context, name string) (func(), error) {
if !domain.IsValidDomainNoWildcard(name) {
return nil, fmt.Errorf("invalid domain %q for lock file", name)
}
lockPath := filepath.Join(l.certDir, name+".lock")
lockFile, err := flock.Lock(ctx, lockPath) lockFile, err := flock.Lock(ctx, lockPath)
if err != nil { if err != nil {
return nil, err return nil, err
@@ -89,7 +97,7 @@ func (l *flockLocker) Lock(ctx context.Context, domain string) (func(), error) {
return func() { return func() {
if err := flock.Unlock(lockFile); err != nil { if err := flock.Unlock(lockFile); err != nil {
l.logger.Debugf("release cert lock for domain %q: %v", domain, err) l.logger.Debugf("release cert lock for domain %q: %v", name, err)
} }
}, nil }, nil
} }
+30
View File
@@ -63,3 +63,33 @@ func TestNewCertLockerK8sFallsBackToFlock(t *testing.T) {
_, ok := locker.(*flockLocker) _, ok := locker.(*flockLocker)
assert.True(t, ok, "k8s-lease without SA should fall back to flockLocker") assert.True(t, ok, "k8s-lease without SA should fall back to flockLocker")
} }
func TestFlockLockerRejectsUnsafeDomain(t *testing.T) {
root := t.TempDir()
certDir := filepath.Join(root, "certs")
require.NoError(t, os.Mkdir(certDir, 0o700))
locker := newFlockLocker(certDir, nil)
for _, d := range []string{
"",
".",
"..",
"../escape",
"../../etc/cron.d/attacker",
"sub/dir.example.com",
`back\slash.example.com`,
"*.example.com",
} {
unlock, err := locker.Lock(context.Background(), d)
assert.Error(t, err, "domain %q", d)
assert.Nil(t, unlock, "domain %q", d)
}
assert.NoFileExists(t, filepath.Join(root, "escape.lock"))
certEntries, err := os.ReadDir(certDir)
require.NoError(t, err)
assert.Empty(t, certEntries)
rootEntries, err := os.ReadDir(root)
require.NoError(t, err)
assert.Len(t, rootEntries, 1)
}
+176 -13
View File
@@ -105,6 +105,20 @@ type Handler struct {
startTime time.Time startTime time.Time
templates *template.Template templates *template.Template
templateMu sync.RWMutex templateMu sync.RWMutex
// setPerformance applies a buffer cap to one client. Held as a field so
// tests can drive applyBufferCap without a live embedded client.
setPerformance func(*nbembed.Client, uint32) error
perfMu sync.Mutex
perfInflight map[types.AccountID]*perfWorker
}
// perfWorker is the single in-flight retune for one account. err is valid once
// done is closed.
type perfWorker struct {
done chan struct{}
err error
} }
// NewHandler creates a new debug handler. // NewHandler creates a new debug handler.
@@ -113,10 +127,11 @@ func NewHandler(provider clientProvider, healthChecker healthChecker, logger *lo
logger = log.StandardLogger() logger = log.StandardLogger()
} }
h := &Handler{ h := &Handler{
provider: provider, provider: provider,
health: healthChecker, health: healthChecker,
logger: logger, logger: logger,
startTime: time.Now(), startTime: time.Now(),
setPerformance: setClientPerformance,
} }
if err := h.loadTemplates(); err != nil { if err := h.loadTemplates(); err != nil {
logger.Errorf("failed to load embedded templates: %v", err) logger.Errorf("failed to load embedded templates: %v", err)
@@ -716,15 +731,7 @@ func (h *Handler) handlePerf(w http.ResponseWriter, r *http.Request) {
} }
capN := uint32(n) capN := uint32(n)
applied := 0 applied, failed, inFlight := h.applyBufferCap(capN)
failed := map[string]string{}
for accountID, client := range h.provider.ListClientsForStartup() {
if err := client.SetPerformance(nbembed.Performance{PreallocatedBuffersPerPool: &capN}); err != nil {
failed[string(accountID)] = err.Error()
continue
}
applied++
}
resp := map[string]any{ resp := map[string]any{
"success": true, "success": true,
@@ -734,9 +741,165 @@ func (h *Handler) handlePerf(w http.ResponseWriter, r *http.Request) {
if len(failed) > 0 { if len(failed) > 0 {
resp["failed"] = failed resp["failed"] = failed
} }
if len(inFlight) > 0 {
resp["in_flight"] = inFlight
}
h.writeJSON(w, resp) h.writeJSON(w, resp)
} }
// perfApplyTimeout bounds the whole apply, however many clients are registered.
// A var, not a const, so tests can shorten the wait.
var perfApplyTimeout = 5 * time.Second
type perfResult struct {
accountID types.AccountID
err error
}
// setClientPerformance is the production implementation behind Handler.setPerformance.
func setClientPerformance(client *nbembed.Client, capN uint32) error {
return client.SetPerformance(nbembed.Performance{PreallocatedBuffersPerPool: &capN})
}
// collectBuffered takes every result already sitting in the channel, removing
// those accounts from pending, and returns how many of them succeeded. It is
// called when the deadline fires: select picks at random among ready cases, so
// a result that landed in time would otherwise be reported as a timeout.
func collectBuffered(results <-chan perfResult, pending map[types.AccountID]*perfWorker, failed map[string]string) int {
applied := 0
for {
select {
case res := <-results:
delete(pending, res.accountID)
if res.err != nil {
failed[string(res.accountID)] = res.err.Error()
continue
}
applied++
default:
return applied
}
}
}
// resolvePending closes out the accounts still pending when the deadline fires.
// A worker whose done channel is closed has finished, whatever the results
// channel has managed to deliver, so its own error is the truth; the rest are
// genuinely still running and are reported as timed out. Returns how many of
// them had in fact succeeded.
func resolvePending(pending map[types.AccountID]*perfWorker, failed map[string]string) int {
applied := 0
for accountID, w := range pending {
select {
case <-w.done:
if w.err != nil {
failed[string(accountID)] = w.err.Error()
continue
}
applied++
default:
failed[string(accountID)] = fmt.Sprintf("timed out after %s waiting for the client", perfApplyTimeout)
}
}
return applied
}
// startPerfWorker returns the in-flight retune for the account, starting one if
// there is none. The bool reports whether this call started it.
//
// At most one retune runs per account at a time. A client wedged inside its own
// lock never returns, so without this a caller could add one permanently blocked
// goroutine per request just by retrying the endpoint.
func (h *Handler) startPerfWorker(accountID types.AccountID, client *nbembed.Client, capN uint32, results chan<- perfResult) (*perfWorker, bool) {
h.perfMu.Lock()
defer h.perfMu.Unlock()
if w, ok := h.perfInflight[accountID]; ok {
return w, false
}
w := &perfWorker{done: make(chan struct{})}
if h.perfInflight == nil {
h.perfInflight = make(map[types.AccountID]*perfWorker)
}
h.perfInflight[accountID] = w
go func() {
err := h.setPerformance(client, capN)
w.err = err
close(w.done)
// Publish before touching the registry: perfMu is taken once per
// account by every caller walking the fleet, so a finishing worker
// can queue behind a long apply and miss its own deadline.
results <- perfResult{accountID: accountID, err: err}
h.perfMu.Lock()
delete(h.perfInflight, accountID)
h.perfMu.Unlock()
}()
return w, true
}
// applyBufferCap sets the WireGuard buffer pool cap on every registered client
// and reports how many took it, a per-account error for those that did not, and
// the accounts whose earlier retune has not come back yet.
//
// Clients are handled concurrently and the wait is bounded: SetPerformance goes
// through the embedded client's lock, which Start and Stop hold for as long as
// they take - and on a wedged client Stop never returns. This endpoint is the
// recovery path for exactly that fleet, so one stuck account must neither delay
// the others nor accumulate goroutines across retries.
func (h *Handler) applyBufferCap(capN uint32) (int, map[string]string, []string) {
clients := h.provider.ListClientsForStartup()
results := make(chan perfResult, len(clients))
applied := 0
failed := map[string]string{}
var inFlight []string
pending := make(map[types.AccountID]*perfWorker, len(clients))
for accountID, client := range clients {
w, started := h.startPerfWorker(accountID, client, capN, results)
if started {
pending[accountID] = w
continue
}
// Another request owns this account's retune. Take its result if it
// has already landed, otherwise report it as still running instead of
// waiting on it again.
select {
case <-w.done:
if w.err != nil {
failed[string(accountID)] = w.err.Error()
continue
}
applied++
default:
inFlight = append(inFlight, string(accountID))
}
}
deadline := time.After(perfApplyTimeout)
for range len(pending) {
select {
case res := <-results:
delete(pending, res.accountID)
if res.err != nil {
failed[string(res.accountID)] = res.err.Error()
continue
}
applied++
case <-deadline:
applied += collectBuffered(results, pending, failed)
applied += resolvePending(pending, failed)
return applied, failed, inFlight
}
}
return applied, failed, inFlight
}
// handleRuntime returns cheap runtime and process stats. Safe to hit on a // handleRuntime returns cheap runtime and process stats. Safe to hit on a
// running proxy; does not read pprof profiles. // running proxy; does not read pprof profiles.
func (h *Handler) handleRuntime(w http.ResponseWriter, _ *http.Request) { func (h *Handler) handleRuntime(w http.ResponseWriter, _ *http.Request) {
+158
View File
@@ -0,0 +1,158 @@
package debug
import (
"context"
"errors"
"sync/atomic"
"testing"
"time"
nbembed "github.com/netbirdio/netbird/client/embed"
"github.com/netbirdio/netbird/proxy/internal/health"
"github.com/netbirdio/netbird/proxy/internal/roundtrip"
"github.com/netbirdio/netbird/proxy/internal/types"
)
// perfProvider serves a fixed set of accounts. The clients are nil: the tests
// drive Handler.setPerformance, which never dereferences them.
type perfProvider struct {
accounts []types.AccountID
}
func (p *perfProvider) GetClient(types.AccountID) (*nbembed.Client, bool) { return nil, false }
func (p *perfProvider) ListClientsForDebug() map[types.AccountID]roundtrip.ClientDebugInfo {
return nil
}
func (p *perfProvider) ListClientsForStartup() map[types.AccountID]*nbembed.Client {
out := make(map[types.AccountID]*nbembed.Client, len(p.accounts))
for _, id := range p.accounts {
out[id] = nil
}
return out
}
type stubHealth struct{}
func (stubHealth) ReadinessProbe() bool { return true }
func (stubHealth) StartupProbe(context.Context) bool { return true }
func (stubHealth) CheckClientsConnected(context.Context) (bool, map[types.AccountID]health.ClientHealth) {
return true, nil
}
func shortenPerfTimeout(t *testing.T, d time.Duration) {
t.Helper()
prev := perfApplyTimeout
perfApplyTimeout = d
t.Cleanup(func() { perfApplyTimeout = prev })
}
// TestCollectBufferedCountsResultsReadyAtTheDeadline covers the select-ordering
// trap: when the deadline fires, results already buffered must be counted, not
// reported as timeouts. Driving collectBuffered directly keeps it deterministic
// - through applyBufferCap the two select cases race by construction.
func TestCollectBufferedCountsResultsReadyAtTheDeadline(t *testing.T) {
results := make(chan perfResult, 3)
results <- perfResult{accountID: "ok"}
results <- perfResult{accountID: "broken", err: errors.New("boom")}
pending := map[types.AccountID]*perfWorker{
"ok": {done: make(chan struct{})},
"broken": {done: make(chan struct{})},
"wedged": {done: make(chan struct{})},
}
failed := map[string]string{}
applied := collectBuffered(results, pending, failed)
if applied != 1 {
t.Fatalf("applied = %d, want 1", applied)
}
if failed["broken"] != "boom" {
t.Fatalf("failed = %v, want the error recorded for \"broken\"", failed)
}
if _, ok := pending["wedged"]; !ok || len(pending) != 1 {
t.Fatalf("pending = %v, want only the account that never answered", pending)
}
}
// TestApplyBufferCapSingleFlightPerAccount covers the goroutine accumulation
// reported on PR #7452: repeated calls against a client stuck in its own lock
// must not start a second attempt for the same account.
func TestApplyBufferCapSingleFlightPerAccount(t *testing.T) {
shortenPerfTimeout(t, 50*time.Millisecond)
release := make(chan struct{})
t.Cleanup(func() { close(release) })
var calls atomic.Int32
h := &Handler{
provider: &perfProvider{accounts: []types.AccountID{"wedged"}},
health: stubHealth{},
setPerformance: func(_ *nbembed.Client, _ uint32) error {
calls.Add(1)
<-release
return nil
},
}
for i := range 5 {
applied, failed, inFlight := h.applyBufferCap(4096)
if applied != 0 {
t.Fatalf("call %d: applied = %d, want 0", i, applied)
}
if i == 0 {
if len(failed) != 1 {
t.Fatalf("first call: failed = %v, want the account reported as timed out", failed)
}
continue
}
if len(inFlight) != 1 {
t.Fatalf("call %d: inFlight = %v, want the account reported as still running", i, inFlight)
}
if len(failed) != 0 {
t.Fatalf("call %d: failed = %v, want empty while the retune is in flight", i, failed)
}
}
if got := calls.Load(); got != 1 {
t.Fatalf("setPerformance called %d times, want 1: each retry started another blocked worker", got)
}
}
// TestResolvePendingTrustsFinishedWorkers covers the reporting race cubic
// flagged on PR #7452: a retune that finished just before the deadline must be
// reported by its outcome, not as a timeout, whatever the results channel has
// delivered so far.
func TestResolvePendingTrustsFinishedWorkers(t *testing.T) {
ok := &perfWorker{done: make(chan struct{})}
close(ok.done)
broken := &perfWorker{done: make(chan struct{}), err: errors.New("boom")}
close(broken.done)
stillRunning := &perfWorker{done: make(chan struct{})}
pending := map[types.AccountID]*perfWorker{
"ok": ok,
"broken": broken,
"running": stillRunning,
}
failed := map[string]string{}
applied := resolvePending(pending, failed)
if applied != 1 {
t.Fatalf("applied = %d, want 1", applied)
}
if failed["broken"] != "boom" {
t.Fatalf("failed[broken] = %q, want the worker's own error", failed["broken"])
}
if _, ok := failed["ok"]; ok {
t.Fatalf("failed = %v, want no entry for the account that succeeded", failed)
}
if got := failed["running"]; got == "" || got == "boom" {
t.Fatalf("failed[running] = %q, want the timeout message", got)
}
}
+23 -18
View File
@@ -279,7 +279,7 @@ func (c *Client) Connect(ctx context.Context) error {
c.stateSubscription = NewPeersStateSubscription(c.log, c.relayConn, c.closeConnsByPeerID) c.stateSubscription = NewPeersStateSubscription(c.log, c.relayConn, c.closeConnsByPeerID)
c.log = c.log.WithField("relay", instanceURL.String()) c.log = c.log.WithField("relay", instanceURL.String())
c.log.Infof("relay connection established") c.log.Infof("relay connection established, server IP: %s", connectedIP(c.relayConn))
c.serviceIsRunning = true c.serviceIsRunning = true
@@ -364,23 +364,6 @@ func (c *Client) ServerInstanceURL() (string, error) {
return c.instanceURL.String(), nil return c.instanceURL.String(), nil
} }
// ConnectedIP returns the IP address of the live relay-server connection,
// extracted from the underlying socket's RemoteAddr. Zero value if not
// connected or if the address is not an IP literal.
func (c *Client) ConnectedIP() netip.Addr {
c.mu.Lock()
conn := c.relayConn
c.mu.Unlock()
if conn == nil {
return netip.Addr{}
}
addr := conn.RemoteAddr()
if addr == nil {
return netip.Addr{}
}
return extractIPLiteral(addr.String())
}
// SetOnDisconnectListener sets a function that will be called when the connection to the relay server is closed. // SetOnDisconnectListener sets a function that will be called when the connection to the relay server is closed.
func (c *Client) SetOnDisconnectListener(fn func(string)) { func (c *Client) SetOnDisconnectListener(fn func(string)) {
c.listenerMutex.Lock() c.listenerMutex.Lock()
@@ -777,6 +760,17 @@ func (c *Client) listenForStopEvents(ctx context.Context, hc *healthcheck.Receiv
} }
} }
func (c *Client) serverInstanceAddress() (string, netip.Addr, error) {
c.mu.Lock()
defer c.mu.Unlock()
addr, err := c.ServerInstanceURL()
if err != nil {
return "", netip.Addr{}, err
}
return addr, connectedIP(c.relayConn), nil
}
func (c *Client) closeAllConns() { func (c *Client) closeAllConns() {
for _, container := range c.conns { for _, container := range c.conns {
container.close() container.close()
@@ -923,6 +917,17 @@ func (c *Client) handlePeersWentOfflineMsg(buf []byte) {
c.stateSubscription.OnPeersWentOffline(peersID) c.stateSubscription.OnPeersWentOffline(peersID)
} }
func connectedIP(conn net.Conn) netip.Addr {
if conn == nil {
return netip.Addr{}
}
addr := conn.RemoteAddr()
if addr == nil {
return netip.Addr{}
}
return extractIPLiteral(addr.String())
}
// extractIPLiteral returns the IP from address forms produced by the relay // extractIPLiteral returns the IP from address forms produced by the relay
// dialers (URL or host:port). Zero value if the host is not an IP. // dialers (URL or host:port). Zero value if the host is not an IP.
func extractIPLiteral(s string) netip.Addr { func extractIPLiteral(s string) netip.Addr {
+20 -25
View File
@@ -8,6 +8,8 @@ import (
"testing" "testing"
"time" "time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.opentelemetry.io/otel" "go.opentelemetry.io/otel"
"github.com/netbirdio/netbird/client/iface" "github.com/netbirdio/netbird/client/iface"
@@ -68,18 +70,17 @@ func TestClient_ServerIPRecoversFromUnresolvableFQDN(t *testing.T) {
if !c.Ready() { if !c.Ready() {
t.Fatalf("client not ready after connect") t.Fatalf("client not ready after connect")
} }
if got := c.ConnectedIP(); got.String() != "127.0.0.1" { url, ip, err := c.serverInstanceAddress()
t.Fatalf("ConnectedIP = %q, want 127.0.0.1", got) require.NoError(t, err)
} assert.Equal(t, srvCfg.ExposedAddress, url, "relay URL must come from the handshake")
assert.Equal(t, netip.MustParseAddr("127.0.0.1"), ip, "relay IP must come from the connection")
}) })
} }
// TestClient_ConnectedIPAfterFQDNDial verifies ConnectedIP returns the // TestClient_ServerInstanceAddressAfterFQDNDial verifies the relay address
// resolved IP after a successful FQDN-based dial. The underlying socket's // includes the resolved IP after an FQDN dial. The dialer wrappers must expose
// RemoteAddr must be exposed through the dialer wrappers; if it returns // the socket's RemoteAddr; returning the dial-time URL would lose the IP.
// the dial-time URL instead, ConnectedIP returns empty and the dial func TestClient_ServerInstanceAddressAfterFQDNDial(t *testing.T) {
// IP we advertise to peers is empty too.
func TestClient_ConnectedIPAfterFQDNDial(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel() defer cancel()
@@ -111,10 +112,10 @@ func TestClient_ConnectedIPAfterFQDNDial(t *testing.T) {
} }
t.Cleanup(func() { _ = c.Close() }) t.Cleanup(func() { _ = c.Close() })
got := c.ConnectedIP().String() url, ip, err := c.serverInstanceAddress()
if got != "127.0.0.1" && got != "::1" { require.NoError(t, err)
t.Fatalf("ConnectedIP after FQDN dial = %q, want 127.0.0.1 or ::1", got) assert.Equal(t, srvCfg.ExposedAddress, url, "relay URL must come from the handshake")
} assert.Contains(t, []string{"127.0.0.1", "::1"}, ip.String(), "relay IP must resolve to localhost")
} }
func TestSubstituteHost(t *testing.T) { func TestSubstituteHost(t *testing.T) {
@@ -214,15 +215,12 @@ func TestSubstituteHost(t *testing.T) {
} }
} }
func TestClient_ConnectedIPEmptyWhenNotConnected(t *testing.T) { func TestConnectedIPNilConnection(t *testing.T) {
c := NewClient("rel://example.invalid:80", hmacTokenStore, "x", iface.DefaultMTU) assert.False(t, connectedIP(nil).IsValid(), "missing connection must not provide an IP")
if got := c.ConnectedIP(); got.IsValid() {
t.Fatalf("ConnectedIP on disconnected client = %q, want zero", got)
}
} }
// staticAddr is a net.Addr that returns a fixed string. Used to verify // staticAddr is a net.Addr that returns a fixed string. Used to verify
// ConnectedIP parses RemoteAddr correctly. // connectedIP parses RemoteAddr correctly.
type staticAddr struct{ s string } type staticAddr struct{ s string }
func (a staticAddr) Network() string { return "tcp" } func (a staticAddr) Network() string { return "tcp" }
@@ -235,7 +233,7 @@ type stubConn struct {
func (s stubConn) RemoteAddr() net.Addr { return s.remote } func (s stubConn) RemoteAddr() net.Addr { return s.remote }
func TestClient_ConnectedIPParsesRemoteAddr(t *testing.T) { func TestConnectedIPParsesRemoteAddr(t *testing.T) {
tests := []struct { tests := []struct {
name string name string
s string s string
@@ -252,15 +250,12 @@ func TestClient_ConnectedIPParsesRemoteAddr(t *testing.T) {
} }
for _, tt := range tests { for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
c := &Client{relayConn: stubConn{remote: staticAddr{s: tt.s}}} got := connectedIP(stubConn{remote: staticAddr{s: tt.s}})
got := c.ConnectedIP()
var gotStr string var gotStr string
if got.IsValid() { if got.IsValid() {
gotStr = got.String() gotStr = got.String()
} }
if gotStr != tt.want { assert.Equal(t, tt.want, gotStr, "IP extracted from RemoteAddr %q", tt.s)
t.Errorf("ConnectedIP(%q) = %q, want %q", tt.s, gotStr, tt.want)
}
}) })
} }
} }
+1 -5
View File
@@ -256,11 +256,7 @@ func (m *Manager) RelayInstanceAddress() (string, netip.Addr, error) {
if m.relayClient == nil { if m.relayClient == nil {
return "", netip.Addr{}, ErrRelayClientNotConnected return "", netip.Addr{}, ErrRelayClientNotConnected
} }
addr, err := m.relayClient.ServerInstanceURL() return m.relayClient.serverInstanceAddress()
if err != nil {
return "", netip.Addr{}, err
}
return addr, m.relayClient.ConnectedIP(), nil
} }
// ServerURLs returns the addresses of the relay servers. // ServerURLs returns the addresses of the relay servers.

Some files were not shown because too many files have changed in this diff Show More