diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index c1bbe9c44..9d3fe3641 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -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" diff --git a/.goreleaser.yaml b/.goreleaser.yaml index c5d260376..778ccb892 100644 --- a/.goreleaser.yaml +++ b/.goreleaser.yaml @@ -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: diff --git a/client/Dockerfile-rootless.ubi b/client/Dockerfile-rootless.ubi new file mode 100644 index 000000000..4701728c1 --- /dev/null +++ b/client/Dockerfile-rootless.ubi @@ -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 " \ + 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"] diff --git a/client/android/client.go b/client/android/client.go index 5bd0d1e10..e47a1c13d 100644 --- a/client/android/client.go +++ b/client/android/client.go @@ -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() } diff --git a/client/android/client_mdm.go b/client/android/client_mdm.go new file mode 100644 index 000000000..d043b85d3 --- /dev/null +++ b/client/android/client_mdm.go @@ -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 +} diff --git a/client/android/login.go b/client/android/login.go index 3742e01a5..155c6eadd 100644 --- a/client/android/login.go +++ b/client/android/login.go @@ -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 diff --git a/client/android/login_test.go b/client/android/login_test.go index b04790f6b..130a846fc 100644 --- a/client/android/login_test.go +++ b/client/android/login_test.go @@ -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) } diff --git a/client/android/mdm.go b/client/android/mdm.go new file mode 100644 index 000000000..617d8f7cb --- /dev/null +++ b/client/android/mdm.go @@ -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) +} diff --git a/client/android/preferences.go b/client/android/preferences.go index d90365518..5ce31026c 100644 --- a/client/android/preferences.go +++ b/client/android/preferences.go @@ -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 } diff --git a/client/android/preferences_test.go b/client/android/preferences_test.go index 2bbccef86..d9f5b1918 100644 --- a/client/android/preferences_test.go +++ b/client/android/preferences_test.go @@ -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") } } diff --git a/client/android/profile_manager.go b/client/android/profile_manager.go index 557c837a7..4bc60c453 100644 --- a/client/android/profile_manager.go +++ b/client/android/profile_manager.go @@ -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) { diff --git a/client/cmd/login.go b/client/cmd/login.go index 4e08334eb..11867be09 100644 --- a/client/cmd/login.go +++ b/client/cmd/login.go @@ -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 diff --git a/client/cmd/up.go b/client/cmd/up.go index 2e53224df..f5fac9749 100644 --- a/client/cmd/up.go +++ b/client/cmd/up.go @@ -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) diff --git a/client/collect-licenses.sh b/client/collect-licenses.sh new file mode 100644 index 000000000..7dfabada9 --- /dev/null +++ b/client/collect-licenses.sh @@ -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" diff --git a/client/embed/embed.go b/client/embed/embed.go index 5a3d11f24..5a3d540ec 100644 --- a/client/embed/embed.go +++ b/client/embed/embed.go @@ -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 diff --git a/client/internal/dns/service_listener.go b/client/internal/dns/service_listener.go index 3dc29c4dc..d65a727b1 100644 --- a/client/internal/dns/service_listener.go +++ b/client/internal/dns/service_listener.go @@ -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) } diff --git a/client/internal/dns/service_listener_test.go b/client/internal/dns/service_listener_test.go index 90ef71d19..b158a79fd 100644 --- a/client/internal/dns/service_listener_test.go +++ b/client/internal/dns/service_listener_test.go @@ -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") + } +} diff --git a/client/internal/ebpf/ebpf/bpf_bpfeb.go b/client/internal/ebpf/ebpf/bpf_bpfeb.go index 04b19883b..4b6230217 100644 --- a/client/internal/ebpf/ebpf/bpf_bpfeb.go +++ b/client/internal/ebpf/ebpf/bpf_bpfeb.go @@ -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. diff --git a/client/internal/ebpf/ebpf/bpf_bpfeb.o b/client/internal/ebpf/ebpf/bpf_bpfeb.o index 7433ad740..b435d4964 100644 Binary files a/client/internal/ebpf/ebpf/bpf_bpfeb.o and b/client/internal/ebpf/ebpf/bpf_bpfeb.o differ diff --git a/client/internal/ebpf/ebpf/bpf_bpfel.go b/client/internal/ebpf/ebpf/bpf_bpfel.go index 03b494aa2..f56efc901 100644 --- a/client/internal/ebpf/ebpf/bpf_bpfel.go +++ b/client/internal/ebpf/ebpf/bpf_bpfel.go @@ -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. diff --git a/client/internal/ebpf/ebpf/bpf_bpfel.o b/client/internal/ebpf/ebpf/bpf_bpfel.o index 779f43a00..a388b6d6d 100644 Binary files a/client/internal/ebpf/ebpf/bpf_bpfel.o and b/client/internal/ebpf/ebpf/bpf_bpfel.o differ diff --git a/client/internal/ebpf/ebpf/dns_fwd_linux.go b/client/internal/ebpf/ebpf/dns_fwd_linux.go deleted file mode 100644 index 1e7774573..000000000 --- a/client/internal/ebpf/ebpf/dns_fwd_linux.go +++ /dev/null @@ -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) -} - diff --git a/client/internal/ebpf/ebpf/manager_linux.go b/client/internal/ebpf/ebpf/manager_linux.go index 7520a6387..a13f5f19a 100644 --- a/client/internal/ebpf/ebpf/manager_linux.go +++ b/client/internal/ebpf/ebpf/manager_linux.go @@ -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 diff --git a/client/internal/ebpf/ebpf/manager_linux_test.go b/client/internal/ebpf/ebpf/manager_linux_test.go index 5664a4565..e09fcb977 100644 --- a/client/internal/ebpf/ebpf/manager_linux_test.go +++ b/client/internal/ebpf/ebpf/manager_linux_test.go @@ -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) } diff --git a/client/internal/ebpf/ebpf/src/bpf_map_def.h b/client/internal/ebpf/ebpf/src/bpf_map_def.h new file mode 100644 index 000000000..9528fb592 --- /dev/null +++ b/client/internal/ebpf/ebpf/src/bpf_map_def.h @@ -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 diff --git a/client/internal/ebpf/ebpf/src/dns_fwd.c b/client/internal/ebpf/ebpf/src/dns_fwd.c deleted file mode 100644 index 9f8de2001..000000000 --- a/client/internal/ebpf/ebpf/src/dns_fwd.c +++ /dev/null @@ -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; -} diff --git a/client/internal/ebpf/ebpf/src/prog.c b/client/internal/ebpf/ebpf/src/prog.c index f32103f28..44ee53458 100644 --- a/client/internal/ebpf/ebpf/src/prog.c +++ b/client/internal/ebpf/ebpf/src/prog.c @@ -5,11 +5,9 @@ #include #include #include -#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); } diff --git a/client/internal/ebpf/ebpf/src/readme.md b/client/internal/ebpf/ebpf/src/readme.md index 0ab393dd4..aa47847da 100644 --- a/client/internal/ebpf/ebpf/src/readme.md +++ b/client/internal/ebpf/ebpf/src/readme.md @@ -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 diff --git a/client/internal/ebpf/manager/manager.go b/client/internal/ebpf/manager/manager.go index 25a767090..fdc5d8d82 100644 --- a/client/internal/ebpf/manager/manager.go +++ b/client/internal/ebpf/manager/manager.go @@ -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 } diff --git a/client/internal/engine.go b/client/internal/engine.go index 369bc70e4..54489ac38 100644 --- a/client/internal/engine.go +++ b/client/internal/engine.go @@ -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) diff --git a/client/internal/peer/handshaker.go b/client/internal/peer/handshaker.go index 6ecb2a947..654e32158 100644 --- a/client/internal/peer/handshaker.go +++ b/client/internal/peer/handshaker.go @@ -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) } diff --git a/client/internal/profilemanager/config.go b/client/internal/profilemanager/config.go index 10c1758d1..412f81b5c 100644 --- a/client/internal/profilemanager/config.go +++ b/client/internal/profilemanager/config.go @@ -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 } diff --git a/client/internal/profilemanager/config_mdm.go b/client/internal/profilemanager/config_mdm.go new file mode 100644 index 000000000..25b9f18f7 --- /dev/null +++ b/client/internal/profilemanager/config_mdm.go @@ -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) +} diff --git a/client/internal/profilemanager/config_mdm_test.go b/client/internal/profilemanager/config_mdm_test.go index f8dfddb33..716b7a553 100644 --- a/client/internal/profilemanager/config_mdm_test.go +++ b/client/internal/profilemanager/config_mdm_test.go @@ -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 } diff --git a/client/internal/profilemanager/invoking_user.go b/client/internal/profilemanager/invoking_user.go index c86a6ce43..7ba612ffb 100644 --- a/client/internal/profilemanager/invoking_user.go +++ b/client/internal/profilemanager/invoking_user.go @@ -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 diff --git a/client/internal/profilemanager/invoking_user_test.go b/client/internal/profilemanager/invoking_user_test.go index 54c8ad8fd..159d2616b 100644 --- a/client/internal/profilemanager/invoking_user_test.go +++ b/client/internal/profilemanager/invoking_user_test.go @@ -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 { diff --git a/client/ios/NetBirdSDK/client.go b/client/ios/NetBirdSDK/client.go index bbbb969c9..96c747ae4 100644 --- a/client/ios/NetBirdSDK/client.go +++ b/client/ios/NetBirdSDK/client.go @@ -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 { diff --git a/client/ios/NetBirdSDK/login.go b/client/ios/NetBirdSDK/login.go index cf7aa6730..0dfff620e 100644 --- a/client/ios/NetBirdSDK/login.go +++ b/client/ios/NetBirdSDK/login.go @@ -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) +} diff --git a/client/ios/NetBirdSDK/mdm.go b/client/ios/NetBirdSDK/mdm.go new file mode 100644 index 000000000..93a31916c --- /dev/null +++ b/client/ios/NetBirdSDK/mdm.go @@ -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) +} diff --git a/client/ios/NetBirdSDK/preferences.go b/client/ios/NetBirdSDK/preferences.go index 39aa7ed83..5297920a3 100644 --- a/client/ios/NetBirdSDK/preferences.go +++ b/client/ios/NetBirdSDK/preferences.go @@ -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) diff --git a/client/ios/NetBirdSDK/preferences_test.go b/client/ios/NetBirdSDK/preferences_test.go index 5f75e7c9a..2382e123c 100644 --- a/client/ios/NetBirdSDK/preferences_test.go +++ b/client/ios/NetBirdSDK/preferences_test.go @@ -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") } } diff --git a/client/ios/NetBirdSDK/profile_manager.go b/client/ios/NetBirdSDK/profile_manager.go index 139521c7f..df962e227 100644 --- a/client/ios/NetBirdSDK/profile_manager.go +++ b/client/ios/NetBirdSDK/profile_manager.go @@ -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) { diff --git a/client/mdm/changedetector.go b/client/mdm/changedetector.go new file mode 100644 index 000000000..5c21ae355 --- /dev/null +++ b/client/mdm/changedetector.go @@ -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 +} diff --git a/client/mdm/conflicts.go b/client/mdm/conflicts.go new file mode 100644 index 000000000..160212afb --- /dev/null +++ b/client/mdm/conflicts.go @@ -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() +} diff --git a/client/mdm/conflicts_test.go b/client/mdm/conflicts_test.go new file mode 100644 index 000000000..d145ec103 --- /dev/null +++ b/client/mdm/conflicts_test.go @@ -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, "")})) +} diff --git a/client/mdm/jsonloader.go b/client/mdm/jsonloader.go new file mode 100644 index 000000000..7139b0e4f --- /dev/null +++ b/client/mdm/jsonloader.go @@ -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 +} diff --git a/client/mdm/policy.go b/client/mdm/policy.go index dac135ea6..638fa0d80 100644 --- a/client/mdm/policy.go +++ b/client/mdm/policy.go @@ -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 { diff --git a/client/mdm/policy_darwin.go b/client/mdm/policy_darwin.go index 57aa1168c..4159f5b7e 100644 --- a/client/mdm/policy_darwin.go +++ b/client/mdm/policy_darwin.go @@ -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) diff --git a/client/mdm/policy_mobile.go b/client/mdm/policy_mobile.go index ec25d4bb1..2e25a2bb5 100644 --- a/client/mdm/policy_mobile.go +++ b/client/mdm/policy_mobile.go @@ -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 } diff --git a/client/mdm/policy_other.go b/client/mdm/policy_other.go index f4263afa2..5d0b17cfd 100644 --- a/client/mdm/policy_other.go +++ b/client/mdm/policy_other.go @@ -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 } diff --git a/client/mdm/policy_test.go b/client/mdm/policy_test.go index 6cbe69776..ea467f861 100644 --- a/client/mdm/policy_test.go +++ b/client/mdm/policy_test.go @@ -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()) diff --git a/client/mdm/policy_windows.go b/client/mdm/policy_windows.go index 0c2629f98..9363db436 100644 --- a/client/mdm/policy_windows.go +++ b/client/mdm/policy_windows.go @@ -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) diff --git a/client/mdm/restrictions.go b/client/mdm/restrictions.go new file mode 100644 index 000000000..200756b78 --- /dev/null +++ b/client/mdm/restrictions.go @@ -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 +} diff --git a/client/mdm/ticker.go b/client/mdm/ticker.go index abd6ae233..be8fdcce7 100644 --- a/client/mdm/ticker.go +++ b/client/mdm/ticker.go @@ -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 +} diff --git a/client/mdm/ticker_test.go b/client/mdm/ticker_test.go index 17f3cfc2f..29e48e728 100644 --- a/client/mdm/ticker_test.go +++ b/client/mdm/ticker_test.go @@ -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") diff --git a/client/mobile/profile_manager.go b/client/mobile/profile_manager.go index 1ddabf0a9..348b7253b 100644 --- a/client/mobile/profile_manager.go +++ b/client/mobile/profile_manager.go @@ -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") + /* / ← 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 { diff --git a/client/mobile/profile_manager_mdm_test.go b/client/mobile/profile_manager_mdm_test.go new file mode 100644 index 000000000..305becac3 --- /dev/null +++ b/client/mobile/profile_manager_mdm_test.go @@ -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)) +} diff --git a/client/server/mdm.go b/client/server/mdm.go index b41e2b590..7a47b2a57 100644 --- a/client/server/mdm.go +++ b/client/server/mdm.go @@ -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), }) } diff --git a/client/server/server.go b/client/server/server.go index 410a9d98f..108aa8a41 100644 --- a/client/server/server.go +++ b/client/server/server.go @@ -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 diff --git a/client/server/setconfig_mdm_test.go b/client/server/setconfig_mdm_test.go index ad3b7ade7..a392af6d3 100644 --- a/client/server/setconfig_mdm_test.go +++ b/client/server/setconfig_mdm_test.go @@ -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, diff --git a/client/ssh/handshake.go b/client/ssh/handshake.go index e78a806be..a718748df 100644 --- a/client/ssh/handshake.go +++ b/client/ssh/handshake.go @@ -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) +} diff --git a/client/ssh/handshake_test.go b/client/ssh/handshake_test.go new file mode 100644 index 000000000..77a6f916b --- /dev/null +++ b/client/ssh/handshake_test.go @@ -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 +} diff --git a/client/ui/autostart_default.go b/client/ui/autostart_default.go index 162922579..0c67667dd 100644 --- a/client/ui/autostart_default.go +++ b/client/ui/autostart_default.go @@ -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 { diff --git a/client/ui/services/settings.go b/client/ui/services/settings.go index 91aac0467..7c20184bd 100644 --- a/client/ui/services/settings.go +++ b/client/ui/services/settings.go @@ -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) } diff --git a/combined/cmd/root.go b/combined/cmd/root.go index 3e583ef20..917312e57 100644 --- a/combined/cmd/root.go +++ b/combined/cmd/root.go @@ -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) diff --git a/infrastructure_files/getting-started-enterprise.sh b/infrastructure_files/getting-started-enterprise.sh index 3f7cf6357..701598a60 100755 --- a/infrastructure_files/getting-started-enterprise.sh +++ b/infrastructure_files/getting-started-enterprise.sh @@ -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" diff --git a/infrastructure_files/getting-started.sh b/infrastructure_files/getting-started.sh index 5efc0181e..afbc5c282 100755 --- a/infrastructure_files/getting-started.sh +++ b/infrastructure_files/getting-started.sh @@ -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 < 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. diff --git a/management/internals/modules/reverseproxy/domain/manager/manager_realstore_test.go b/management/internals/modules/reverseproxy/domain/manager/manager_realstore_test.go new file mode 100644 index 000000000..8a0b56171 --- /dev/null +++ b/management/internals/modules/reverseproxy/domain/manager/manager_realstore_test.go @@ -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") +} diff --git a/management/internals/modules/reverseproxy/domain/manager/manager_test.go b/management/internals/modules/reverseproxy/domain/manager/manager_test.go index 12281b447..519f5efeb 100644 --- a/management/internals/modules/reverseproxy/domain/manager/manager_test.go +++ b/management/internals/modules/reverseproxy/domain/manager/manager_test.go @@ -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") } diff --git a/management/internals/modules/reverseproxy/service/manager/domain_validation_test.go b/management/internals/modules/reverseproxy/service/manager/domain_validation_test.go new file mode 100644 index 000000000..ccb955cd8 --- /dev/null +++ b/management/internals/modules/reverseproxy/service/manager/domain_validation_test.go @@ -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") +} diff --git a/management/internals/modules/reverseproxy/service/manager/manager.go b/management/internals/modules/reverseproxy/service/manager/manager.go index 365fbab40..9c7f95eb4 100644 --- a/management/internals/modules/reverseproxy/service/manager/manager.go +++ b/management/internals/modules/reverseproxy/service/manager/manager.go @@ -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 { diff --git a/management/internals/server/boot.go b/management/internals/server/boot.go index 87a36a93b..c28758ab8 100644 --- a/management/internals/server/boot.go +++ b/management/internals/server/boot.go @@ -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}), + } +} diff --git a/management/internals/server/realip_test.go b/management/internals/server/realip_test.go new file mode 100644 index 000000000..89ac02730 --- /dev/null +++ b/management/internals/server/realip_test.go @@ -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") +} diff --git a/management/internals/server/server.go b/management/internals/server/server.go index 22a61bada..9709d1099 100644 --- a/management/internals/server/server.go +++ b/management/internals/server/server.go @@ -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) diff --git a/management/server/account.go b/management/server/account.go index 3ceef79db..6ccf673f5 100644 --- a/management/server/account.go +++ b/management/server/account.go @@ -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)) } } diff --git a/management/server/account_test.go b/management/server/account_test.go index b462cc2a6..bd7bf2d97 100644 --- a/management/server/account_test.go +++ b/management/server/account_test.go @@ -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", diff --git a/management/server/group.go b/management/server/group.go index 33870f25e..ca20a6b08 100644 --- a/management/server/group.go +++ b/management/server/group.go @@ -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 } diff --git a/management/server/group_test.go b/management/server/group_test.go index f5aeceea8..da056c8a9 100644 --- a/management/server/group_test.go +++ b/management/server/group_test.go @@ -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") + }) +} diff --git a/management/server/peer.go b/management/server/peer.go index 07619f51e..9f5572252 100644 --- a/management/server/peer.go +++ b/management/server/peer.go @@ -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) diff --git a/management/server/scheduler.go b/management/server/scheduler.go index b61643295..1daea4295 100644 --- a/management/server/scheduler.go +++ b/management/server/scheduler.go @@ -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) diff --git a/management/server/scheduler_test.go b/management/server/scheduler_test.go index e3af551ad..9dd13ce6b 100644 --- a/management/server/scheduler_test.go +++ b/management/server/scheduler_test.go @@ -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") +} diff --git a/management/server/store/sql_store.go b/management/server/store/sql_store.go index ddb78cfdb..351b25d6e 100644 --- a/management/server/store/sql_store.go +++ b/management/server/store/sql_store.go @@ -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") } diff --git a/management/server/store/sql_store_test.go b/management/server/store/sql_store_test.go index 4b7bcf068..fbcff5257 100644 --- a/management/server/store/sql_store_test.go +++ b/management/server/store/sql_store_test.go @@ -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) diff --git a/management/server/store/store.go b/management/server/store/store.go index 7daeb28a9..da2b3c6e0 100644 --- a/management/server/store/store.go +++ b/management/server/store/store.go @@ -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 diff --git a/management/server/store/store_mock.go b/management/server/store/store_mock.go index 70acb9f58..9bf49f076 100644 --- a/management/server/store/store_mock.go +++ b/management/server/store/store_mock.go @@ -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() diff --git a/management/server/types/account.go b/management/server/types/account.go index d689b0175..d0688d1ee 100644 --- a/management/server/types/account.go +++ b/management/server/types/account.go @@ -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) diff --git a/management/server/user.go b/management/server/user.go index 7c0a3088d..823c1b2e4 100644 --- a/management/server/user.go +++ b/management/server/user.go @@ -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) diff --git a/management/server/user_test.go b/management/server/user_test.go index 3a2414540..ec0bbc54e 100644 --- a/management/server/user_test.go +++ b/management/server/user_test.go @@ -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 { diff --git a/proxy/internal/acme/locker.go b/proxy/internal/acme/locker.go index 2f0f18885..f42324736 100644 --- a/proxy/internal/acme/locker.go +++ b/proxy/internal/acme/locker.go @@ -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 } diff --git a/proxy/internal/acme/locker_test.go b/proxy/internal/acme/locker_test.go index 39245df0c..f131f64f3 100644 --- a/proxy/internal/acme/locker_test.go +++ b/proxy/internal/acme/locker_test.go @@ -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) +} diff --git a/proxy/internal/debug/handler.go b/proxy/internal/debug/handler.go index 6300228d7..960c3e089 100644 --- a/proxy/internal/debug/handler.go +++ b/proxy/internal/debug/handler.go @@ -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) { diff --git a/proxy/internal/debug/perf_test.go b/proxy/internal/debug/perf_test.go new file mode 100644 index 000000000..abcfccb50 --- /dev/null +++ b/proxy/internal/debug/perf_test.go @@ -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) + } +} diff --git a/shared/relay/client/client.go b/shared/relay/client/client.go index 38c9c7375..7171b40ad 100644 --- a/shared/relay/client/client.go +++ b/shared/relay/client/client.go @@ -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 { diff --git a/shared/relay/client/client_serverip_test.go b/shared/relay/client/client_serverip_test.go index 7e699e37d..a52d434f7 100644 --- a/shared/relay/client/client_serverip_test.go +++ b/shared/relay/client/client_serverip_test.go @@ -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) }) } } diff --git a/shared/relay/client/manager.go b/shared/relay/client/manager.go index 50fcc0b8f..367c6dfc5 100644 --- a/shared/relay/client/manager.go +++ b/shared/relay/client/manager.go @@ -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. diff --git a/shared/relay/client/manager_address_test.go b/shared/relay/client/manager_address_test.go new file mode 100644 index 000000000..4f669e60d --- /dev/null +++ b/shared/relay/client/manager_address_test.go @@ -0,0 +1,103 @@ +package client + +import ( + "net/netip" + "runtime" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestManager_RelayInstanceAddressAcrossReconnect(t *testing.T) { + relays := []struct { + url *RelayAddr + conn stubConn + ip netip.Addr + }{ + { + url: &RelayAddr{addr: "rels://relay-a.example:443"}, + conn: stubConn{remote: staticAddr{s: "192.0.2.1:443"}}, + ip: netip.MustParseAddr("192.0.2.1"), + }, + { + url: &RelayAddr{addr: "rels://relay-b.example:443"}, + conn: stubConn{remote: staticAddr{s: "192.0.2.2:443"}}, + ip: netip.MustParseAddr("192.0.2.2"), + }, + } + c := &Client{ + instanceURL: relays[0].url, + relayConn: relays[0].conn, + serviceIsRunning: true, + } + m := &Manager{relayClient: c} + started := make(chan struct{}) + stop := make(chan struct{}) + done := make(chan struct{}) + t.Cleanup(func() { + close(stop) + <-done + }) + go func() { + defer close(done) + for i := 0; ; i++ { + select { + case <-stop: + return + default: + } + // Publish successive connection states using the lifecycle locks. + // Yield before publication so a getter using only muInstanceURL + // can read the old URL while waiting for the new connection's IP. + c.mu.Lock() + runtime.Gosched() + relay := relays[i%len(relays)] + c.muInstanceURL.Lock() + c.instanceURL = relay.url + c.muInstanceURL.Unlock() + c.relayConn = relay.conn + c.mu.Unlock() + if i == 0 { + close(started) + } + } + }() + <-started + + for range 1000 { + url, ip, err := m.RelayInstanceAddress() + require.NoError(t, err) + wantIP := relays[0].ip + if url == relays[1].url.String() { + wantIP = relays[1].ip + } + if !assert.Equal(t, wantIP, ip, "advertised IP must belong to relay %s", url) { + return + } + } +} + +func TestManager_RelayInstanceAddressDisconnected(t *testing.T) { + for _, tt := range []struct { + name string + client *Client + }{ + {name: "no client"}, + {name: "not connected", client: &Client{}}, + { + name: "closed connection", + client: &Client{ + relayConn: stubConn{remote: staticAddr{s: "192.0.2.1:443"}}, + }, + }, + } { + t.Run(tt.name, func(t *testing.T) { + m := &Manager{relayClient: tt.client} + url, ip, err := m.RelayInstanceAddress() + assert.Error(t, err) + assert.Empty(t, url, "disconnected relay must not advertise a URL") + assert.False(t, ip.IsValid(), "disconnected relay must not advertise a stale IP") + }) + } +} diff --git a/shared/relay/client/picker.go b/shared/relay/client/picker.go index 17b1390b1..fc1d8c1cb 100644 --- a/shared/relay/client/picker.go +++ b/shared/relay/client/picker.go @@ -63,7 +63,12 @@ func (sp *ServerPicker) PickServer(parentCtx context.Context) (*Client, error) { if !ok { return nil, <-errChan } - log.Infof("chosen home Relay server: %s", cr.Url) + instanceURL, serverIP, err := cr.RelayClient.serverInstanceAddress() + if err != nil { + log.Infof("chosen home Relay server: %s, instance address unavailable: %v", cr.Url, err) + return cr.RelayClient, nil + } + log.Infof("chosen home Relay server: %s, instance URL: %s, server IP: %s", cr.Url, instanceURL, serverIP) return cr.RelayClient, nil case <-ctx.Done(): return nil, fmt.Errorf("connect to relay server: %w", ctx.Err()) diff --git a/util/serviceurl.go b/util/serviceurl.go new file mode 100644 index 000000000..ffd287df1 --- /dev/null +++ b/util/serviceurl.go @@ -0,0 +1,69 @@ +package util + +import ( + "net/url" + "strconv" + "strings" +) + +// SameServiceURL reports whether two service URLs address the same endpoint. +// One endpoint can be written several ways, and every spelling below reaches +// the same server, so none of them is a divergence from another: +// +// an implicit default port https://mgmt.example.com :443 +// a zero-padded port https://mgmt.example.com:0443 +// a different host case https://MGMT.example.com +// a trailing slash https://mgmt.example.com/ +// +// A path is otherwise part of the identity: https://mgmt.example.com and +// https://mgmt.example.com/other are two endpoints. +// +// It lives here rather than next to any one caller because several of them +// compare the same kind of URL — an MDM-enforced management URL against a +// requested one, a stored profile URL against a command-line one — and every +// copy of these rules that drifts turns an equivalent URL into a refused +// request. +func SameServiceURL(a, b *url.URL) bool { + if a == nil || b == nil { + return a == b + } + + return strings.EqualFold(a.Hostname(), b.Hostname()) && + strings.EqualFold(a.Scheme, b.Scheme) && + ServiceURLPort(a) == ServiceURLPort(b) && + strings.TrimSuffix(a.Path, "/") == strings.TrimSuffix(b.Path, "/") +} + +// SameServiceURLStrings is SameServiceURL for unparsed input. Input that does +// not parse falls back to string equality, which is the strictest thing left +// to do with it. +func SameServiceURLStrings(a, b string) bool { + ua, errA := url.ParseRequestURI(a) + ub, errB := url.ParseRequestURI(b) + if errA != nil || errB != nil { + return a == b + } + + return SameServiceURL(ua, ub) +} + +// ServiceURLPort is the port a URL addresses: the one it carries, normalized +// numerically so ":0443" and ":443" are one port, or the scheme's default. +func ServiceURLPort(u *url.URL) string { + port := u.Port() + if port == "" { + switch strings.ToLower(u.Scheme) { + case "https": + return "443" + case "http": + return "80" + default: + return "" + } + } + + if n, err := strconv.Atoi(port); err == nil { + return strconv.Itoa(n) + } + return port +} diff --git a/util/serviceurl_test.go b/util/serviceurl_test.go new file mode 100644 index 000000000..af32a7c29 --- /dev/null +++ b/util/serviceurl_test.go @@ -0,0 +1,73 @@ +package util + +import ( + "net/url" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestSameServiceURLSpellings(t *testing.T) { + tests := []struct { + a, b string + want bool + }{ + // One endpoint, written several ways. + {a: "https://mgmt.example.com", b: "https://mgmt.example.com:443", want: true}, + {a: "https://mgmt.example.com", b: "https://mgmt.example.com/", want: true}, + {a: "https://mgmt.example.com/", b: "https://mgmt.example.com:443/", want: true}, + {a: "https://MGMT.example.com", b: "https://mgmt.example.com", want: true}, + {a: "https://mgmt.example.com:0443", b: "https://mgmt.example.com:443", want: true}, + {a: "http://mgmt.example.com", b: "http://mgmt.example.com:80", want: true}, + {a: "HTTPS://mgmt.example.com", b: "https://mgmt.example.com", want: true}, + + // Different endpoints. + {a: "https://mgmt.example.com", b: "http://mgmt.example.com", want: false}, + {a: "https://mgmt.example.com", b: "https://mgmt.example.com:8443", want: false}, + {a: "https://mgmt.example.com", b: "https://other.example.com", want: false}, + {a: "https://mgmt.example.com", b: "https://mgmt.example.com/other", want: false}, + + // Unparseable input falls back to string equality. + {a: "mgmt.example.com", b: "mgmt.example.com", want: true}, + {a: "mgmt.example.com", b: "https://mgmt.example.com", want: false}, + } + + for _, tt := range tests { + t.Run(tt.a+" vs "+tt.b, func(t *testing.T) { + assert.Equal(t, tt.want, SameServiceURLStrings(tt.a, tt.b)) + assert.Equal(t, tt.want, SameServiceURLStrings(tt.b, tt.a), "the comparison must be symmetric") + }) + } +} + +// The parsed form is the primitive the string form delegates to, so it must +// answer the same for a spelling that only the parser can tell apart. +func TestSameServiceURLParsed(t *testing.T) { + parse := func(raw string) *url.URL { + t.Helper() + u, err := url.ParseRequestURI(raw) + require.NoError(t, err) + return u + } + + assert.True(t, SameServiceURL(parse("https://mgmt.example.com:0443/"), parse("https://MGMT.example.com"))) + assert.False(t, SameServiceURL(parse("https://mgmt.example.com"), parse("https://mgmt.example.com:8443"))) + + assert.True(t, SameServiceURL(nil, nil), "two absent URLs are the same absence") + assert.False(t, SameServiceURL(nil, parse("https://mgmt.example.com"))) +} + +func TestServiceURLPort(t *testing.T) { + parse := func(raw string) *url.URL { + t.Helper() + u, err := url.ParseRequestURI(raw) + require.NoError(t, err) + return u + } + + assert.Equal(t, "443", ServiceURLPort(parse("https://mgmt.example.com"))) + assert.Equal(t, "80", ServiceURLPort(parse("http://mgmt.example.com"))) + assert.Equal(t, "443", ServiceURLPort(parse("https://mgmt.example.com:0443"))) + assert.Equal(t, "8443", ServiceURLPort(parse("https://mgmt.example.com:8443"))) +} diff --git a/util/wsproxy/server/proxy.go b/util/wsproxy/server/proxy.go index ffb622200..0618beb91 100644 --- a/util/wsproxy/server/proxy.go +++ b/util/wsproxy/server/proxy.go @@ -1,12 +1,8 @@ package server import ( - "context" - "io" - "net" "net/http" - "sync" - "time" + "sync/atomic" "github.com/coder/websocket" log "github.com/sirupsen/logrus" @@ -15,11 +11,6 @@ import ( "github.com/netbirdio/netbird/util/wsproxy" ) -const ( - bufferSize = 32 * 1024 - ioTimeout = 5 * time.Second -) - // Config contains the configuration for the WebSocket proxy. type Config struct { Handler http.Handler @@ -53,14 +44,23 @@ func New(handler http.Handler, opts ...Option) *Proxy { // Handler returns an http.Handler that proxies WebSocket connections to the local gRPC server. func (p *Proxy) Handler() http.Handler { - return http.HandlerFunc(p.handleWebSocket) + return &proxyHandler{ + metrics: p.config.MetricsRecorder, + handler: p.config.Handler, + } } -func (p *Proxy) handleWebSocket(w http.ResponseWriter, r *http.Request) { +type proxyHandler struct { + metrics MetricsRecorder + handler http.Handler + conn atomic.Pointer[wsConnAdapter] +} + +func (ph *proxyHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { ctx := r.Context() - p.metrics.RecordConnection(ctx) - defer p.metrics.RecordDisconnection(ctx) + ph.metrics.RecordConnection(ctx) + defer ph.metrics.RecordDisconnection(ctx) log.Debugf("WebSocket proxy handling connection from %s, forwarding to internal gRPC handler", r.RemoteAddr) acceptOptions := &websocket.AcceptOptions{ @@ -69,121 +69,44 @@ func (p *Proxy) handleWebSocket(w http.ResponseWriter, r *http.Request) { wsConn, err := websocket.Accept(w, r, acceptOptions) if err != nil { - p.metrics.RecordError(ctx, "websocket_accept_failed") + ph.metrics.RecordError(ctx, "websocket_accept_failed") log.Errorf("WebSocket upgrade failed from %s: %v", r.RemoteAddr, err) return } - defer func() { - _ = wsConn.Close(websocket.StatusNormalClosure, "") - }() + serverConn := (&wsConnAdapter{ + ctx: ctx, + conn: wsConn, + metrics: ph.metrics, + clientAddr: r.RemoteAddr, + }) - clientConn, serverConn := net.Pipe() defer func() { - _ = clientConn.Close() _ = serverConn.Close() }() + ph.conn.Store(serverConn) // used in tests only + log.Debugf("WebSocket proxy established: %s -> gRPC handler", r.RemoteAddr) - go func() { - (&http2.Server{}).ServeConn(serverConn, &http2.ServeConnOpts{ - Context: ctx, - Handler: p.config.Handler, - }) - }() + (&http2.Server{ + // TODO (dmitri) we should limit the number of concurrent streams per connection (peer) + // and idle timeouts + // MaxConcurrentStreams: 20, + // IdleTimeout: 10 * time.Second, + }).ServeConn(serverConn, &http2.ServeConnOpts{ + Context: ctx, + Handler: ph.handler, + BaseConfig: &http.Server{ + // b/c we are wrapping a ws connection, read and write connection deadlines normally set + // via ReadTimeout and WriteTimeout http.Server fields aren't available to us. The ws + // library doesn't expose connection deadline timer config, and we ignore these calls in "wsConnAdapter". + // + // Another issue is that Server.ServeConn() call bypasses setting of connection deadlines altogether, + // ReadTimeout and Writetimeout set here would only apply to h2 streams, i.e. after a HEADERS frame + // arrival and processing, turning ReadTimeout into a request body read deadline, and WriteTimeout into + // a response deadline (the latter not useful for streaming requests). + }, + }) - p.proxyData(ctx, wsConn, clientConn, r.RemoteAddr) -} - -func (p *Proxy) proxyData(ctx context.Context, wsConn *websocket.Conn, pipeConn net.Conn, clientAddr string) { - proxyCtx, cancel := context.WithCancel(ctx) - defer cancel() - - var wg sync.WaitGroup - wg.Add(2) - - go p.wsToPipe(proxyCtx, cancel, &wg, wsConn, pipeConn, clientAddr) - go p.pipeToWS(proxyCtx, cancel, &wg, wsConn, pipeConn, clientAddr) - - wg.Wait() -} - -func (p *Proxy) wsToPipe(ctx context.Context, cancel context.CancelFunc, wg *sync.WaitGroup, wsConn *websocket.Conn, pipeConn net.Conn, clientAddr string) { - defer wg.Done() - defer cancel() - - for { - msgType, data, err := wsConn.Read(ctx) - if err != nil { - switch { - case ctx.Err() != nil: - log.Debugf("WebSocket from %s terminating due to context cancellation", clientAddr) - case websocket.CloseStatus(err) != -1: - log.Debugf("WebSocket from %s disconnected", clientAddr) - default: - p.metrics.RecordError(ctx, "websocket_read_error") - log.Debugf("WebSocket read error from %s: %v", clientAddr, err) - } - return - } - - if msgType != websocket.MessageBinary { - log.Warnf("Unexpected WebSocket message type from %s: %v", clientAddr, msgType) - continue - } - - if ctx.Err() != nil { - log.Tracef("wsToPipe goroutine terminating due to context cancellation before pipe write") - return - } - - if err := pipeConn.SetWriteDeadline(time.Now().Add(ioTimeout)); err != nil { - log.Debugf("Failed to set pipe write deadline: %v", err) - } - - n, err := pipeConn.Write(data) - if err != nil { - p.metrics.RecordError(ctx, "pipe_write_error") - log.Warnf("Pipe write error for %s: %v", clientAddr, err) - return - } - - p.metrics.RecordBytesTransferred(ctx, "ws_to_grpc", int64(n)) - } -} - -func (p *Proxy) pipeToWS(ctx context.Context, cancel context.CancelFunc, wg *sync.WaitGroup, wsConn *websocket.Conn, pipeConn net.Conn, clientAddr string) { - defer wg.Done() - defer cancel() - - buf := make([]byte, bufferSize) - for { - n, err := pipeConn.Read(buf) - if err != nil { - if ctx.Err() != nil { - log.Tracef("pipeToWS goroutine terminating due to context cancellation") - return - } - - if err != io.EOF { - log.Debugf("Pipe read error for %s: %v", clientAddr, err) - } - return - } - - if ctx.Err() != nil { - log.Tracef("pipeToWS goroutine terminating due to context cancellation before WebSocket write") - return - } - - if n > 0 { - if err := wsConn.Write(ctx, websocket.MessageBinary, buf[:n]); err != nil { - p.metrics.RecordError(ctx, "websocket_write_error") - log.Warnf("WebSocket write error for %s: %v", clientAddr, err) - return - } - - p.metrics.RecordBytesTransferred(ctx, "grpc_to_ws", int64(n)) - } - } + log.Debugf("WebSocket proxy closing: %s -> gRPC handler", r.RemoteAddr) } diff --git a/util/wsproxy/server/ws_conn_adapter.go b/util/wsproxy/server/ws_conn_adapter.go new file mode 100644 index 000000000..eb29ab0cb --- /dev/null +++ b/util/wsproxy/server/ws_conn_adapter.go @@ -0,0 +1,126 @@ +package server + +import ( + "context" + "net" + "sync/atomic" + "time" + + "github.com/coder/websocket" + log "github.com/sirupsen/logrus" +) + +type wsConnAdapter struct { + prefix string + ctx context.Context + conn *websocket.Conn + metrics MetricsRecorder + clientAddr string + closed atomic.Bool + bufferedRead []byte +} + +var _ net.Conn = &wsConnAdapter{} + +type wsAddr struct{ prefix string } + +func (wa wsAddr) Network() string { return wa.prefix + "ws-proxy" } +func (wa wsAddr) String() string { return wa.prefix + "ws-proxy" } + +func (ws *wsConnAdapter) Read(b []byte) (int, error) { + if len(ws.bufferedRead) > 0 { + return ws.readFromBuffer(b) + } + + msgType, data, err := ws.conn.Read(ws.ctx) + if err != nil { + switch { + case ws.ctx.Err() != nil: + log.Debugf("WebSocket from %s terminating due to context cancellation", ws.clientAddr) + case websocket.CloseStatus(err) != -1: + log.Debugf("WebSocket from %s disconnected", ws.clientAddr) + default: + ws.recordError(ws.ctx, "websocket_read_error") + log.Debugf("WebSocket read error from %s: %v", ws.clientAddr, err) + } + return copy(b, data), err + } + if msgType != websocket.MessageBinary { + log.Warnf("Unexpected WebSocket message type from %s: %v", ws.clientAddr, msgType) + return 0, nil + } + + ws.bufferedRead = data + return ws.readFromBuffer(b) +} + +func (ws *wsConnAdapter) readFromBuffer(b []byte) (int, error) { + n := copy(b, ws.bufferedRead) + + ws.recordBytesTransferred(ws.ctx, "ws_to_grpc", n) + if n == len(ws.bufferedRead) { + ws.bufferedRead = nil + return n, nil + } else { + ws.bufferedRead = ws.bufferedRead[n:] + } + return n, nil +} + +func (ws *wsConnAdapter) Write(b []byte) (int, error) { + maybeErr := ws.ctx.Err() + + n := len(b) + if n == 0 { + return n, maybeErr + } + if maybeErr != nil { + return 0, maybeErr + } + if err := ws.conn.Write(ws.ctx, websocket.MessageBinary, b[:n]); err != nil { + ws.recordError(ws.ctx, "websocket_write_error") + log.Warnf("WebSocket write error for %s: %v", ws.clientAddr, err) + return 0, err // we don't know how many bytes have been written + } + + ws.recordBytesTransferred(ws.ctx, "grpc_to_ws", n) + return n, nil +} + +func (ws *wsConnAdapter) Close() error { + ws.closed.Store(true) + return ws.conn.Close(websocket.StatusNormalClosure, "") +} + +func (ws *wsConnAdapter) LocalAddr() net.Addr { return wsAddr{ws.prefix} } +func (ws *wsConnAdapter) RemoteAddr() net.Addr { return wsAddr{ws.prefix} } + +func (ws *wsConnAdapter) SetDeadline(t time.Time) error { + return nil +} + +func (ws *wsConnAdapter) SetReadDeadline(t time.Time) error { + return nil +} + +func (ws *wsConnAdapter) SetWriteDeadline(t time.Time) error { + return nil +} + +func (ws *wsConnAdapter) recordError(ctx context.Context, errorType string) { + if ws.metrics == nil { + return + } + ws.metrics.RecordError(ctx, errorType) +} + +func (ws *wsConnAdapter) recordBytesTransferred(ctx context.Context, direction string, bytes int) { + if ws.metrics == nil { + return + } + ws.metrics.RecordBytesTransferred(ctx, direction, int64(bytes)) +} + +func (ws *wsConnAdapter) IsClosed() bool { + return ws.closed.Load() +} diff --git a/util/wsproxy/server/ws_conn_adapter_test.go b/util/wsproxy/server/ws_conn_adapter_test.go new file mode 100644 index 000000000..d46e4830b --- /dev/null +++ b/util/wsproxy/server/ws_conn_adapter_test.go @@ -0,0 +1,204 @@ +package server + +import ( + "bytes" + "context" + "crypto/tls" + "io" + "math/rand/v2" + "net" + "net/http" + "os" + "path/filepath" + "strconv" + "strings" + "testing" + "time" + + "github.com/coder/websocket" + "github.com/stretchr/testify/assert" + "golang.org/x/net/http2" + "golang.org/x/net/http2/hpack" +) + +func TestAdapterHandlingConnectionClosures(t *testing.T) { + var cases = []struct { + description string + casenum int + }{ + {"client-side ws connection is closed", 0}, + {"server-side ws connection is closed", 1}, + {"client-side context is cancelled", 2}, + {"server-side context is cancelled", 3}, + } + + for _, c := range cases { + t.Run(c.description, func(t *testing.T) { + serversock := filepath.Join(os.TempDir(), "http-server-"+strconv.FormatInt(rand.Int64(), 10)+".sock") + t.Cleanup(func() { os.Remove(serversock) }) + + l, err := net.Listen("unix", serversock) + assert.NoError(t, err) + + proxy := New(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + buf, _ := io.ReadAll(r.Body) + defer r.Body.Close() + w.Write([]byte("echo: " + string(buf))) //nolint:errcheck + })) + + handler, ok := proxy.Handler().(*proxyHandler) + assert.True(t, ok) + + protocols := new(http.Protocols) + protocols.SetHTTP1(true) + protocols.SetUnencryptedHTTP2(true) + httpServer := http.Server{ + Handler: handler, + } + go httpServer.Serve(l) //nolint:errcheck + t.Cleanup(func() { httpServer.Close() }) + + clientconn, _, err := websocket.Dial(context.Background(), "http://whatever", //nolint:bodyclose + &websocket.DialOptions{HTTPClient: &http.Client{ + Transport: &http.Transport{ + DialContext: func(_ context.Context, _, _ string) (net.Conn, error) { + return net.Dial("unix", serversock) + }, + }}}) + assert.NoError(t, err) + + clientCtx, cancel := context.WithCancel(context.Background()) //nolint:govet + h2client := &http.Client{ + Transport: &http2.Transport{ + AllowHTTP: true, + DialTLSContext: func(_ context.Context, _, _ string, _ *tls.Config) (net.Conn, error) { + return &wsConnAdapter{ + prefix: "test-client", + ctx: clientCtx, + conn: clientconn, + }, nil + }, + }} + + resp, err := h2client.Post("http://whatever", "text/html", strings.NewReader("g'day")) + assert.NoError(t, err) + + body, err := io.ReadAll(resp.Body) + defer resp.Body.Close() + + assert.NoError(t, err) + assert.Equal(t, "echo: g'day", string(body)) + + switch c.casenum { + case 0: + clientconn.Close(websocket.StatusNormalClosure, "") + case 1: + handler.conn.Load().Close() + case 2: + cancel() + case 3: + resp.Body.Close() + h2client.CloseIdleConnections() + } + + assert.EventuallyWithT(t, func(c *assert.CollectT) { + assert.True(c, handler.conn.Load().IsClosed()) + }, 3*time.Second, 100*time.Millisecond) + }) //nolint:govet + } +} + +func TestAdapterHandlingHttpConnection_NoHeadersSent(t *testing.T) { + t.Skip("currently disabled as it requires idle timeout to be set") + + serversock := filepath.Join(os.TempDir(), "http-server-"+strconv.FormatInt(rand.Int64(), 10)+".sock") + defer os.Remove(serversock) + + l, err := net.Listen("unix", serversock) + assert.NoError(t, err) + + proxy := New(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + buf, _ := io.ReadAll(r.Body) + defer r.Body.Close() //nolint:errcheck + w.Write([]byte("echo: " + string(buf))) //nolint:errcheck + })) + + handler, ok := proxy.Handler().(*proxyHandler) + assert.True(t, ok) + + protocols := new(http.Protocols) + protocols.SetHTTP1(true) + protocols.SetUnencryptedHTTP2(true) + httpServer := http.Server{ + Handler: handler, + } + go httpServer.Serve(l) //nolint:errcheck + + clientconn, _, err := websocket.Dial(context.Background(), "http://whatever", //nolint:bodyclose + &websocket.DialOptions{HTTPClient: &http.Client{ + Transport: &http.Transport{ + DialContext: func(_ context.Context, _, _ string) (net.Conn, error) { + return net.Dial("unix", serversock) + }, + }}}) + assert.NoError(t, err) + + h2client := &http.Client{ + Transport: &http2.Transport{ + AllowHTTP: true, + DialTLSContext: func(_ context.Context, _, _ string, _ *tls.Config) (net.Conn, error) { + return &h2ConnectionSnooper{wrappedConn: &wsConnAdapter{ + prefix: "test-client", + ctx: context.Background(), + conn: clientconn, + }, shouldDropFrame: func(f http2.FrameType) bool { return f == http2.FrameHeaders || f == http2.FrameData }}, nil + }, + }} + + _, err = h2client.Post("http://whatever", "text/html", strings.NewReader("g'day")) + assert.Error(t, err) + + assert.EventuallyWithT(t, func(c *assert.CollectT) { + assert.True(c, handler.conn.Load().IsClosed()) + }, 3*time.Second, 100*time.Millisecond) +} + +type h2ConnectionSnooper struct { + wrappedConn net.Conn + shouldDropFrame func(f http2.FrameType) bool +} + +func (hs *h2ConnectionSnooper) Read(b []byte) (n int, err error) { + return hs.wrappedConn.Read(b) +} + +func (hs *h2ConnectionSnooper) Write(b []byte) (n int, err error) { + fr := http2.NewFramer(nil, bytes.NewReader(b)) + fr.ReadMetaHeaders = hpack.NewDecoder(0, nil) + f, err := fr.ReadFrame() + if err != nil { + return hs.wrappedConn.Write(b) + } + + if hs.shouldDropFrame != nil && hs.shouldDropFrame(f.Header().Type) { + return len(b), nil + } + + return hs.wrappedConn.Write(b) +} + +func (hs *h2ConnectionSnooper) Close() error { return hs.wrappedConn.Close() } + +func (hs *h2ConnectionSnooper) LocalAddr() net.Addr { return hs.wrappedConn.LocalAddr() } + +func (hs *h2ConnectionSnooper) RemoteAddr() net.Addr { return hs.wrappedConn.RemoteAddr() } + +func (hs *h2ConnectionSnooper) SetDeadline(t time.Time) error { return hs.wrappedConn.SetDeadline(t) } + +func (hs *h2ConnectionSnooper) SetReadDeadline(t time.Time) error { + return hs.wrappedConn.SetReadDeadline(t) +} + +func (hs *h2ConnectionSnooper) SetWriteDeadline(t time.Time) error { + return hs.wrappedConn.SetWriteDeadline(t) +}