mirror of
https://github.com/netbirdio/netbird.git
synced 2026-09-12 17:59:06 +02:00
Merge branch 'main' into poc/certificate-posture
This commit is contained in:
@@ -287,10 +287,15 @@ jobs:
|
||||
image_refs=()
|
||||
|
||||
tag_and_push() {
|
||||
local src="$1" img_name tag dst
|
||||
local src="$1" img_name tag dst variant=""
|
||||
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
|
||||
dst="${img_name}:${tag}"
|
||||
dst="${img_name}:${tag}${variant}"
|
||||
echo "Tagging ${src} -> ${dst}"
|
||||
docker tag "$src" "$dst"
|
||||
docker push "$dst"
|
||||
|
||||
@@ -289,6 +289,43 @@ dockers_v2:
|
||||
"org.opencontainers.image.revision": "{{.FullCommit}}"
|
||||
"org.opencontainers.image.source": "{{.GitURL}}"
|
||||
"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
|
||||
disable: "{{ .Env.SKIP_DOCKER_PUSH }}"
|
||||
ids:
|
||||
|
||||
@@ -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"]
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"slices"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"golang.org/x/exp/maps"
|
||||
@@ -90,6 +91,14 @@ type Client struct {
|
||||
connectClient *internal.ConnectClient
|
||||
config *profilemanager.Config
|
||||
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.
|
||||
cfgPath string
|
||||
|
||||
@@ -178,6 +187,7 @@ func (c *Client) Run(platformFiles PlatformFiles, urlOpener URLOpener, isAndroid
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
c.applyMDMOverlay(cfg)
|
||||
c.recorder.UpdateManagementAddress(cfg.ManagementURL.String())
|
||||
c.recorder.UpdateRosenpass(cfg.RosenpassEnabled, cfg.RosenpassPermissive)
|
||||
|
||||
@@ -229,6 +239,7 @@ func (c *Client) RunWithoutLogin(platformFiles PlatformFiles, dns *DNSList, dnsR
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
c.applyMDMOverlay(cfg)
|
||||
c.recorder.UpdateManagementAddress(cfg.ManagementURL.String())
|
||||
c.recorder.UpdateRosenpass(cfg.RosenpassEnabled, cfg.RosenpassPermissive)
|
||||
|
||||
@@ -327,6 +338,7 @@ func (c *Client) DebugBundle(platformFiles PlatformFiles, anonymize bool, anonym
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("load config: %w", err)
|
||||
}
|
||||
c.applyMDMOverlay(cfg)
|
||||
cacheDir = platformFiles.CacheDir()
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
//go:build android
|
||||
|
||||
package android
|
||||
|
||||
import (
|
||||
"github.com/netbirdio/netbird/client/internal/profilemanager"
|
||||
"github.com/netbirdio/netbird/client/mdm"
|
||||
)
|
||||
|
||||
type mdmSource struct {
|
||||
loader *mdm.Loader
|
||||
detector *mdm.ChangeDetector
|
||||
}
|
||||
|
||||
// SetMDMPolicyFetcher registers the native-provided MDM policy fetcher on
|
||||
// this Client; passing nil disables MDM enforcement.
|
||||
func (c *Client) SetMDMPolicyFetcher(p PolicyFetcher) {
|
||||
loader := loaderFor(p)
|
||||
c.mdmSource.Store(&mdmSource{loader: loader, detector: mdm.NewChangeDetector(loader)})
|
||||
}
|
||||
|
||||
// HasMDMPolicyChanged re-reads the managed configuration and reports whether
|
||||
// it changed since the last observation; call it from the native OS-change
|
||||
// notification and restart the engine only on true.
|
||||
func (c *Client) HasMDMPolicyChanged() bool {
|
||||
src := c.mdmSource.Load()
|
||||
if src == nil {
|
||||
return false
|
||||
}
|
||||
return src.detector.Changed()
|
||||
}
|
||||
|
||||
// GetRestrictionsJSON returns the UI enforcement snapshot derived from the
|
||||
// active MDM policy, in the JSON shape shared with the desktop frontend.
|
||||
func (c *Client) GetRestrictionsJSON() (string, error) {
|
||||
return mdm.BuildRestrictions(c.mdmLoader().Load()).JSON()
|
||||
}
|
||||
|
||||
func (c *Client) applyMDMOverlay(cfg *profilemanager.Config) {
|
||||
loader := c.mdmLoader()
|
||||
if cfg == nil || loader == nil {
|
||||
return
|
||||
}
|
||||
cfg.ApplyMDMPolicy(loader.Load())
|
||||
}
|
||||
|
||||
func (c *Client) mdmLoader() *mdm.Loader {
|
||||
if src := c.mdmSource.Load(); src != nil {
|
||||
return src.loader
|
||||
}
|
||||
return nil
|
||||
}
|
||||
+17
-16
@@ -8,6 +8,7 @@ import (
|
||||
|
||||
"github.com/netbirdio/netbird/client/internal/auth"
|
||||
"github.com/netbirdio/netbird/client/internal/profilemanager"
|
||||
"github.com/netbirdio/netbird/client/mdm"
|
||||
"github.com/netbirdio/netbird/client/mobile"
|
||||
"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
|
||||
// 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".
|
||||
func NewAuth(cfgPath string, mgmURL string) (*Auth, error) {
|
||||
inputCfg := profilemanager.ConfigInput{
|
||||
ConfigPath: cfgPath,
|
||||
ManagementURL: mgmURL,
|
||||
//
|
||||
// Auth is constructed under the active MDM policy: the policy is overlaid on
|
||||
// the resolved config so the login runs against the enforced values, while
|
||||
// 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)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cfg.ApplyMDMPolicy(policy)
|
||||
|
||||
return &Auth{
|
||||
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.
|
||||
// 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.
|
||||
// SaveConfigIfSSOSupported reports whether the management server supports SSO; the config is already persisted by NewAuth.
|
||||
func (a *Auth) SaveConfigIfSSOSupported(listener SSOListener) {
|
||||
go func() {
|
||||
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)
|
||||
}
|
||||
|
||||
if !supportsSSO {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
err = profilemanager.WriteOutConfig(a.cfgPath, a.config)
|
||||
return true, err
|
||||
return supportsSSO, nil
|
||||
}
|
||||
|
||||
// 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) {
|
||||
go func() {
|
||||
err := a.loginWithSetupKeyAndSaveConfig(setupKey, deviceName)
|
||||
@@ -134,8 +136,7 @@ func (a *Auth) loginWithSetupKeyAndSaveConfig(setupKey string, deviceName string
|
||||
if err != nil {
|
||||
return fmt.Errorf("login failed: %v", err)
|
||||
}
|
||||
|
||||
return profilemanager.WriteOutConfig(a.cfgPath, a.config)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Login try register the client on the server
|
||||
|
||||
@@ -16,7 +16,7 @@ import (
|
||||
func TestNewAuth_ReusesPersistedIdentity(t *testing.T) {
|
||||
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 {
|
||||
t.Fatalf("first NewAuth: %v", err)
|
||||
}
|
||||
@@ -24,7 +24,7 @@ func TestNewAuth_ReusesPersistedIdentity(t *testing.T) {
|
||||
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 {
|
||||
t.Fatalf("second NewAuth: %v", err)
|
||||
}
|
||||
@@ -38,7 +38,7 @@ func TestNewAuth_ReusesPersistedIdentity(t *testing.T) {
|
||||
func TestNewAuth_CreatesConfigWhenAbsent(t *testing.T) {
|
||||
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 {
|
||||
t.Fatalf("NewAuth: %v", err)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
package android
|
||||
|
||||
import (
|
||||
"github.com/netbirdio/netbird/client/mdm"
|
||||
)
|
||||
|
||||
// PolicyFetcher is implemented by the native layer to return the current
|
||||
// managed configuration as a JSON-encoded object string; "" means no MDM
|
||||
// source is present.
|
||||
type PolicyFetcher interface {
|
||||
FetchJSON() string
|
||||
}
|
||||
|
||||
func loaderFor(p PolicyFetcher) *mdm.Loader {
|
||||
if p == nil {
|
||||
return mdm.NewJSONLoader(nil)
|
||||
}
|
||||
return mdm.NewJSONLoader(p.FetchJSON)
|
||||
}
|
||||
@@ -1,12 +1,16 @@
|
||||
package android
|
||||
|
||||
import (
|
||||
"sync/atomic"
|
||||
|
||||
"github.com/netbirdio/netbird/client/internal/profilemanager"
|
||||
"github.com/netbirdio/netbird/client/mdm"
|
||||
)
|
||||
|
||||
// Preferences exports a subset of the internal config for gomobile
|
||||
type Preferences struct {
|
||||
configInput profilemanager.ConfigInput
|
||||
mdmLoader atomic.Pointer[mdm.Loader]
|
||||
}
|
||||
|
||||
// NewPreferences creates a new Preferences instance
|
||||
@@ -14,11 +18,30 @@ func NewPreferences(configPath string) *Preferences {
|
||||
ci := profilemanager.ConfigInput{
|
||||
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
|
||||
func (p *Preferences) GetManagementURL() (string, error) {
|
||||
if v, ok := p.policy().GetString(mdm.KeyManagementURL); ok {
|
||||
return mdm.CanonicalURL(v), nil
|
||||
}
|
||||
if p.configInput.ManagementURL != "" {
|
||||
return p.configInput.ManagementURL, nil
|
||||
}
|
||||
@@ -27,7 +50,7 @@ func (p *Preferences) GetManagementURL() (string, error) {
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return cfg.ManagementURL.String(), err
|
||||
return cfg.ManagementURL.String(), nil
|
||||
}
|
||||
|
||||
// SetManagementURL stores the given URL and waits for commit
|
||||
@@ -53,17 +76,21 @@ func (p *Preferences) SetAdminURL(url string) {
|
||||
p.configInput.AdminURL = url
|
||||
}
|
||||
|
||||
// GetPreSharedKey reads pre-shared key from config file
|
||||
func (p *Preferences) GetPreSharedKey() (string, error) {
|
||||
// HasPreSharedKey reports whether a pre-shared key is staged, persisted, or
|
||||
// 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 {
|
||||
return *p.configInput.PreSharedKey, nil
|
||||
return *p.configInput.PreSharedKey != "", nil
|
||||
}
|
||||
|
||||
cfg, err := profilemanager.ReadConfig(p.configInput.ConfigPath)
|
||||
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
|
||||
@@ -78,6 +105,9 @@ func (p *Preferences) SetRosenpassEnabled(enabled bool) {
|
||||
|
||||
// GetRosenpassEnabled reads Rosenpass enabled status from config file
|
||||
func (p *Preferences) GetRosenpassEnabled() (bool, error) {
|
||||
if v, ok := p.policy().GetBool(mdm.KeyRosenpassEnabled); ok {
|
||||
return v, nil
|
||||
}
|
||||
if 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
|
||||
func (p *Preferences) GetRosenpassPermissive() (bool, error) {
|
||||
if v, ok := p.policy().GetBool(mdm.KeyRosenpassPermissive); ok {
|
||||
return v, nil
|
||||
}
|
||||
if 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
|
||||
func (p *Preferences) GetDisableClientRoutes() (bool, error) {
|
||||
if v, ok := p.policy().GetBool(mdm.KeyDisableClientRoutes); ok {
|
||||
return v, nil
|
||||
}
|
||||
if 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
|
||||
func (p *Preferences) GetDisableServerRoutes() (bool, error) {
|
||||
if v, ok := p.policy().GetBool(mdm.KeyDisableServerRoutes); ok {
|
||||
return v, nil
|
||||
}
|
||||
if 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
|
||||
func (p *Preferences) GetServerSSHAllowed() (bool, error) {
|
||||
if v, ok := p.policy().GetBool(mdm.KeyAllowServerSSH); ok {
|
||||
return v, nil
|
||||
}
|
||||
if 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
|
||||
func (p *Preferences) GetBlockInbound() (bool, error) {
|
||||
if v, ok := p.policy().GetBool(mdm.KeyBlockInbound); ok {
|
||||
return v, nil
|
||||
}
|
||||
if 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
|
||||
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
|
||||
}
|
||||
|
||||
@@ -335,10 +381,11 @@ func (p *Preferences) GetRemoteJobsAllowed() (bool, error) {
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
cfg.ApplyMDMPolicy(policy)
|
||||
if cfg.RemoteJobsAllowed == nil {
|
||||
return false, nil
|
||||
}
|
||||
return *cfg.RemoteJobsAllowed, err
|
||||
return *cfg.RemoteJobsAllowed, nil
|
||||
}
|
||||
|
||||
// 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
|
||||
func (p *Preferences) Commit() error {
|
||||
if err := profilemanager.CheckMDMConflicts(p.configInput, p.policy()); err != nil {
|
||||
return err
|
||||
}
|
||||
_, err := profilemanager.UpdateOrCreateConfig(p.configInput)
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -28,14 +28,13 @@ func TestPreferences_DefaultValues(t *testing.T) {
|
||||
t.Errorf("invalid default management url: %s", defaultVar)
|
||||
}
|
||||
|
||||
var preSharedKey string
|
||||
preSharedKey, err = p.GetPreSharedKey()
|
||||
hasPSK, err := p.HasPreSharedKey()
|
||||
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 != "" {
|
||||
t.Errorf("invalid preshared key: %s", preSharedKey)
|
||||
if hasPSK {
|
||||
t.Errorf("unexpected preshared key presence on fresh config")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -65,13 +64,13 @@ func TestPreferences_ReadUncommitedValues(t *testing.T) {
|
||||
}
|
||||
|
||||
p.SetPreSharedKey(exampleString)
|
||||
resp, err = p.GetPreSharedKey()
|
||||
hasPSK, err := p.HasPreSharedKey()
|
||||
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 {
|
||||
t.Errorf("unexpected preshared key: %s", resp)
|
||||
if !hasPSK {
|
||||
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)
|
||||
}
|
||||
|
||||
resp, err = p.GetPreSharedKey()
|
||||
hasPSK, err := p.HasPreSharedKey()
|
||||
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 {
|
||||
t.Errorf("unexpected preshared key: %s", resp)
|
||||
if !hasPSK {
|
||||
t.Errorf("expected preshared key presence after commit")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -54,6 +54,12 @@ func NewProfileManager(configDir string) *ProfileManager {
|
||||
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,
|
||||
// with their active status set.
|
||||
func (pm *ProfileManager) ListProfiles() (*ProfileArray, error) {
|
||||
|
||||
@@ -15,6 +15,7 @@ import (
|
||||
"github.com/netbirdio/netbird/client/internal"
|
||||
"github.com/netbirdio/netbird/client/internal/auth"
|
||||
"github.com/netbirdio/netbird/client/internal/profilemanager"
|
||||
"github.com/netbirdio/netbird/client/mdm"
|
||||
nbnet "github.com/netbirdio/netbird/client/net"
|
||||
"github.com/netbirdio/netbird/client/proto"
|
||||
"github.com/netbirdio/netbird/client/server"
|
||||
@@ -330,6 +331,11 @@ func doForegroundLogin(ctx context.Context, cmd *cobra.Command, setupKey string,
|
||||
if err != nil {
|
||||
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,
|
||||
// ssh config, legacy routing) from a previous unclean shutdown and
|
||||
|
||||
@@ -21,6 +21,7 @@ import (
|
||||
"github.com/netbirdio/netbird/client/internal"
|
||||
"github.com/netbirdio/netbird/client/internal/peer"
|
||||
"github.com/netbirdio/netbird/client/internal/profilemanager"
|
||||
"github.com/netbirdio/netbird/client/mdm"
|
||||
nbnet "github.com/netbirdio/netbird/client/net"
|
||||
"github.com/netbirdio/netbird/client/proto"
|
||||
"github.com/netbirdio/netbird/client/server"
|
||||
@@ -234,6 +235,10 @@ func runInForegroundMode(ctx context.Context, cmd *cobra.Command, activeProf *pr
|
||||
if err != nil {
|
||||
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)
|
||||
|
||||
|
||||
@@ -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"
|
||||
@@ -21,6 +21,7 @@ import (
|
||||
"github.com/netbirdio/netbird/client/internal/auth"
|
||||
"github.com/netbirdio/netbird/client/internal/peer"
|
||||
"github.com/netbirdio/netbird/client/internal/profilemanager"
|
||||
"github.com/netbirdio/netbird/client/mdm"
|
||||
nbssh "github.com/netbirdio/netbird/client/ssh"
|
||||
"github.com/netbirdio/netbird/client/system"
|
||||
"github.com/netbirdio/netbird/shared/management/domain"
|
||||
@@ -229,6 +230,10 @@ func New(opts Options) (*Client, error) {
|
||||
if err != nil {
|
||||
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 != "" {
|
||||
config.PrivateKey = opts.PrivateKey
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"net"
|
||||
"net/netip"
|
||||
"runtime"
|
||||
"slices"
|
||||
"strconv"
|
||||
"sync"
|
||||
"time"
|
||||
@@ -17,17 +18,20 @@ import (
|
||||
nberrors "github.com/netbirdio/netbird/client/errors"
|
||||
|
||||
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 (
|
||||
customPort = 5053
|
||||
// randomPortAttempts bounds the search for a port free on both protocols.
|
||||
randomPortAttempts = 5
|
||||
)
|
||||
|
||||
var (
|
||||
defaultIP = netip.MustParseAddr("127.0.0.1")
|
||||
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 {
|
||||
@@ -40,9 +44,20 @@ type serviceViaListener struct {
|
||||
listenPort uint16
|
||||
listenerIsRunning bool
|
||||
listenerFlagLock sync.Mutex
|
||||
ebpfService ebpfMgr.Manager
|
||||
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 {
|
||||
@@ -112,34 +127,93 @@ func (s *serviceViaListener) Listen() error {
|
||||
}
|
||||
}()
|
||||
|
||||
// When eBPF redirects UDP port 53 to our listen port, TCP still needs
|
||||
// a DNAT rule because eBPF only handles UDP.
|
||||
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)
|
||||
}
|
||||
if s.listenPort != DefaultPort {
|
||||
s.setupDNAT()
|
||||
}
|
||||
|
||||
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 {
|
||||
s.listenerFlagLock.Lock()
|
||||
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 {
|
||||
return nil
|
||||
return nberrors.FormatErrorOrNil(merr)
|
||||
}
|
||||
s.listenerIsRunning = false
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
var merr *multierror.Error
|
||||
|
||||
if err := s.server.ShutdownContext(ctx); err != nil {
|
||||
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))
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -177,11 +238,23 @@ func (s *serviceViaListener) RuntimePort() int {
|
||||
s.listenerFlagLock.Lock()
|
||||
defer s.listenerFlagLock.Unlock()
|
||||
|
||||
if s.ebpfService != nil {
|
||||
if s.redirectInstalled() {
|
||||
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 {
|
||||
@@ -190,30 +263,29 @@ func (s *serviceViaListener) RuntimeIP() netip.Addr {
|
||||
|
||||
// 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.
|
||||
// First checks port 53 on WG interface or lo, then tries eBPF on a random port,
|
||||
// then falls back to port 5053.
|
||||
// Prefers port 53 on the overlay interface or lo, so no redirect is needed at
|
||||
// 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) {
|
||||
if s.customAddr != nil {
|
||||
return s.customAddr.Addr(), s.customAddr.Port(), nil
|
||||
}
|
||||
|
||||
ip, ok := s.testFreePort(DefaultPort)
|
||||
if ok {
|
||||
if ip, ok := s.testFreePort(DefaultPort); ok {
|
||||
return ip, DefaultPort, nil
|
||||
}
|
||||
|
||||
ebpfSrv, port, ok := s.tryToUseeBPF()
|
||||
if ok {
|
||||
s.ebpfService = ebpfSrv
|
||||
return s.wgInterface.Address().IP, port, nil
|
||||
}
|
||||
|
||||
ip, ok = s.testFreePort(customPort)
|
||||
if ok {
|
||||
if ip, ok := s.testFreePort(customPort); ok {
|
||||
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) {
|
||||
@@ -260,48 +332,25 @@ func (s *serviceViaListener) tryToBind(ip netip.Addr, port int) bool {
|
||||
return true
|
||||
}
|
||||
|
||||
// tryToUseeBPF decides whether to apply eBPF program to capture DNS traffic on port 53.
|
||||
// This is needed because on some operating systems if we start a DNS server not on a default port 53,
|
||||
// the domain name resolution won't work. So, in case we are running on Linux and picked a free
|
||||
// port we should fall back to the eBPF solution that will capture traffic on port 53 and forward
|
||||
// it to a local DNS server running on the chosen port.
|
||||
func (s *serviceViaListener) tryToUseeBPF() (ebpfMgr.Manager, uint16, bool) {
|
||||
if runtime.GOOS != "linux" {
|
||||
return nil, 0, false
|
||||
// randomFreePort returns a port that is free on ip for both UDP and TCP, since
|
||||
// the DNS server binds both. The probe listeners are closed again, so the port
|
||||
// is only likely, not guaranteed, to still be free when the server binds it.
|
||||
func (s *serviceViaListener) randomFreePort(ip netip.Addr) (uint16, error) {
|
||||
for range randomPortAttempts {
|
||||
probeListener, err := net.ListenUDP("udp4", &net.UDPAddr{})
|
||||
if err != nil {
|
||||
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
|
||||
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
|
||||
return 0, fmt.Errorf("no port free for UDP and TCP on %s after %d attempts", ip, randomPortAttempts)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package dns
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/netip"
|
||||
@@ -10,6 +11,8 @@ import (
|
||||
"github.com/miekg/dns"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
firewall "github.com/netbirdio/netbird/client/firewall/manager"
|
||||
)
|
||||
|
||||
func TestServiceViaListener_TCPAndUDP(t *testing.T) {
|
||||
@@ -84,3 +87,133 @@ func TestServiceViaListener_TCPAndUDP(t *testing.T) {
|
||||
require.NotEmpty(t, tcpResp.Answer)
|
||||
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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// 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
|
||||
|
||||
@@ -47,9 +47,10 @@ func loadBpfObjects(obj interface{}, opts *ebpf.CollectionOptions) error {
|
||||
type bpfSpecs struct {
|
||||
bpfProgramSpecs
|
||||
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.
|
||||
type bpfProgramSpecs struct {
|
||||
@@ -61,17 +62,28 @@ type bpfProgramSpecs struct {
|
||||
// It can be passed ebpf.CollectionSpec.Assign.
|
||||
type bpfMapSpecs struct {
|
||||
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"`
|
||||
}
|
||||
|
||||
// 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.
|
||||
//
|
||||
// It can be passed to loadBpfObjects or ebpf.CollectionSpec.LoadAndAssign.
|
||||
type bpfObjects struct {
|
||||
bpfPrograms
|
||||
bpfMaps
|
||||
bpfVariables
|
||||
}
|
||||
|
||||
func (o *bpfObjects) Close() error {
|
||||
@@ -86,20 +98,28 @@ func (o *bpfObjects) Close() error {
|
||||
// It can be passed to loadBpfObjects or ebpf.CollectionSpec.LoadAndAssign.
|
||||
type bpfMaps struct {
|
||||
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"`
|
||||
}
|
||||
|
||||
func (m *bpfMaps) Close() error {
|
||||
return _BpfClose(
|
||||
m.NbFeatures,
|
||||
m.NbMapDnsIp,
|
||||
m.NbMapDnsPort,
|
||||
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.
|
||||
//
|
||||
// It can be passed to loadBpfObjects or ebpf.CollectionSpec.LoadAndAssign.
|
||||
|
||||
Binary file not shown.
@@ -1,5 +1,5 @@
|
||||
// Code generated by bpf2go; DO NOT EDIT.
|
||||
//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
|
||||
|
||||
@@ -47,9 +47,10 @@ func loadBpfObjects(obj interface{}, opts *ebpf.CollectionOptions) error {
|
||||
type bpfSpecs struct {
|
||||
bpfProgramSpecs
|
||||
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.
|
||||
type bpfProgramSpecs struct {
|
||||
@@ -61,17 +62,28 @@ type bpfProgramSpecs struct {
|
||||
// It can be passed ebpf.CollectionSpec.Assign.
|
||||
type bpfMapSpecs struct {
|
||||
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"`
|
||||
}
|
||||
|
||||
// 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.
|
||||
//
|
||||
// It can be passed to loadBpfObjects or ebpf.CollectionSpec.LoadAndAssign.
|
||||
type bpfObjects struct {
|
||||
bpfPrograms
|
||||
bpfMaps
|
||||
bpfVariables
|
||||
}
|
||||
|
||||
func (o *bpfObjects) Close() error {
|
||||
@@ -86,20 +98,28 @@ func (o *bpfObjects) Close() error {
|
||||
// It can be passed to loadBpfObjects or ebpf.CollectionSpec.LoadAndAssign.
|
||||
type bpfMaps struct {
|
||||
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"`
|
||||
}
|
||||
|
||||
func (m *bpfMaps) Close() error {
|
||||
return _BpfClose(
|
||||
m.NbFeatures,
|
||||
m.NbMapDnsIp,
|
||||
m.NbMapDnsPort,
|
||||
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.
|
||||
//
|
||||
// It can be passed to loadBpfObjects or ebpf.CollectionSpec.LoadAndAssign.
|
||||
|
||||
Binary file not shown.
@@ -1,52 +0,0 @@
|
||||
package ebpf
|
||||
|
||||
import (
|
||||
"encoding/binary"
|
||||
"fmt"
|
||||
"net/netip"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
const (
|
||||
mapKeyDNSIP uint32 = 0
|
||||
mapKeyDNSPort uint32 = 1
|
||||
)
|
||||
|
||||
func (tf *GeneralManager) LoadDNSFwd(ip netip.Addr, dnsPort int) error {
|
||||
log.Debugf("load eBPF DNS forwarder, watching addr: %s:53, redirect to port: %d", ip, dnsPort)
|
||||
tf.lock.Lock()
|
||||
defer tf.lock.Unlock()
|
||||
|
||||
err := tf.loadXdp()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if !ip.Is4() {
|
||||
return fmt.Errorf("eBPF DNS forwarder only supports IPv4, got %s", ip)
|
||||
}
|
||||
ip4 := ip.As4()
|
||||
err = tf.bpfObjs.NbMapDnsIp.Put(mapKeyDNSIP, binary.BigEndian.Uint32(ip4[:]))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
err = tf.bpfObjs.NbMapDnsPort.Put(mapKeyDNSPort, uint16(dnsPort))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
tf.setFeatureFlag(featureFlagDnsForwarder)
|
||||
err = tf.bpfObjs.NbFeatures.Put(mapKeyFeatures, tf.featureFlags)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (tf *GeneralManager) FreeDNSFwd() error {
|
||||
log.Debugf("free ebpf DNS forwarder")
|
||||
return tf.unsetFeatureFlag(featureFlagDnsForwarder)
|
||||
}
|
||||
|
||||
@@ -15,8 +15,7 @@ import (
|
||||
const (
|
||||
mapKeyFeatures uint32 = 0
|
||||
|
||||
featureFlagWGProxy = 0b00000001
|
||||
featureFlagDnsForwarder = 0b00000010
|
||||
featureFlagWGProxy = 0b00000001
|
||||
)
|
||||
|
||||
var (
|
||||
@@ -28,9 +27,9 @@ var (
|
||||
|
||||
// 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.
|
||||
// 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 {
|
||||
lock sync.Mutex
|
||||
link link.Link
|
||||
|
||||
@@ -7,33 +7,24 @@ import (
|
||||
func TestManager_setFeatureFlag(t *testing.T) {
|
||||
mgr := GeneralManager{}
|
||||
mgr.setFeatureFlag(featureFlagWGProxy)
|
||||
if mgr.featureFlags != 1 {
|
||||
if mgr.featureFlags != featureFlagWGProxy {
|
||||
t.Errorf("invalid feature state")
|
||||
}
|
||||
|
||||
mgr.setFeatureFlag(featureFlagDnsForwarder)
|
||||
if mgr.featureFlags != 3 {
|
||||
t.Errorf("invalid feature state")
|
||||
mgr.setFeatureFlag(featureFlagWGProxy)
|
||||
if mgr.featureFlags != featureFlagWGProxy {
|
||||
t.Errorf("setting a flag twice must be idempotent, got: %d", mgr.featureFlags)
|
||||
}
|
||||
}
|
||||
|
||||
func TestManager_unsetFeatureFlag(t *testing.T) {
|
||||
mgr := GeneralManager{}
|
||||
mgr.setFeatureFlag(featureFlagWGProxy)
|
||||
mgr.setFeatureFlag(featureFlagDnsForwarder)
|
||||
|
||||
err := mgr.unsetFeatureFlag(featureFlagWGProxy)
|
||||
if err != nil {
|
||||
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 {
|
||||
t.Errorf("invalid feature state, expected: %d, got: %d", 0, mgr.featureFlags)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
// libbpf 1.0 removed struct bpf_map_def, but the programs here keep the legacy
|
||||
// map definitions: they load on kernels built without BTF, which BTF-style
|
||||
// (SEC(".maps")) definitions do not. Define the struct ourselves so the
|
||||
// programs compile against current libbpf headers.
|
||||
#ifndef NB_BPF_MAP_DEF_H
|
||||
#define NB_BPF_MAP_DEF_H
|
||||
|
||||
struct bpf_map_def {
|
||||
unsigned int type;
|
||||
unsigned int key_size;
|
||||
unsigned int value_size;
|
||||
unsigned int max_entries;
|
||||
unsigned int map_flags;
|
||||
};
|
||||
|
||||
#endif
|
||||
@@ -1,67 +0,0 @@
|
||||
const __u32 map_key_dns_ip = 0;
|
||||
const __u32 map_key_dns_port = 1;
|
||||
|
||||
struct bpf_map_def SEC("maps") nb_map_dns_ip = {
|
||||
.type = BPF_MAP_TYPE_ARRAY,
|
||||
.key_size = sizeof(__u32),
|
||||
.value_size = sizeof(__u32),
|
||||
.max_entries = 10,
|
||||
};
|
||||
|
||||
struct bpf_map_def SEC("maps") nb_map_dns_port = {
|
||||
.type = BPF_MAP_TYPE_ARRAY,
|
||||
.key_size = sizeof(__u32),
|
||||
.value_size = sizeof(__u16),
|
||||
.max_entries = 10,
|
||||
};
|
||||
|
||||
__be32 dns_ip = 0;
|
||||
__be16 dns_port = 0;
|
||||
|
||||
// 13568 is 53 in big endian
|
||||
__be16 GENERAL_DNS_PORT = 13568;
|
||||
|
||||
bool read_settings() {
|
||||
__u16 *port_value;
|
||||
__u32 *ip_value;
|
||||
|
||||
// read dns ip
|
||||
ip_value = bpf_map_lookup_elem(&nb_map_dns_ip, &map_key_dns_ip);
|
||||
if(!ip_value) {
|
||||
return false;
|
||||
}
|
||||
dns_ip = htonl(*ip_value);
|
||||
|
||||
// read dns port
|
||||
port_value = bpf_map_lookup_elem(&nb_map_dns_port, &map_key_dns_port);
|
||||
if (!port_value) {
|
||||
return false;
|
||||
}
|
||||
dns_port = htons(*port_value);
|
||||
return true;
|
||||
}
|
||||
|
||||
int xdp_dns_fwd(struct iphdr *ip, struct udphdr *udp) {
|
||||
if (dns_port == 0) {
|
||||
if(!read_settings()){
|
||||
return XDP_PASS;
|
||||
}
|
||||
// bpf_printk("dns port: %d", ntohs(dns_port));
|
||||
// bpf_printk("dns ip: %d", ntohl(dns_ip));
|
||||
}
|
||||
|
||||
if (udp->dest == GENERAL_DNS_PORT && ip->daddr == dns_ip) {
|
||||
udp->dest = dns_port;
|
||||
// Clear the now-stale checksum; zero means "not computed" for IPv4.
|
||||
udp->check = 0;
|
||||
return XDP_PASS;
|
||||
}
|
||||
|
||||
if (udp->source == dns_port && ip->saddr == dns_ip) {
|
||||
udp->source = GENERAL_DNS_PORT;
|
||||
udp->check = 0;
|
||||
return XDP_PASS;
|
||||
}
|
||||
|
||||
return XDP_PASS;
|
||||
}
|
||||
@@ -5,11 +5,9 @@
|
||||
#include <netinet/in.h>
|
||||
#include <linux/bpf.h>
|
||||
#include <bpf/bpf_helpers.h>
|
||||
#include "dns_fwd.c"
|
||||
#include "wg_proxy.c"
|
||||
|
||||
const __u16 flag_feature_wg_proxy = 0b01;
|
||||
const __u16 flag_feature_dns_fwd = 0b10;
|
||||
|
||||
const __u32 map_key_features = 0;
|
||||
struct bpf_map_def SEC("maps") nb_features = {
|
||||
@@ -48,10 +46,6 @@ int nb_xdp_prog(struct xdp_md *ctx) {
|
||||
return XDP_PASS;
|
||||
}
|
||||
|
||||
if (*features & flag_feature_dns_fwd) {
|
||||
xdp_dns_fwd(ip, udp);
|
||||
}
|
||||
|
||||
if (*features & flag_feature_wg_proxy) {
|
||||
xdp_wg_proxy(ip, udp);
|
||||
}
|
||||
|
||||
@@ -1,8 +1,18 @@
|
||||
# DNS forwarder
|
||||
# XDP programs
|
||||
|
||||
The agent attach the XDP program to the lo device. We can not use fake address in eBPF because the
|
||||
traffic does not appear in the eBPF program. The program capture the traffic on wg_ip:53 and
|
||||
overwrite in it the destination port to 5053.
|
||||
`prog.c` is attached to the `lo` device and dispatches to the features enabled in the
|
||||
`nb_features` map. The only feature is the WireGuard proxy (`wg_proxy.c`): it rewrites
|
||||
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
|
||||
|
||||
|
||||
@@ -1,11 +1,7 @@
|
||||
package manager
|
||||
|
||||
import "net/netip"
|
||||
|
||||
// Manager is used to load multiple eBPF programs. E.g., current DNS programs and WireGuard proxy
|
||||
// Manager is used to load multiple eBPF programs. E.g., the WireGuard proxy
|
||||
type Manager interface {
|
||||
LoadDNSFwd(ip netip.Addr, dnsPort int) error
|
||||
FreeDNSFwd() error
|
||||
LoadWgProxy(proxyPort, wgPort int) error
|
||||
FreeWGProxy() error
|
||||
}
|
||||
|
||||
@@ -14,12 +14,14 @@ import (
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/hashicorp/go-multierror"
|
||||
"github.com/pion/ice/v4"
|
||||
"github.com/pion/stun/v3"
|
||||
log "github.com/sirupsen/logrus"
|
||||
wgdevice "golang.zx2c4.com/wireguard/device"
|
||||
"golang.zx2c4.com/wireguard/tun/netstack"
|
||||
"golang.zx2c4.com/wireguard/wgctrl/wgtypes"
|
||||
|
||||
@@ -237,6 +239,12 @@ type Engine struct {
|
||||
|
||||
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
|
||||
|
||||
// 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())
|
||||
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
|
||||
// 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)
|
||||
|
||||
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 {
|
||||
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.
|
||||
//
|
||||
// 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 {
|
||||
e.syncMsgMux.Lock()
|
||||
defer e.syncMsgMux.Unlock()
|
||||
if e.wgInterface == nil {
|
||||
return fmt.Errorf("wg interface not initialized")
|
||||
}
|
||||
dev := e.wgInterface.GetWGDevice()
|
||||
dev := e.wgDevice.Load()
|
||||
if dev == nil {
|
||||
return fmt.Errorf("wg device not initialized")
|
||||
return errors.New("wg device not initialized")
|
||||
}
|
||||
if t.PreallocatedBuffersPerPool != nil {
|
||||
dev.SetPreallocatedBuffersPerPool(*t.PreallocatedBuffersPerPool)
|
||||
|
||||
@@ -116,7 +116,7 @@ func (h *Handshaker) Listen(ctx context.Context) {
|
||||
for {
|
||||
select {
|
||||
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
|
||||
if h.metricsStages != nil {
|
||||
@@ -138,7 +138,7 @@ func (h *Handshaker) Listen(ctx context.Context) {
|
||||
continue
|
||||
}
|
||||
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
|
||||
if h.metricsStages != nil {
|
||||
@@ -209,14 +209,14 @@ func (h *Handshaker) sendOffer() error {
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
func (h *Handshaker) sendAnswer() error {
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -58,10 +58,6 @@ var DefaultInterfaceBlacklist = []string{
|
||||
"Tailscale", "tailscale", "docker", "veth", "br-", "lo",
|
||||
}
|
||||
|
||||
// loadMDMPolicy is the package-level indirection used by apply() to read the
|
||||
// active MDM policy. Tests override this to inject a fake policy.
|
||||
var loadMDMPolicy = mdm.LoadPolicy
|
||||
|
||||
// ConfigInput carries configuration changes to the client
|
||||
type ConfigInput struct {
|
||||
ManagementURL string
|
||||
@@ -202,14 +198,26 @@ type Config struct {
|
||||
|
||||
MTU uint16
|
||||
|
||||
// policy is the MDM policy that produced the currently-set values for
|
||||
// any MDM-enforced fields. Set by applyMDMPolicy at the tail of apply()
|
||||
// and reset on every apply() invocation. Never persisted to disk.
|
||||
// Callers query enforcement state via Policy() and the mdm.Policy API
|
||||
// (HasKey, ManagedKeys, IsEmpty).
|
||||
// policy is the MDM policy that produced the currently-set values
|
||||
// for any MDM-enforced fields. Set by ApplyMDMPolicy on every
|
||||
// invocation. Never persisted to disk. Callers query enforcement
|
||||
// state via Policy() and the mdm.Policy API (HasKey, ManagedKeys,
|
||||
// IsEmpty).
|
||||
policy *mdm.Policy `json:"-"`
|
||||
}
|
||||
|
||||
// ApplyMDMPolicy overlays the supplied MDM Policy on top of the current
|
||||
// Config values and records it as Policy(). The overlay is not reversible:
|
||||
// an empty Policy only clears the enforcement metadata, so resolve the base
|
||||
// Config again (from disk or JSON) before applying a changed policy, the way
|
||||
// the lifecycle owners do on every load.
|
||||
func (config *Config) ApplyMDMPolicy(policy *mdm.Policy) {
|
||||
if config == nil {
|
||||
return
|
||||
}
|
||||
config.applyMDMPolicy(policy)
|
||||
}
|
||||
|
||||
// Policy returns the MDM policy applied to this Config. Returns a non-nil
|
||||
// empty Policy when MDM enforcement is inactive; callers can always invoke
|
||||
// HasKey / ManagedKeys / IsEmpty without a nil check.
|
||||
@@ -712,9 +720,11 @@ func (config *Config) apply(input ConfigInput) (updated bool, err error) {
|
||||
updated = true
|
||||
}
|
||||
|
||||
// MDM is the last override layer: any key present in the policy
|
||||
// supersedes defaults, on-disk config, env vars and CLI input.
|
||||
config.applyMDMPolicy(loadMDMPolicy())
|
||||
// Initialise the MDM overlay to "no enforcement" so Config.Policy()
|
||||
// never returns a stale or nil policy on a freshly applied Config.
|
||||
// Lifecycle owners that want to enforce a real MDM policy invoke
|
||||
// Config.ApplyMDMPolicy(loader.Load()) after this returns.
|
||||
config.applyMDMPolicy(mdm.NewPolicy(nil))
|
||||
|
||||
return updated, nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
package profilemanager
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"github.com/netbirdio/netbird/client/mdm"
|
||||
)
|
||||
|
||||
// ErrMDMManagedFields marks a config change rejected because it diverges from
|
||||
// MDM-enforced values.
|
||||
var ErrMDMManagedFields = errors.New("fields managed by MDM cannot be modified")
|
||||
|
||||
// MDMConflicts returns the names of MDM-managed keys whose requested value in
|
||||
// the ConfigInput differs from the policy-enforced value; a field set to the
|
||||
// enforced value is a no-op echo, not a conflict.
|
||||
func MDMConflicts(input ConfigInput, policy *mdm.Policy) []string {
|
||||
pskGot := input.PreSharedKey
|
||||
if isPreSharedKeyHidden(pskGot) {
|
||||
pskGot = nil
|
||||
}
|
||||
var port *int64
|
||||
if input.WireguardPort != nil {
|
||||
v := int64(*input.WireguardPort)
|
||||
port = &v
|
||||
}
|
||||
return mdm.ResolveConflicts(policy, []mdm.ConflictCheck{
|
||||
mdm.ConflictURL(mdm.KeyManagementURL, input.ManagementURL),
|
||||
mdm.ConflictStringPtr(mdm.KeyPreSharedKey, pskGot),
|
||||
mdm.ConflictBool(mdm.KeyRosenpassEnabled, input.RosenpassEnabled),
|
||||
mdm.ConflictBool(mdm.KeyRosenpassPermissive, input.RosenpassPermissive),
|
||||
mdm.ConflictBool(mdm.KeyDisableAutoConnect, input.DisableAutoConnect),
|
||||
mdm.ConflictBool(mdm.KeyAllowServerSSH, input.ServerSSHAllowed),
|
||||
mdm.ConflictBool(mdm.KeyRemoteJobsAllowed, input.RemoteJobsAllowed),
|
||||
mdm.ConflictBool(mdm.KeyDisableClientRoutes, input.DisableClientRoutes),
|
||||
mdm.ConflictBool(mdm.KeyDisableServerRoutes, input.DisableServerRoutes),
|
||||
mdm.ConflictBool(mdm.KeyBlockInbound, input.BlockInbound),
|
||||
mdm.ConflictInt64(mdm.KeyWireguardPort, port),
|
||||
mdm.ConflictBool(mdm.KeyEnableLocalMetrics, input.LocalMetricsEnabled),
|
||||
mdm.ConflictStringPtr(mdm.KeyLocalMetricsAddress, input.LocalMetricsAddress),
|
||||
})
|
||||
}
|
||||
|
||||
// CheckMDMConflicts returns an ErrMDMManagedFields-wrapped error naming the
|
||||
// conflicting keys, or nil when the input does not fight the policy.
|
||||
func CheckMDMConflicts(input ConfigInput, policy *mdm.Policy) error {
|
||||
conflicts := MDMConflicts(input, policy)
|
||||
if len(conflicts) == 0 {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("%w: %v", ErrMDMManagedFields, conflicts)
|
||||
}
|
||||
@@ -10,24 +10,58 @@ import (
|
||||
"github.com/netbirdio/netbird/client/mdm"
|
||||
)
|
||||
|
||||
// withMDMPolicy temporarily overrides the package-level loadMDMPolicy hook so
|
||||
// apply() observes the supplied Policy. The original loader is restored at
|
||||
// test cleanup.
|
||||
func withMDMPolicy(t *testing.T, policy *mdm.Policy) {
|
||||
// fakeFetcher implements mdm.PolicyFetcher returning a pre-set policy
|
||||
// map. Test helper used to construct a Loader without touching the OS
|
||||
// or any package-level state.
|
||||
type fakeFetcher struct{ values map[string]any }
|
||||
|
||||
func (f *fakeFetcher) Fetch() map[string]any { return f.values }
|
||||
|
||||
// loaderFor builds an mdm.Loader whose loadPlatform returns the
|
||||
// supplied Policy's underlying values.
|
||||
func loaderFor(policy *mdm.Policy) *mdm.Loader {
|
||||
if policy == nil || policy.IsEmpty() {
|
||||
return mdm.NewLoader(&fakeFetcher{values: nil})
|
||||
}
|
||||
values := make(map[string]any)
|
||||
for _, k := range policy.ManagedKeys() {
|
||||
if v, ok := policy.GetString(k); ok {
|
||||
values[k] = v
|
||||
continue
|
||||
}
|
||||
if v, ok := policy.GetInt(k); ok {
|
||||
values[k] = v
|
||||
continue
|
||||
}
|
||||
if v, ok := policy.GetBool(k); ok {
|
||||
values[k] = v
|
||||
continue
|
||||
}
|
||||
if v, ok := policy.GetStringSlice(k); ok {
|
||||
values[k] = v
|
||||
}
|
||||
}
|
||||
return mdm.NewLoader(&fakeFetcher{values: values})
|
||||
}
|
||||
|
||||
// configWithMDM is the test convenience that builds a Config via
|
||||
// UpdateOrCreateConfig and overlays the supplied MDM policy on top —
|
||||
// mirrors the production pattern (Server.getConfig / Client.applyMDMOverlay)
|
||||
// where the Loader lives outside Config and the apply step is driven
|
||||
// by the lifecycle owner.
|
||||
func configWithMDM(t *testing.T, input ConfigInput, policy *mdm.Policy) *Config {
|
||||
t.Helper()
|
||||
prev := loadMDMPolicy
|
||||
loadMDMPolicy = func() *mdm.Policy { return policy }
|
||||
t.Cleanup(func() { loadMDMPolicy = prev })
|
||||
cfg, err := UpdateOrCreateConfig(input)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, cfg)
|
||||
cfg.ApplyMDMPolicy(loaderFor(policy).Load())
|
||||
return cfg
|
||||
}
|
||||
|
||||
func TestApply_MDMEmpty_NoEnforcement(t *testing.T) {
|
||||
withMDMPolicy(t, mdm.NewPolicy(nil))
|
||||
|
||||
cfg, err := UpdateOrCreateConfig(ConfigInput{
|
||||
cfg := configWithMDM(t, ConfigInput{
|
||||
ConfigPath: filepath.Join(t.TempDir(), "config.json"),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, cfg)
|
||||
}, mdm.NewPolicy(nil))
|
||||
|
||||
assert.True(t, cfg.Policy().IsEmpty(), "no MDM source ⇒ empty Policy")
|
||||
assert.False(t, cfg.Policy().HasKey(mdm.KeyManagementURL))
|
||||
@@ -39,18 +73,15 @@ func TestApply_MDMEmpty_NoEnforcement(t *testing.T) {
|
||||
|
||||
func TestApply_MDMOnly_OverridesDefaults(t *testing.T) {
|
||||
const mdmURL = "https://corp.mdm.example.com:443"
|
||||
withMDMPolicy(t, mdm.NewPolicy(map[string]any{
|
||||
|
||||
cfg := configWithMDM(t, ConfigInput{
|
||||
ConfigPath: filepath.Join(t.TempDir(), "config.json"),
|
||||
}, mdm.NewPolicy(map[string]any{
|
||||
mdm.KeyManagementURL: mdmURL,
|
||||
mdm.KeyDisableClientRoutes: true,
|
||||
mdm.KeyBlockInbound: true,
|
||||
}))
|
||||
|
||||
cfg, err := UpdateOrCreateConfig(ConfigInput{
|
||||
ConfigPath: filepath.Join(t.TempDir(), "config.json"),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, cfg)
|
||||
|
||||
assert.Equal(t, mdmURL, cfg.ManagementURL.String())
|
||||
assert.True(t, cfg.DisableClientRoutes)
|
||||
assert.True(t, cfg.BlockInbound)
|
||||
@@ -65,16 +96,12 @@ func TestApply_MDMBeatsCLIInput(t *testing.T) {
|
||||
const mdmURL = "https://mdm.example.com:443"
|
||||
const cliURL = "https://cli.example.com:443"
|
||||
|
||||
withMDMPolicy(t, mdm.NewPolicy(map[string]any{
|
||||
mdm.KeyManagementURL: mdmURL,
|
||||
}))
|
||||
|
||||
cfg, err := UpdateOrCreateConfig(ConfigInput{
|
||||
cfg := configWithMDM(t, ConfigInput{
|
||||
ConfigPath: filepath.Join(t.TempDir(), "config.json"),
|
||||
ManagementURL: cliURL,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, cfg)
|
||||
}, mdm.NewPolicy(map[string]any{
|
||||
mdm.KeyManagementURL: mdmURL,
|
||||
}))
|
||||
|
||||
// MDM wins over CLI-supplied management URL.
|
||||
assert.Equal(t, mdmURL, cfg.ManagementURL.String())
|
||||
@@ -82,16 +109,12 @@ func TestApply_MDMBeatsCLIInput(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestApply_MDMInvalidURL_KeepsPreviousValue(t *testing.T) {
|
||||
withMDMPolicy(t, mdm.NewPolicy(map[string]any{
|
||||
cfg := configWithMDM(t, ConfigInput{
|
||||
ConfigPath: filepath.Join(t.TempDir(), "config.json"),
|
||||
}, mdm.NewPolicy(map[string]any{
|
||||
mdm.KeyManagementURL: "not-a-url",
|
||||
}))
|
||||
|
||||
cfg, err := UpdateOrCreateConfig(ConfigInput{
|
||||
ConfigPath: filepath.Join(t.TempDir(), "config.json"),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, cfg)
|
||||
|
||||
// Invalid MDM URL is logged and skipped: default URL stays in place
|
||||
// to keep the client functional.
|
||||
assert.Equal(t, DefaultManagementURL, cfg.ManagementURL.String())
|
||||
@@ -106,24 +129,20 @@ func TestApply_MDMBoolKeysOverrideOnDiskValue(t *testing.T) {
|
||||
tmp := filepath.Join(t.TempDir(), "config.json")
|
||||
|
||||
// Seed without MDM.
|
||||
withMDMPolicy(t, mdm.NewPolicy(nil))
|
||||
_, err := UpdateOrCreateConfig(ConfigInput{
|
||||
configWithMDM(t, ConfigInput{
|
||||
ConfigPath: tmp,
|
||||
DisableClientRoutes: boolPtr(false),
|
||||
RosenpassEnabled: boolPtr(false),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
}, mdm.NewPolicy(nil))
|
||||
|
||||
// Now enable MDM enforcement for these keys.
|
||||
withMDMPolicy(t, mdm.NewPolicy(map[string]any{
|
||||
cfg := configWithMDM(t, ConfigInput{
|
||||
ConfigPath: tmp,
|
||||
}, mdm.NewPolicy(map[string]any{
|
||||
mdm.KeyDisableClientRoutes: true,
|
||||
mdm.KeyRosenpassEnabled: true,
|
||||
}))
|
||||
|
||||
cfg, err := UpdateOrCreateConfig(ConfigInput{ConfigPath: tmp})
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, cfg)
|
||||
|
||||
assert.True(t, cfg.DisableClientRoutes, "MDM override should flip on-disk false to true")
|
||||
assert.True(t, cfg.RosenpassEnabled)
|
||||
assert.True(t, cfg.Policy().HasKey(mdm.KeyDisableClientRoutes))
|
||||
@@ -134,22 +153,19 @@ func TestApply_MDMLocalMetrics(t *testing.T) {
|
||||
tmp := filepath.Join(t.TempDir(), "config.json")
|
||||
|
||||
// Seed without MDM.
|
||||
withMDMPolicy(t, mdm.NewPolicy(nil))
|
||||
_, err := UpdateOrCreateConfig(ConfigInput{
|
||||
configWithMDM(t, ConfigInput{
|
||||
ConfigPath: tmp,
|
||||
LocalMetricsEnabled: boolPtr(false),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
}, mdm.NewPolicy(nil))
|
||||
|
||||
withMDMPolicy(t, mdm.NewPolicy(map[string]any{
|
||||
// Now enable MDM enforcement for these keys.
|
||||
cfg := configWithMDM(t, ConfigInput{
|
||||
ConfigPath: tmp,
|
||||
}, mdm.NewPolicy(map[string]any{
|
||||
mdm.KeyEnableLocalMetrics: true,
|
||||
mdm.KeyLocalMetricsAddress: "127.0.0.1:9292",
|
||||
}))
|
||||
|
||||
cfg, err := UpdateOrCreateConfig(ConfigInput{ConfigPath: tmp})
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, cfg)
|
||||
|
||||
assert.True(t, cfg.LocalMetricsEnabled, "MDM override should flip on-disk false to true")
|
||||
assert.Equal(t, "127.0.0.1:9292", cfg.LocalMetricsAddress)
|
||||
assert.True(t, cfg.Policy().HasKey(mdm.KeyEnableLocalMetrics))
|
||||
@@ -171,16 +187,12 @@ func TestApply_MDMLazyConnection(t *testing.T) {
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
withMDMPolicy(t, mdm.NewPolicy(map[string]any{
|
||||
cfg := configWithMDM(t, ConfigInput{
|
||||
ConfigPath: filepath.Join(t.TempDir(), "config.json"),
|
||||
}, mdm.NewPolicy(map[string]any{
|
||||
mdm.KeyLazyConnection: c.raw,
|
||||
}))
|
||||
|
||||
cfg, err := UpdateOrCreateConfig(ConfigInput{
|
||||
ConfigPath: filepath.Join(t.TempDir(), "config.json"),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, cfg)
|
||||
|
||||
assert.Equal(t, c.want, cfg.LazyConnection)
|
||||
assert.True(t, cfg.Policy().HasKey(mdm.KeyLazyConnection))
|
||||
})
|
||||
@@ -188,22 +200,83 @@ func TestApply_MDMLazyConnection(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestApply_MDMPreSharedKeyRedactionSentinelRejected(t *testing.T) {
|
||||
const maskSentinel = "**********"
|
||||
const maskSentinel = mdm.PreSharedKeyRedactedSentinel
|
||||
|
||||
withMDMPolicy(t, mdm.NewPolicy(map[string]any{
|
||||
cfg := configWithMDM(t, ConfigInput{
|
||||
ConfigPath: filepath.Join(t.TempDir(), "config.json"),
|
||||
}, mdm.NewPolicy(map[string]any{
|
||||
mdm.KeyPreSharedKey: maskSentinel,
|
||||
}))
|
||||
|
||||
cfg, err := UpdateOrCreateConfig(ConfigInput{
|
||||
ConfigPath: filepath.Join(t.TempDir(), "config.json"),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, cfg)
|
||||
|
||||
// Mask sentinel must not be persisted as the actual PSK.
|
||||
assert.NotEqual(t, maskSentinel, cfg.PreSharedKey)
|
||||
// Key still marked managed so user writes are still rejected.
|
||||
assert.True(t, cfg.Policy().HasKey(mdm.KeyPreSharedKey))
|
||||
}
|
||||
|
||||
func TestMDMConflicts_PreSharedKey(t *testing.T) {
|
||||
policy := mdm.NewPolicy(map[string]any{
|
||||
mdm.KeyPreSharedKey: "mdm-enforced-psk",
|
||||
})
|
||||
empty := ""
|
||||
sentinel := mdm.PreSharedKeyRedactedSentinel
|
||||
same := "mdm-enforced-psk"
|
||||
other := "user-psk"
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
psk *string
|
||||
want []string
|
||||
}{
|
||||
{name: "unset", psk: nil, want: nil},
|
||||
{name: "explicit empty", psk: &empty, want: []string{mdm.KeyPreSharedKey}},
|
||||
{name: "sentinel echo", psk: &sentinel, want: nil},
|
||||
{name: "same value", psk: &same, want: nil},
|
||||
{name: "divergent", psk: &other, want: []string{mdm.KeyPreSharedKey}},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
assert.Equal(t, tc.want, MDMConflicts(ConfigInput{PreSharedKey: tc.psk}, policy))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestMDMConflicts_RemoteJobsAndLocalMetrics(t *testing.T) {
|
||||
policy := mdm.NewPolicy(map[string]any{
|
||||
mdm.KeyRemoteJobsAllowed: false,
|
||||
mdm.KeyEnableLocalMetrics: true,
|
||||
mdm.KeyLocalMetricsAddress: "127.0.0.1:9999",
|
||||
})
|
||||
sameAddr := "127.0.0.1:9999"
|
||||
otherAddr := "0.0.0.0:9999"
|
||||
emptyAddr := ""
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
input ConfigInput
|
||||
want []string
|
||||
}{
|
||||
{name: "unset", input: ConfigInput{}, want: nil},
|
||||
{name: "echo", input: ConfigInput{
|
||||
RemoteJobsAllowed: boolPtr(false),
|
||||
LocalMetricsEnabled: boolPtr(true),
|
||||
LocalMetricsAddress: &sameAddr,
|
||||
}, want: nil},
|
||||
{name: "remote jobs divergent", input: ConfigInput{RemoteJobsAllowed: boolPtr(true)}, want: []string{mdm.KeyRemoteJobsAllowed}},
|
||||
{name: "metrics disabled", input: ConfigInput{LocalMetricsEnabled: boolPtr(false)}, want: []string{mdm.KeyEnableLocalMetrics}},
|
||||
{name: "metrics address divergent", input: ConfigInput{LocalMetricsAddress: &otherAddr}, want: []string{mdm.KeyLocalMetricsAddress}},
|
||||
{name: "metrics address explicit empty", input: ConfigInput{LocalMetricsAddress: &emptyAddr}, want: []string{mdm.KeyLocalMetricsAddress}},
|
||||
{name: "all divergent", input: ConfigInput{
|
||||
RemoteJobsAllowed: boolPtr(true),
|
||||
LocalMetricsEnabled: boolPtr(false),
|
||||
LocalMetricsAddress: &otherAddr,
|
||||
}, want: []string{mdm.KeyRemoteJobsAllowed, mdm.KeyEnableLocalMetrics, mdm.KeyLocalMetricsAddress}},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
assert.Equal(t, tc.want, MDMConflicts(tc.input, policy))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func boolPtr(b bool) *bool { return &b }
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"os/user"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strconv"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
@@ -13,17 +14,21 @@ import (
|
||||
const envSudoUser = "SUDO_USER"
|
||||
|
||||
var (
|
||||
geteuid = os.Geteuid
|
||||
lookupUser = user.Lookup
|
||||
currentUser = user.Current
|
||||
getegid = os.Getegid
|
||||
geteuid = os.Geteuid
|
||||
lookupUser = user.Lookup
|
||||
)
|
||||
|
||||
// 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
|
||||
// sudo, and resolving profiles as root would silently switch the daemon to
|
||||
// root's (default) profile instead of the invoking user's. Privilege decisions
|
||||
// are not made here — those stay 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.
|
||||
// root's (default) profile instead of the invoking user's. An unmapped positive
|
||||
// process UID uses its numeric kernel identity; root, sudo lookup failures, and
|
||||
// unavailable platform identities still fail closed. Privilege decisions stay
|
||||
// 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) {
|
||||
if u, ok := sudoInvokingUser(); ok {
|
||||
return u, nil
|
||||
@@ -35,7 +40,23 @@ func InvokingUser() (*user.User, error) {
|
||||
if sudoActive() {
|
||||
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
|
||||
|
||||
@@ -2,6 +2,7 @@ package profilemanager
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"os"
|
||||
"os/user"
|
||||
@@ -21,7 +22,51 @@ func TestInvokingUserFallsBackToProcessUser(t *testing.T) {
|
||||
|
||||
current, err := user.Current()
|
||||
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) {
|
||||
@@ -60,6 +105,13 @@ func TestInvokingUserFailsClosedWhenSudoLookupFails(t *testing.T) {
|
||||
fakeSudo(t, filepath.Join("/home", "misha"))
|
||||
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()
|
||||
require.Error(t, err)
|
||||
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) {
|
||||
t.Helper()
|
||||
err := filepath.WalkDir(root, func(path string, _ fs.DirEntry, err error) error {
|
||||
|
||||
@@ -88,9 +88,15 @@ type Client struct {
|
||||
// netMgr outlives engine restarts: it mirrors the OS connectivity, not
|
||||
// the engine lifecycle. Run injects its state and sweeper into each new
|
||||
// ConnectClient.
|
||||
netMgr *netevents.Manager
|
||||
// preloadedConfig holds config loaded from JSON (used on tvOS where file writes are blocked)
|
||||
preloadedConfig *profilemanager.Config
|
||||
netMgr *netevents.Manager
|
||||
preloadedConfigJSON atomic.Pointer[string]
|
||||
|
||||
// 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
|
||||
// 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.
|
||||
// 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.
|
||||
// SetConfigFromJSON stores the JSON config that later loads resolve instead of the config file (tvOS).
|
||||
func (c *Client) SetConfigFromJSON(jsonStr string) error {
|
||||
cfg, err := profilemanager.ConfigFromJSON(jsonStr)
|
||||
if err != nil {
|
||||
if _, err := profilemanager.ConfigFromJSON(jsonStr); err != nil {
|
||||
log.Errorf("SetConfigFromJSON: failed to parse config JSON: %v", err)
|
||||
return err
|
||||
}
|
||||
c.preloadedConfig = cfg
|
||||
c.preloadedConfigJSON.Store(&jsonStr)
|
||||
log.Infof("SetConfigFromJSON: config loaded successfully from JSON")
|
||||
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
|
||||
func (c *Client) Run(fd int32, interfaceName string, envList *EnvList) error {
|
||||
exportEnvList(envList)
|
||||
log.Infof("Starting NetBird client")
|
||||
log.Debugf("Tunnel uses interface: %s", interfaceName)
|
||||
|
||||
var cfg *profilemanager.Config
|
||||
var err error
|
||||
|
||||
// Use preloaded config if available (tvOS where file writes are blocked)
|
||||
if c.preloadedConfig != nil {
|
||||
log.Infof("Run: using preloaded config from memory")
|
||||
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
|
||||
}
|
||||
cfg, err := c.loadConfig(profilemanager.ConfigInput{
|
||||
ConfigPath: c.cfgFile,
|
||||
StateFilePath: c.stateFile,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
c.recorder.UpdateManagementAddress(cfg.ManagementURL.String())
|
||||
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 cfg == nil {
|
||||
if c.preloadedConfig != nil {
|
||||
cfg = c.preloadedConfig
|
||||
} else {
|
||||
var err error
|
||||
// Use DirectUpdateOrCreateConfig to avoid atomic file operations
|
||||
// (temp file + rename) blocked by the tvOS sandbox.
|
||||
cfg, err = profilemanager.DirectUpdateOrCreateConfig(profilemanager.ConfigInput{
|
||||
ConfigPath: c.cfgFile,
|
||||
StateFilePath: c.stateFile,
|
||||
})
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("load config: %w", err)
|
||||
}
|
||||
var err error
|
||||
cfg, err = c.loadConfig(profilemanager.ConfigInput{
|
||||
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)
|
||||
defer cancel()
|
||||
|
||||
var cfg *profilemanager.Config
|
||||
var err error
|
||||
|
||||
// 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")
|
||||
cfg, err := c.loadConfig(profilemanager.ConfigInput{ConfigPath: c.cfgFile})
|
||||
if err != nil {
|
||||
log.Errorf("IsLoginRequired: failed to load config: %v", err)
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -493,6 +473,7 @@ func (c *Client) LoginForMobile() string {
|
||||
log.Errorf("LoginForMobile: 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, "")
|
||||
if err != nil {
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
|
||||
"github.com/netbirdio/netbird/client/internal/auth"
|
||||
"github.com/netbirdio/netbird/client/internal/profilemanager"
|
||||
"github.com/netbirdio/netbird/client/mdm"
|
||||
"github.com/netbirdio/netbird/client/mobile"
|
||||
"github.com/netbirdio/netbird/client/system"
|
||||
)
|
||||
@@ -39,14 +40,22 @@ type Auth struct {
|
||||
ctx context.Context
|
||||
cancel context.CancelFunc
|
||||
config *profilemanager.Config
|
||||
base *profilemanager.Config
|
||||
policy *mdm.Policy
|
||||
cfgPath string
|
||||
}
|
||||
|
||||
// NewAuth instantiate Auth struct and validate the management URL
|
||||
func NewAuth(cfgPath string, mgmURL string) (*Auth, error) {
|
||||
inputCfg := profilemanager.ConfigInput{
|
||||
ConfigPath: cfgPath,
|
||||
ManagementURL: mgmURL,
|
||||
// NewAuth instantiate Auth struct and validate the management URL.
|
||||
// Auth is constructed under the active MDM policy: the policy is overlaid on
|
||||
// the resolved config so the login runs against the enforced values, while
|
||||
// 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
|
||||
}
|
||||
|
||||
// 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 {
|
||||
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
|
||||
// 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
|
||||
// lingers after the user dismisses the browser and the next connect stalls
|
||||
// trying to bind the same port.
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
|
||||
return &Auth{
|
||||
ctx: ctx,
|
||||
cancel: cancel,
|
||||
config: cfg,
|
||||
cfgPath: cfgPath,
|
||||
}, nil
|
||||
a.ctx, a.cancel = context.WithCancel(context.Background())
|
||||
return a, nil
|
||||
}
|
||||
|
||||
// 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.
|
||||
// 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.
|
||||
// SaveConfigIfSSOSupported reports whether the management server supports SSO; the config is already persisted by NewAuth.
|
||||
func (a *Auth) SaveConfigIfSSOSupported(listener SSOListener) {
|
||||
if listener == 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)
|
||||
}
|
||||
|
||||
if !supportsSSO {
|
||||
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
|
||||
return supportsSSO, nil
|
||||
}
|
||||
|
||||
// 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) {
|
||||
if resultListener == nil {
|
||||
log.Errorf("LoginWithSetupKeyAndSaveConfig: resultListener is nil")
|
||||
@@ -175,10 +173,7 @@ func (a *Auth) loginWithSetupKeyAndSaveConfig(setupKey string, deviceName string
|
||||
if err != nil {
|
||||
return fmt.Errorf("login failed: %v", err)
|
||||
}
|
||||
|
||||
// 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)
|
||||
return nil
|
||||
}
|
||||
|
||||
// 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
|
||||
urlOpener.OnLoginSuccess()
|
||||
|
||||
@@ -375,23 +357,44 @@ func (a *Auth) foregroundGetTokenInfo(authClient *auth.Auth, urlOpener URLOpener
|
||||
return &tokenInfo, nil
|
||||
}
|
||||
|
||||
// GetConfigJSON returns the current config as a JSON string.
|
||||
// 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).
|
||||
// GetConfigJSON returns the config without the MDM overlay as JSON, for persisting it outside the config file (tvOS).
|
||||
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 profilemanager.ConfigToJSON(a.config)
|
||||
return profilemanager.ConfigToJSON(cfg)
|
||||
}
|
||||
|
||||
// SetConfigFromJSON loads config from a JSON string.
|
||||
// This can be used to restore config from alternative storage mechanisms.
|
||||
// SetConfigFromJSON replaces the config from JSON; the MDM overlay is applied on top for the login.
|
||||
func (a *Auth) SetConfigFromJSON(jsonStr string) error {
|
||||
cfg, err := profilemanager.ConfigFromJSON(jsonStr)
|
||||
if err != nil {
|
||||
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
|
||||
}
|
||||
|
||||
func copyConfig(cfg *profilemanager.Config) (*profilemanager.Config, error) {
|
||||
raw, err := profilemanager.ConfigToJSON(cfg)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return profilemanager.ConfigFromJSON(raw)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
//go:build ios
|
||||
|
||||
package NetBirdSDK
|
||||
|
||||
import (
|
||||
"github.com/netbirdio/netbird/client/internal/profilemanager"
|
||||
"github.com/netbirdio/netbird/client/mdm"
|
||||
)
|
||||
|
||||
// PolicyFetcher is implemented by the native layer to return the current
|
||||
// managed configuration as a JSON-encoded object string; "" means no MDM
|
||||
// source is present.
|
||||
type PolicyFetcher interface {
|
||||
FetchJSON() string
|
||||
}
|
||||
|
||||
type mdmSource struct {
|
||||
loader *mdm.Loader
|
||||
detector *mdm.ChangeDetector
|
||||
}
|
||||
|
||||
// SetMDMPolicyFetcher registers the native-provided MDM policy fetcher on
|
||||
// this Client; passing nil disables MDM enforcement.
|
||||
func (c *Client) SetMDMPolicyFetcher(p PolicyFetcher) {
|
||||
loader := loaderFor(p)
|
||||
c.mdmSource.Store(&mdmSource{loader: loader, detector: mdm.NewChangeDetector(loader)})
|
||||
}
|
||||
|
||||
// HasMDMPolicyChanged re-reads the managed configuration and reports whether
|
||||
// it changed since the last observation; call it from the native OS-change
|
||||
// notification and restart the engine only on true.
|
||||
func (c *Client) HasMDMPolicyChanged() bool {
|
||||
src := c.mdmSource.Load()
|
||||
if src == nil {
|
||||
return false
|
||||
}
|
||||
return src.detector.Changed()
|
||||
}
|
||||
|
||||
// GetRestrictionsJSON returns the UI enforcement snapshot derived from the
|
||||
// active MDM policy, in the JSON shape shared with the desktop frontend.
|
||||
func (c *Client) GetRestrictionsJSON() (string, error) {
|
||||
return mdm.BuildRestrictions(c.mdmLoader().Load()).JSON()
|
||||
}
|
||||
|
||||
func (c *Client) applyMDMOverlay(cfg *profilemanager.Config) {
|
||||
loader := c.mdmLoader()
|
||||
if cfg == nil || loader == nil {
|
||||
return
|
||||
}
|
||||
cfg.ApplyMDMPolicy(loader.Load())
|
||||
}
|
||||
|
||||
func (c *Client) mdmLoader() *mdm.Loader {
|
||||
if src := c.mdmSource.Load(); src != nil {
|
||||
return src.loader
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func loaderFor(p PolicyFetcher) *mdm.Loader {
|
||||
if p == nil {
|
||||
return mdm.NewJSONLoader(nil)
|
||||
}
|
||||
return mdm.NewJSONLoader(p.FetchJSON)
|
||||
}
|
||||
@@ -3,12 +3,16 @@
|
||||
package NetBirdSDK
|
||||
|
||||
import (
|
||||
"sync/atomic"
|
||||
|
||||
"github.com/netbirdio/netbird/client/internal/profilemanager"
|
||||
"github.com/netbirdio/netbird/client/mdm"
|
||||
)
|
||||
|
||||
// Preferences export a subset of the internal config for gomobile
|
||||
type Preferences struct {
|
||||
configInput profilemanager.ConfigInput
|
||||
mdmLoader atomic.Pointer[mdm.Loader]
|
||||
}
|
||||
|
||||
// NewPreferences create new Preferences instance
|
||||
@@ -17,11 +21,30 @@ func NewPreferences(configPath string, stateFilePath string) *Preferences {
|
||||
ConfigPath: configPath,
|
||||
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
|
||||
func (p *Preferences) GetManagementURL() (string, error) {
|
||||
if v, ok := p.policy().GetString(mdm.KeyManagementURL); ok {
|
||||
return mdm.CanonicalURL(v), nil
|
||||
}
|
||||
if p.configInput.ManagementURL != "" {
|
||||
return p.configInput.ManagementURL, nil
|
||||
}
|
||||
@@ -30,7 +53,7 @@ func (p *Preferences) GetManagementURL() (string, error) {
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return cfg.ManagementURL.String(), err
|
||||
return cfg.ManagementURL.String(), nil
|
||||
}
|
||||
|
||||
// SetManagementURL store the given url and wait for commit
|
||||
@@ -56,17 +79,21 @@ func (p *Preferences) SetAdminURL(url string) {
|
||||
p.configInput.AdminURL = url
|
||||
}
|
||||
|
||||
// GetPreSharedKey read preshared key from config file
|
||||
func (p *Preferences) GetPreSharedKey() (string, error) {
|
||||
// HasPreSharedKey reports whether a pre-shared key is staged, persisted, or
|
||||
// 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 {
|
||||
return *p.configInput.PreSharedKey, nil
|
||||
return *p.configInput.PreSharedKey != "", nil
|
||||
}
|
||||
|
||||
cfg, err := profilemanager.ReadConfig(p.configInput.ConfigPath)
|
||||
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
|
||||
@@ -81,6 +108,9 @@ func (p *Preferences) SetRosenpassEnabled(enabled bool) {
|
||||
|
||||
// GetRosenpassEnabled read rosenpass enabled from config file
|
||||
func (p *Preferences) GetRosenpassEnabled() (bool, error) {
|
||||
if v, ok := p.policy().GetBool(mdm.KeyRosenpassEnabled); ok {
|
||||
return v, nil
|
||||
}
|
||||
if 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
|
||||
func (p *Preferences) GetRosenpassPermissive() (bool, error) {
|
||||
if v, ok := p.policy().GetBool(mdm.KeyRosenpassPermissive); ok {
|
||||
return v, nil
|
||||
}
|
||||
if 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
|
||||
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
|
||||
}
|
||||
|
||||
@@ -138,10 +172,11 @@ func (p *Preferences) GetRemoteJobsAllowed() (bool, error) {
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
cfg.ApplyMDMPolicy(policy)
|
||||
if cfg.RemoteJobsAllowed == nil {
|
||||
return false, nil
|
||||
}
|
||||
return *cfg.RemoteJobsAllowed, err
|
||||
return *cfg.RemoteJobsAllowed, nil
|
||||
}
|
||||
|
||||
// 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
|
||||
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)
|
||||
// which are blocked by the tvOS sandbox in App Group containers
|
||||
_, err := profilemanager.DirectUpdateOrCreateConfig(p.configInput)
|
||||
|
||||
@@ -31,14 +31,13 @@ func TestPreferences_DefaultValues(t *testing.T) {
|
||||
t.Errorf("invalid default management url: %s", defaultVar)
|
||||
}
|
||||
|
||||
var preSharedKey string
|
||||
preSharedKey, err = p.GetPreSharedKey()
|
||||
hasPSK, err := p.HasPreSharedKey()
|
||||
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 != "" {
|
||||
t.Errorf("invalid preshared key: %s", preSharedKey)
|
||||
if hasPSK {
|
||||
t.Errorf("unexpected preshared key presence on fresh config")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -69,13 +68,13 @@ func TestPreferences_ReadUncommitedValues(t *testing.T) {
|
||||
}
|
||||
|
||||
p.SetPreSharedKey(exampleString)
|
||||
resp, err = p.GetPreSharedKey()
|
||||
hasPSK, err := p.HasPreSharedKey()
|
||||
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 {
|
||||
t.Errorf("unexpected preshared key: %s", resp)
|
||||
if !hasPSK {
|
||||
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)
|
||||
}
|
||||
|
||||
resp, err = p.GetPreSharedKey()
|
||||
hasPSK, err := p.HasPreSharedKey()
|
||||
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 {
|
||||
t.Errorf("unexpected preshared key: %s", resp)
|
||||
if !hasPSK {
|
||||
t.Errorf("expected preshared key presence after commit")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -52,6 +52,12 @@ func NewProfileManager(configDir string) *ProfileManager {
|
||||
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,
|
||||
// with their active status set.
|
||||
func (pm *ProfileManager) ListProfiles() (*ProfileArray, error) {
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
package mdm
|
||||
|
||||
import "sync"
|
||||
|
||||
// ChangeDetector tracks the last observed policy of a Loader so an
|
||||
// OS-notification-driven caller can ask whether the managed configuration
|
||||
// actually changed before restarting anything.
|
||||
type ChangeDetector struct {
|
||||
mu sync.Mutex
|
||||
loader *Loader
|
||||
prev *Policy
|
||||
}
|
||||
|
||||
// NewChangeDetector constructs a ChangeDetector seeded with the loader's
|
||||
// current policy, so only a later change reports as changed.
|
||||
func NewChangeDetector(loader *Loader) *ChangeDetector {
|
||||
return &ChangeDetector{
|
||||
loader: loader,
|
||||
prev: loader.Load(),
|
||||
}
|
||||
}
|
||||
|
||||
// Changed re-reads the policy, logs the per-key diff, and reports whether it
|
||||
// diverged from the last observation; the new snapshot becomes the baseline.
|
||||
func (d *ChangeDetector) Changed() bool {
|
||||
d.mu.Lock()
|
||||
defer d.mu.Unlock()
|
||||
curr := d.loader.Load()
|
||||
if !policyChanged(d.prev, curr) {
|
||||
return false
|
||||
}
|
||||
d.prev = curr
|
||||
return true
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
package mdm
|
||||
|
||||
import (
|
||||
"net/url"
|
||||
|
||||
"github.com/netbirdio/netbird/util"
|
||||
)
|
||||
|
||||
// PreSharedKeyRedactedSentinel is the redaction mask returned in place of a
|
||||
// real pre-shared key; an incoming value equal to it is a round-trip echo,
|
||||
// never an override.
|
||||
const PreSharedKeyRedactedSentinel = "**********"
|
||||
|
||||
// ConflictCheck is a value-aware comparison between a single requested field
|
||||
// and the corresponding MDM-enforced value.
|
||||
type ConflictCheck struct {
|
||||
Key string
|
||||
Check func(*Policy) bool
|
||||
}
|
||||
|
||||
// ConflictBool builds a ConflictCheck for a boolean MDM key.
|
||||
func ConflictBool(key string, p *bool) ConflictCheck {
|
||||
return ConflictCheck{
|
||||
Key: key,
|
||||
Check: func(pol *Policy) bool {
|
||||
if p == nil {
|
||||
return true
|
||||
}
|
||||
want, ok := pol.GetBool(key)
|
||||
return ok && want == *p
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// ConflictStringPtr builds a ConflictCheck for an optional string MDM key,
|
||||
// where an explicit empty value is still a request to change the setting. A
|
||||
// nil p means "field not set" (no override requested).
|
||||
func ConflictStringPtr(key string, p *string) ConflictCheck {
|
||||
return ConflictCheck{
|
||||
Key: key,
|
||||
Check: func(pol *Policy) bool {
|
||||
if p == nil {
|
||||
return true
|
||||
}
|
||||
want, ok := pol.GetString(key)
|
||||
return ok && want == *p
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// ConflictURL builds a ConflictCheck for a URL-typed MDM key. The two sides are
|
||||
// compared as the endpoints they address, not as strings: see
|
||||
// util.SameServiceURL.
|
||||
func ConflictURL(key, got string) ConflictCheck {
|
||||
return ConflictCheck{
|
||||
Key: key,
|
||||
Check: func(pol *Policy) bool {
|
||||
if got == "" {
|
||||
return true
|
||||
}
|
||||
want, ok := pol.GetString(key)
|
||||
return ok && util.SameServiceURLStrings(want, got)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// ConflictInt64 builds a ConflictCheck for an integer MDM key.
|
||||
func ConflictInt64(key string, p *int64) ConflictCheck {
|
||||
return ConflictCheck{
|
||||
Key: key,
|
||||
Check: func(pol *Policy) bool {
|
||||
if p == nil {
|
||||
return true
|
||||
}
|
||||
want, ok := pol.GetInt(key)
|
||||
return ok && want == *p
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// ResolveConflicts returns the names of keys whose requested value diverges
|
||||
// from the policy-enforced value; keys the policy does not manage are skipped,
|
||||
// a managed key without a Check counts as a conflict.
|
||||
func ResolveConflicts(policy *Policy, checks []ConflictCheck) []string {
|
||||
if policy.IsEmpty() {
|
||||
return nil
|
||||
}
|
||||
var conflicts []string
|
||||
for _, c := range checks {
|
||||
if !policy.HasKey(c.Key) {
|
||||
continue
|
||||
}
|
||||
if c.Check == nil || !c.Check(policy) {
|
||||
conflicts = append(conflicts, c.Key)
|
||||
}
|
||||
}
|
||||
return conflicts
|
||||
}
|
||||
|
||||
// CanonicalURL normalizes a service URL by appending the scheme default port
|
||||
// when none is present; unparseable input is returned unchanged.
|
||||
func CanonicalURL(s string) string {
|
||||
u, err := url.ParseRequestURI(s)
|
||||
if err != nil {
|
||||
return s
|
||||
}
|
||||
if u.Port() == "" {
|
||||
switch u.Scheme {
|
||||
case "https":
|
||||
u.Host += ":443"
|
||||
case "http":
|
||||
u.Host += ":80"
|
||||
}
|
||||
}
|
||||
return u.String()
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
package mdm
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// The same spellings, through the conflict check that decides whether a request
|
||||
// is refused. An enforced URL restated in another spelling addresses the very
|
||||
// server the policy names, so it must not be reported as a conflict.
|
||||
func TestConflictURLComparesEndpoints(t *testing.T) {
|
||||
policy := NewPolicy(map[string]any{KeyManagementURL: "https://mgmt.example.com"})
|
||||
require.True(t, policy.HasKey(KeyManagementURL))
|
||||
|
||||
for _, restated := range []string{
|
||||
"https://mgmt.example.com",
|
||||
"https://mgmt.example.com:443",
|
||||
"https://mgmt.example.com/",
|
||||
"https://MGMT.example.com",
|
||||
"https://mgmt.example.com:0443",
|
||||
} {
|
||||
conflicts := ResolveConflicts(policy, []ConflictCheck{ConflictURL(KeyManagementURL, restated)})
|
||||
assert.Empty(t, conflicts, "%q is the enforced endpoint written differently", restated)
|
||||
}
|
||||
|
||||
for _, diverging := range []string{
|
||||
"https://other.example.com",
|
||||
"http://mgmt.example.com",
|
||||
"https://mgmt.example.com:8443",
|
||||
"https://mgmt.example.com/other",
|
||||
} {
|
||||
conflicts := ResolveConflicts(policy, []ConflictCheck{ConflictURL(KeyManagementURL, diverging)})
|
||||
assert.Equal(t, []string{KeyManagementURL}, conflicts, "%q addresses another endpoint", diverging)
|
||||
}
|
||||
|
||||
// An unset field is not a request to change anything.
|
||||
assert.Empty(t, ResolveConflicts(policy, []ConflictCheck{ConflictURL(KeyManagementURL, "")}))
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package mdm
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
type jsonPolicyFetcher struct {
|
||||
fetch func() string
|
||||
}
|
||||
|
||||
// NewJSONLoader constructs a Loader whose policy source is a JSON-encoded
|
||||
// object string, as produced by the mobile native layers; a nil fetch
|
||||
// disables MDM enforcement.
|
||||
func NewJSONLoader(fetch func() string) *Loader {
|
||||
if fetch == nil {
|
||||
return NewLoader(nil)
|
||||
}
|
||||
return NewLoader(&jsonPolicyFetcher{fetch: fetch})
|
||||
}
|
||||
|
||||
func (f *jsonPolicyFetcher) Fetch() map[string]any {
|
||||
raw := f.fetch()
|
||||
if raw == "" {
|
||||
return nil
|
||||
}
|
||||
var out map[string]any
|
||||
if err := json.Unmarshal([]byte(raw), &out); err != nil {
|
||||
log.Warnf("MDM mobile fetcher: invalid JSON payload from native: %v", err)
|
||||
return nil
|
||||
}
|
||||
return out
|
||||
}
|
||||
+38
-6
@@ -119,16 +119,46 @@ func NewPolicy(values map[string]any) *Policy {
|
||||
return &Policy{values: values}
|
||||
}
|
||||
|
||||
// LoadPolicy reads the platform-native MDM configuration. Returns an
|
||||
// empty (but non-nil) Policy when no source is present, the source is
|
||||
// empty, or the platform is unsupported.
|
||||
// PolicyFetcher supplies the managed configuration to a Loader. Mobile
|
||||
// platforms (Android / iOS) implement it to push the OS-managed values
|
||||
// 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:
|
||||
// - source absent / unsupported platform: trace log only
|
||||
// - source present, zero keys: info "MDM enrolled (no managed keys)"
|
||||
// - source present, N keys: info "MDM enrolled with N managed keys: [...]"
|
||||
func LoadPolicy() *Policy {
|
||||
values, err := loadPlatformPolicy()
|
||||
func (l *Loader) Load() *Policy {
|
||||
if l == nil {
|
||||
return &Policy{values: map[string]any{}}
|
||||
}
|
||||
values, err := l.loadPlatform()
|
||||
if err != nil {
|
||||
log.Tracef("MDM policy load: %v", err)
|
||||
return &Policy{values: map[string]any{}}
|
||||
@@ -205,6 +235,8 @@ func (p *Policy) GetBool(key string) (bool, bool) {
|
||||
return t != 0, true
|
||||
case int64:
|
||||
return t != 0, true
|
||||
case float64:
|
||||
return t != 0, true
|
||||
}
|
||||
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
|
||||
// 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
|
||||
// regardless of Go's randomised map iteration.
|
||||
func sortedKeys(m map[string]any) []string {
|
||||
|
||||
@@ -25,8 +25,9 @@ import (
|
||||
// writable plist, as a defense against tampered installs.
|
||||
const policyPlistPath = "/Library/Managed Preferences/io.netbird.client.plist"
|
||||
|
||||
// loadPlatformPolicy reads the MDM-managed configuration from the macOS
|
||||
// managed-preferences plist at policyPlistPath. Returns:
|
||||
// loadPlatform reads the MDM-managed configuration from the macOS
|
||||
// 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
|
||||
// NetBird, or admin has not yet pushed a payload)
|
||||
// - (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.
|
||||
// Native plist value types map naturally onto the Policy accessor
|
||||
// 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)
|
||||
if err != nil {
|
||||
if errors.Is(err, fs.ErrNotExist) {
|
||||
// Not enrolled for NetBird. 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, fmt.Errorf("open %s: %w", policyPlistPath, err)
|
||||
|
||||
@@ -2,13 +2,14 @@
|
||||
|
||||
package mdm
|
||||
|
||||
// loadPlatformPolicy is unused on mobile: the native layer (Swift on iOS,
|
||||
// Kotlin/Java on Android) reads the OS managed-config store and pushes the
|
||||
// resulting dictionary in-process via a gomobile entry point that lands in
|
||||
// Phase 5 / Phase 6. The stub keeps the package compilable for mobile
|
||||
// builds and returns (nil, nil) — the platform-absent sentinel that
|
||||
// LoadPolicy in policy.go treats as "no MDM source present".
|
||||
func loadPlatformPolicy() (map[string]any, error) {
|
||||
//nolint:nilnil // (nil, nil) is the documented platform-absent sentinel; see LoadPolicy.
|
||||
return nil, nil
|
||||
// loadPlatform reads the OS-managed configuration via the native
|
||||
// PolicyFetcher injected at Loader construction. Returns
|
||||
// (nil, nil) — the platform-absent sentinel that Loader.Load treats as
|
||||
// "no MDM source present" — when no fetcher was provided.
|
||||
func (l *Loader) loadPlatform() (map[string]any, error) {
|
||||
if l == nil || l.fetcher == nil {
|
||||
//nolint:nilnil // (nil, nil) is the documented platform-absent sentinel; see Loader.Load.
|
||||
return nil, nil
|
||||
}
|
||||
return l.fetcher.Fetch(), nil
|
||||
}
|
||||
|
||||
@@ -2,13 +2,17 @@
|
||||
|
||||
package mdm
|
||||
|
||||
// loadPlatformPolicy returns no policy on platforms without an MDM channel
|
||||
// (Linux, FreeBSD). MDM enforcement is off and the client behaves as if
|
||||
// the feature did not exist. Returns (nil, nil) — the platform-absent
|
||||
// sentinel the caller (LoadPolicy in policy.go) treats as "no MDM
|
||||
// source present"; an error here would just translate to the same
|
||||
// outcome with an extra log line.
|
||||
func loadPlatformPolicy() (map[string]any, error) {
|
||||
//nolint:nilnil // (nil, nil) is the documented platform-absent sentinel; see LoadPolicy.
|
||||
// loadPlatform reads the MDM policy on platforms without a native MDM
|
||||
// channel (Linux, FreeBSD). When no fetcher was injected the policy is
|
||||
// (nil, nil) — the platform-absent sentinel that Loader.Load treats as
|
||||
// "MDM enforcement disabled". A non-nil fetcher takes precedence: it
|
||||
// is the test-seam used by unit tests to inject a scripted policy
|
||||
// without touching the OS, and the same hook supports any future
|
||||
// non-mobile OS that grows an out-of-band MDM channel.
|
||||
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
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package mdm
|
||||
|
||||
import (
|
||||
"runtime"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
@@ -95,7 +96,8 @@ func TestPolicy_GetBool(t *testing.T) {
|
||||
{"int64 nonzero", int64(2), true, true},
|
||||
{"int64 zero", int64(0), false, true},
|
||||
{"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 {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
@@ -155,10 +157,29 @@ func TestPolicy_GetStringSlice(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
func TestLoadPolicy_PlatformStubReturnsEmpty(t *testing.T) {
|
||||
// loadPlatformPolicy is a stub on every OS for Phase 1. LoadPolicy must
|
||||
// degrade gracefully and never return nil.
|
||||
p := LoadPolicy()
|
||||
// encoding/json decodes every JSON number into float64, so the mobile
|
||||
// loaders never see int.
|
||||
func TestJSONLoader_BoolFromNumber(t *testing.T) {
|
||||
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)
|
||||
assert.True(t, p.IsEmpty())
|
||||
assert.Empty(t, p.ManagedKeys())
|
||||
|
||||
@@ -61,8 +61,9 @@ func readRegistryValue(k registry.Key, name, canonical string, out map[string]an
|
||||
}
|
||||
}
|
||||
|
||||
// loadPlatformPolicy reads the MDM-managed configuration from the
|
||||
// Windows registry under HKLM\Software\Policies\NetBird. Returns:
|
||||
// loadPlatform reads the MDM-managed configuration from the Windows
|
||||
// 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)
|
||||
// - (map, nil) with N entries when N managed values are set (N may be 0)
|
||||
// - (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
|
||||
// readRegistryValue. Unknown value names are logged and skipped so a
|
||||
// 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)
|
||||
if err != nil {
|
||||
if errors.Is(err, registry.ErrNotExist) {
|
||||
// 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, fmt.Errorf("open %s: %w", policyRegistryPath, err)
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
package mdm
|
||||
|
||||
import "encoding/json"
|
||||
|
||||
// Fields carries the per-key MDM enforcement state for a UI: value-typed
|
||||
// fields hold the enforced value (nil pointer = not managed), boolean
|
||||
// fields report that the key is managed.
|
||||
type Fields struct {
|
||||
ManagementURL string `json:"managementURL"`
|
||||
PreSharedKey bool `json:"preSharedKey"`
|
||||
WireguardPort bool `json:"wireguardPort"`
|
||||
RosenpassEnabled bool `json:"rosenpassEnabled"`
|
||||
RosenpassPermissive bool `json:"rosenpassPermissive"`
|
||||
DisableClientRoutes bool `json:"disableClientRoutes"`
|
||||
DisableServerRoutes bool `json:"disableServerRoutes"`
|
||||
AllowServerSSH *bool `json:"allowServerSSH"`
|
||||
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
@@ -15,33 +15,33 @@ import (
|
||||
// instead, hence anticipating the ticker mechanism entirely.
|
||||
const DefaultReloadInterval = 1 * time.Minute
|
||||
|
||||
// policyLoader is the indirection through which the ticker reads the
|
||||
// OS-native policy, both for the initial observation and on every tick.
|
||||
// Production points it at LoadPolicy; tests in this package override it to
|
||||
// feed a scripted sequence of policies without touching the real OS store.
|
||||
var policyLoader = LoadPolicy
|
||||
|
||||
// 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.
|
||||
// Ticker periodically re-reads the OS-native MDM policy via the
|
||||
// injected Loader 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 {
|
||||
interval time.Duration
|
||||
loader *Loader
|
||||
prev *Policy
|
||||
}
|
||||
|
||||
// NewTicker constructs a Ticker that will re-read the OS-native policy
|
||||
// every reloadInterval once Run is called.
|
||||
// The initial snapshot is populated by calling policyLoader at
|
||||
// every reloadInterval once Run is called. The Loader is injected so
|
||||
// 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
|
||||
// onChange when the policy actually changed since boot — without
|
||||
// this baseline the first tick would report every currently-managed
|
||||
// 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{
|
||||
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")
|
||||
return
|
||||
case <-tk.C:
|
||||
curr := policyLoader()
|
||||
if policiesEqual(t.prev, curr) {
|
||||
curr := t.loader.Load()
|
||||
if !policyChanged(t.prev, curr) {
|
||||
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
|
||||
if err := onChange(prev, curr); err != nil {
|
||||
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
|
||||
}
|
||||
|
||||
func policyChanged(prev, curr *Policy) bool {
|
||||
if policiesEqual(prev, curr) {
|
||||
return false
|
||||
}
|
||||
added, removed, changed := diffPolicies(prev, curr)
|
||||
log.Infof("MDM policy changed: added=%v removed=%v changed=%v", added, removed, changed)
|
||||
return true
|
||||
}
|
||||
|
||||
+38
-29
@@ -13,28 +13,40 @@ import (
|
||||
// testReloadInterval for speeding up the ticker cadence under `go test`
|
||||
const testReloadInterval = 1 * time.Second
|
||||
|
||||
// withPolicyLoader overrides the package-level policyLoader for the duration
|
||||
// of the test so the ticker observes a scripted policy instead of the real
|
||||
// OS-native store. The original loader is restored on cleanup.
|
||||
func withPolicyLoader(t *testing.T, fn func() *Policy) {
|
||||
t.Helper()
|
||||
prev := policyLoader
|
||||
policyLoader = fn
|
||||
t.Cleanup(func() { policyLoader = prev })
|
||||
// fakePolicyFetcher implements PolicyFetcher returning a scripted
|
||||
// policy map. Goroutine-safe so the test can mutate the script while
|
||||
// the ticker is observing it.
|
||||
type fakePolicyFetcher struct {
|
||||
mu sync.Mutex
|
||||
values map[string]any
|
||||
}
|
||||
|
||||
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) {
|
||||
var mu sync.Mutex
|
||||
current := NewPolicy(nil) // initial observation: empty (no enforcement)
|
||||
withPolicyLoader(t, func() *Policy {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
return current
|
||||
})
|
||||
fetcher := &fakePolicyFetcher{} // initial observation: empty (no enforcement)
|
||||
loader := NewLoader(fetcher)
|
||||
|
||||
type change struct{ prev, curr *Policy }
|
||||
changes := make(chan change, 1)
|
||||
tk := NewTicker(testReloadInterval)
|
||||
tk := NewTicker(testReloadInterval, loader)
|
||||
require.Equal(t, testReloadInterval, tk.interval)
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
@@ -49,15 +61,13 @@ func TestTicker_FiresOnChangeWithDelta(t *testing.T) {
|
||||
})
|
||||
close(done)
|
||||
}()
|
||||
// Stop Run and wait for it to exit before returning, so the policyLoader
|
||||
// restore in t.Cleanup can't race the ticker goroutine still reading it.
|
||||
// Stop Run and wait for it to exit before returning, so the test
|
||||
// goroutine doesn't race the still-running ticker.
|
||||
defer func() { cancel(); <-done }()
|
||||
|
||||
// Flip the OS-observed policy from empty to one managed key. The next
|
||||
// tick must detect the diff and invoke onChange.
|
||||
mu.Lock()
|
||||
current = NewPolicy(map[string]any{KeyManagementURL: "https://mdm.example.com:443"})
|
||||
mu.Unlock()
|
||||
// Flip the OS-observed policy from empty to one managed key. The
|
||||
// next tick must detect the diff and invoke onChange.
|
||||
fetcher.set(map[string]any{KeyManagementURL: "https://mdm.example.com:443"})
|
||||
|
||||
select {
|
||||
case c := <-changes:
|
||||
@@ -69,12 +79,11 @@ func TestTicker_FiresOnChangeWithDelta(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestTicker_NoCallbackWhenPolicyUnchanged(t *testing.T) {
|
||||
withPolicyLoader(t, func() *Policy {
|
||||
return NewPolicy(map[string]any{KeyBlockInbound: true})
|
||||
})
|
||||
fetcher := &fakePolicyFetcher{values: map[string]any{KeyBlockInbound: true}}
|
||||
loader := NewLoader(fetcher)
|
||||
|
||||
fired := make(chan struct{}, 1)
|
||||
tk := NewTicker(testReloadInterval)
|
||||
tk := NewTicker(testReloadInterval, loader)
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
done := make(chan struct{})
|
||||
@@ -90,8 +99,8 @@ func TestTicker_NoCallbackWhenPolicyUnchanged(t *testing.T) {
|
||||
}()
|
||||
defer func() { cancel(); <-done }()
|
||||
|
||||
// Over ~2 ticks at the 1s test cadence the policy never changes, so the
|
||||
// diff guard must suppress the callback entirely.
|
||||
// Over ~2 ticks at the 1s test cadence the policy never changes,
|
||||
// so the diff guard must suppress the callback entirely.
|
||||
select {
|
||||
case <-fired:
|
||||
t.Fatal("onChange fired despite an unchanged policy")
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
package mobile
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
@@ -11,6 +12,7 @@ import (
|
||||
log "github.com/sirupsen/logrus"
|
||||
|
||||
"github.com/netbirdio/netbird/client/internal/profilemanager"
|
||||
"github.com/netbirdio/netbird/client/mdm"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -22,6 +24,9 @@ const (
|
||||
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
|
||||
@@ -55,6 +60,7 @@ type ProfileManager struct {
|
||||
configDir string
|
||||
username string
|
||||
serviceMgr *profilemanager.ServiceManager
|
||||
mdmLoader *mdm.Loader
|
||||
}
|
||||
|
||||
// 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
|
||||
// must stop the VPN tunnel before switching.
|
||||
func (pm *ProfileManager) SwitchProfile(id string) error {
|
||||
if err := pm.checkProfilesAllowed(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := pm.serviceMgr.SetActiveProfileState(&profilemanager.ActiveProfileState{
|
||||
ID: profilemanager.ID(id),
|
||||
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
|
||||
// generated ID. It returns the created profile so the caller learns the ID.
|
||||
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)
|
||||
if err != nil {
|
||||
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
|
||||
// on-disk filename (the ID) is left unchanged.
|
||||
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 {
|
||||
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
|
||||
// URL and other settings are preserved.
|
||||
func (pm *ProfileManager) LogoutProfile(id string) error {
|
||||
if err := pm.checkProfileLogoutAllowed(id); err != nil {
|
||||
return err
|
||||
}
|
||||
configPath, err := pm.getProfileConfigPath(id)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -196,6 +214,9 @@ func (pm *ProfileManager) LogoutProfile(id string) error {
|
||||
// RemoveProfile deletes a profile. The default profile and the active profile
|
||||
// cannot be removed.
|
||||
func (pm *ProfileManager) RemoveProfile(id string) error {
|
||||
if err := pm.checkProfilesAllowed(); err != nil {
|
||||
return err
|
||||
}
|
||||
configPath, err := pm.getProfileConfigPath(id)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -267,6 +288,27 @@ func (pm *ProfileManager) GetActiveStateFilePath() (string, error) {
|
||||
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,
|
||||
// so an unresolvable path degrades to "" rather than an error.
|
||||
func (pm *ProfileManager) profileEmail(id string) string {
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
package mobile
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/netbirdio/netbird/client/internal/profilemanager"
|
||||
"github.com/netbirdio/netbird/client/mdm"
|
||||
)
|
||||
|
||||
type fakeFetcher struct{ values map[string]any }
|
||||
|
||||
func (f *fakeFetcher) Fetch() map[string]any { return f.values }
|
||||
|
||||
func newTestProfileManager(t *testing.T) *ProfileManager {
|
||||
t.Helper()
|
||||
origDir := profilemanager.DefaultConfigPathDir
|
||||
origPath := profilemanager.DefaultConfigPath
|
||||
origActive := profilemanager.ActiveProfileStatePath
|
||||
t.Cleanup(func() {
|
||||
profilemanager.DefaultConfigPathDir = origDir
|
||||
profilemanager.DefaultConfigPath = origPath
|
||||
profilemanager.ActiveProfileStatePath = origActive
|
||||
})
|
||||
|
||||
configDir := t.TempDir()
|
||||
pm := NewProfileManager(configDir, "mobile")
|
||||
_, err := profilemanager.UpdateOrCreateConfig(profilemanager.ConfigInput{
|
||||
ConfigPath: filepath.Join(configDir, defaultConfigFilename),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
return pm
|
||||
}
|
||||
|
||||
func privateKeyOf(t *testing.T, pm *ProfileManager, id string) string {
|
||||
t.Helper()
|
||||
path, err := pm.getProfileConfigPath(id)
|
||||
require.NoError(t, err)
|
||||
raw, err := os.ReadFile(path)
|
||||
require.NoError(t, err)
|
||||
var cfg struct{ PrivateKey string }
|
||||
require.NoError(t, json.Unmarshal(raw, &cfg))
|
||||
return cfg.PrivateKey
|
||||
}
|
||||
|
||||
func TestLogoutProfile_DisableProfiles(t *testing.T) {
|
||||
pm := newTestProfileManager(t)
|
||||
other, err := pm.AddProfile("work")
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, pm.SwitchProfile(profilemanager.DefaultProfileName))
|
||||
require.NotEmpty(t, privateKeyOf(t, pm, profilemanager.DefaultProfileName))
|
||||
require.NotEmpty(t, privateKeyOf(t, pm, other.ID))
|
||||
|
||||
pm.SetMDMLoader(mdm.NewLoader(&fakeFetcher{values: map[string]any{
|
||||
mdm.KeyDisableProfiles: true,
|
||||
}}))
|
||||
|
||||
err = pm.LogoutProfile(other.ID)
|
||||
assert.ErrorIs(t, err, ErrProfilesDisabled)
|
||||
assert.NotEmpty(t, privateKeyOf(t, pm, other.ID))
|
||||
|
||||
require.NoError(t, pm.LogoutProfile(profilemanager.DefaultProfileName))
|
||||
assert.Empty(t, privateKeyOf(t, pm, profilemanager.DefaultProfileName))
|
||||
}
|
||||
|
||||
func TestLogoutProfile_ProfilesAllowed(t *testing.T) {
|
||||
pm := newTestProfileManager(t)
|
||||
other, err := pm.AddProfile("work")
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, pm.SwitchProfile(profilemanager.DefaultProfileName))
|
||||
|
||||
pm.SetMDMLoader(mdm.NewLoader(&fakeFetcher{values: map[string]any{
|
||||
mdm.KeyDisableProfiles: false,
|
||||
}}))
|
||||
|
||||
require.NoError(t, pm.LogoutProfile(other.ID))
|
||||
assert.Empty(t, privateKeyOf(t, pm, other.ID))
|
||||
}
|
||||
+36
-187
@@ -3,7 +3,6 @@ package server
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"time"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
@@ -14,28 +13,6 @@ import (
|
||||
"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
|
||||
// OS-native managed-config store reports a diff vs the last observation.
|
||||
//
|
||||
@@ -168,126 +145,6 @@ func (s *Server) restartEngineForMDMLocked() error {
|
||||
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
|
||||
// requested value in the SetConfigRequest differs from the MDM-enforced
|
||||
// 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
|
||||
}
|
||||
|
||||
// PSK round-trip echo: collapse the sentinel to empty so the
|
||||
// shared check treats it as "field not set".
|
||||
pskGot := ""
|
||||
if msg.OptionalPreSharedKey != nil && *msg.OptionalPreSharedKey != preSharedKeyRedactedSentinel {
|
||||
pskGot = *msg.OptionalPreSharedKey
|
||||
pskGot := msg.OptionalPreSharedKey
|
||||
if pskGot != nil && *pskGot == mdm.PreSharedKeyRedactedSentinel {
|
||||
pskGot = nil
|
||||
}
|
||||
|
||||
return resolveConflicts(policy, []conflictCheck{
|
||||
conflictURL(mdm.KeyManagementURL, msg.ManagementUrl),
|
||||
conflictString(mdm.KeyPreSharedKey, pskGot),
|
||||
conflictBool(mdm.KeyRosenpassEnabled, msg.RosenpassEnabled),
|
||||
conflictBool(mdm.KeyRosenpassPermissive, msg.RosenpassPermissive),
|
||||
conflictBool(mdm.KeyDisableAutoConnect, msg.DisableAutoConnect),
|
||||
conflictBool(mdm.KeyAllowServerSSH, msg.ServerSSHAllowed),
|
||||
conflictBool(mdm.KeyRemoteJobsAllowed, msg.RemoteJobsAllowed),
|
||||
conflictBool(mdm.KeyDisableClientRoutes, msg.DisableClientRoutes),
|
||||
conflictBool(mdm.KeyDisableServerRoutes, msg.DisableServerRoutes),
|
||||
conflictBool(mdm.KeyBlockInbound, msg.BlockInbound),
|
||||
conflictInt64(mdm.KeyWireguardPort, msg.WireguardPort),
|
||||
conflictBool(mdm.KeyEnableLocalMetrics, msg.EnableLocalMetrics),
|
||||
conflictStringPtr(mdm.KeyLocalMetricsAddress, msg.LocalMetricsAddress),
|
||||
return mdm.ResolveConflicts(policy, []mdm.ConflictCheck{
|
||||
mdm.ConflictURL(mdm.KeyManagementURL, msg.ManagementUrl),
|
||||
mdm.ConflictStringPtr(mdm.KeyPreSharedKey, pskGot),
|
||||
mdm.ConflictBool(mdm.KeyRosenpassEnabled, msg.RosenpassEnabled),
|
||||
mdm.ConflictBool(mdm.KeyRosenpassPermissive, msg.RosenpassPermissive),
|
||||
mdm.ConflictBool(mdm.KeyDisableAutoConnect, msg.DisableAutoConnect),
|
||||
mdm.ConflictBool(mdm.KeyAllowServerSSH, msg.ServerSSHAllowed),
|
||||
mdm.ConflictBool(mdm.KeyRemoteJobsAllowed, msg.RemoteJobsAllowed),
|
||||
mdm.ConflictBool(mdm.KeyDisableClientRoutes, msg.DisableClientRoutes),
|
||||
mdm.ConflictBool(mdm.KeyDisableServerRoutes, msg.DisableServerRoutes),
|
||||
mdm.ConflictBool(mdm.KeyBlockInbound, msg.BlockInbound),
|
||||
mdm.ConflictInt64(mdm.KeyWireguardPort, msg.WireguardPort),
|
||||
mdm.ConflictBool(mdm.KeyEnableLocalMetrics, msg.EnableLocalMetrics),
|
||||
mdm.ConflictStringPtr(mdm.KeyLocalMetricsAddress, msg.LocalMetricsAddress),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -424,34 +279,28 @@ func loginRequestMDMConflicts(msg *proto.LoginRequest, policy *mdm.Policy) []str
|
||||
return nil
|
||||
}
|
||||
|
||||
// Collapse the two PSK fields + the redaction sentinel down to a
|
||||
// single "got" string the shared check can compare against the
|
||||
// policy: OptionalPreSharedKey wins if set; PreSharedKey (deprecated)
|
||||
// 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
|
||||
pskGot := msg.OptionalPreSharedKey
|
||||
if pskGot == nil && msg.PreSharedKey != "" { //nolint:staticcheck // SA1019: legacy proto field still accepted by Login
|
||||
pskGot = &msg.PreSharedKey //nolint:staticcheck // SA1019
|
||||
}
|
||||
if pskGot == preSharedKeyRedactedSentinel {
|
||||
pskGot = ""
|
||||
if pskGot != nil && *pskGot == mdm.PreSharedKeyRedactedSentinel {
|
||||
pskGot = nil
|
||||
}
|
||||
|
||||
return resolveConflicts(policy, []conflictCheck{
|
||||
conflictURL(mdm.KeyManagementURL, msg.ManagementUrl),
|
||||
conflictString(mdm.KeyPreSharedKey, pskGot),
|
||||
conflictBool(mdm.KeyRosenpassEnabled, msg.RosenpassEnabled),
|
||||
conflictBool(mdm.KeyRosenpassPermissive, msg.RosenpassPermissive),
|
||||
conflictBool(mdm.KeyDisableAutoConnect, msg.DisableAutoConnect),
|
||||
conflictBool(mdm.KeyAllowServerSSH, msg.ServerSSHAllowed),
|
||||
conflictBool(mdm.KeyRemoteJobsAllowed, msg.RemoteJobsAllowed),
|
||||
conflictBool(mdm.KeyDisableClientRoutes, msg.DisableClientRoutes),
|
||||
conflictBool(mdm.KeyDisableServerRoutes, msg.DisableServerRoutes),
|
||||
conflictBool(mdm.KeyBlockInbound, msg.BlockInbound),
|
||||
conflictInt64(mdm.KeyWireguardPort, msg.WireguardPort),
|
||||
conflictBool(mdm.KeyEnableLocalMetrics, msg.EnableLocalMetrics),
|
||||
conflictStringPtr(mdm.KeyLocalMetricsAddress, msg.LocalMetricsAddress),
|
||||
return mdm.ResolveConflicts(policy, []mdm.ConflictCheck{
|
||||
mdm.ConflictURL(mdm.KeyManagementURL, msg.ManagementUrl),
|
||||
mdm.ConflictStringPtr(mdm.KeyPreSharedKey, pskGot),
|
||||
mdm.ConflictBool(mdm.KeyRosenpassEnabled, msg.RosenpassEnabled),
|
||||
mdm.ConflictBool(mdm.KeyRosenpassPermissive, msg.RosenpassPermissive),
|
||||
mdm.ConflictBool(mdm.KeyDisableAutoConnect, msg.DisableAutoConnect),
|
||||
mdm.ConflictBool(mdm.KeyAllowServerSSH, msg.ServerSSHAllowed),
|
||||
mdm.ConflictBool(mdm.KeyRemoteJobsAllowed, msg.RemoteJobsAllowed),
|
||||
mdm.ConflictBool(mdm.KeyDisableClientRoutes, msg.DisableClientRoutes),
|
||||
mdm.ConflictBool(mdm.KeyDisableServerRoutes, msg.DisableServerRoutes),
|
||||
mdm.ConflictBool(mdm.KeyBlockInbound, msg.BlockInbound),
|
||||
mdm.ConflictInt64(mdm.KeyWireguardPort, msg.WireguardPort),
|
||||
mdm.ConflictBool(mdm.KeyEnableLocalMetrics, msg.EnableLocalMetrics),
|
||||
mdm.ConflictStringPtr(mdm.KeyLocalMetricsAddress, msg.LocalMetricsAddress),
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
+32
-3
@@ -138,6 +138,15 @@ type Server struct {
|
||||
// stopped by the rootCtx cancellation.
|
||||
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
|
||||
|
||||
jwtCache *jwtCache
|
||||
@@ -246,8 +255,14 @@ func (s *Server) Start() error {
|
||||
// Runs re-resolves Config (re-running profilemanager.Config.apply which
|
||||
// applies the freshly-read MDM policy as the last layer) and brings
|
||||
// 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 {
|
||||
s.mdmTicker = mdm.NewTicker(mdm.DefaultReloadInterval)
|
||||
s.mdmTicker = mdm.NewTicker(mdm.DefaultReloadInterval, s.mdmLoader)
|
||||
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-
|
||||
// Violation detail listing the offending key names. Non-conflicting
|
||||
// fields in the same request are not applied either.
|
||||
policy := loadMDMPolicy()
|
||||
policy := s.mdmLoader.Load()
|
||||
if err := rejectMDMManagedFieldConflicts(mdmManagedFieldConflicts(msg, policy)); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -636,7 +651,7 @@ func (s *Server) Login(callerCtx context.Context, msg *proto.LoginRequest) (*pro
|
||||
if s.checkUpdateSettingsDisabled() {
|
||||
return nil, gstatus.Errorf(codes.Unavailable, errUpdateSettingsDisabled)
|
||||
}
|
||||
policy := loadMDMPolicy()
|
||||
policy := s.mdmLoader.Load()
|
||||
if err := rejectMDMManagedFieldConflicts(loginRequestMDMConflicts(msg, policy)); err != nil {
|
||||
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)
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
@@ -1543,6 +1564,9 @@ func (s *Server) logoutFromProfile(ctx context.Context, profile *profilemanager.
|
||||
if err != nil {
|
||||
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)
|
||||
}
|
||||
@@ -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)
|
||||
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
|
||||
adminURL := cfg.AdminURL
|
||||
|
||||
|
||||
@@ -16,14 +16,40 @@ import (
|
||||
"github.com/netbirdio/netbird/client/proto"
|
||||
)
|
||||
|
||||
// withMDMPolicy temporarily overrides the server-package loadMDMPolicy hook
|
||||
// so SetConfig observes the supplied Policy. Restores the original loader
|
||||
// at test cleanup.
|
||||
func withMDMPolicy(t *testing.T, policy *mdm.Policy) {
|
||||
// fakeMDMFetcher implements mdm.PolicyFetcher returning a pre-set
|
||||
// policy map. Tests build one per Server instance to inject a
|
||||
// scripted MDM overlay via a Loader rather than via package-level state.
|
||||
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()
|
||||
prev := loadMDMPolicy
|
||||
loadMDMPolicy = func() *mdm.Policy { return policy }
|
||||
t.Cleanup(func() { loadMDMPolicy = prev })
|
||||
values := map[string]any{}
|
||||
if policy != nil {
|
||||
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:
|
||||
@@ -93,12 +119,11 @@ func extractViolation(t *testing.T, err error) *proto.MDMManagedFieldsViolation
|
||||
}
|
||||
|
||||
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",
|
||||
}))
|
||||
|
||||
s, ctx, profName, username, _ := setupServerWithProfile(t)
|
||||
|
||||
_, err := s.SetConfig(ctx, &proto.SetConfigRequest{
|
||||
ProfileName: profName,
|
||||
Username: username,
|
||||
@@ -110,14 +135,13 @@ func TestSetConfig_MDMReject_SingleField(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.KeyBlockInbound: true,
|
||||
mdm.KeyRosenpassEnabled: true,
|
||||
}))
|
||||
|
||||
s, ctx, profName, username, _ := setupServerWithProfile(t)
|
||||
|
||||
blockInbound := false
|
||||
rosenpassEnabled := false
|
||||
_, err := s.SetConfig(ctx, &proto.SetConfigRequest{
|
||||
@@ -137,13 +161,12 @@ func TestSetConfig_MDMReject_MultipleFields(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.KeyLocalMetricsAddress: "127.0.0.1:9191",
|
||||
}))
|
||||
|
||||
s, ctx, profName, username, _ := setupServerWithProfile(t)
|
||||
|
||||
enabled := false
|
||||
addr := "0.0.0.0:9999"
|
||||
_, 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
|
||||
// rather than collapsed to "field not set".
|
||||
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",
|
||||
}))
|
||||
|
||||
s, ctx, profName, username, _ := setupServerWithProfile(t)
|
||||
|
||||
addr := ""
|
||||
_, err := s.SetConfig(ctx, &proto.SetConfigRequest{
|
||||
ProfileName: profName,
|
||||
@@ -181,17 +203,80 @@ func TestSetConfig_MDMReject_LocalMetricsEmptyAddress(t *testing.T) {
|
||||
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) {
|
||||
// MDM enforces ManagementURL only; user request touches both the
|
||||
// enforced field AND a non-enforced field (RosenpassEnabled).
|
||||
// The whole request must be rejected — non-conflicting fields are not
|
||||
// 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",
|
||||
}))
|
||||
|
||||
s, ctx, profName, username, cfgPath := setupServerWithProfile(t)
|
||||
|
||||
rosenpassEnabled := true
|
||||
_, err := s.SetConfig(ctx, &proto.SetConfigRequest{
|
||||
ProfileName: profName,
|
||||
@@ -213,12 +298,11 @@ func TestSetConfig_MDMReject_AllOrNothing(t *testing.T) {
|
||||
func TestSetConfig_MDMAllow_NonManagedFields(t *testing.T) {
|
||||
// MDM enforces ManagementURL but the user only writes RosenpassEnabled.
|
||||
// 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",
|
||||
}))
|
||||
|
||||
s, ctx, profName, username, _ := setupServerWithProfile(t)
|
||||
|
||||
rosenpassEnabled := true
|
||||
resp, err := s.SetConfig(ctx, &proto.SetConfigRequest{
|
||||
ProfileName: profName,
|
||||
@@ -247,12 +331,11 @@ func TestSetConfig_MDMAllow_ManagementURLPortNormalized(t *testing.T) {
|
||||
|
||||
for _, tc := range tests {
|
||||
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,
|
||||
}))
|
||||
|
||||
s, ctx, profName, username, _ := setupServerWithProfile(t)
|
||||
|
||||
rosenpassEnabled := true
|
||||
resp, err := s.SetConfig(ctx, &proto.SetConfigRequest{
|
||||
ProfileName: profName,
|
||||
@@ -269,9 +352,8 @@ func TestSetConfig_MDMAllow_ManagementURLPortNormalized(t *testing.T) {
|
||||
|
||||
func TestSetConfig_MDMEmpty_NoEnforcement(t *testing.T) {
|
||||
// No MDM policy active: any field can be written.
|
||||
withMDMPolicy(t, mdm.NewPolicy(nil))
|
||||
|
||||
s, ctx, profName, username, _ := setupServerWithProfile(t)
|
||||
withMDMPolicy(t, s, mdm.NewPolicy(nil))
|
||||
|
||||
resp, err := s.SetConfig(ctx, &proto.SetConfigRequest{
|
||||
ProfileName: profName,
|
||||
|
||||
+18
-15
@@ -5,7 +5,6 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"time"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
"golang.org/x/crypto/ssh"
|
||||
@@ -13,26 +12,23 @@ import (
|
||||
|
||||
// Handshake runs the SSH client handshake on an already dialed conn and
|
||||
// returns the resulting client. Dialing bounds only the TCP establishment;
|
||||
// without a deadline on the socket a peer that accepts and then goes silent
|
||||
// blocks the handshake forever, so the context deadline is applied to conn
|
||||
// for the duration of the handshake. conn is closed on any error.
|
||||
// a peer that accepts and then goes silent would block the handshake forever,
|
||||
// so conn is closed as soon as ctx is done, which unblocks the handshake and
|
||||
// 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) {
|
||||
if deadline, ok := ctx.Deadline(); ok {
|
||||
if err := conn.SetDeadline(deadline); err != nil {
|
||||
closeHandshake(conn, "conn after deadline error")
|
||||
return nil, fmt.Errorf("set handshake deadline: %w", err)
|
||||
}
|
||||
}
|
||||
stop := context.AfterFunc(ctx, func() { closeHandshake(conn, "conn on context done") })
|
||||
|
||||
sshConn, chans, reqs, err := ssh.NewClientConn(conn, addr, config)
|
||||
if err != nil {
|
||||
closeHandshake(conn, "conn after handshake error")
|
||||
return nil, fmt.Errorf("ssh handshake: %w", err)
|
||||
if stop() {
|
||||
closeHandshake(conn, "conn after handshake error")
|
||||
}
|
||||
return nil, handshakeError(ctx, err)
|
||||
}
|
||||
|
||||
if err := conn.SetDeadline(time.Time{}); err != nil {
|
||||
closeHandshake(sshConn, "ssh conn after deadline clear error")
|
||||
return nil, fmt.Errorf("clear handshake deadline: %w", err)
|
||||
if !stop() {
|
||||
closeHandshake(sshConn, "ssh conn after context done")
|
||||
return nil, fmt.Errorf("ssh handshake: %w", ctx.Err())
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
func handshakeError(ctx context.Context, err error) error {
|
||||
if ctxErr := ctx.Err(); ctxErr != nil {
|
||||
return fmt.Errorf("ssh handshake: %w: %w", ctxErr, err)
|
||||
}
|
||||
return fmt.Errorf("ssh handshake: %w", err)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
package ssh
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
"golang.org/x/crypto/ssh"
|
||||
)
|
||||
|
||||
func TestHandshake_ContextDeadlineWrapped(t *testing.T) {
|
||||
conn := dialSilentServer(t)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond)
|
||||
defer cancel()
|
||||
|
||||
_, err := Handshake(ctx, conn, conn.RemoteAddr().String(), testClientConfig())
|
||||
require.Error(t, err)
|
||||
require.True(t, errors.Is(err, context.DeadlineExceeded), "expected context.DeadlineExceeded, got: %v", err)
|
||||
}
|
||||
|
||||
func TestHandshake_ContextCancelUnblocks(t *testing.T) {
|
||||
conn := dialSilentServer(t)
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
time.AfterFunc(50*time.Millisecond, cancel)
|
||||
|
||||
errCh := make(chan error, 1)
|
||||
go func() {
|
||||
_, err := Handshake(ctx, conn, conn.RemoteAddr().String(), testClientConfig())
|
||||
errCh <- err
|
||||
}()
|
||||
|
||||
select {
|
||||
case err := <-errCh:
|
||||
require.Error(t, err)
|
||||
require.True(t, errors.Is(err, context.Canceled), "expected context.Canceled, got: %v", err)
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("handshake did not return after context cancellation")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandshake_NonContextErrorNotWrapped(t *testing.T) {
|
||||
conn := dialSilentServer(t)
|
||||
require.NoError(t, conn.Close())
|
||||
|
||||
_, err := Handshake(context.Background(), conn, conn.RemoteAddr().String(), testClientConfig())
|
||||
require.Error(t, err)
|
||||
require.False(t, errors.Is(err, context.Canceled))
|
||||
require.False(t, errors.Is(err, context.DeadlineExceeded))
|
||||
}
|
||||
|
||||
func testClientConfig() *ssh.ClientConfig {
|
||||
return &ssh.ClientConfig{
|
||||
User: "test",
|
||||
HostKeyCallback: ssh.InsecureIgnoreHostKey(),
|
||||
}
|
||||
}
|
||||
|
||||
// dialSilentServer returns a client conn to a server that accepts and never
|
||||
// sends anything, so the SSH handshake blocks until the context is done.
|
||||
func dialSilentServer(t *testing.T) net.Conn {
|
||||
t.Helper()
|
||||
|
||||
listener, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() { _ = listener.Close() })
|
||||
|
||||
done := make(chan struct{})
|
||||
t.Cleanup(func() { close(done) })
|
||||
|
||||
go func() {
|
||||
c, err := listener.Accept()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
defer func() { _ = c.Close() }()
|
||||
<-done
|
||||
}()
|
||||
|
||||
conn, err := net.Dial("tcp", listener.Addr().String())
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() { _ = conn.Close() })
|
||||
|
||||
return conn
|
||||
}
|
||||
@@ -72,7 +72,7 @@ func netbirdFootprintExists() bool {
|
||||
// retrying autostart entry writes on every launch. A user's later disable in
|
||||
// Settings is never overridden: the marker guarantees at-most-once, ever.
|
||||
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 enabled, err := autostart.IsEnabled(ctx); err != nil {
|
||||
|
||||
@@ -11,37 +11,18 @@ import (
|
||||
|
||||
"github.com/netbirdio/netbird/client/internal/daemonaddr"
|
||||
"github.com/netbirdio/netbird/client/internal/ipcauth"
|
||||
"github.com/netbirdio/netbird/client/mdm"
|
||||
"github.com/netbirdio/netbird/client/proto"
|
||||
)
|
||||
|
||||
type MDMFields struct {
|
||||
ManagementURL string `json:"managementURL"`
|
||||
PreSharedKey bool `json:"preSharedKey"`
|
||||
WireguardPort bool `json:"wireguardPort"`
|
||||
RosenpassEnabled bool `json:"rosenpassEnabled"`
|
||||
RosenpassPermissive bool `json:"rosenpassPermissive"`
|
||||
DisableClientRoutes bool `json:"disableClientRoutes"`
|
||||
DisableServerRoutes bool `json:"disableServerRoutes"`
|
||||
AllowServerSSH *bool `json:"allowServerSSH"`
|
||||
DisableAutoConnect bool `json:"disableAutoConnect"`
|
||||
DisableAutostart bool `json:"disableAutostart"`
|
||||
BlockInbound bool `json:"blockInbound"`
|
||||
DisableMetricsCollection bool `json:"disableMetricsCollection"`
|
||||
SplitTunnelMode bool `json:"splitTunnelMode"`
|
||||
SplitTunnelApps bool `json:"splitTunnelApps"`
|
||||
DisableAdvancedView bool `json:"disableAdvancedView"`
|
||||
}
|
||||
// MDMFields is the shared per-key MDM enforcement snapshot; see mdm.Fields.
|
||||
type MDMFields = mdm.Fields
|
||||
|
||||
type Features struct {
|
||||
DisableProfiles bool `json:"disableProfiles"`
|
||||
DisableNetworks bool `json:"disableNetworks"`
|
||||
DisableUpdateSettings bool `json:"disableUpdateSettings"`
|
||||
}
|
||||
// Features is the shared feature-gate snapshot; see mdm.Features.
|
||||
type Features = mdm.Features
|
||||
|
||||
type Restrictions struct {
|
||||
MDM MDMFields `json:"mdm"`
|
||||
Features Features `json:"features"`
|
||||
}
|
||||
// Restrictions is the shared UI enforcement snapshot; see mdm.Restrictions.
|
||||
type Restrictions = mdm.Restrictions
|
||||
|
||||
// Privilege tells the frontend whether this process may perform the changes the
|
||||
// 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)
|
||||
r.MDM.DisableAdvancedView = featResp.GetDisableAdvancedView()
|
||||
r.MDM.DisableAdvancedView = featResp.DisableAdvancedView
|
||||
return r, nil
|
||||
}
|
||||
|
||||
@@ -411,9 +392,6 @@ func applyMDMRestrictions(mdm *MDMFields, cfgResp *proto.GetConfigResponse) {
|
||||
if v.Field(i).Kind() != reflect.Bool {
|
||||
continue
|
||||
}
|
||||
if t.Field(i).Name == "DisableAdvancedView" {
|
||||
continue
|
||||
}
|
||||
if _, ok := set[t.Field(i).Tag.Get("json")]; ok {
|
||||
v.Field(i).SetBool(true)
|
||||
}
|
||||
|
||||
@@ -205,7 +205,7 @@ func createAllServers(ctx context.Context, cfg *CombinedConfig) (*serverInstance
|
||||
metricsServer: metricsServer,
|
||||
}
|
||||
|
||||
_, tlsSupport, err := handleTLSConfig(cfg)
|
||||
tlsConfig, tlsSupport, err := handleTLSConfig(cfg)
|
||||
if err != nil {
|
||||
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
|
||||
}
|
||||
|
||||
if err := servers.createManagementServer(ctx, cfg); err != nil {
|
||||
if err := servers.createManagementServer(ctx, cfg, tlsConfig); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -264,7 +264,7 @@ func (s *serverInstances) createRelayServer(cfg *CombinedConfig, tlsSupport bool
|
||||
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 {
|
||||
return nil
|
||||
}
|
||||
@@ -297,7 +297,7 @@ func (s *serverInstances) createManagementServer(ctx context.Context, cfg *Combi
|
||||
|
||||
LogConfigInfo(mgmtConfig)
|
||||
|
||||
s.mgmtSrv, err = createManagementServer(cfg, mgmtConfig)
|
||||
s.mgmtSrv, err = createManagementServer(cfg, mgmtConfig, tlsConfig)
|
||||
if err != nil {
|
||||
cleanupSTUNListeners(s.stunListeners)
|
||||
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
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
// Extract port from listen address
|
||||
@@ -542,6 +542,7 @@ func createManagementServer(cfg *CombinedConfig, mgmtConfig *nbconfig.Config) (m
|
||||
AutoResolveDomains: true,
|
||||
MgmtPort: mgmtPort,
|
||||
MgmtMetricsPort: cfg.Server.MetricsPort,
|
||||
TLSConfig: tlsConfig,
|
||||
DisableMetrics: mgmt.DisableAnonymousMetrics,
|
||||
DisableGeoliteUpdate: mgmt.DisableGeoliteUpdate,
|
||||
// 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
|
||||
# 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
|
||||
# it unset falls back to 0.0.0.0/0.
|
||||
# trustedPeers decides whether forwarded headers are read at all. Leaving it
|
||||
# unset trusts nothing and records Traefik's own address as every peer's
|
||||
# connection IP.
|
||||
reverseProxy:
|
||||
trustedPeers:
|
||||
- "${TRAEFIK_IP}/32"
|
||||
|
||||
@@ -153,6 +153,7 @@ check_domain_resolves() {
|
||||
# NETBIRD_TRAEFIK_CERTRESOLVER external-Traefik cert resolver (type 1)
|
||||
# NETBIRD_BIND_LOCALHOST_ONLY true/false (default true, types 2-5)
|
||||
# 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
|
||||
|
||||
# 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)
|
||||
BIND_LOCALHOST_ONLY="true"
|
||||
EXTERNAL_PROXY_NETWORK=""
|
||||
TRUSTED_PEERS="" # Address the reverse proxy connects to management from
|
||||
|
||||
|
||||
# Traefik static IP within the internal bridge network
|
||||
TRAEFIK_IP="172.30.0.10"
|
||||
@@ -519,6 +522,7 @@ apply_agent_network_preset() {
|
||||
REVERSE_PROXY_TYPE="0"
|
||||
ENABLE_PROXY="true"
|
||||
ENABLE_CROWDSEC="false"
|
||||
TRUSTED_PEERS="${NETBIRD_TRUSTED_PEERS:-$TRAEFIK_IP/32}"
|
||||
|
||||
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") ;;
|
||||
*) ;; # No network prompt for other options
|
||||
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
|
||||
}
|
||||
|
||||
@@ -1033,6 +1052,7 @@ server:
|
||||
reverseProxy:
|
||||
trustedHTTPProxies:
|
||||
- "$TRAEFIK_IP/32"
|
||||
$(render_trusted_peers)
|
||||
|
||||
store:
|
||||
engine: "sqlite"
|
||||
@@ -1041,6 +1061,12 @@ EOF
|
||||
return 0
|
||||
}
|
||||
|
||||
render_trusted_peers() {
|
||||
if [[ -n "$TRUSTED_PEERS" ]]; then
|
||||
printf ' trustedPeers:\n - "%s"' "$TRUSTED_PEERS"
|
||||
fi
|
||||
}
|
||||
|
||||
render_dashboard_env() {
|
||||
cat <<EOF
|
||||
# Endpoints
|
||||
@@ -1453,6 +1479,9 @@ location ~ ^/(relay|ws-proxy/) {
|
||||
# Native gRPC (signal + management)
|
||||
location ~ ^/(signalexchange\.SignalExchange|management\.ManagementService)/ {
|
||||
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_send_timeout 1d;
|
||||
grpc_socket_keepalive on;
|
||||
|
||||
@@ -34,9 +34,7 @@
|
||||
"ReverseProxy": {
|
||||
"TrustedHTTPProxies": [],
|
||||
"TrustedHTTPProxiesCount": 0,
|
||||
"TrustedPeers": [
|
||||
"0.0.0.0/0"
|
||||
]
|
||||
"TrustedPeers": []
|
||||
},
|
||||
"DisableDefaultPolicy": $NETBIRD_MGMT_DISABLE_DEFAULT_POLICY,
|
||||
"Datadir": "",
|
||||
|
||||
@@ -54,6 +54,15 @@ func Execute() error {
|
||||
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() {
|
||||
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)")
|
||||
|
||||
@@ -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) {
|
||||
customDomains := []*domain.Domain{
|
||||
{Domain: "example.com", TargetCluster: "eu1.proxy.netbird.io"},
|
||||
{Domain: "proxy.corp.io", TargetCluster: "us1.proxy.netbird.io"},
|
||||
{Domain: "example.com", TargetCluster: "eu1.proxy.netbird.io", Validated: true},
|
||||
{Domain: "proxy.corp.io", TargetCluster: "us1.proxy.netbird.io", Validated: true},
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
@@ -120,19 +120,49 @@ func TestExtractClusterFromCustomDomains(t *testing.T) {
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
cluster, ok := extractClusterFromCustomDomains(tc.domain, customDomains)
|
||||
assert.Equal(t, tc.wantOK, ok)
|
||||
if ok {
|
||||
assert.Equal(t, tc.wantVal, cluster)
|
||||
cluster, match := extractClusterFromCustomDomains(tc.domain, customDomains)
|
||||
if !tc.wantOK {
|
||||
assert.Equal(t, customDomainNoMatch, match, "unrelated domain should not match any custom domain")
|
||||
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) {
|
||||
customDomains := []*domain.Domain{
|
||||
{Domain: "example.com", TargetCluster: "cluster-generic"},
|
||||
{Domain: "app.example.com", TargetCluster: "cluster-app"},
|
||||
{Domain: "example.com", TargetCluster: "cluster-generic", Validated: true},
|
||||
{Domain: "app.example.com", TargetCluster: "cluster-app", Validated: true},
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
@@ -164,8 +194,8 @@ func TestExtractClusterFromCustomDomains_OverlappingDomains(t *testing.T) {
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
cluster, ok := extractClusterFromCustomDomains(tc.domain, customDomains)
|
||||
assert.True(t, ok)
|
||||
cluster, match := extractClusterFromCustomDomains(tc.domain, customDomains)
|
||||
assert.Equal(t, customDomainValidated, match)
|
||||
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)
|
||||
|
||||
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)
|
||||
ListCustomDomains(ctx context.Context, accountID string) ([]*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)
|
||||
}
|
||||
|
||||
if err := m.checkDomainAvailable(ctx, domainName); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Attempt an initial validation against the specified cluster only
|
||||
var validated bool
|
||||
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
|
||||
}
|
||||
|
||||
// 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 {
|
||||
ok, ctx, err := m.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Services, operations.Delete)
|
||||
if err != nil {
|
||||
@@ -203,7 +225,9 @@ func (m Manager) ValidateDomain(ctx context.Context, accountID, userID, domainID
|
||||
log.WithFields(log.Fields{
|
||||
"accountID": accountID,
|
||||
"domainID": domainID,
|
||||
}).WithError(err).Error("validate domain")
|
||||
"userID": userID,
|
||||
}).Error("validate domain: permission denied")
|
||||
return
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
targetCluster, valid := extractClusterFromCustomDomains(domain, customDomains)
|
||||
if valid {
|
||||
targetCluster, match := extractClusterFromCustomDomains(domain, customDomains)
|
||||
switch match {
|
||||
case customDomainValidated:
|
||||
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)
|
||||
@@ -363,19 +390,46 @@ func (m Manager) reservedGatewayAddress(ctx context.Context, accountID string) (
|
||||
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 := ""
|
||||
bestLen := -1
|
||||
matched := false
|
||||
for _, cd := range customDomains {
|
||||
if serviceDomain != cd.Domain && !strings.HasSuffix(serviceDomain, "."+cd.Domain) {
|
||||
continue
|
||||
}
|
||||
matched = true
|
||||
if !cd.Validated {
|
||||
continue
|
||||
}
|
||||
if l := len(cd.Domain); l > bestLen {
|
||||
bestLen = l
|
||||
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.
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
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) {
|
||||
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
|
||||
}
|
||||
|
||||
if m.clusterDeriver != nil {
|
||||
derived, err := m.clusterDeriver.DeriveClusterFromDomain(ctx, accountID, svc.Domain)
|
||||
if err != nil {
|
||||
log.WithError(err).Warnf("could not derive cluster from domain %s", svc.Domain)
|
||||
} else {
|
||||
return derived, nil
|
||||
}
|
||||
if m.clusterDeriver == nil {
|
||||
return existing.ProxyCluster, 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 {
|
||||
|
||||
@@ -31,6 +31,7 @@ import (
|
||||
rpservice "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/service"
|
||||
networkmapdb "github.com/netbirdio/netbird/management/internals/network_map_db"
|
||||
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"
|
||||
"github.com/netbirdio/netbird/management/server/activity"
|
||||
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.Datadir,
|
||||
s.IntegratedValidator(),
|
||||
s.SettingsManager())
|
||||
s.SettingsManager(),
|
||||
)
|
||||
// networkmap db store supports postgres and sqlite backends only
|
||||
// for other backends a fallback is used, so NotSupportedStoreEngineError
|
||||
// is not a fatal error
|
||||
@@ -180,24 +182,7 @@ func (s *BaseServer) RateLimiter() *middleware.APIRateLimiter {
|
||||
|
||||
func (s *BaseServer) GRPCServer() *grpc.Server {
|
||||
return Create(s, func() *grpc.Server {
|
||||
trustedPeers := s.Config.ReverseProxy.TrustedPeers
|
||||
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}),
|
||||
}
|
||||
realipOpts := realIPOptions(s.Config.ReverseProxy)
|
||||
proxyUnary, proxyStream, proxyAuthClose := nbgrpc.NewProxyAuthInterceptors(s.Store())
|
||||
s.proxyAuthClose = proxyAuthClose
|
||||
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
|
||||
serverCert, err := tls.LoadX509KeyPair(certFile, certKey)
|
||||
if err != nil {
|
||||
@@ -380,3 +365,34 @@ func streamInterceptor(
|
||||
wrapped.WrappedContext = context.WithValue(ctx, nbContext.RequestIDKey, reqID)
|
||||
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}),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
@@ -74,6 +74,7 @@ type BaseServer struct {
|
||||
grpcExtensions []GRPCExtension
|
||||
|
||||
listener net.Listener
|
||||
tlsConfig *tls.Config
|
||||
certManager *autocert.Manager
|
||||
update *version.Update
|
||||
|
||||
@@ -94,6 +95,7 @@ type Config struct {
|
||||
DisableGeoliteUpdate bool
|
||||
UserDeleteFromIDPEnabled bool
|
||||
AutoResolveDomains bool
|
||||
TLSConfig *tls.Config
|
||||
}
|
||||
|
||||
// NewServer initializes and configures a new Server instance
|
||||
@@ -110,6 +112,7 @@ func NewServer(cfg *Config) *BaseServer {
|
||||
disableLegacyManagementPort: cfg.DisableLegacyManagementPort,
|
||||
mgmtMetricsPort: cfg.MgmtMetricsPort,
|
||||
autoResolveDomains: cfg.AutoResolveDomains,
|
||||
tlsConfig: cfg.TLSConfig,
|
||||
}
|
||||
s.container[ContainerKeyBaseServer] = s
|
||||
|
||||
@@ -139,21 +142,9 @@ func (s *BaseServer) Start(ctx context.Context) error {
|
||||
}
|
||||
s.EphemeralManager().LoadInitialPeers(srvCtx)
|
||||
|
||||
var tlsConfig *tls.Config
|
||||
tlsEnabled := false
|
||||
if s.Config.HttpConfig.LetsEncryptDomain != "" {
|
||||
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
|
||||
tlsEnabled, err := s.setupTLS(srvCtx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
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())
|
||||
s.serveHTTP(ctx, cml, s.certManager.HTTPHandler(nil))
|
||||
}
|
||||
case tlsConfig != nil:
|
||||
s.listener, err = tls.Listen("tcp", fmt.Sprintf(":%d", s.mgmtPort), tlsConfig)
|
||||
case s.tlsConfig != nil:
|
||||
s.listener, err = tls.Listen("tcp", fmt.Sprintf(":%d", s.mgmtPort), s.tlsConfig)
|
||||
if err != nil {
|
||||
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
|
||||
}
|
||||
|
||||
// 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
|
||||
func (s *BaseServer) Stop() error {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
|
||||
@@ -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)
|
||||
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 {
|
||||
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
|
||||
func (am *DefaultAccountManager) checkAndSchedulePeerInactivityExpiration(ctx context.Context, accountID string) {
|
||||
am.peerInactivityExpiry.Cancel(ctx, []string{accountID})
|
||||
jobCtx := context.WithoutCancel(ctx)
|
||||
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))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
manager, _, err := createManager(t)
|
||||
require.NoError(t, err, "unable to create account manager")
|
||||
@@ -2702,7 +2850,7 @@ func TestAccount_GetNextPeerExpiration(t *testing.T) {
|
||||
expectedNextExpiration: time.Duration(0),
|
||||
},
|
||||
{
|
||||
name: "No connected peers, no expiration",
|
||||
name: "Offline peer with expiration, return expiration",
|
||||
peers: map[string]*nbpeer.Peer{
|
||||
"peer-1": {
|
||||
Status: &nbpeer.PeerStatus{
|
||||
@@ -2721,8 +2869,33 @@ func TestAccount_GetNextPeerExpiration(t *testing.T) {
|
||||
},
|
||||
expiration: time.Second,
|
||||
expirationEnabled: false,
|
||||
expectedNextRun: false,
|
||||
expectedNextExpiration: time.Duration(0),
|
||||
expectedNextRun: true,
|
||||
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",
|
||||
|
||||
@@ -101,10 +101,8 @@ func (am *DefaultAccountManager) CreateGroup(ctx context.Context, accountID, use
|
||||
return status.Errorf(status.Internal, "failed to create group: %v", err)
|
||||
}
|
||||
|
||||
for _, peerID := range newGroup.Peers {
|
||||
if err := transaction.AddPeerToGroup(ctx, accountID, peerID, newGroup.ID); err != nil {
|
||||
return status.Errorf(status.Internal, "failed to add peer %s to group %s: %v", peerID, newGroup.ID, err)
|
||||
}
|
||||
if err = syncGroupMembership(ctx, transaction, accountID, newGroup.ID, newGroup.Peers, nil); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
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.
|
||||
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 {
|
||||
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)
|
||||
@@ -213,6 +214,25 @@ func syncGroupMembership(ctx context.Context, transaction store.Store, accountID
|
||||
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.
|
||||
// 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.
|
||||
@@ -540,7 +560,7 @@ func (am *DefaultAccountManager) GroupAddPeer(ctx context.Context, accountID, gr
|
||||
change := affectedpeers.Change{OutputPeerIDs: []string{peerID}, LinkGroups: []string{groupID}}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
|
||||
@@ -11,10 +11,10 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"go.uber.org/mock/gomock"
|
||||
"github.com/google/uuid"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"go.uber.org/mock/gomock"
|
||||
"golang.org/x/exp/maps"
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
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")
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
expired, expiresIn := peer.LoginExpired(settings.PeerLoginExpiration)
|
||||
expired = settings.PeerLoginExpirationEnabled && expired
|
||||
if expired || peer.Status.LoginExpired {
|
||||
log.WithContext(ctx).Debugf("peer's %s login expired %v ago", peer.ID, expiresIn)
|
||||
if settings.PeerLoginExpirationEnabled && expired {
|
||||
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 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.
|
||||
// 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) {
|
||||
peersWithExpiry, err := am.Store.GetAccountPeersWithExpiration(ctx, store.LockingStrengthNone, accountID)
|
||||
if err != nil {
|
||||
@@ -1663,8 +1668,7 @@ func (am *DefaultAccountManager) getNextPeerExpiration(ctx context.Context, acco
|
||||
|
||||
var nextExpiry *time.Duration
|
||||
for _, peer := range peersWithExpiry {
|
||||
// consider only connected peers because others will require login on connecting to the management server
|
||||
if peer.Status.LoginExpired || !peer.Status.Connected {
|
||||
if peer.Status.LoginExpired {
|
||||
continue
|
||||
}
|
||||
_, duration := peer.LoginExpired(settings.PeerLoginExpiration)
|
||||
|
||||
@@ -117,6 +117,7 @@ func (wm *DefaultScheduler) Schedule(ctx context.Context, in time.Duration, ID s
|
||||
}
|
||||
|
||||
ticker := time.NewTicker(in)
|
||||
period := in
|
||||
|
||||
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))
|
||||
@@ -136,14 +137,18 @@ func (wm *DefaultScheduler) Schedule(ctx context.Context, in time.Duration, ID s
|
||||
if !reschedule {
|
||||
wm.mu.Lock()
|
||||
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)
|
||||
ticker.Stop()
|
||||
return
|
||||
}
|
||||
// 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)
|
||||
period = runIn
|
||||
}
|
||||
case <-cancel:
|
||||
log.WithContext(ctx).Debugf("job %s was canceled, stopping timer", ID)
|
||||
|
||||
@@ -6,10 +6,12 @@ import (
|
||||
"math/rand"
|
||||
"runtime"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestScheduler_Performance(t *testing.T) {
|
||||
@@ -150,3 +152,90 @@ func TestScheduler_Schedule(t *testing.T) {
|
||||
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")
|
||||
}
|
||||
|
||||
@@ -3476,7 +3476,7 @@ func (s *SqlStore) GetPeerGroups(ctx context.Context, lockStrength LockingStreng
|
||||
var groups []*types.Group
|
||||
query := tx.
|
||||
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).
|
||||
Find(&groups)
|
||||
|
||||
@@ -5056,7 +5056,7 @@ func (s *SqlStore) GetPeersByGroupIDs(ctx context.Context, accountID string, gro
|
||||
Select("DISTINCT peer_id").
|
||||
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 {
|
||||
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")
|
||||
@@ -5689,6 +5689,23 @@ func (s *SqlStore) ListCustomDomains(ctx context.Context, accountID string) ([]*
|
||||
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) {
|
||||
newDomain := &domain.Domain{
|
||||
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)
|
||||
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)
|
||||
return nil, status.Errorf(status.Internal, "failed to create reverse proxy custom domain to store")
|
||||
}
|
||||
|
||||
@@ -2844,6 +2844,14 @@ func TestSqlStore_GetPeerGroups(t *testing.T) {
|
||||
groups, err = store.GetPeerGroups(context.Background(), LockingStrengthNone, accountID, peerID)
|
||||
require.NoError(t, err)
|
||||
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) {
|
||||
@@ -4039,9 +4047,15 @@ func TestSqlStore_GetPeersByGroupIDs(t *testing.T) {
|
||||
}
|
||||
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, peer2, group1ID))
|
||||
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)
|
||||
require.NoError(t, err)
|
||||
|
||||
@@ -302,6 +302,7 @@ type Store interface {
|
||||
GetCustomDomain(ctx context.Context, accountID string, domainID string) (*domain.Domain, error)
|
||||
ListFreeDomains(ctx context.Context, accountID string) ([]string, 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)
|
||||
UpdateCustomDomain(ctx context.Context, accountID string, d *domain.Domain) (*domain.Domain, error)
|
||||
DeleteCustomDomain(ctx context.Context, accountID string, domainID string) error
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
// 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.
|
||||
func (m *MockStore) GetCustomDomainsCounts(ctx context.Context) (int64, int64, error) {
|
||||
m.ctrl.T.Helper()
|
||||
|
||||
@@ -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.
|
||||
// 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) {
|
||||
peersWithExpiry := a.GetPeersWithExpiration()
|
||||
if len(peersWithExpiry) == 0 {
|
||||
@@ -412,8 +412,7 @@ func (a *Account) GetNextPeerExpiration() (time.Duration, bool) {
|
||||
}
|
||||
var nextExpiry *time.Duration
|
||||
for _, peer := range peersWithExpiry {
|
||||
// consider only connected peers because others will require login on connecting to the management server
|
||||
if peer.Status.LoginExpired || !peer.Status.Connected {
|
||||
if peer.Status.LoginExpired {
|
||||
continue
|
||||
}
|
||||
_, duration := peer.LoginExpired(a.Settings.PeerLoginExpiration)
|
||||
|
||||
+65
-16
@@ -1177,28 +1177,35 @@ func (am *DefaultAccountManager) expireAndUpdatePeers(ctx context.Context, accou
|
||||
dnsDomain := am.networkMapController.GetDNSDomain(settings)
|
||||
|
||||
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
|
||||
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
|
||||
continue
|
||||
}
|
||||
|
||||
if peer.Status.LoginExpired {
|
||||
peer, err := am.expirePeerIfStillDue(peerCtx, accountID, candidate.ID, settings, reason)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if peer == nil {
|
||||
continue
|
||||
}
|
||||
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["reason"] = string(reason)
|
||||
am.StoreEvent(
|
||||
ctx,
|
||||
peerCtx,
|
||||
peer.UserID, peer.ID, accountID,
|
||||
activity.PeerLoginExpired, meta,
|
||||
)
|
||||
@@ -1215,15 +1222,53 @@ func (am *DefaultAccountManager) expireAndUpdatePeers(ctx context.Context, accou
|
||||
if err != nil {
|
||||
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
|
||||
}
|
||||
|
||||
// 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 {
|
||||
if am.userDeleteFromIDPEnabled {
|
||||
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)
|
||||
}
|
||||
|
||||
if targetUser.Role == types.UserRoleOwner && targetUser.Id != initiatorUserID {
|
||||
return status.NewOwnerDeletePermissionError()
|
||||
}
|
||||
|
||||
settings, err = transaction.GetAccountSettings(ctx, store.LockingStrengthNone, accountID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get account settings: %w", err)
|
||||
|
||||
@@ -942,6 +942,49 @@ func TestUser_DeleteUser_regularUser(t *testing.T) {
|
||||
|
||||
}
|
||||
|
||||
func TestUser_deleteRegularUser_RejectsOwner(t *testing.T) {
|
||||
s, cleanup, err := store.NewTestStoreFromSQL(context.Background(), "", t.TempDir())
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(cleanup)
|
||||
|
||||
account := newAccountWithId(context.Background(), mockAccountID, mockUserID, "", "", "", false)
|
||||
account.Users[mockTargetUserId] = &types.User{
|
||||
Id: mockTargetUserId,
|
||||
Issued: types.UserIssuedAPI,
|
||||
Role: types.UserRoleOwner,
|
||||
}
|
||||
require.NoError(t, s.SaveAccount(context.Background(), account))
|
||||
|
||||
am := DefaultAccountManager{Store: s}
|
||||
|
||||
_, err = am.deleteRegularUser(context.Background(), mockAccountID, mockUserID, &types.UserInfo{ID: mockTargetUserId})
|
||||
assert.EqualError(t, err, status.NewOwnerDeletePermissionError().Error())
|
||||
}
|
||||
|
||||
func TestUser_deleteRegularUser_InitiatorOwnerDeletesThemself(t *testing.T) {
|
||||
s, cleanup, err := store.NewTestStoreFromSQL(context.Background(), "", t.TempDir())
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(cleanup)
|
||||
|
||||
account := newAccountWithId(context.Background(), mockAccountID, mockUserID, "", "", "", false)
|
||||
require.NoError(t, s.SaveAccount(context.Background(), account))
|
||||
|
||||
networkMapControllerMock := network_map.NewMockController(gomock.NewController(t))
|
||||
networkMapControllerMock.EXPECT().OnPeersDeleted(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return(nil)
|
||||
|
||||
am := DefaultAccountManager{
|
||||
Store: s,
|
||||
eventStore: &activity.InMemoryEventStore{},
|
||||
networkMapController: networkMapControllerMock,
|
||||
}
|
||||
|
||||
_, err = am.deleteRegularUser(context.Background(), mockAccountID, mockUserID, &types.UserInfo{ID: mockUserID})
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = s.GetUserByUserID(context.Background(), store.LockingStrengthNone, mockUserID)
|
||||
assert.Equal(t, status.NewUserNotFoundError(mockUserID), err)
|
||||
}
|
||||
|
||||
func TestUser_DeleteUser_RegularUsers(t *testing.T) {
|
||||
store, cleanup, err := store.NewTestStoreFromSQL(context.Background(), "", t.TempDir())
|
||||
if err != nil {
|
||||
|
||||
@@ -2,12 +2,14 @@ package acme
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
|
||||
"github.com/netbirdio/netbird/proxy/internal/flock"
|
||||
"github.com/netbirdio/netbird/proxy/internal/k8s"
|
||||
"github.com/netbirdio/netbird/shared/management/domain"
|
||||
)
|
||||
|
||||
// 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}
|
||||
}
|
||||
|
||||
// Lock acquires an advisory file lock for the given domain.
|
||||
func (l *flockLocker) Lock(ctx context.Context, domain string) (func(), error) {
|
||||
lockPath := filepath.Join(l.certDir, domain+".lock")
|
||||
// Lock acquires an advisory file lock for the given domain. The domain must
|
||||
// be a valid hostname so the lock file always resolves to a direct child of
|
||||
// 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)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -89,7 +97,7 @@ func (l *flockLocker) Lock(ctx context.Context, domain string) (func(), error) {
|
||||
|
||||
return func() {
|
||||
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
|
||||
}
|
||||
|
||||
@@ -63,3 +63,33 @@ func TestNewCertLockerK8sFallsBackToFlock(t *testing.T) {
|
||||
_, ok := locker.(*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
@@ -105,6 +105,20 @@ type Handler struct {
|
||||
startTime time.Time
|
||||
templates *template.Template
|
||||
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.
|
||||
@@ -113,10 +127,11 @@ func NewHandler(provider clientProvider, healthChecker healthChecker, logger *lo
|
||||
logger = log.StandardLogger()
|
||||
}
|
||||
h := &Handler{
|
||||
provider: provider,
|
||||
health: healthChecker,
|
||||
logger: logger,
|
||||
startTime: time.Now(),
|
||||
provider: provider,
|
||||
health: healthChecker,
|
||||
logger: logger,
|
||||
startTime: time.Now(),
|
||||
setPerformance: setClientPerformance,
|
||||
}
|
||||
if err := h.loadTemplates(); err != nil {
|
||||
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)
|
||||
applied := 0
|
||||
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++
|
||||
}
|
||||
applied, failed, inFlight := h.applyBufferCap(capN)
|
||||
|
||||
resp := map[string]any{
|
||||
"success": true,
|
||||
@@ -734,9 +741,165 @@ func (h *Handler) handlePerf(w http.ResponseWriter, r *http.Request) {
|
||||
if len(failed) > 0 {
|
||||
resp["failed"] = failed
|
||||
}
|
||||
if len(inFlight) > 0 {
|
||||
resp["in_flight"] = inFlight
|
||||
}
|
||||
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
|
||||
// running proxy; does not read pprof profiles.
|
||||
func (h *Handler) handleRuntime(w http.ResponseWriter, _ *http.Request) {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -279,7 +279,7 @@ func (c *Client) Connect(ctx context.Context) error {
|
||||
c.stateSubscription = NewPeersStateSubscription(c.log, c.relayConn, c.closeConnsByPeerID)
|
||||
|
||||
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
|
||||
|
||||
@@ -364,23 +364,6 @@ func (c *Client) ServerInstanceURL() (string, error) {
|
||||
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.
|
||||
func (c *Client) SetOnDisconnectListener(fn func(string)) {
|
||||
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() {
|
||||
for _, container := range c.conns {
|
||||
container.close()
|
||||
@@ -923,6 +917,17 @@ func (c *Client) handlePeersWentOfflineMsg(buf []byte) {
|
||||
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
|
||||
// dialers (URL or host:port). Zero value if the host is not an IP.
|
||||
func extractIPLiteral(s string) netip.Addr {
|
||||
|
||||
@@ -8,6 +8,8 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"go.opentelemetry.io/otel"
|
||||
|
||||
"github.com/netbirdio/netbird/client/iface"
|
||||
@@ -68,18 +70,17 @@ func TestClient_ServerIPRecoversFromUnresolvableFQDN(t *testing.T) {
|
||||
if !c.Ready() {
|
||||
t.Fatalf("client not ready after connect")
|
||||
}
|
||||
if got := c.ConnectedIP(); got.String() != "127.0.0.1" {
|
||||
t.Fatalf("ConnectedIP = %q, want 127.0.0.1", got)
|
||||
}
|
||||
url, ip, err := c.serverInstanceAddress()
|
||||
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
|
||||
// resolved IP after a successful FQDN-based dial. The underlying socket's
|
||||
// RemoteAddr must be exposed through the dialer wrappers; if it returns
|
||||
// the dial-time URL instead, ConnectedIP returns empty and the dial
|
||||
// IP we advertise to peers is empty too.
|
||||
func TestClient_ConnectedIPAfterFQDNDial(t *testing.T) {
|
||||
// TestClient_ServerInstanceAddressAfterFQDNDial verifies the relay address
|
||||
// includes the resolved IP after an FQDN dial. The dialer wrappers must expose
|
||||
// the socket's RemoteAddr; returning the dial-time URL would lose the IP.
|
||||
func TestClient_ServerInstanceAddressAfterFQDNDial(t *testing.T) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||||
defer cancel()
|
||||
|
||||
@@ -111,10 +112,10 @@ func TestClient_ConnectedIPAfterFQDNDial(t *testing.T) {
|
||||
}
|
||||
t.Cleanup(func() { _ = c.Close() })
|
||||
|
||||
got := c.ConnectedIP().String()
|
||||
if got != "127.0.0.1" && got != "::1" {
|
||||
t.Fatalf("ConnectedIP after FQDN dial = %q, want 127.0.0.1 or ::1", got)
|
||||
}
|
||||
url, ip, err := c.serverInstanceAddress()
|
||||
require.NoError(t, err)
|
||||
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) {
|
||||
@@ -214,15 +215,12 @@ func TestSubstituteHost(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestClient_ConnectedIPEmptyWhenNotConnected(t *testing.T) {
|
||||
c := NewClient("rel://example.invalid:80", hmacTokenStore, "x", iface.DefaultMTU)
|
||||
if got := c.ConnectedIP(); got.IsValid() {
|
||||
t.Fatalf("ConnectedIP on disconnected client = %q, want zero", got)
|
||||
}
|
||||
func TestConnectedIPNilConnection(t *testing.T) {
|
||||
assert.False(t, connectedIP(nil).IsValid(), "missing connection must not provide an IP")
|
||||
}
|
||||
|
||||
// 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 }
|
||||
|
||||
func (a staticAddr) Network() string { return "tcp" }
|
||||
@@ -235,7 +233,7 @@ type stubConn struct {
|
||||
|
||||
func (s stubConn) RemoteAddr() net.Addr { return s.remote }
|
||||
|
||||
func TestClient_ConnectedIPParsesRemoteAddr(t *testing.T) {
|
||||
func TestConnectedIPParsesRemoteAddr(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
s string
|
||||
@@ -252,15 +250,12 @@ func TestClient_ConnectedIPParsesRemoteAddr(t *testing.T) {
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
c := &Client{relayConn: stubConn{remote: staticAddr{s: tt.s}}}
|
||||
got := c.ConnectedIP()
|
||||
got := connectedIP(stubConn{remote: staticAddr{s: tt.s}})
|
||||
var gotStr string
|
||||
if got.IsValid() {
|
||||
gotStr = got.String()
|
||||
}
|
||||
if gotStr != tt.want {
|
||||
t.Errorf("ConnectedIP(%q) = %q, want %q", tt.s, gotStr, tt.want)
|
||||
}
|
||||
assert.Equal(t, tt.want, gotStr, "IP extracted from RemoteAddr %q", tt.s)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -256,11 +256,7 @@ func (m *Manager) RelayInstanceAddress() (string, netip.Addr, error) {
|
||||
if m.relayClient == nil {
|
||||
return "", netip.Addr{}, ErrRelayClientNotConnected
|
||||
}
|
||||
addr, err := m.relayClient.ServerInstanceURL()
|
||||
if err != nil {
|
||||
return "", netip.Addr{}, err
|
||||
}
|
||||
return addr, m.relayClient.ConnectedIP(), nil
|
||||
return m.relayClient.serverInstanceAddress()
|
||||
}
|
||||
|
||||
// ServerURLs returns the addresses of the relay servers.
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user