Merge remote-tracking branch 'origin/main' into HEAD

# Conflicts:
#	client/internal/ebpf/ebpf/manager_linux.go
#	client/internal/ebpf/ebpf/manager_linux_test.go
#	client/internal/ebpf/manager/manager.go
This commit is contained in:
Viktor Liu
2026-09-23 07:43:31 +02:00
339 changed files with 17843 additions and 3440 deletions
+10
View File
@@ -14,5 +14,15 @@ reviews:
- "!**/*.ts"
- "!**/*.js"
- "!**/*.svg"
pre_merge_checks:
custom_checks:
- name: "No attribution trailers"
mode: error
instructions: >-
Fail when the PR description or any commit message carries an
attribution trailer or footer: Co-Authored-By, Claude-Session,
Generated-By, or a "Generated with"/"Generated by" tool line.
Contributors own their contributions (AGENTS.md); ask for the
lines to be removed.
chat:
auto_reply: true
+26
View File
@@ -0,0 +1,26 @@
#!/bin/bash
# Refuses commit messages that carry attribution trailers. Contributors own
# their contributions (AGENTS.md, "No Co-Authored-By or tool-attribution
# trailers"); a trailer spreads that ownership onto a tool or a bystander.
msg_file="$1"
# Trailer keys in any casing, with any bullet or emoji in front.
trailers='^[^[:alnum:]]*(co-authored-by|claude-session|generated-by):'
# "Generated with/by" footers, including "Generated with <emoji> by".
footer='^[^[:alnum:]]*generated (with|by)( [^[:alnum:]]*by)? '
# A footer names a product, so a capitalized word must follow the phrase
# itself. Prose such as "generated by the protobuf compiler" stays legal.
tool='[Gg][Ee][Nn][Ee][Rr][Aa][Tt][Ee][Dd] ([Ww][Ii][Tt][Hh]|[Bb][Yy])( [^[:alnum:]]*[Bb][Yy])? [^[:alnum:]]*[A-Z]'
offending=$( {
grep -Ein "$trailers" "$msg_file"
grep -Ein "$footer" "$msg_file" | grep -E "$tool"
} | sort -un )
if [ -n "$offending" ]; then
echo "commit-msg: attribution trailers are not accepted in this repository:" >&2
printf '%s\n' "$offending" | sed 's/^/ /' >&2
echo "Remove them and commit again (see AGENTS.md)." >&2
exit 1
fi
+17 -2
View File
@@ -27,7 +27,22 @@ jobs:
push: false
archive: false
pr_comment: false
build: false
lint: false
format: false
breaking: true
# A push that creates a branch carries no `before` commit, so the
# action's default baseline is the all-zero SHA and `buf breaking`
# dies cloning it. Skipping costs nothing: every commit on a freshly
# cut release branch should have already passed this check on main.
breaking: ${{ !github.event.created }}
# The alternative is to compare against the default branch instead of
# skipping. Not used: buf clones the baseline when the job runs, so a
# main that has moved on since the branch was cut reads as protos
# deleted on the release branch. Resolving to an empty string on every
# other event is what keeps the action's own default in place, which
# stacked pull requests need.
# breaking_against: >-
# ${{ github.event.created
# && format('{0}#format=git,branch={1}',
# github.event.repository.clone_url,
# github.event.repository.default_branch)
# || '' }}
+7 -5
View File
@@ -46,15 +46,17 @@ jobs:
run: git --no-pager diff --exit-code
- name: Test
# Exclude client/ui: its main.go uses //go:embed all:frontend/dist,
# which fails to compile until the frontend has been built. The Wails UI
# has no Go-side unit tests, and its release pipeline runs `pnpm build`
# before goreleaser.
# Exclude the client/ui package itself: its main.go uses //go:embed
# all:frontend/dist, which fails to compile until the frontend has been
# built, and its release pipeline runs `pnpm build` before goreleaser.
# The pattern is anchored so the subpackages (services, preferences,
# i18n, authsession) still run: they hold Go-side unit tests and need no
# frontend bundle.
# `go list -e` lets the listing succeed even though the embed fails to
# resolve; the grep then drops the broken package by path. Without -e,
# go list aborts with empty stdout and `go test` falls back to the repo
# root, which has no Go files.
run: NETBIRD_STORE_ENGINE=${{ matrix.store }} CI=true go test -coverprofile=coverage.txt -tags 'devcert privileged' -exec 'sudo --preserve-env=CI,NETBIRD_STORE_ENGINE' -timeout 5m -p 1 $(go list -e ./... | grep -v -e /management -e /signal -e /relay -e /proxy -e /combined -e /client/ui -e /client/testutil/privileged)
run: NETBIRD_STORE_ENGINE=${{ matrix.store }} CI=true go test -coverprofile=coverage.txt -tags 'devcert privileged' -exec 'sudo --preserve-env=CI,NETBIRD_STORE_ENGINE' -timeout 5m -p 1 $(go list -e ./... | grep -v -e /management -e /signal -e /relay -e /proxy -e /combined -e '/client/ui$' -e /client/testutil/privileged)
- name: Upload coverage reports to Codecov
uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f #v7.0.0
+72 -6
View File
@@ -160,9 +160,10 @@ jobs:
- name: Test
# Exclude client/ui: its main.go uses //go:embed all:frontend/dist,
# which fails to compile until the frontend has been built. The Wails UI
# has no Go-side unit tests, and its release pipeline runs `pnpm build`
# before goreleaser.
# which fails to compile until the frontend has been built, and its
# release pipeline runs `pnpm build` before goreleaser. The subpackages
# go with it because this runner's gtk4 is older than the wails runtime
# needs; the Client UI / Unit job below covers them instead.
# `go list -e` lets the listing succeed even though the embed fails to
# resolve; the grep then drops the broken package by path. Without -e,
# go list aborts with empty stdout and `go test` falls back to the repo
@@ -177,6 +178,35 @@ jobs:
slug: netbirdio/netbird
flags: unit,client
test_client_ui:
name: "Client UI / Unit"
# Pinned to 24.04 rather than the 22.04 the other client jobs use: the wails
# runtime's linux cgo layer needs GtkFileDialog, which arrived in gtk4 4.10,
# and jammy ships 4.6. Not ubuntu-latest, so a runner image rollover cannot
# move this out from under us.
runs-on: ubuntu-24.04
steps:
- name: Checkout code
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
- name: Install Go
uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0
with:
go-version-file: "go.mod"
cache: false
- name: Install dependencies
run: sudo apt update && sudo apt install -y -q libgtk-4-dev libwebkitgtk-6.0-dev libsoup-3.0-dev
- name: Test
# client/ui itself stays out: its main.go embeds all:frontend/dist,
# which only exists after `pnpm build`. The subpackages carry the
# Go-side unit tests, including the window manager re-entrancy
# regression test, and need no frontend bundle.
run: CGO_ENABLED=1 go test -timeout 5m ./client/ui/authsession/... ./client/ui/i18n/... ./client/ui/preferences/... ./client/ui/services/...
test_client_on_docker:
name: "Client (Docker) / Unit"
needs: [build-cache]
@@ -211,6 +241,9 @@ jobs:
${{ runner.os }}-gotest-cache-
- name: Run tests in container
# Unlike the native job above, this one drops all of client/ui including
# the subpackages: the alpine container has no gtk4/webkitgtk, so the
# Wails application package they import would fail to link.
env:
HOST_GOCACHE: ${{ steps.go-env.outputs.cache_dir }}
HOST_GOMODCACHE: ${{ steps.go-env.outputs.modcache_dir }}
@@ -481,14 +514,32 @@ jobs:
if: matrix.store == 'mysql'
run: docker pull mlsmaycon/warmed-mysql:8
# The -json stream goes through tools/gotestsummary so the log shows one
# line per test, the output of failed tests, the head of a timeout panic
# with the still-running tests, and the slowest tests per package.
- name: Test
shell: bash
run: |
set -o pipefail
CGO_ENABLED=1 GOARCH=${{ matrix.arch }} \
NETBIRD_STORE_ENGINE=${{ matrix.store }} \
CI=true \
go test -tags=devcert -coverprofile=coverage.txt \
go test -json -tags=devcert -coverprofile=coverage.txt \
-exec "sudo --preserve-env=CI,NETBIRD_STORE_ENGINE" \
-timeout 20m ./management/... ./shared/management/...
-timeout 20m ./management/... ./shared/management/... \
| tee management-test-events.jsonl \
| go run ./tools/gotestsummary
# The summary trims long outputs; the raw stream keeps every line for
# the failures that need it. A green run has no use for it.
- name: Upload raw test events
if: failure()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a #v7.0.1
with:
name: management-unit-test-events-${{ matrix.store }}
path: management-test-events.jsonl
if-no-files-found: ignore
retention-days: 14
- name: Upload coverage reports to Codecov
if: matrix.arch == 'amd64'
@@ -738,12 +789,27 @@ jobs:
- name: check git status
run: git --no-pager diff --exit-code
# Same summary as the unit job: a timeout here names the tests still
# running instead of ending in a goroutine dump.
- name: Test
shell: bash
run: |
set -o pipefail
CGO_ENABLED=1 GOARCH=${{ matrix.arch }} \
NETBIRD_STORE_ENGINE=${{ matrix.store }} \
CI=true \
mage integrationtest:all -gotestflags="-coverprofile=coverage.txt"
mage integrationtest:all -gotestflags="-json -coverprofile=coverage.txt" \
| tee management-integration-test-events.jsonl \
| go run ./tools/gotestsummary
- name: Upload raw test events
if: failure()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a #v7.0.1
with:
name: management-integration-test-events-${{ matrix.store }}
path: management-integration-test-events.jsonl
if-no-files-found: ignore
retention-days: 14
- name: Upload coverage reports to Codecov
if: matrix.arch == 'amd64'
+7 -5
View File
@@ -66,15 +66,17 @@ jobs:
- run: PsExec64 -s -w ${{ github.workspace }} C:\hostedtoolcache\windows\go\${{ steps.go.outputs.go-version }}\x64\bin\go.exe env -w GOCACHE=${{ env.modcache }}
- run: PsExec64 -s -w ${{ github.workspace }} C:\hostedtoolcache\windows\go\${{ steps.go.outputs.go-version }}\x64\bin\go.exe mod tidy
- name: Generate test script
# Exclude client/ui: its main.go uses //go:embed all:frontend/dist,
# which fails to compile until the frontend has been built. The Wails UI
# has no Go-side unit tests, and its release pipeline runs `pnpm build`
# before goreleaser.
# Exclude the client/ui package itself: its main.go uses //go:embed
# all:frontend/dist, which fails to compile until the frontend has been
# built, and its release pipeline runs `pnpm build` before goreleaser.
# The pattern is anchored so the subpackages (services, preferences,
# i18n, authsession) still run: they hold Go-side unit tests and need no
# frontend bundle.
# `go list -e` lets the listing succeed even though the embed fails to
# resolve; the Where-Object pipeline then drops the broken package by
# path. Without -e, go list aborts with empty stdout.
run: |
$packages = go list -e ./... | Where-Object { $_ -notmatch '/management' } | Where-Object { $_ -notmatch '/relay' } | Where-Object { $_ -notmatch '/signal' } | Where-Object { $_ -notmatch '/proxy' } | Where-Object { $_ -notmatch '/combined' } | Where-Object { $_ -notmatch '/client/ui' }
$packages = go list -e ./... | Where-Object { $_ -notmatch '/management' } | Where-Object { $_ -notmatch '/relay' } | Where-Object { $_ -notmatch '/signal' } | Where-Object { $_ -notmatch '/proxy' } | Where-Object { $_ -notmatch '/combined' } | Where-Object { $_ -notmatch '/client/ui$' }
$goExe = "C:\hostedtoolcache\windows\go\${{ steps.go.outputs.go-version }}\x64\bin\go.exe"
$cmd = "$goExe test -tags `"devcert privileged`" -timeout 10m -p 1 $($packages -join ' ') > test-out.txt 2>&1"
Set-Content -Path "${{ github.workspace }}\run-tests.cmd" -Value $cmd
+12 -2
View File
@@ -186,6 +186,11 @@ jobs:
run: bash shared/management/http/api/generate.sh
- name: check git status
run: git --no-pager diff --exit-code
- name: Generate RPM changelog from git tags
# nfpm embeds changelog.yml into the RPM; Red Hat software certification
# requires a changelog. Generated, not committed (see .gitignore).
# chglog is a go.mod tool directive, so go.sum pins it and its deps.
run: bash release_files/rpm-changelog.sh
- name: Set up QEMU
uses: docker/setup-qemu-action@06116385d9baf250c9f4dcb4858b16962ea869c3 #v4.1.0
- name: Set up Docker Buildx
@@ -287,10 +292,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"
+4
View File
@@ -35,3 +35,7 @@ vendor/
/netbird
client/netbird-electron/
management/server/types/testdata/
# generated by chglog in the release workflow, embedded into the RPM
changelog.yml
.chglog.yml
+60
View File
@@ -234,10 +234,33 @@ nfpms:
- netbird
formats:
- rpm
# The client verifies TLS to management and signal against the system trust
# store. Red Hat software certification (RPM Dependency Tracking) also
# rejects packages that declare no dependencies at all.
dependencies:
- ca-certificates
# Generated in CI by chglog from git tags; Red Hat certification requires an
# RPM changelog (RPM Version Handling subtest).
changelog: changelog.yml
# License, documentation and a config file so the RPM Provenance subtest sees
# %license, %doc and %config entries instead of a bare binary.
contents:
- src: LICENSE
dst: /usr/share/licenses/netbird/LICENSE
type: license
- src: README.md
dst: /usr/share/doc/netbird/README.md
type: doc
- src: release_files/netbird.sysconfig
dst: /etc/sysconfig/netbird
type: config|noreplace
scripts:
postinstall: "release_files/post_install.sh"
preremove: "release_files/pre_remove.sh"
rpm:
summary: NetBird client
group: Applications/Internet
packager: NetBird <dev@netbird.io>
signature:
key_file: '{{ if index .Env "GPG_RPM_KEY_FILE" }}{{ .Env.GPG_RPM_KEY_FILE }}{{ end }}'
dockers_v2:
@@ -289,6 +312,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:
+1 -1
View File
@@ -77,7 +77,7 @@ make lint # golangci-lint on files changed vs origin/main (also the p
make lint-all # full-repository lint, matches CI
make test-unit # host-safe unit tests, -tags devcert, no sudo
make test-privileged # privileged-tagged suite in a Docker container with NET_ADMIN
make setup-hooks # wire make lint into .githooks/pre-push
make setup-hooks # wire .githooks: pre-push runs make lint, commit-msg refuses attribution trailers
# Narrow runs
go test ./client/internal/dns/...
+4 -1
View File
@@ -1 +1,4 @@
See [AGENTS.md](AGENTS.md) for the agent guidelines in this repository.
The agent guidelines live in [AGENTS.md](AGENTS.md). It is imported here so
every session loads it in full rather than following a pointer.
@AGENTS.md
+2 -2
View File
@@ -23,8 +23,8 @@ lint-install: $(GOLANGCI_LINT)
# Setup git hooks for all developers
setup-hooks:
@git config core.hooksPath .githooks
@chmod +x .githooks/pre-push
@echo "✅ Git hooks configured! Pre-push will now run 'make lint'"
@chmod +x .githooks/pre-push .githooks/commit-msg
@echo "✅ Git hooks configured! Pre-push runs 'make lint'; commit-msg refuses attribution trailers"
# Host-safe unit tests: excludes the privileged-tagged tests (root / system-mutating).
# Runs as a normal user with no sudo and leaves host networking untouched.
+45
View File
@@ -0,0 +1,45 @@
FROM registry.access.redhat.com/ubi9/ubi-minimal@sha256:7fbeae18dc9476399f565e68255f602a3374ea8614ba3d14843565131a13ff93
ARG TARGETPLATFORM
ARG NETBIRD_BINARY=$TARGETPLATFORM/netbird
ARG VERSION=dev
ARG RELEASE=1
LABEL name="netbird-rootless" \
maintainer="NetBird <dev@netbird.io>" \
vendor="NetBird GmbH" \
version="${VERSION}" \
release="${RELEASE}" \
summary="NetBird Rootless Client" \
description="NetBird connects devices through an encrypted overlay using userspace networking without a TUN device or network administration capabilities."
RUN microdnf install -y bash ca-certificates && microdnf clean all
COPY --chmod=0555 client/netbird-entrypoint.sh /usr/local/bin/netbird-entrypoint.sh
COPY --chmod=0555 ${NETBIRD_BINARY} /usr/local/bin/netbird
COPY licenses/ /licenses/
# Only application storage is group-writable for arbitrary non-root UIDs.
# Runtime-created credentials keep the client's restrictive file modes.
RUN mkdir -p /var/lib/netbird && \
chown 1000:0 /var/lib/netbird && \
chmod 0770 /var/lib/netbird && \
chmod -R a+rX /licenses
WORKDIR /var/lib/netbird
USER 1000:0
ENV \
HOME="/var/lib/netbird" \
NETBIRD_BIN="/usr/local/bin/netbird" \
NB_USE_NETSTACK_MODE="true" \
NB_ENABLE_NETSTACK_LOCAL_FORWARDING="true" \
NB_CONFIG="/var/lib/netbird/config.json" \
NB_STATE_DIR="/var/lib/netbird" \
NB_DAEMON_ADDR="unix:///var/lib/netbird/netbird.sock" \
NB_LOG_FILE="console,/var/lib/netbird/client.log" \
NB_DISABLE_DNS="true" \
NB_ENABLE_CAPTURE="false" \
NB_ENTRYPOINT_SERVICE_TIMEOUT="30"
STOPSIGNAL SIGTERM
ENTRYPOINT ["/usr/local/bin/netbird-entrypoint.sh"]
+12
View File
@@ -9,6 +9,7 @@ import (
"slices"
"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()
}
+52
View File
@@ -0,0 +1,52 @@
//go:build android
package android
import (
"github.com/netbirdio/netbird/client/internal/profilemanager"
"github.com/netbirdio/netbird/client/mdm"
)
type mdmSource struct {
loader *mdm.Loader
detector *mdm.ChangeDetector
}
// SetMDMPolicyFetcher registers the native-provided MDM policy fetcher on
// this Client; passing nil disables MDM enforcement.
func (c *Client) SetMDMPolicyFetcher(p PolicyFetcher) {
loader := loaderFor(p)
c.mdmSource.Store(&mdmSource{loader: loader, detector: mdm.NewChangeDetector(loader)})
}
// HasMDMPolicyChanged re-reads the managed configuration and reports whether
// it changed since the last observation; call it from the native OS-change
// notification and restart the engine only on true.
func (c *Client) HasMDMPolicyChanged() bool {
src := c.mdmSource.Load()
if src == nil {
return false
}
return src.detector.Changed()
}
// GetRestrictionsJSON returns the UI enforcement snapshot derived from the
// active MDM policy, in the JSON shape shared with the desktop frontend.
func (c *Client) GetRestrictionsJSON() (string, error) {
return mdm.BuildRestrictions(c.mdmLoader().Load()).JSON()
}
func (c *Client) applyMDMOverlay(cfg *profilemanager.Config) {
loader := c.mdmLoader()
if cfg == nil || loader == nil {
return
}
cfg.ApplyMDMPolicy(loader.Load())
}
func (c *Client) mdmLoader() *mdm.Loader {
if src := c.mdmSource.Load(); src != nil {
return src.loader
}
return nil
}
+17 -16
View File
@@ -8,6 +8,7 @@ import (
"github.com/netbirdio/netbird/client/internal/auth"
"github.com/netbirdio/netbird/client/internal/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
+3 -3
View File
@@ -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)
}
+19
View File
@@ -0,0 +1,19 @@
package android
import (
"github.com/netbirdio/netbird/client/mdm"
)
// PolicyFetcher is implemented by the native layer to return the current
// managed configuration as a JSON-encoded object string; "" means no MDM
// source is present.
type PolicyFetcher interface {
FetchJSON() string
}
func loaderFor(p PolicyFetcher) *mdm.Loader {
if p == nil {
return mdm.NewJSONLoader(nil)
}
return mdm.NewJSONLoader(p.FetchJSON)
}
+78 -7
View File
@@ -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
}
@@ -325,8 +370,34 @@ func (p *Preferences) SetDisableIPv6(disable bool) {
p.configInput.DisableIPv6 = &disable
}
// GetRemoteJobsAllowed reads the remote jobs opt-in from config file
func (p *Preferences) GetRemoteJobsAllowed() (bool, error) {
policy := p.policy()
if !policy.HasKey(mdm.KeyRemoteJobsAllowed) && p.configInput.RemoteJobsAllowed != nil {
return *p.configInput.RemoteJobsAllowed, nil
}
cfg, err := profilemanager.ReadConfig(p.configInput.ConfigPath)
if err != nil {
return false, err
}
cfg.ApplyMDMPolicy(policy)
if cfg.RemoteJobsAllowed == nil {
return false, nil
}
return *cfg.RemoteJobsAllowed, nil
}
// SetRemoteJobsAllowed stores the given value and waits for commit
func (p *Preferences) SetRemoteJobsAllowed(allowed bool) {
p.configInput.RemoteJobsAllowed = &allowed
}
// Commit writes out the changes to the config file
func (p *Preferences) Commit() error {
if err := profilemanager.CheckMDMConflicts(p.configInput, p.policy()); err != nil {
return err
}
_, err := profilemanager.UpdateOrCreateConfig(p.configInput)
return err
}
+12 -13
View File
@@ -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")
}
}
+6
View File
@@ -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) {
+6
View File
@@ -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
+5
View File
@@ -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)
+77
View File
@@ -0,0 +1,77 @@
#!/bin/sh
set -eu
if [ "$#" -lt 2 ]; then
printf '%s\n' "usage: $0 OUTPUT_DIRECTORY GOARCH..." >&2
exit 2
fi
repo_root=$(CDPATH= cd -- "$(dirname "$0")/.." && pwd)
output_name=$(basename "$1")
if [ -z "$output_name" ] || [ "$output_name" = "." ] ||
[ "$output_name" = ".." ] || [ "$output_name" = "/" ]; then
printf '%s\n' "OUTPUT_DIRECTORY must name a directory" >&2
exit 2
fi
output_parent=$(CDPATH= cd -- "$(dirname "$1")" && pwd)
output="$output_parent/$output_name"
shift
modules=$(mktemp "${TMPDIR:-/tmp}/netbird-client-licenses.modules.XXXXXX")
sorted_modules=$(mktemp "${TMPDIR:-/tmp}/netbird-client-licenses.sorted.XXXXXX")
trap 'rm -f "$modules" "$sorted_modules"' EXIT HUP INT TERM
if [ -e "$output" ] || [ -L "$output" ]; then
printf 'output directory already exists: %s\n' "$output" >&2
exit 1
fi
mkdir "$output"
mkdir "$output/third_party"
cp "$repo_root/LICENSE" "$output/BSD-3-Clause.txt"
cd "$repo_root"
for arch in "$@"; do
GOOS=${GOOS:-linux} GOARCH="$arch" CGO_ENABLED=${CGO_ENABLED:-0} \
go list -deps -f '{{with .Module}}{{if .Replace}}{{.Replace.Path}}{{"\t"}}{{.Replace.Version}}{{"\t"}}{{.Replace.Dir}}{{else}}{{.Path}}{{"\t"}}{{.Version}}{{"\t"}}{{.Dir}}{{end}}{{end}}' -tags load_wgnt_from_rsrc ./client >>"$modules"
done
LC_ALL=C sort -u "$modules" >"$sorted_modules"
goroot=$(go env GOROOT)
for term in LICENSE PATENTS; do
if [ ! -f "$goroot/$term" ]; then
printf 'missing Go standard-library term: %s\n' "$goroot/$term" >&2
exit 1
fi
cp "$goroot/$term" "$output/Go-$term"
done
while IFS=' ' read -r module version module_dir; do
[ -n "$module" ] || continue
[ "$module" = "github.com/netbirdio/netbird" ] && continue
if [ -z "$version" ] || [ ! -d "$module_dir" ]; then
printf 'cannot collect terms for module %s at version %s\n' "$module" "$version" >&2
exit 1
fi
destination="$output/third_party/$module/$version"
mkdir -p "$destination"
printf 'module: %s\nversion: %s\n' "$module" "$version" >"$destination/MODULE"
found=false
for term in \
"$module_dir"/LICENSE* "$module_dir"/License* "$module_dir"/license* \
"$module_dir"/LICENCE* "$module_dir"/Licence* "$module_dir"/licence* \
"$module_dir"/COPYING* "$module_dir"/Copying* "$module_dir"/copying* \
"$module_dir"/NOTICE* "$module_dir"/Notice* "$module_dir"/notice* \
"$module_dir"/PATENTS* "$module_dir"/Patents* "$module_dir"/patents*; do
[ -f "$term" ] || continue
cp "$term" "$destination/"
found=true
done
if [ "$found" = false ]; then
printf 'no root license terms found for module %s at %s\n' "$module" "$module_dir" >&2
exit 1
fi
done <"$sorted_modules"
+5
View File
@@ -21,6 +21,7 @@ import (
"github.com/netbirdio/netbird/client/internal/auth"
"github.com/netbirdio/netbird/client/internal/peer"
"github.com/netbirdio/netbird/client/internal/profilemanager"
"github.com/netbirdio/netbird/client/mdm"
nbssh "github.com/netbirdio/netbird/client/ssh"
"github.com/netbirdio/netbird/client/system"
"github.com/netbirdio/netbird/shared/management/domain"
@@ -229,6 +230,10 @@ func New(opts Options) (*Client, error) {
if err != nil {
return nil, fmt.Errorf("create config: %w", err)
}
// Embedded path runs without the daemon Server: apply the active
// MDM policy explicitly so a forced ManagementURL / PSK / other
// managed key takes effect on this embedded engine instance.
config.ApplyMDMPolicy(mdm.NewLoader(nil).Load())
if opts.PrivateKey != "" {
config.PrivateKey = opts.PrivateKey
+6 -1
View File
@@ -63,7 +63,12 @@ func (t *WGTunDevice) Create(routes []string, dns string, searchDomains []string
searchDomainsToString = ""
}
fd, err := t.tunAdapter.ConfigureInterface(t.address.String(), t.address.IPv6String(), int(t.mtu), dns, searchDomainsToString, routesString)
ipv6Host := ""
if t.address.HasIPv6() {
ipv6Host = t.address.IPv6HostPrefix().String()
}
fd, err := t.tunAdapter.ConfigureInterface(t.address.HostPrefix().String(), ipv6Host, int(t.mtu), dns, searchDomainsToString, routesString)
if err != nil {
log.Errorf("failed to create Android interface: %s", err)
return nil, err
+13
View File
@@ -59,6 +59,19 @@ func (addr Address) IPv6Prefix() netip.Prefix {
return netip.PrefixFrom(addr.IPv6, addr.IPv6Net.Bits())
}
// HostPrefix returns the v4 address as a single-host prefix.
func (addr Address) HostPrefix() netip.Prefix {
return netip.PrefixFrom(addr.IP, addr.IP.BitLen())
}
// IPv6HostPrefix returns the v6 address as a single-host prefix, or an invalid prefix when no v6 overlay address is assigned.
func (addr Address) IPv6HostPrefix() netip.Prefix {
if !addr.HasIPv6() {
return netip.Prefix{}
}
return netip.PrefixFrom(addr.IPv6, addr.IPv6.BitLen())
}
// SetIPv6FromCompact decodes a compact prefix (5 or 17 bytes) and sets the IPv6 fields.
// Returns an error if the bytes are invalid. A nil or empty input is a no-op.
//
+24
View File
@@ -0,0 +1,24 @@
package wgaddr
import (
"net/netip"
"testing"
"github.com/stretchr/testify/assert"
)
func TestAddress_HostPrefix(t *testing.T) {
addr := MustParseWGAddress("100.91.96.107/16")
assert.Equal(t, netip.MustParsePrefix("100.91.96.107/32"), addr.HostPrefix(), "v4 host prefix must be a single host")
assert.Equal(t, netip.MustParsePrefix("100.91.0.0/16"), addr.Network, "network must keep the overlay prefix length")
assert.False(t, addr.IPv6HostPrefix().IsValid(), "no v6 overlay means no v6 host prefix")
}
func TestAddress_IPv6HostPrefix(t *testing.T) {
addr := MustParseWGAddress("100.91.96.107/16")
addr.IPv6 = netip.MustParseAddr("fd00:1234::1")
addr.IPv6Net = netip.MustParsePrefix("fd00:1234::/64")
assert.Equal(t, netip.MustParsePrefix("fd00:1234::1/128"), addr.IPv6HostPrefix(), "v6 host prefix must be a single host")
}
+84 -14
View File
@@ -124,19 +124,9 @@ func newHostManager(wgInterface WGIface) (*registryConfigurator, error) {
return nil, err
}
var useGPO bool
k, err := registry.OpenKey(registry.LOCAL_MACHINE, GPODNSPolicyConfigRoot, registry.QUERY_VALUE)
if err != nil {
log.Debugf("failed to open GPO DNS policy root: %v", err)
} else {
closer(k)
useGPO = true
log.Infof("detected GPO DNS policy configuration, using policy store")
}
configurator := &registryConfigurator{
guid: guid,
gpo: useGPO,
gpo: useGPOPolicyStore(),
}
origNameservers, err := configurator.captureOriginalNameservers()
@@ -576,14 +566,22 @@ func (r *registryConfigurator) setInterfaceRegistryKeyStringValue(key, value str
return nil
}
// deleteInterfaceRegistryKeyProperty removes a value from the interface key.
// A value that is already gone, or an interface key that is, is not an error:
// the caller asked for the value not to be there, and a cleanup that runs twice
// has to reach its later steps on the second run as well.
func (r *registryConfigurator) deleteInterfaceRegistryKeyProperty(propertyKey string) error {
regKey, err := r.getInterfaceRegistryKey()
if err != nil {
switch {
case errors.Is(err, registry.ErrNotExist), errors.Is(err, syscall.ERROR_PATH_NOT_FOUND):
log.Debugf("interface key of %s does not exist, nothing to delete %s from", r.guid, propertyKey)
return nil
case err != nil:
return fmt.Errorf("get interface registry key: %w", err)
}
defer closer(regKey)
if err := regKey.DeleteValue(propertyKey); err != nil {
if err := regKey.DeleteValue(propertyKey); err != nil && !errors.Is(err, registry.ErrNotExist) {
return fmt.Errorf("delete registry key %s: %w", propertyKey, err)
}
return nil
@@ -612,7 +610,12 @@ func (r *registryConfigurator) restoreHostDNS() error {
go r.flushDNSCache()
return nil
// Last, and only on the way out, once no rule of ours is left: during a
// session the store is where the rules of this run live, and emptying it
// mid-session would have the next rule recreate it anyway. Propagated so a
// failure keeps the shutdown state for the next run to retry, rather than
// leaving the store to hold up every rule change from here on.
return removeEmptyGPOPolicyStore()
}
// removeDNSMatchPolicies deletes every NRPT rule this client may have created,
@@ -651,6 +654,73 @@ func (r *registryConfigurator) restoreUncleanShutdownDNS() error {
return r.restoreHostDNS()
}
// useGPOPolicyStore reports whether NRPT rules have to go into the group policy
// store, and clears an empty one out of the way first.
//
// The order is the point. A store left empty by an earlier run would otherwise
// decide this run too, sending its rules somewhere the resolver only reads when
// the policy engine next applies DNS client policy. Removing it before the
// choice is made leaves the local store authoritative for the whole session,
// including the first one after an upgrade.
func useGPOPolicyStore() bool {
if err := removeEmptyGPOPolicyStore(); err != nil {
// Nothing to retry against here: the worst case is the run going
// through the group policy store, which is where it would have gone
// before this check existed.
log.Warnf("%v", err)
}
k, err := registry.OpenKey(registry.LOCAL_MACHINE, GPODNSPolicyConfigRoot, registry.QUERY_VALUE)
if err != nil {
log.Debugf("failed to open GPO DNS policy root: %v", err)
return false
}
closer(k)
log.Infof("detected GPO DNS policy configuration, using policy store")
return true
}
// removeEmptyGPOPolicyStore deletes the group policy DnsPolicyConfig key once
// nothing is left in it. The key survives the deletion of the last rule it
// held, and the client treats its presence as "group policy configures the
// NRPT", so an empty one left behind keeps every later run writing rules there.
// Rules in that store reach the resolver only when the policy engine next
// applies DNS client policy, and a rule this client writes belongs to no GPO,
// so nothing schedules that application: both adding and removing a rule are
// held up by a minute or more, and for a removal that is a catch-all rule
// resolving every name over an interface that no longer exists. With the store
// absent the local one is authoritative and a change applies at once.
//
// A store that still holds rules, values or subkeys of somebody else's is left
// alone.
func removeEmptyGPOPolicyStore() error {
k, err := registry.OpenKey(registry.LOCAL_MACHINE, GPODNSPolicyConfigRoot, registry.QUERY_VALUE)
switch {
case errors.Is(err, registry.ErrNotExist), errors.Is(err, syscall.ERROR_PATH_NOT_FOUND):
return nil
case err != nil:
return fmt.Errorf("open HKEY_LOCAL_MACHINE\\%s: %w", GPODNSPolicyConfigRoot, err)
}
info, err := k.Stat()
closer(k)
if err != nil {
return fmt.Errorf("stat HKEY_LOCAL_MACHINE\\%s: %w", GPODNSPolicyConfigRoot, err)
}
if info.SubKeyCount != 0 || info.ValueCount != 0 {
return nil
}
if err := registry.DeleteKey(registry.LOCAL_MACHINE, GPODNSPolicyConfigRoot); err != nil {
return fmt.Errorf("delete empty HKEY_LOCAL_MACHINE\\%s: %w", GPODNSPolicyConfigRoot, err)
}
log.Infof("removed the empty GPO DNS policy store, leaving the local one authoritative")
return nil
}
// listNRPTRuleKeys returns the names of our NRPT rule keys under a policy store
// root. An absent root holds nothing to clean up, which is the normal state of
// the GPO store on a machine without DNS Client policy.
+129
View File
@@ -8,6 +8,8 @@ import (
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"golang.org/x/sys/windows/registry"
"github.com/netbirdio/netbird/client/internal/winregistry"
)
// TestNRPTEntriesCleanupOnConfigChange tests that old NRPT entries are properly cleaned up
@@ -405,3 +407,130 @@ func TestNRPTDomainBatching(t *testing.T) {
})
}
}
// TestRemoveEmptyGPOPolicyStore verifies that cleanup takes the GPO policy
// store itself with it once our rules are gone, since the store existing keeps
// the local one from being applied, and that a store with somebody else's rule
// in it is left alone.
func TestRemoveEmptyGPOPolicyStore(t *testing.T) {
if testing.Short() {
t.Skip("skipping registry integration test in short mode")
}
t.Cleanup(func() { cleanupRegistryKeys(t) })
cleanupRegistryKeys(t)
testIP := netip.MustParseAddr("100.64.0.1")
cfg := &registryConfigurator{gpo: true}
// a store holding a rule of ours is kept, because the rule is still applied
require.NoError(t, cfg.addDNSMatchPolicy([]string{".example.com"}, testIP))
exists, err := registryKeyExists(gpoDnsPolicyConfigMatchPath + "-0")
require.NoError(t, err)
require.True(t, exists, "Should write the rule to the GPO policy store")
require.NoError(t, removeEmptyGPOPolicyStore())
exists, err = registryKeyExists(GPODNSPolicyConfigRoot)
require.NoError(t, err)
assert.True(t, exists, "Should keep a policy store that still holds a rule")
// once the rules are gone the store goes with them
require.NoError(t, cfg.removeDNSMatchPolicies())
require.NoError(t, removeEmptyGPOPolicyStore())
exists, err = registryKeyExists(GPODNSPolicyConfigRoot)
require.NoError(t, err)
assert.False(t, exists, "Should remove the GPO policy store once it is empty")
// A store is not ours to remove while somebody else has a rule in it. The
// rule is written volatile like our own: the rules above created the parent
// chain volatile, and Windows refuses a stable subkey under a volatile
// parent.
foreignRule := GPODNSPolicyConfigRoot + `\{2A3B4C5D-6E7F-4041-8283-84858687888A}`
foreignKey, _, err := winregistry.CreateVolatileKey(registry.LOCAL_MACHINE, foreignRule, registry.SET_VALUE)
require.NoError(t, err, "Should create a foreign GPO rule")
foreignKey.Close()
t.Cleanup(func() {
_ = registry.DeleteKey(registry.LOCAL_MACHINE, foreignRule)
_ = registry.DeleteKey(registry.LOCAL_MACHINE, GPODNSPolicyConfigRoot)
})
require.NoError(t, cfg.removeDNSMatchPolicies())
require.NoError(t, removeEmptyGPOPolicyStore())
exists, err = registryKeyExists(foreignRule)
require.NoError(t, err)
assert.True(t, exists, "Should not remove a foreign rule")
exists, err = registryKeyExists(GPODNSPolicyConfigRoot)
require.NoError(t, err)
assert.True(t, exists, "Should keep a policy store that still holds a foreign rule")
}
// TestDeleteInterfaceRegistryKeyPropertyTwice verifies that removing a value
// that is already gone, or one on an interface key that is, reports success.
// Teardown runs again after a failed cleanup, and the steps that follow this
// one have to be reached on that second run.
func TestDeleteInterfaceRegistryKeyPropertyTwice(t *testing.T) {
if testing.Short() {
t.Skip("skipping registry integration test in short mode")
}
testGUID := "{12345678-1234-1234-1234-123456789ABC}"
interfacePath := InterfaceConfigPath + `\` + testGUID
testKey, _, err := registry.CreateKey(registry.LOCAL_MACHINE, interfacePath, registry.SET_VALUE)
require.NoError(t, err, "Should create test interface registry key")
testKey.Close()
t.Cleanup(func() {
_ = registry.DeleteKey(registry.LOCAL_MACHINE, interfacePath)
})
cfg := &registryConfigurator{guid: testGUID}
require.NoError(t, cfg.setInterfaceRegistryKeyStringValue(interfaceConfigSearchListKey, "example.com"))
require.NoError(t, cfg.deleteInterfaceRegistryKeyProperty(interfaceConfigSearchListKey))
assert.NoError(t, cfg.deleteInterfaceRegistryKeyProperty(interfaceConfigSearchListKey),
"Should report success for a value that is already gone")
// and with the interface key itself gone, as it is once the adapter is
require.NoError(t, registry.DeleteKey(registry.LOCAL_MACHINE, interfacePath))
assert.NoError(t, cfg.deleteInterfaceRegistryKeyProperty(interfaceConfigSearchListKey),
"Should report success when the interface key does not exist")
}
// TestUseGPOPolicyStoreClearsEmptyStore verifies that the store is cleared
// before it is consulted, so an empty one left by an earlier run does not send
// this run's rules to the group policy store. A store somebody else has a rule
// in still decides where the rules go.
func TestUseGPOPolicyStoreClearsEmptyStore(t *testing.T) {
if testing.Short() {
t.Skip("skipping registry integration test in short mode")
}
t.Cleanup(func() { cleanupRegistryKeys(t) })
cleanupRegistryKeys(t)
// the leftover an earlier run used to keep, which the client read as
// "group policy configures the NRPT" for every run after it
emptyStore, _, err := winregistry.CreateVolatileKey(registry.LOCAL_MACHINE, GPODNSPolicyConfigRoot, registry.SET_VALUE)
require.NoError(t, err, "Should create the GPO policy store")
emptyStore.Close()
assert.False(t, useGPOPolicyStore(), "An empty store should not decide where the rules go")
exists, err := registryKeyExists(GPODNSPolicyConfigRoot)
require.NoError(t, err)
assert.False(t, exists, "Should clear the empty store before consulting it")
foreignRule := GPODNSPolicyConfigRoot + `\{2A3B4C5D-6E7F-4041-8283-84858687888A}`
foreignKey, _, err := winregistry.CreateVolatileKey(registry.LOCAL_MACHINE, foreignRule, registry.SET_VALUE)
require.NoError(t, err, "Should create a foreign GPO rule")
foreignKey.Close()
t.Cleanup(func() {
_ = registry.DeleteKey(registry.LOCAL_MACHINE, foreignRule)
_ = registry.DeleteKey(registry.LOCAL_MACHINE, GPODNSPolicyConfigRoot)
})
assert.True(t, useGPOPolicyStore(), "A store holding a rule should decide where the rules go")
exists, err = registryKeyExists(GPODNSPolicyConfigRoot)
require.NoError(t, err)
assert.True(t, exists, "Should keep a store that holds a rule")
}
+136 -87
View File
@@ -6,6 +6,7 @@ import (
"net"
"net/netip"
"runtime"
"slices"
"strconv"
"sync"
"time"
@@ -17,17 +18,20 @@ import (
nberrors "github.com/netbirdio/netbird/client/errors"
firewall "github.com/netbirdio/netbird/client/firewall/manager"
"github.com/netbirdio/netbird/client/internal/ebpf"
ebpfMgr "github.com/netbirdio/netbird/client/internal/ebpf/manager"
)
const (
customPort = 5053
// randomPortAttempts bounds the search for a port free on both protocols.
randomPortAttempts = 5
)
var (
defaultIP = netip.MustParseAddr("127.0.0.1")
customIP = netip.MustParseAddr("127.0.0.153")
// dnatProtocols are the protocols the port 53 redirect covers.
dnatProtocols = []firewall.Protocol{firewall.ProtocolUDP, firewall.ProtocolTCP}
)
type serviceViaListener struct {
@@ -40,9 +44,20 @@ type serviceViaListener struct {
listenPort uint16
listenerIsRunning bool
listenerFlagLock sync.Mutex
ebpfService ebpfMgr.Manager
firewall Firewall
tcpDNATConfigured bool
// dnatRules holds the port 53 redirects that are installed and not yet
// removed, so a removal that fails can be retried.
dnatRules []dnatRule
}
// dnatRule is a port 53 redirect as it was installed. The target is kept with
// the rule because the listener can come back on a different address or port,
// and a retried removal has to name the address and port the rule was added
// with, not the ones in use now.
type dnatRule struct {
protocol firewall.Protocol
ip netip.Addr
port uint16
}
func newServiceViaListener(wgIface WGIface, customAddr *netip.AddrPort, fw Firewall) *serviceViaListener {
@@ -112,34 +127,93 @@ func (s *serviceViaListener) Listen() error {
}
}()
// When eBPF redirects UDP port 53 to our listen port, TCP still needs
// a DNAT rule because eBPF only handles UDP.
if s.ebpfService != nil && s.firewall != nil && s.listenPort != DefaultPort {
if err := s.firewall.AddOutputDNAT(s.listenIP, firewall.ProtocolTCP, DefaultPort, s.listenPort); err != nil {
log.Warnf("failed to add DNS TCP DNAT rule, TCP DNS on port 53 will not work: %v", err)
} else {
s.tcpDNATConfigured = true
log.Infof("added DNS TCP DNAT rule: %s:%d -> %s:%d", s.listenIP, DefaultPort, s.listenIP, s.listenPort)
}
if s.listenPort != DefaultPort {
s.setupDNAT()
}
return nil
}
// setupDNAT redirects port 53 to the port the DNS server actually listens on.
// Both protocols must be redirected or none: RuntimePort reports port 53 only
// while the full redirect is in place, so a half-configured redirect would
// advertise a resolver that answers over one protocol.
func (s *serviceViaListener) setupDNAT() {
if s.firewall == nil {
log.Errorf("no firewall manager available to redirect DNS port %d to %d, "+
"clients pointed at %s will not reach the resolver", DefaultPort, s.listenPort, s.listenIP)
return
}
// Clear whatever an earlier removal left behind first. Those rules can point
// at an address or port this listener no longer uses, and they are matched
// before anything added now, so adding a redirect on top of one would keep
// sending port 53 traffic to the previous listener while reporting the
// redirect as complete. The rules stay recorded for a later attempt.
if err := s.removeDNAT(); err != nil {
log.Errorf("failed to remove stale DNS DNAT rules, leaving port %d redirected to the previous listener: %v",
DefaultPort, err)
return
}
for _, proto := range dnatProtocols {
if err := s.firewall.AddOutputDNAT(s.listenIP, proto, DefaultPort, s.listenPort); err != nil {
log.Errorf("failed to add DNS %s DNAT rule, DNS on port %d will not work: %v",
proto, DefaultPort, err)
if err := s.removeDNAT(); err != nil {
log.Warnf("failed to roll back DNS DNAT rules, retrying on stop: %v", err)
}
return
}
s.dnatRules = append(s.dnatRules, dnatRule{protocol: proto, ip: s.listenIP, port: s.listenPort})
}
log.Infof("added DNS DNAT rules: %s:%d -> %s:%d (UDP + TCP)", s.listenIP, DefaultPort, s.listenIP, s.listenPort)
}
// removeDNAT removes every installed port 53 redirect. A rule whose removal
// fails stays recorded so a later setup or Stop retries it, rather than leaving
// port 53 pointing at a resolver that is no longer listening.
func (s *serviceViaListener) removeDNAT() error {
if s.firewall == nil {
return nil
}
var merr *multierror.Error
var remaining []dnatRule
for _, rule := range s.dnatRules {
if err := s.firewall.RemoveOutputDNAT(rule.ip, rule.protocol, DefaultPort, rule.port); err != nil {
merr = multierror.Append(merr, fmt.Errorf("remove DNS %s DNAT rule for %s:%d: %w",
rule.protocol, rule.ip, rule.port, err))
remaining = append(remaining, rule)
}
}
s.dnatRules = remaining
return nberrors.FormatErrorOrNil(merr)
}
func (s *serviceViaListener) Stop() error {
s.listenerFlagLock.Lock()
defer s.listenerFlagLock.Unlock()
var merr *multierror.Error
// Redirects are removed even when the listener is already stopped, so that
// a removal which failed earlier is retried instead of leaving port 53
// pointing at a resolver that no longer listens.
if err := s.removeDNAT(); err != nil {
merr = multierror.Append(merr, err)
}
if !s.listenerIsRunning {
return nil
return nberrors.FormatErrorOrNil(merr)
}
s.listenerIsRunning = false
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
var merr *multierror.Error
if err := s.server.ShutdownContext(ctx); err != nil {
merr = multierror.Append(merr, fmt.Errorf("stop DNS UDP server: %w", err))
}
@@ -148,19 +222,6 @@ func (s *serviceViaListener) Stop() error {
merr = multierror.Append(merr, fmt.Errorf("stop DNS TCP server: %w", err))
}
if s.tcpDNATConfigured && s.firewall != nil {
if err := s.firewall.RemoveOutputDNAT(s.listenIP, firewall.ProtocolTCP, DefaultPort, s.listenPort); err != nil {
merr = multierror.Append(merr, fmt.Errorf("remove DNS TCP DNAT rule: %w", err))
}
s.tcpDNATConfigured = false
}
if s.ebpfService != nil {
if err := s.ebpfService.FreeDNSFwd(); err != nil {
merr = multierror.Append(merr, fmt.Errorf("stop traffic forwarder: %w", err))
}
}
return nberrors.FormatErrorOrNil(merr)
}
@@ -177,11 +238,23 @@ func (s *serviceViaListener) RuntimePort() int {
s.listenerFlagLock.Lock()
defer s.listenerFlagLock.Unlock()
if s.ebpfService != nil {
if s.redirectInstalled() {
return DefaultPort
} else {
return int(s.listenPort)
}
return int(s.listenPort)
}
// redirectInstalled reports whether every protocol is redirected from port 53
// to the address and port the listener currently serves. Rules left over from
// an earlier listener do not count.
func (s *serviceViaListener) redirectInstalled() bool {
for _, proto := range dnatProtocols {
current := dnatRule{protocol: proto, ip: s.listenIP, port: s.listenPort}
if !slices.Contains(s.dnatRules, current) {
return false
}
}
return true
}
func (s *serviceViaListener) RuntimeIP() netip.Addr {
@@ -190,30 +263,29 @@ func (s *serviceViaListener) RuntimeIP() netip.Addr {
// evalListenAddress figures out the listen address for the DNS server.
// IPv4-only: all peers have a v4 overlay address, and DNS config points to v4.
// First checks port 53 on WG interface or lo, then tries eBPF on a random port,
// then falls back to port 5053.
// Prefers port 53 on the overlay interface or lo, so no redirect is needed at
// all; when it is taken it falls back to port 5053 and then to a random free
// port, both of which need the port 53 redirect set up by setupDNAT.
func (s *serviceViaListener) evalListenAddress() (netip.Addr, uint16, error) {
if s.customAddr != nil {
return s.customAddr.Addr(), s.customAddr.Port(), nil
}
ip, ok := s.testFreePort(DefaultPort)
if ok {
if ip, ok := s.testFreePort(DefaultPort); ok {
return ip, DefaultPort, nil
}
ebpfSrv, port, ok := s.tryToUseeBPF()
if ok {
s.ebpfService = ebpfSrv
return s.wgInterface.Address().IP, port, nil
}
ip, ok = s.testFreePort(customPort)
if ok {
if ip, ok := s.testFreePort(customPort); ok {
return ip, customPort, nil
}
return netip.Addr{}, 0, fmt.Errorf("failed to find a free port for DNS server")
ip := s.wgInterface.Address().IP
port, err := s.randomFreePort(ip)
if err != nil {
return netip.Addr{}, 0, fmt.Errorf("find a free port for DNS server: %w", err)
}
return ip, port, nil
}
func (s *serviceViaListener) testFreePort(port int) (netip.Addr, bool) {
@@ -260,48 +332,25 @@ func (s *serviceViaListener) tryToBind(ip netip.Addr, port int) bool {
return true
}
// tryToUseeBPF decides whether to apply eBPF program to capture DNS traffic on port 53.
// This is needed because on some operating systems if we start a DNS server not on a default port 53,
// the domain name resolution won't work. So, in case we are running on Linux and picked a free
// port we should fall back to the eBPF solution that will capture traffic on port 53 and forward
// it to a local DNS server running on the chosen port.
func (s *serviceViaListener) tryToUseeBPF() (ebpfMgr.Manager, uint16, bool) {
if runtime.GOOS != "linux" {
return nil, 0, false
// randomFreePort returns a port that is free on ip for both UDP and TCP, since
// the DNS server binds both. The probe listeners are closed again, so the port
// is only likely, not guaranteed, to still be free when the server binds it.
func (s *serviceViaListener) randomFreePort(ip netip.Addr) (uint16, error) {
for range randomPortAttempts {
probeListener, err := net.ListenUDP("udp4", &net.UDPAddr{})
if err != nil {
return 0, fmt.Errorf("bind random port: %w", err)
}
port := uint16(probeListener.LocalAddr().(*net.UDPAddr).Port)
if err := probeListener.Close(); err != nil {
return 0, fmt.Errorf("free up probed port: %w", err)
}
if s.tryToBind(ip, int(port)) {
return port, nil
}
}
port, err := s.generateFreePort() //nolint:staticcheck,unused
if err != nil {
log.Warnf("failed to generate a free port for eBPF DNS forwarder server: %s", err)
return nil, 0, false
}
ebpfSrv := ebpf.GetEbpfManagerInstance()
err = ebpfSrv.LoadDNSFwd(s.wgInterface.Address().IP, int(port))
if err != nil {
log.Warnf("failed to load DNS forwarder eBPF program, error: %s", err)
return nil, 0, false
}
return ebpfSrv, port, true
}
func (s *serviceViaListener) generateFreePort() (uint16, error) {
ok := s.tryToBind(s.wgInterface.Address().IP, customPort)
if ok {
return customPort, nil
}
probeListener, err := net.ListenUDP("udp4", &net.UDPAddr{})
if err != nil {
log.Debugf("failed to bind random port for DNS: %s", err)
return 0, err
}
port := uint16(probeListener.LocalAddr().(*net.UDPAddr).Port)
if err = probeListener.Close(); err != nil {
log.Debugf("failed to free up DNS port: %s", err)
return 0, err
}
return port, nil
return 0, fmt.Errorf("no port free for UDP and TCP on %s after %d attempts", ip, randomPortAttempts)
}
@@ -1,6 +1,7 @@
package dns
import (
"errors"
"fmt"
"net"
"net/netip"
@@ -10,6 +11,8 @@ import (
"github.com/miekg/dns"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
firewall "github.com/netbirdio/netbird/client/firewall/manager"
)
func TestServiceViaListener_TCPAndUDP(t *testing.T) {
@@ -84,3 +87,133 @@ func TestServiceViaListener_TCPAndUDP(t *testing.T) {
require.NotEmpty(t, tcpResp.Answer)
assert.Contains(t, tcpResp.Answer[0].String(), "192.0.2.1", "TCP response should contain expected IP")
}
type dnatCall struct {
rule dnatRule
added bool
}
// fakeFirewall records DNAT calls and fails the ones named in addErrs/removeErrs.
type fakeFirewall struct {
calls []dnatCall
addErrs map[firewall.Protocol]error
removeErrs map[firewall.Protocol]error
}
func (f *fakeFirewall) AddOutputDNAT(ip netip.Addr, protocol firewall.Protocol, _, translatedPort uint16) error {
if err := f.addErrs[protocol]; err != nil {
return err
}
f.calls = append(f.calls, dnatCall{rule: dnatRule{protocol: protocol, ip: ip, port: translatedPort}, added: true})
return nil
}
func (f *fakeFirewall) RemoveOutputDNAT(ip netip.Addr, protocol firewall.Protocol, _, translatedPort uint16) error {
if err := f.removeErrs[protocol]; err != nil {
return err
}
f.calls = append(f.calls, dnatCall{rule: dnatRule{protocol: protocol, ip: ip, port: translatedPort}})
return nil
}
func newDNATTestService(fw Firewall) *serviceViaListener {
return &serviceViaListener{
listenIP: netip.MustParseAddr("100.64.0.1"),
listenPort: customPort,
firewall: fw,
}
}
func TestSetupDNAT_BothProtocols(t *testing.T) {
svc := newDNATTestService(&fakeFirewall{})
svc.setupDNAT()
assert.Len(t, svc.dnatRules, len(dnatProtocols))
assert.Equal(t, DefaultPort, svc.RuntimePort(), "port 53 is advertised once both redirects are installed")
}
func TestSetupDNAT_RollsBackPartialRedirect(t *testing.T) {
fw := &fakeFirewall{addErrs: map[firewall.Protocol]error{firewall.ProtocolTCP: errors.New("nftables busy")}}
svc := newDNATTestService(fw)
svc.setupDNAT()
assert.Empty(t, svc.dnatRules, "the UDP redirect installed before the failure must be rolled back")
assert.Equal(t, int(svc.listenPort), svc.RuntimePort(), "an incomplete redirect must not advertise port 53")
udp := dnatRule{protocol: firewall.ProtocolUDP, ip: svc.listenIP, port: svc.listenPort}
assert.Contains(t, fw.calls, dnatCall{rule: udp}, "UDP removal should have been attempted")
}
// A rollback that fails must keep the rule recorded, so port 53 is not left
// redirected to a resolver that no longer listens.
func TestStop_RetriesFailedDNATRemoval(t *testing.T) {
fw := &fakeFirewall{
addErrs: map[firewall.Protocol]error{firewall.ProtocolTCP: errors.New("nftables busy")},
removeErrs: map[firewall.Protocol]error{firewall.ProtocolUDP: errors.New("nftables busy")},
}
svc := newDNATTestService(fw)
svc.setupDNAT()
udp := dnatRule{protocol: firewall.ProtocolUDP, ip: svc.listenIP, port: svc.listenPort}
require.Equal(t, []dnatRule{udp}, svc.dnatRules, "a failed rollback keeps the rule for a later retry")
require.Error(t, svc.Stop(), "the failing removal should be reported")
require.Equal(t, []dnatRule{udp}, svc.dnatRules)
delete(fw.removeErrs, firewall.ProtocolUDP)
require.NoError(t, svc.Stop(), "a later stop retries the removal")
assert.Empty(t, svc.dnatRules)
}
// A stale rule that cannot be removed is matched before anything added now, so
// no new redirect may be installed on top of it and port 53 must not be
// advertised as reaching this listener.
func TestSetupDNAT_AbortsWhileStaleRuleRemains(t *testing.T) {
fw := &fakeFirewall{removeErrs: map[firewall.Protocol]error{firewall.ProtocolUDP: errors.New("nftables busy")}}
svc := newDNATTestService(fw)
stalePort := svc.listenPort
svc.setupDNAT()
require.Error(t, svc.Stop())
staleUDP := dnatRule{protocol: firewall.ProtocolUDP, ip: svc.listenIP, port: stalePort}
require.Equal(t, []dnatRule{staleUDP}, svc.dnatRules)
svc.listenPort = stalePort + 1
fw.calls = nil
svc.setupDNAT()
assert.Equal(t, []dnatRule{staleUDP}, svc.dnatRules, "the stale rule stays recorded for a later attempt")
for _, call := range fw.calls {
assert.False(t, call.added, "no redirect may be installed while a stale one is still in place")
}
assert.Equal(t, int(svc.listenPort), svc.RuntimePort(), "port 53 must not be advertised")
}
// A rule left behind by a failed removal must be removed with the address and
// port it was installed with, even when the listener has since moved to another
// port, and it must not count towards the redirect the new listener advertises.
func TestSetupDNAT_ClearsStaleRuleAfterPortChange(t *testing.T) {
fw := &fakeFirewall{removeErrs: map[firewall.Protocol]error{firewall.ProtocolUDP: errors.New("nftables busy")}}
svc := newDNATTestService(fw)
stalePort := svc.listenPort
svc.setupDNAT()
require.Error(t, svc.Stop())
staleUDP := dnatRule{protocol: firewall.ProtocolUDP, ip: svc.listenIP, port: stalePort}
require.Equal(t, []dnatRule{staleUDP}, svc.dnatRules)
delete(fw.removeErrs, firewall.ProtocolUDP)
svc.listenPort = stalePort + 1
fw.calls = nil
svc.setupDNAT()
assert.Contains(t, fw.calls, dnatCall{rule: staleUDP}, "the stale rule must be removed with its original port")
assert.Len(t, svc.dnatRules, len(dnatProtocols))
assert.Equal(t, DefaultPort, svc.RuntimePort(), "the new listener is fully redirected")
for _, rule := range svc.dnatRules {
assert.Equal(t, svc.listenPort, rule.port, "only rules for the current listener remain")
}
}
+111 -14
View File
@@ -54,12 +54,20 @@ type DNSForwarder struct {
ttl uint32
statusRecorder *peer.Status
dnsServer *dns.Server
mux *dns.ServeMux
tcpServer *dns.Server
tcpMux *dns.ServeMux
mux *dns.ServeMux
tcpMux *dns.ServeMux
mutex sync.RWMutex
mutex sync.RWMutex
// closed records that Close has run, so a Listen still in flight does not
// go on to serve sockets nobody will shut down.
closed bool
// The sockets are kept alongside the servers because closing them is the
// only stop that always works: a server whose ActivateAndServe has not run
// yet refuses to shut down, and would otherwise start serving afterwards.
udpConn net.PacketConn
tcpLn net.Listener
dnsServer *dns.Server
tcpServer *dns.Server
fwdEntries []*ForwarderEntry
firewall firewaller
resolver resolver
@@ -106,7 +114,7 @@ func (f *DNSForwarder) Listen(entries []*ForwarderEntry) error {
mux := dns.NewServeMux()
f.mux = mux
mux.HandleFunc(".", f.handleDNSQueryUDP)
f.dnsServer = &dns.Server{
dnsServer := &dns.Server{
PacketConn: udpLn,
Handler: mux,
}
@@ -114,22 +122,32 @@ func (f *DNSForwarder) Listen(entries []*ForwarderEntry) error {
tcpMux := dns.NewServeMux()
f.tcpMux = tcpMux
tcpMux.HandleFunc(".", f.handleDNSQueryTCP)
f.tcpServer = &dns.Server{
tcpServer := &dns.Server{
Listener: tcpLn,
Handler: tcpMux,
}
f.UpdateDomains(entries)
if !f.publish(udpLn, tcpLn, dnsServer, tcpServer, entries) {
log.Infof("DNS forwarder on %s was closed before it started serving", addrDesc)
if err := udpLn.Close(); err != nil {
log.Debugf("close UDP listener of a closed forwarder: %v", err)
}
if err := tcpLn.Close(); err != nil {
log.Debugf("close TCP listener of a closed forwarder: %v", err)
}
return nil
}
log.Debugf("DNS forwarder serving %d domains", len(entries))
errCh := make(chan error, 2)
go func() {
log.Infof("DNS UDP listener running on %s", addrDesc)
errCh <- f.dnsServer.ActivateAndServe()
errCh <- dnsServer.ActivateAndServe()
}()
go func() {
log.Infof("DNS TCP listener running on %s", addrDesc)
errCh <- f.tcpServer.ActivateAndServe()
errCh <- tcpServer.ActivateAndServe()
}()
return <-errCh
@@ -151,6 +169,46 @@ func (f *DNSForwarder) createTCPListener(netstackNet *netstack.Net) (net.Listene
return net.ListenTCP("tcp", net.TCPAddrFromAddrPort(f.listenAddress))
}
// publish hands the sockets, servers and entries to the forwarder so Close can
// reach them and Domains can report them, and says whether serving may begin.
// Listen runs on its own goroutine, so a Close can arrive before it gets this
// far; false means the caller must close what it created instead of serving on
// it.
//
// The entries go in under the same lock rather than afterwards. Anything that
// reads them in between would otherwise see a forwarder that is listening and
// serves no domain, which for a caller rebuilding one means it comes back
// refusing every routed query.
func (f *DNSForwarder) publish(
udpConn net.PacketConn,
tcpLn net.Listener,
dnsServer, tcpServer *dns.Server,
entries []*ForwarderEntry,
) bool {
f.mutex.Lock()
defer f.mutex.Unlock()
if f.closed {
return false
}
f.udpConn = udpConn
f.tcpLn = tcpLn
f.dnsServer = dnsServer
f.tcpServer = tcpServer
f.fwdEntries = entries
return true
}
// Domains returns the entries currently being served. The slice is replaced
// wholesale by UpdateDomains rather than mutated, so the caller may read it but
// must not write to it.
func (f *DNSForwarder) Domains() []*ForwarderEntry {
f.mutex.RLock()
defer f.mutex.RUnlock()
return f.fwdEntries
}
func (f *DNSForwarder) UpdateDomains(entries []*ForwarderEntry) {
f.mutex.Lock()
defer f.mutex.Unlock()
@@ -189,19 +247,45 @@ func (f *DNSForwarder) removeStaleCacheEntries(oldEntries, newEntries []*Forward
}
func (f *DNSForwarder) Close(ctx context.Context) error {
// Marked closed under the lock so a Listen that has not published its
// servers yet gives up instead of racing this shutdown. The shutdowns
// themselves block, so they run outside it.
f.mutex.Lock()
f.closed = true
dnsServer, tcpServer := f.dnsServer, f.tcpServer
udpConn, tcpLn := f.udpConn, f.tcpLn
f.mutex.Unlock()
var result *multierror.Error
if f.dnsServer != nil {
if err := f.dnsServer.ShutdownContext(ctx); err != nil {
if dnsServer != nil {
if err := shutdownServer(ctx, dnsServer); err != nil {
result = multierror.Append(result, fmt.Errorf("UDP shutdown: %w", err))
}
}
if f.tcpServer != nil {
if err := f.tcpServer.ShutdownContext(ctx); err != nil {
if tcpServer != nil {
if err := shutdownServer(ctx, tcpServer); err != nil {
result = multierror.Append(result, fmt.Errorf("TCP shutdown: %w", err))
}
}
// The sockets are closed even when the shutdowns above reported nothing to
// do. A server that has been published but has not reached
// ActivateAndServe refuses to shut down, and closing what it was about to
// serve on is what stops it: the alternative is a listener still answering
// on an interface that has gone away. A shutdown that did run has already
// closed these, so the second close is expected to fail.
if udpConn != nil {
if err := udpConn.Close(); err != nil {
log.Debugf("close UDP socket of the DNS forwarder: %v", err)
}
}
if tcpLn != nil {
if err := tcpLn.Close(); err != nil {
log.Debugf("close TCP socket of the DNS forwarder: %v", err)
}
}
return nberrors.FormatErrorOrNil(result)
}
@@ -514,3 +598,16 @@ func attachEDE(resp *dns.Msg, code uint16, text string) {
}
opt.Option = append(opt.Option, &dns.EDNS0_EDE{InfoCode: code, ExtraText: text})
}
// shutdownServer shuts a server down gracefully, treating "never started" as
// success. A server that was published but has not reached ActivateAndServe
// has nothing to wind down, and the caller closes its socket regardless, which
// is what actually stops it. dns exports no sentinel for this, so the message
// is all there is to match on.
func shutdownServer(ctx context.Context, server *dns.Server) error {
err := server.ShutdownContext(ctx)
if err == nil || strings.Contains(err.Error(), "server not started") {
return nil
}
return err
}
+52
View File
@@ -1238,3 +1238,55 @@ func TestDNSForwarder_EmptyQuery(t *testing.T) {
assert.Nil(t, mockWriter.GetLastResponse(), "Should not write response for empty query")
}
// TestDNSForwarder_ClosedBeforeItServes covers Listen reaching the point of
// serving after the forwarder has already been closed. Listen runs on its own
// goroutine, so it can get there late, and a socket it starts serving then is
// one nothing will ever close: on Android it keeps answering on an interface
// that has been replaced. The close is sequenced first here rather than raced,
// which pins the same state deterministically.
func TestDNSForwarder_ClosedBeforeItServes(t *testing.T) {
f := NewDNSForwarder(netip.MustParseAddrPort("127.0.0.1:0"), 60, nil, nil, nil)
require.NoError(t, f.Close(context.Background()), "closing a forwarder that never started")
done := make(chan error, 1)
go func() { done <- f.Listen(nil) }()
select {
case err := <-done:
assert.NoError(t, err, "a closed forwarder should give up quietly, not serve")
case <-time.After(5 * time.Second):
t.Fatal("Listen went on to serve after the forwarder was closed")
}
}
// TestDNSForwarder_CloseStopsUnactivatedServers covers the window between
// Listen publishing its servers and reaching ActivateAndServe. A server that
// has not been activated refuses to shut down, so Close has to close the
// sockets itself or they are left serving.
func TestDNSForwarder_CloseStopsUnactivatedServers(t *testing.T) {
f := NewDNSForwarder(netip.MustParseAddrPort("127.0.0.1:0"), 60, nil, nil, nil)
udpConn, err := f.createUDPListener(nil)
require.NoError(t, err, "create UDP listener")
tcpLn, err := f.createTCPListener(nil)
require.NoError(t, err, "create TCP listener")
// Published but deliberately never activated, which is the state Listen is
// in for the moment before it starts serving.
require.True(t, f.publish(udpConn, tcpLn, &dns.Server{PacketConn: udpConn}, &dns.Server{Listener: tcpLn}, nil),
"publishing to an open forwarder")
tcpAddr := tcpLn.Addr().String()
require.NoError(t, f.Close(context.Background()), "close should report no error for servers it could not shut down")
_, err = tcpLn.Accept()
assert.Error(t, err, "the TCP socket should be closed after Close")
conn, err := net.DialTimeout("tcp", tcpAddr, time.Second)
if err == nil {
_ = conn.Close()
t.Fatal("the forwarder is still accepting connections after Close")
}
}
+10
View File
@@ -117,6 +117,16 @@ func (m *Manager) UpdateDomains(entries []*ForwarderEntry) {
m.dnsForwarder.UpdateDomains(entries)
}
// Domains returns the entries currently being served, or nil when the
// forwarder is not running.
func (m *Manager) Domains() []*ForwarderEntry {
if m.dnsForwarder == nil {
return nil
}
return m.dnsForwarder.Domains()
}
func (m *Manager) Stop(ctx context.Context) error {
if m.dnsForwarder == nil {
return nil
-128
View File
@@ -1,128 +0,0 @@
// Code generated by bpf2go; DO NOT EDIT.
//go:build arm64be || armbe || mips || mips64 || mips64p32 || ppc64 || s390 || s390x || sparc || sparc64
package ebpf
import (
"bytes"
_ "embed"
"fmt"
"io"
"github.com/cilium/ebpf"
)
// loadBpf returns the embedded CollectionSpec for bpf.
func loadBpf() (*ebpf.CollectionSpec, error) {
reader := bytes.NewReader(_BpfBytes)
spec, err := ebpf.LoadCollectionSpecFromReader(reader)
if err != nil {
return nil, fmt.Errorf("can't load bpf: %w", err)
}
return spec, err
}
// loadBpfObjects loads bpf and converts it into a struct.
//
// The following types are suitable as obj argument:
//
// *bpfObjects
// *bpfPrograms
// *bpfMaps
//
// See ebpf.CollectionSpec.LoadAndAssign documentation for details.
func loadBpfObjects(obj interface{}, opts *ebpf.CollectionOptions) error {
spec, err := loadBpf()
if err != nil {
return err
}
return spec.LoadAndAssign(obj, opts)
}
// bpfSpecs contains maps and programs before they are loaded into the kernel.
//
// It can be passed ebpf.CollectionSpec.Assign.
type bpfSpecs struct {
bpfProgramSpecs
bpfMapSpecs
}
// bpfSpecs contains programs before they are loaded into the kernel.
//
// It can be passed ebpf.CollectionSpec.Assign.
type bpfProgramSpecs struct {
NbXdpProg *ebpf.ProgramSpec `ebpf:"nb_xdp_prog"`
}
// bpfMapSpecs contains maps before they are loaded into the kernel.
//
// 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"`
}
// 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
}
func (o *bpfObjects) Close() error {
return _BpfClose(
&o.bpfPrograms,
&o.bpfMaps,
)
}
// bpfMaps contains all maps after they have been loaded into the kernel.
//
// 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,
)
}
// bpfPrograms contains all programs after they have been loaded into the kernel.
//
// It can be passed to loadBpfObjects or ebpf.CollectionSpec.LoadAndAssign.
type bpfPrograms struct {
NbXdpProg *ebpf.Program `ebpf:"nb_xdp_prog"`
}
func (p *bpfPrograms) Close() error {
return _BpfClose(
p.NbXdpProg,
)
}
func _BpfClose(closers ...io.Closer) error {
for _, closer := range closers {
if err := closer.Close(); err != nil {
return err
}
}
return nil
}
// Do not access this directly.
//
//go:embed bpf_bpfeb.o
var _BpfBytes []byte
Binary file not shown.
-128
View File
@@ -1,128 +0,0 @@
// Code generated by bpf2go; DO NOT EDIT.
//go:build 386 || amd64 || amd64p32 || arm || arm64 || loong64 || mips64le || mips64p32le || mipsle || ppc64le || riscv64
package ebpf
import (
"bytes"
_ "embed"
"fmt"
"io"
"github.com/cilium/ebpf"
)
// loadBpf returns the embedded CollectionSpec for bpf.
func loadBpf() (*ebpf.CollectionSpec, error) {
reader := bytes.NewReader(_BpfBytes)
spec, err := ebpf.LoadCollectionSpecFromReader(reader)
if err != nil {
return nil, fmt.Errorf("can't load bpf: %w", err)
}
return spec, err
}
// loadBpfObjects loads bpf and converts it into a struct.
//
// The following types are suitable as obj argument:
//
// *bpfObjects
// *bpfPrograms
// *bpfMaps
//
// See ebpf.CollectionSpec.LoadAndAssign documentation for details.
func loadBpfObjects(obj interface{}, opts *ebpf.CollectionOptions) error {
spec, err := loadBpf()
if err != nil {
return err
}
return spec.LoadAndAssign(obj, opts)
}
// bpfSpecs contains maps and programs before they are loaded into the kernel.
//
// It can be passed ebpf.CollectionSpec.Assign.
type bpfSpecs struct {
bpfProgramSpecs
bpfMapSpecs
}
// bpfSpecs contains programs before they are loaded into the kernel.
//
// It can be passed ebpf.CollectionSpec.Assign.
type bpfProgramSpecs struct {
NbXdpProg *ebpf.ProgramSpec `ebpf:"nb_xdp_prog"`
}
// bpfMapSpecs contains maps before they are loaded into the kernel.
//
// 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"`
}
// 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
}
func (o *bpfObjects) Close() error {
return _BpfClose(
&o.bpfPrograms,
&o.bpfMaps,
)
}
// bpfMaps contains all maps after they have been loaded into the kernel.
//
// 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,
)
}
// bpfPrograms contains all programs after they have been loaded into the kernel.
//
// It can be passed to loadBpfObjects or ebpf.CollectionSpec.LoadAndAssign.
type bpfPrograms struct {
NbXdpProg *ebpf.Program `ebpf:"nb_xdp_prog"`
}
func (p *bpfPrograms) Close() error {
return _BpfClose(
p.NbXdpProg,
)
}
func _BpfClose(closers ...io.Closer) error {
for _, closer := range closers {
if err := closer.Close(); err != nil {
return err
}
}
return nil
}
// Do not access this directly.
//
//go:embed bpf_bpfel.o
var _BpfBytes []byte
Binary file not shown.
@@ -1,52 +0,0 @@
package ebpf
import (
"encoding/binary"
"fmt"
"net/netip"
log "github.com/sirupsen/logrus"
)
const (
mapKeyDNSIP uint32 = 0
mapKeyDNSPort uint32 = 1
)
func (tf *GeneralManager) LoadDNSFwd(ip netip.Addr, dnsPort int) error {
log.Debugf("load eBPF DNS forwarder, watching addr: %s:53, redirect to port: %d", ip, dnsPort)
tf.lock.Lock()
defer tf.lock.Unlock()
err := tf.loadXdp()
if err != nil {
return err
}
if !ip.Is4() {
return fmt.Errorf("eBPF DNS forwarder only supports IPv4, got %s", ip)
}
ip4 := ip.As4()
err = tf.bpfObjs.NbMapDnsIp.Put(mapKeyDNSIP, binary.BigEndian.Uint32(ip4[:]))
if err != nil {
return err
}
err = tf.bpfObjs.NbMapDnsPort.Put(mapKeyDNSPort, uint16(dnsPort))
if err != nil {
return err
}
tf.setFeatureFlag(featureFlagDnsForwarder)
err = tf.bpfObjs.NbFeatures.Put(mapKeyFeatures, tf.featureFlags)
if err != nil {
return err
}
return nil
}
func (tf *GeneralManager) FreeDNSFwd() error {
log.Debugf("free ebpf DNS forwarder")
return tf.unsetFeatureFlag(featureFlagDnsForwarder)
}
-115
View File
@@ -1,115 +0,0 @@
package ebpf
import (
_ "embed"
"net"
"sync"
"github.com/cilium/ebpf/link"
"github.com/cilium/ebpf/rlimit"
log "github.com/sirupsen/logrus"
"github.com/netbirdio/netbird/client/internal/ebpf/manager"
)
const (
mapKeyFeatures uint32 = 0
featureFlagDnsForwarder = 0b00000010
)
var (
singleton manager.Manager
singletonLock = &sync.Mutex{}
)
// required packages libbpf-dev, libc6-dev-i386-amd64-cross
// 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).
//
//go:generate go run github.com/cilium/ebpf/cmd/bpf2go -cc clang-14 bpf src/prog.c -- -I /usr/x86_64-linux-gnu/include
type GeneralManager struct {
lock sync.Mutex
link link.Link
featureFlags uint16
bpfObjs bpfObjects
}
// GetEbpfManagerInstance return a static eBpf Manager instance
func GetEbpfManagerInstance() manager.Manager {
singletonLock.Lock()
defer singletonLock.Unlock()
if singleton != nil {
return singleton
}
singleton = &GeneralManager{}
return singleton
}
func (tf *GeneralManager) setFeatureFlag(feature uint16) {
tf.featureFlags |= feature
}
func (tf *GeneralManager) loadXdp() error {
if tf.link != nil {
return nil
}
// it required for Docker
err := rlimit.RemoveMemlock()
if err != nil {
return err
}
iFace, err := net.InterfaceByName("lo")
if err != nil {
return err
}
// load pre-compiled programs into the kernel.
err = loadBpfObjects(&tf.bpfObjs, nil)
if err != nil {
return err
}
tf.link, err = link.AttachXDP(link.XDPOptions{
Program: tf.bpfObjs.NbXdpProg,
Interface: iFace.Index,
})
if err != nil {
_ = tf.bpfObjs.Close()
tf.link = nil
return err
}
return nil
}
func (tf *GeneralManager) unsetFeatureFlag(feature uint16) error {
tf.lock.Lock()
defer tf.lock.Unlock()
tf.featureFlags &^= feature
if tf.link == nil {
return nil
}
if tf.featureFlags == 0 {
return tf.close()
}
return tf.bpfObjs.NbFeatures.Put(mapKeyFeatures, tf.featureFlags)
}
func (tf *GeneralManager) close() error {
log.Debugf("detach ebpf program ")
err := tf.bpfObjs.Close()
if err != nil {
log.Warnf("failed to close eBpf objects: %s", err)
}
err = tf.link.Close()
tf.link = nil
return err
}
@@ -1,44 +0,0 @@
package ebpf
import (
"testing"
)
// featureFlagTest stands in for a second feature flag, so the set and unset
// paths can be exercised with more than the one flag the manager defines.
const featureFlagTest = 0b00000001
func TestManager_setFeatureFlag(t *testing.T) {
mgr := GeneralManager{}
mgr.setFeatureFlag(featureFlagTest)
if mgr.featureFlags != 1 {
t.Errorf("invalid feature state")
}
mgr.setFeatureFlag(featureFlagDnsForwarder)
if mgr.featureFlags != 3 {
t.Errorf("invalid feature state")
}
}
func TestManager_unsetFeatureFlag(t *testing.T) {
mgr := GeneralManager{}
mgr.setFeatureFlag(featureFlagTest)
mgr.setFeatureFlag(featureFlagDnsForwarder)
err := mgr.unsetFeatureFlag(featureFlagTest)
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)
}
}
-67
View File
@@ -1,67 +0,0 @@
const __u32 map_key_dns_ip = 0;
const __u32 map_key_dns_port = 1;
struct bpf_map_def SEC("maps") nb_map_dns_ip = {
.type = BPF_MAP_TYPE_ARRAY,
.key_size = sizeof(__u32),
.value_size = sizeof(__u32),
.max_entries = 10,
};
struct bpf_map_def SEC("maps") nb_map_dns_port = {
.type = BPF_MAP_TYPE_ARRAY,
.key_size = sizeof(__u32),
.value_size = sizeof(__u16),
.max_entries = 10,
};
__be32 dns_ip = 0;
__be16 dns_port = 0;
// 13568 is 53 in big endian
__be16 GENERAL_DNS_PORT = 13568;
bool read_settings() {
__u16 *port_value;
__u32 *ip_value;
// read dns ip
ip_value = bpf_map_lookup_elem(&nb_map_dns_ip, &map_key_dns_ip);
if(!ip_value) {
return false;
}
dns_ip = htonl(*ip_value);
// read dns port
port_value = bpf_map_lookup_elem(&nb_map_dns_port, &map_key_dns_port);
if (!port_value) {
return false;
}
dns_port = htons(*port_value);
return true;
}
int xdp_dns_fwd(struct iphdr *ip, struct udphdr *udp) {
if (dns_port == 0) {
if(!read_settings()){
return XDP_PASS;
}
// bpf_printk("dns port: %d", ntohs(dns_port));
// bpf_printk("dns ip: %d", ntohl(dns_ip));
}
if (udp->dest == GENERAL_DNS_PORT && ip->daddr == dns_ip) {
udp->dest = dns_port;
// Clear the now-stale checksum; zero means "not computed" for IPv4.
udp->check = 0;
return XDP_PASS;
}
if (udp->source == dns_port && ip->saddr == dns_ip) {
udp->source = GENERAL_DNS_PORT;
udp->check = 0;
return XDP_PASS;
}
return XDP_PASS;
}
-60
View File
@@ -1,60 +0,0 @@
#include <stdbool.h>
#include <linux/if_ether.h> // ETH_P_IP
#include <linux/udp.h>
#include <linux/ip.h>
#include <netinet/in.h>
#include <linux/bpf.h>
#include <bpf/bpf_helpers.h>
#include "dns_fwd.c"
#include "wg_proxy.c"
const __u16 flag_feature_wg_proxy = 0b01;
const __u16 flag_feature_dns_fwd = 0b10;
const __u32 map_key_features = 0;
struct bpf_map_def SEC("maps") nb_features = {
.type = BPF_MAP_TYPE_ARRAY,
.key_size = sizeof(__u32),
.value_size = sizeof(__u16),
.max_entries = 10,
};
SEC("xdp")
int nb_xdp_prog(struct xdp_md *ctx) {
__u16 *features;
features = bpf_map_lookup_elem(&nb_features, &map_key_features);
if (!features) {
return XDP_PASS;
}
void *data = (void *)(long)ctx->data;
void *data_end = (void *)(long)ctx->data_end;
struct ethhdr *eth = data;
struct iphdr *ip = (data + sizeof(struct ethhdr));
struct udphdr *udp = (data + sizeof(struct ethhdr) + sizeof(struct iphdr));
// return early if not enough data
if (data + sizeof(struct ethhdr) + sizeof(struct iphdr) + sizeof(struct udphdr) > data_end){
return XDP_PASS;
}
// skip non IPv4 packages
if (eth->h_proto != htons(ETH_P_IP)) {
return XDP_PASS;
}
// skip non UPD packages
if (ip->protocol != IPPROTO_UDP) {
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);
}
return XDP_PASS;
}
char _license[] SEC("license") = "GPL";
-17
View File
@@ -1,17 +0,0 @@
# DNS forwarder
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.
# Debug
The CONFIG_BPF_EVENTS kernel module is required for bpf_printk.
Apply this code to use bpf_printk
```
#define bpf_printk(fmt, ...) \
({ \
char ____fmt[] = fmt; \
bpf_trace_printk(____fmt, sizeof(____fmt), ##__VA_ARGS__); \
})
```
-60
View File
@@ -1,60 +0,0 @@
const __u32 map_key_proxy_port = 0;
const __u32 map_key_wg_port = 1;
struct bpf_map_def SEC("maps") nb_wg_proxy_settings_map = {
.type = BPF_MAP_TYPE_ARRAY,
.key_size = sizeof(__u32),
.value_size = sizeof(__u16),
.max_entries = 10,
};
__u16 proxy_port = 0;
__u16 wg_port = 0;
bool read_port_settings() {
__u16 *value;
value = bpf_map_lookup_elem(&nb_wg_proxy_settings_map, &map_key_proxy_port);
if (!value) {
return false;
}
proxy_port = *value;
value = bpf_map_lookup_elem(&nb_wg_proxy_settings_map, &map_key_wg_port);
if (!value) {
return false;
}
wg_port = htons(*value);
return true;
}
int xdp_wg_proxy(struct iphdr *ip, struct udphdr *udp) {
if (proxy_port == 0 || wg_port == 0) {
if (!read_port_settings()){
return XDP_PASS;
}
// bpf_printk("proxy port: %d, wg port: %d", proxy_port, wg_port);
}
// 2130706433 = 127.0.0.1
if (ip->daddr != htonl(2130706433)) {
return XDP_PASS;
}
if (udp->source != wg_port){
return XDP_PASS;
}
__be16 new_src_port = udp->dest;
__be16 new_dst_port = htons(proxy_port);
udp->dest = new_dst_port;
udp->source = new_src_port;
// The ports are covered by the UDP checksum. This is an IPv4 loopback hop
// and the payload is already integrity-protected, so clear the checksum (a
// zero UDP checksum means "not computed" for IPv4) rather than leave a
// stale value the kernel would drop as UDP_CSUM.
udp->check = 0;
return XDP_PASS;
}
@@ -1,15 +0,0 @@
//go:build !android
package ebpf
import (
"github.com/netbirdio/netbird/client/internal/ebpf/ebpf"
"github.com/netbirdio/netbird/client/internal/ebpf/manager"
)
// GetEbpfManagerInstance is a wrapper function. This encapsulation is required because if the code import the internal
// ebpf package the Go compiler will include the object files. But it is not supported on Android. It can cause instant
// panic on older Android version.
func GetEbpfManagerInstance() manager.Manager {
return ebpf.GetEbpfManagerInstance()
}
@@ -1,10 +0,0 @@
//go:build !linux || android
package ebpf
import "github.com/netbirdio/netbird/client/internal/ebpf/manager"
// GetEbpfManagerInstance return error because ebpf is not supported on all os
func GetEbpfManagerInstance() manager.Manager {
panic("unsupported os")
}
-9
View File
@@ -1,9 +0,0 @@
package manager
import "net/netip"
// Manager is used to load eBPF programs. Currently only the DNS forwarder uses one.
type Manager interface {
LoadDNSFwd(ip netip.Addr, dnsPort int) error
FreeDNSFwd() error
}
+135 -19
View File
@@ -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"
@@ -94,6 +96,13 @@ const (
// exec, os.Stat); without this bound a single stuck call freezes handleSync, and
// thus syncMsgMux, for as long as the call hangs (observed multi-minute freezes).
systemInfoTimeout = 15 * time.Second
// dnsForwarderStopTimeout bounds how long stopping the DNS forwarder waits
// for the queries still in flight. One waiting on an unresponsive upstream
// would otherwise hold the stop for the whole upstream timeout, and the
// stop runs with syncMsgMux held. The sockets are closed either way, so
// giving up costs a query that was already failing.
dnsForwarderStopTimeout = 2 * time.Second
)
var ErrResetConnection = fmt.Errorf("reset connection")
@@ -229,6 +238,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
@@ -258,6 +273,8 @@ type Engine struct {
// checks are the client-applied posture checks that need to be evaluated on the client
checks []*mgmProto.Checks
infoSource system.InfoSource
relayManager *relayClient.Manager
stateManager *statemanager.Manager
portForwardManager *portforward.Manager
@@ -321,6 +338,10 @@ type localIpUpdater interface {
UpdateLocalIPs() error
}
// overlayRebind rebuilds one subsystem's sockets on the current interface. The
// error it returns names its own subsystem, since the caller can only log it.
type overlayRebind func() error
// NewEngine creates a new Connection Engine with probes attached
func NewEngine(
clientCtx context.Context,
@@ -638,6 +659,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())
// Start after interface is up since port may have been resolved from 0 or changed if occupied
e.shutdownWg.Add(1)
@@ -1209,9 +1231,7 @@ func (e *Engine) updateChecksIfNew(checks []*mgmProto.Checks) error {
if isChecksEqual(e.checks, checks) {
return nil
}
e.checks = checks
info, ok := system.GetInfoWithChecksTimeout(e.ctx, systemInfoTimeout, checks, e.overlayAddresses()...)
info, ok := e.infoSource.Refresh(e.ctx, systemInfoTimeout, checks, e.overlayAddresses()...)
if !ok {
// Gathering timed out; skip the meta sync this cycle rather than blocking the
// sync loop (and syncMsgMux) on a stuck system call. A later sync will retry.
@@ -1222,6 +1242,7 @@ func (e *Engine) updateChecksIfNew(checks []*mgmProto.Checks) error {
if err := e.mgmClient.SyncMeta(info); err != nil {
return fmt.Errorf("could not sync meta: error %s", err)
}
e.checks = checks
return nil
}
@@ -1248,6 +1269,28 @@ func (e *Engine) applyInfoFlags(info *system.Info) {
)
}
func (e *Engine) currentSystemInfo(ctx context.Context) *system.Info {
info := e.infoSource.Current(ctx, e.overlayAddresses()...)
e.applyInfoFlags(info)
return info
}
// syncInfoFunc returns the info callback for the management sync stream. The
// first connect sends the info refreshed right before it instead of gathering
// again; every reconnect gathers a fresh one. The stream retry loop calls the
// callback sequentially, so the handoff needs no synchronization.
func (e *Engine) syncInfoFunc(refreshed *system.Info) func(ctx context.Context) *system.Info {
return func(ctx context.Context) *system.Info {
if refreshed == nil {
return e.currentSystemInfo(ctx)
}
info := refreshed
refreshed = nil
e.applyInfoFlags(info)
return info
}
}
// overlayAddresses returns our own WireGuard overlay address (v4 and v6) so it
// can be excluded from the reported network addresses; the interface coming and
// going otherwise churns the peer meta on the management server.
@@ -1441,15 +1484,11 @@ func (e *Engine) receiveManagementEvents() {
e.shutdownWg.Add(1)
go func() {
defer e.shutdownWg.Done()
info, ok := system.GetInfoWithChecksTimeout(e.ctx, systemInfoTimeout, e.checks, e.overlayAddresses()...)
info, ok := e.infoSource.Refresh(e.ctx, systemInfoTimeout, e.checks, e.overlayAddresses()...)
if !ok {
// Gathering timed out; connect the stream with base info so management
// connectivity still comes up rather than blocking here.
info = system.GetInfo(e.ctx)
log.Warnf("posture checks not refreshed before the sync connect, sending the previous results")
}
e.applyInfoFlags(info)
err := e.mgmClient.Sync(e.ctx, info, e.handleSync)
err := e.mgmClient.Sync(e.ctx, e.syncInfoFunc(info), e.handleSync)
if err != nil {
// happens if management is unavailable for a long time.
// We want to cancel the operation of the whole client
@@ -2093,6 +2132,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)
}
@@ -2252,15 +2295,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)
@@ -2481,7 +2525,72 @@ func (e *Engine) RenewTun(fd int) error {
return fmt.Errorf("wireguard interface not initialized")
}
return wgInterface.RenewTun(fd)
if err := wgInterface.RenewTun(fd); err != nil {
return err
}
e.rebindOverlayListeners()
return nil
}
// rebindOverlayListeners gives the servers that listen on an overlay address
// sockets on the interface as it is now.
//
// A socket belongs to the interface generation it was created on. Renewing the
// TUN builds a new interface and moves the overlay addresses to it, which
// leaves the old sockets in LISTEN with the uspfilter still logging packets
// arriving for them, while every accept fails with EINVAL for the life of the
// socket: from the outside the server looks alive and answers nothing. On
// Android this happens during a normal startup, where the first TUN is
// established before the routes are known and replaced once they arrive.
//
// Rebinding costs whatever those sockets were carrying, which the renewal has
// already broken. Errors are logged rather than returned: the renewal itself
// succeeded, and failing it would hand the caller a working interface and an
// error.
func (e *Engine) rebindOverlayListeners() {
e.syncMsgMux.Lock()
defer e.syncMsgMux.Unlock()
for _, rebind := range e.overlayRebinds() {
if err := rebind(); err != nil {
log.Errorf("after TUN renewal: %v", err)
}
}
}
// overlayRebinds is every subsystem of this engine that holds sockets bound to
// an overlay address, and how to rebuild each one's.
//
// A subsystem that starts listening on an overlay address belongs in this list.
// Leaving it out costs nothing that review would notice and produces a listener
// that stays in LISTEN, is logged as receiving packets, and refuses every
// connection for the life of the process.
func (e *Engine) overlayRebinds() []overlayRebind {
return []overlayRebind{
e.restartSSHListeners,
e.restartDNSForwarder,
}
}
// restartDNSForwarder rebuilds the DNS forwarder serving the same domains.
// No-op when it is not running. See Engine.rebindOverlayListeners.
func (e *Engine) restartDNSForwarder() error {
if e.dnsForwardMgr == nil {
return nil
}
// Read from the forwarder before it goes away, so the replacement serves
// the domains in force now rather than a copy kept somewhere else.
entries := e.dnsForwardMgr.Domains()
e.stopDNSForwarder()
// Both halves log their own failures, so the only thing left to report is
// the outcome: a start that failed left the manager nil, and the forwarder
// is now down rather than merely rebound.
e.startDNSForwarder(entries)
if e.dnsForwardMgr == nil {
return errors.New("rebind DNS forwarder: it did not come back up")
}
return nil
}
// updateDNSForwarder start or stop the DNS forwarder based on the domains and the feature flag
@@ -2527,7 +2636,14 @@ func (e *Engine) stopDNSForwarder() {
return
}
if err := e.dnsForwardMgr.Stop(context.Background()); err != nil {
// Bounded because the shutdown waits for queries still in flight, and one
// waiting on an unresponsive upstream holds it for as long as that lookup
// is allowed to take. This runs with syncMsgMux held, so that wait is one
// the whole engine spends.
ctx, cancel := context.WithTimeout(context.Background(), dnsForwarderStopTimeout)
defer cancel()
if err := e.dnsForwardMgr.Stop(ctx); err != nil {
log.Errorf("failed to stop DNS forward: %v", err)
}
+1 -1
View File
@@ -193,7 +193,7 @@ func TestEngine_Sync(t *testing.T) {
// feed updates to Engine via mocked Management client
updates := make(chan *mgmtProto.SyncResponse)
defer close(updates)
syncFunc := func(ctx context.Context, info *system.Info, msgHandler func(msg *mgmtProto.SyncResponse) error) error {
syncFunc := func(ctx context.Context, _ func(context.Context) *system.Info, msgHandler func(msg *mgmtProto.SyncResponse) error) error {
for msg := range updates {
err := msgHandler(msg)
if err != nil {
+32 -4
View File
@@ -24,6 +24,8 @@ type sshServer interface {
Stop() error
GetStatus() (bool, []sshserver.SessionInfo)
UpdateSSHAuth(config *sshauth.Config)
JWTConfig() *sshserver.JWTConfig
AuthConfig() *sshauth.Config
}
func (e *Engine) setupSSHPortRedirection() error {
@@ -77,7 +79,7 @@ func (e *Engine) updateSSH(sshConf *mgmProto.SSHConfig) error {
if e.config.DisableSSHAuth != nil && *e.config.DisableSSHAuth {
log.Info("starting SSH server without JWT authentication (authentication disabled by config)")
return e.startSSHServer(nil)
return e.startSSHServer(nil, nil)
}
if protoJWT := sshConf.GetJwtConfig(); protoJWT != nil {
@@ -95,7 +97,7 @@ func (e *Engine) updateSSH(sshConf *mgmProto.SSHConfig) error {
MaxTokenAge: protoJWT.GetMaxTokenAge(),
}
return e.startSSHServer(jwtConfig)
return e.startSSHServer(jwtConfig, nil)
}
return errors.New("SSH server requires valid JWT configuration")
@@ -231,8 +233,33 @@ func (e *Engine) cleanupSSHConfig() {
}
}
// startSSHServer initializes and starts the SSH server with proper configuration.
func (e *Engine) startSSHServer(jwtConfig *sshserver.JWTConfig) error {
// restartSSHListeners rebuilds the SSH server so it listens on new sockets, on
// the same terms it was started with. No-op when it is not running. See
// Engine.rebindOverlayListeners for why this is needed.
func (e *Engine) restartSSHListeners() error {
if e.sshServer == nil {
return nil
}
// Read from the server before it goes away. A rebuilt one starts with an
// empty authorizer, which fails closed, so without carrying the
// authorization over every JWT login is refused until the next network map
// happens to bring one.
jwtConfig, authConfig := e.sshServer.JWTConfig(), e.sshServer.AuthConfig()
if err := e.stopSSHServer(); err != nil {
return fmt.Errorf("rebind SSH listeners: %w", err)
}
if err := e.startSSHServer(jwtConfig, authConfig); err != nil {
return fmt.Errorf("rebind SSH listeners: %w", err)
}
return nil
}
// startSSHServer initializes and starts the SSH server with proper
// configuration. authConfig is the fine-grained authorization to open with, and
// is applied before the server accepts anything: a server that starts listening
// with an empty authorizer refuses the logins that arrive in the meantime.
// Nil leaves it as management has not sent one yet.
func (e *Engine) startSSHServer(jwtConfig *sshserver.JWTConfig, authConfig *sshauth.Config) error {
if e.wgInterface == nil {
return errors.New("wg interface not initialized")
}
@@ -240,6 +267,7 @@ func (e *Engine) startSSHServer(jwtConfig *sshserver.JWTConfig) error {
serverConfig := &sshserver.Config{
HostKeyPEM: e.config.SSHKey,
JWT: jwtConfig,
Auth: authConfig,
}
server := sshserver.New(serverConfig)
+114
View File
@@ -2,6 +2,7 @@ package internal
import (
"context"
"errors"
"fmt"
"net"
"net/netip"
@@ -31,6 +32,7 @@ import (
icemaker "github.com/netbirdio/netbird/client/internal/peer/ice"
"github.com/netbirdio/netbird/client/internal/profilemanager"
"github.com/netbirdio/netbird/client/internal/routemanager"
"github.com/netbirdio/netbird/client/system"
nbdns "github.com/netbirdio/netbird/dns"
"github.com/netbirdio/netbird/monotime"
"github.com/netbirdio/netbird/route"
@@ -246,6 +248,118 @@ func TestEngine_SSHServerConsistency(t *testing.T) {
})
}
func TestEngine_FirstSyncInfoCarriesLoginChecks(t *testing.T) {
key, err := wgtypes.GeneratePrivateKey()
require.NoError(t, err)
exe, err := os.Executable()
require.NoError(t, err)
ctx, cancel := context.WithCancel(CtxInitState(context.Background()))
defer cancel()
infos := make(chan *system.Info, 1)
mgmClient := &mgmt.MockClient{
SyncFunc: func(ctx context.Context, getInfo func(context.Context) *system.Info, _ func(*mgmtProto.SyncResponse) error) error {
infos <- getInfo(ctx)
return nil
},
}
relayMgr := relayClient.NewManager(ctx, nil, key.PublicKey().String(), iface.DefaultMTU)
engine := NewEngine(ctx, cancel, &EngineConfig{
WgIfaceName: "utun104",
WgAddr: wgaddr.MustParseWGAddress("100.64.0.1/24"),
WgPrivateKey: key,
WgPort: 33100,
MTU: iface.DefaultMTU,
}, EngineServices{
SignalClient: &signal.MockClient{},
MgmClient: mgmClient,
RelayManager: relayMgr,
StatusRecorder: peer.NewRecorder("https://mgm"),
Checks: []*mgmtProto.Checks{{Files: []string{exe}}},
}, MobileDependency{})
engine.receiveManagementEvents()
select {
case info := <-infos:
require.Len(t, info.Files, 1)
assert.Equal(t, exe, info.Files[0].Path)
assert.True(t, info.Files[0].Exist)
case <-time.After(20 * time.Second):
t.Fatal("timeout waiting for the first sync info")
}
engine.shutdownWg.Wait()
}
func TestEngine_SyncInfoFuncReusesRefreshedInfoOnce(t *testing.T) {
engine := &Engine{config: &EngineConfig{}}
refreshed := &system.Info{Hostname: "from-refresh"}
getInfo := engine.syncInfoFunc(refreshed)
first := getInfo(context.Background())
assert.Same(t, refreshed, first, "the first connect should send the refreshed info instead of gathering again")
second := getInfo(context.Background())
assert.NotSame(t, refreshed, second, "the reconnect should gather a fresh info")
assert.NotEqual(t, "from-refresh", second.Hostname, "the fresh info should not carry the refreshed hostname")
}
func TestEngine_SyncInfoFuncGathersWhenRefreshFailed(t *testing.T) {
engine := &Engine{config: &EngineConfig{}}
info := engine.syncInfoFunc(nil)(context.Background())
require.NotNil(t, info, "a failed refresh should fall back to gathering the info")
assert.NotEmpty(t, info.Hostname, "the gathered info should carry the hostname")
}
func TestEngine_UpdateChecksIfNewRetriesAfterFailedSyncMeta(t *testing.T) {
key, err := wgtypes.GeneratePrivateKey()
require.NoError(t, err)
exe, err := os.Executable()
require.NoError(t, err)
ctx, cancel := context.WithCancel(CtxInitState(context.Background()))
defer cancel()
syncMetaCalls := 0
mgmClient := &mgmt.MockClient{
SyncMetaFunc: func(*system.Info) error {
syncMetaCalls++
if syncMetaCalls == 1 {
return errors.New("management unavailable")
}
return nil
},
}
relayMgr := relayClient.NewManager(ctx, nil, key.PublicKey().String(), iface.DefaultMTU)
engine := NewEngine(ctx, cancel, &EngineConfig{
WgIfaceName: "utun105",
WgAddr: wgaddr.MustParseWGAddress("100.64.0.1/24"),
WgPrivateKey: key,
WgPort: 33100,
MTU: iface.DefaultMTU,
}, EngineServices{
SignalClient: &signal.MockClient{},
MgmClient: mgmClient,
RelayManager: relayMgr,
StatusRecorder: peer.NewRecorder("https://mgm"),
}, MobileDependency{})
checks := []*mgmtProto.Checks{{Files: []string{exe}}}
require.Error(t, engine.updateChecksIfNew(checks))
require.NoError(t, engine.updateChecksIfNew(checks))
require.NoError(t, engine.updateChecksIfNew(checks))
assert.Equal(t, 2, syncMetaCalls)
}
func TestEngine_UpdateNetworkMap(t *testing.T) {
// test setup
key, err := wgtypes.GeneratePrivateKey()
+25 -9
View File
@@ -135,9 +135,10 @@ type Conn struct {
// used to store the remote Rosenpass key for Relayed connection in case of connection update from ice
rosenpassRemoteKey []byte
wgProxyICE wgproxy.Proxy
wgProxyRelay wgproxy.Proxy
handshaker *Handshaker
wgProxyICE wgproxy.Proxy
wgProxyRelay wgproxy.Proxy
relayedConnRef *relayClient.Conn
handshaker *Handshaker
guard *guard.Guard
wg sync.WaitGroup
@@ -560,7 +561,7 @@ func (conn *Conn) onRelayConnectionIsReady(rci RelayConnInfo) {
conn.mu.Lock()
defer conn.mu.Unlock()
if conn.ctx.Err() != nil {
if conn.ctx.Err() != nil || rci.relayedConn.Context().Err() != nil {
if err := rci.relayedConn.Close(); err != nil {
conn.Log.Warnf("failed to close unnecessary relayed connection: %v", err)
}
@@ -575,7 +576,9 @@ func (conn *Conn) onRelayConnectionIsReady(rci RelayConnInfo) {
conn.Log.Errorf("failed to add relayed net.Conn to local proxy: %v", err)
return
}
wgProxy.SetDisconnectListener(conn.onRelayDisconnected)
wgProxy.SetDisconnectListener(func() {
conn.onRelayDisconnected(rci.relayedConn)
})
conn.dumpState.NewLocalProxy()
@@ -583,7 +586,7 @@ func (conn *Conn) onRelayConnectionIsReady(rci RelayConnInfo) {
if conn.isICEActive() {
conn.Log.Debugf("do not switch to relay because current priority is: %s", conn.currentConnPriority.String())
conn.setRelayedProxy(wgProxy)
conn.setRelayedProxy(wgProxy, rci.relayedConn)
conn.statusRelay.SetConnected()
conn.updateRelayStatus(rci.relayedConn.RemoteAddr().String(), rci.rosenpassPubKey, time.Now())
return
@@ -614,15 +617,26 @@ func (conn *Conn) onRelayConnectionIsReady(rci RelayConnInfo) {
conn.rosenpassRemoteKey = rci.rosenpassPubKey
conn.currentConnPriority = conntype.Relay
conn.statusRelay.SetConnected()
conn.setRelayedProxy(wgProxy)
conn.setRelayedProxy(wgProxy, rci.relayedConn)
conn.updateRelayStatus(rci.relayedConn.RemoteAddr().String(), rci.rosenpassPubKey, updateTime)
conn.Log.Infof("start to communicate with peer via relay")
conn.doOnConnected(rci.rosenpassPubKey, rci.rosenpassAddr, updateTime)
}
func (conn *Conn) onRelayDisconnected() {
// onRelayDisconnected reports the teardown of a relayed connection. relayedConn
// names the connection the signal belongs to, so a signal that arrives after
// its connection was replaced is ignored instead of tearing down its successor.
// A nil relayedConn means the caller does not track generations and the current
// connection is always torn down.
func (conn *Conn) onRelayDisconnected(relayedConn *relayClient.Conn) {
conn.mu.Lock()
defer conn.mu.Unlock()
if relayedConn != nil && conn.relayedConnRef != relayedConn {
conn.Log.Debugf("ignoring relay disconnect of a superseded connection")
return
}
conn.handleRelayDisconnectedLocked()
}
@@ -646,6 +660,7 @@ func (conn *Conn) handleRelayDisconnectedLocked() {
_ = conn.wgProxyRelay.CloseConn()
conn.wgProxyRelay = nil
}
conn.relayedConnRef = nil
changed := conn.statusRelay.Get() != worker.StatusDisconnected
if changed {
@@ -930,13 +945,14 @@ func (conn *Conn) logTraceConnState() {
}
}
func (conn *Conn) setRelayedProxy(proxy wgproxy.Proxy) {
func (conn *Conn) setRelayedProxy(proxy wgproxy.Proxy, relayedConn *relayClient.Conn) {
if conn.wgProxyRelay != nil {
if err := conn.wgProxyRelay.CloseConn(); err != nil {
conn.Log.Warnf("failed to close deprecated wg proxy conn: %v", err)
}
}
conn.wgProxyRelay = proxy
conn.relayedConnRef = relayedConn
}
// onWGHandshakeSuccess is called when the first WireGuard handshake is detected
+4 -4
View File
@@ -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)
}
+2 -2
View File
@@ -819,8 +819,8 @@ func (d *Status) SetSessionExpiresAt(deadline time.Time) {
// "none" would blank the UI at the exact moment it should say the session
// ended.
func (d *Status) GetSessionExpiresAt() time.Time {
d.mux.Lock()
defer d.mux.Unlock()
d.mux.RLock()
defer d.mux.RUnlock()
return d.sessionExpiresAt
}
+13 -14
View File
@@ -3,7 +3,6 @@ package peer
import (
"context"
"errors"
"net"
"net/netip"
"sync"
"sync/atomic"
@@ -14,7 +13,7 @@ import (
)
type RelayConnInfo struct {
relayedConn net.Conn
relayedConn *relayClient.Conn
rosenpassPubKey []byte
rosenpassAddr string
}
@@ -27,7 +26,7 @@ type WorkerRelay struct {
conn *Conn
relayManager *relayClient.Manager
relayedConn net.Conn
relayedConn *relayClient.Conn
relayLock sync.Mutex
relaySupportedOnRemotePeer atomic.Bool
@@ -80,12 +79,7 @@ func (w *WorkerRelay) OnNewOffer(remoteOfferAnswer *OfferAnswer) {
w.relayedConn = relayedConn
w.relayLock.Unlock()
err = w.relayManager.AddCloseListener(srv, w.onRelayClientDisconnected)
if err != nil {
log.Errorf("failed to add close listener: %s", err)
_ = relayedConn.Close()
return
}
go w.watchRelayedConn(relayedConn)
w.log.Debugf("peer conn opened via Relay: %s", srv)
go w.conn.onRelayConnectionIsReady(RelayConnInfo{
@@ -109,12 +103,15 @@ func (w *WorkerRelay) RelayIsSupportedLocally() bool {
func (w *WorkerRelay) CloseConn() {
w.relayLock.Lock()
defer w.relayLock.Unlock()
if w.relayedConn == nil {
conn := w.relayedConn
w.relayedConn = nil
w.relayLock.Unlock()
if conn == nil {
return
}
if err := w.relayedConn.Close(); err != nil {
if err := conn.Close(); err != nil {
w.log.Warnf("failed to close relay connection: %v", err)
}
}
@@ -133,6 +130,8 @@ func (w *WorkerRelay) preferredRelayServer(myRelayAddress, remoteRelayAddress st
return remoteRelayAddress
}
func (w *WorkerRelay) onRelayClientDisconnected() {
go w.conn.onRelayDisconnected()
func (w *WorkerRelay) watchRelayedConn(relayedConn *relayClient.Conn) {
<-relayedConn.Context().Done()
w.conn.onRelayDisconnected(relayedConn)
}
+22 -12
View File
@@ -58,10 +58,6 @@ var DefaultInterfaceBlacklist = []string{
"Tailscale", "tailscale", "docker", "veth", "br-", "lo",
}
// loadMDMPolicy is the package-level indirection used by apply() to read the
// active MDM policy. Tests override this to inject a fake policy.
var loadMDMPolicy = mdm.LoadPolicy
// ConfigInput carries configuration changes to the client
type ConfigInput struct {
ManagementURL string
@@ -202,14 +198,26 @@ type Config struct {
MTU uint16
// policy is the MDM policy that produced the currently-set values for
// any MDM-enforced fields. Set by applyMDMPolicy at the tail of apply()
// and reset on every apply() invocation. Never persisted to disk.
// Callers query enforcement state via Policy() and the mdm.Policy API
// (HasKey, ManagedKeys, IsEmpty).
// policy is the MDM policy that produced the currently-set values
// for any MDM-enforced fields. Set by ApplyMDMPolicy on every
// invocation. Never persisted to disk. Callers query enforcement
// state via Policy() and the mdm.Policy API (HasKey, ManagedKeys,
// IsEmpty).
policy *mdm.Policy `json:"-"`
}
// ApplyMDMPolicy overlays the supplied MDM Policy on top of the current
// Config values and records it as Policy(). The overlay is not reversible:
// an empty Policy only clears the enforcement metadata, so resolve the base
// Config again (from disk or JSON) before applying a changed policy, the way
// the lifecycle owners do on every load.
func (config *Config) ApplyMDMPolicy(policy *mdm.Policy) {
if config == nil {
return
}
config.applyMDMPolicy(policy)
}
// Policy returns the MDM policy applied to this Config. Returns a non-nil
// empty Policy when MDM enforcement is inactive; callers can always invoke
// HasKey / ManagedKeys / IsEmpty without a nil check.
@@ -712,9 +720,11 @@ func (config *Config) apply(input ConfigInput) (updated bool, err error) {
updated = true
}
// MDM is the last override layer: any key present in the policy
// supersedes defaults, on-disk config, env vars and CLI input.
config.applyMDMPolicy(loadMDMPolicy())
// Initialise the MDM overlay to "no enforcement" so Config.Policy()
// never returns a stale or nil policy on a freshly applied Config.
// Lifecycle owners that want to enforce a real MDM policy invoke
// Config.ApplyMDMPolicy(loader.Load()) after this returns.
config.applyMDMPolicy(mdm.NewPolicy(nil))
return updated, nil
}
@@ -0,0 +1,52 @@
package profilemanager
import (
"errors"
"fmt"
"github.com/netbirdio/netbird/client/mdm"
)
// ErrMDMManagedFields marks a config change rejected because it diverges from
// MDM-enforced values.
var ErrMDMManagedFields = errors.New("fields managed by MDM cannot be modified")
// MDMConflicts returns the names of MDM-managed keys whose requested value in
// the ConfigInput differs from the policy-enforced value; a field set to the
// enforced value is a no-op echo, not a conflict.
func MDMConflicts(input ConfigInput, policy *mdm.Policy) []string {
pskGot := input.PreSharedKey
if isPreSharedKeyHidden(pskGot) {
pskGot = nil
}
var port *int64
if input.WireguardPort != nil {
v := int64(*input.WireguardPort)
port = &v
}
return mdm.ResolveConflicts(policy, []mdm.ConflictCheck{
mdm.ConflictURL(mdm.KeyManagementURL, input.ManagementURL),
mdm.ConflictStringPtr(mdm.KeyPreSharedKey, pskGot),
mdm.ConflictBool(mdm.KeyRosenpassEnabled, input.RosenpassEnabled),
mdm.ConflictBool(mdm.KeyRosenpassPermissive, input.RosenpassPermissive),
mdm.ConflictBool(mdm.KeyDisableAutoConnect, input.DisableAutoConnect),
mdm.ConflictBool(mdm.KeyAllowServerSSH, input.ServerSSHAllowed),
mdm.ConflictBool(mdm.KeyRemoteJobsAllowed, input.RemoteJobsAllowed),
mdm.ConflictBool(mdm.KeyDisableClientRoutes, input.DisableClientRoutes),
mdm.ConflictBool(mdm.KeyDisableServerRoutes, input.DisableServerRoutes),
mdm.ConflictBool(mdm.KeyBlockInbound, input.BlockInbound),
mdm.ConflictInt64(mdm.KeyWireguardPort, port),
mdm.ConflictBool(mdm.KeyEnableLocalMetrics, input.LocalMetricsEnabled),
mdm.ConflictStringPtr(mdm.KeyLocalMetricsAddress, input.LocalMetricsAddress),
})
}
// CheckMDMConflicts returns an ErrMDMManagedFields-wrapped error naming the
// conflicting keys, or nil when the input does not fight the policy.
func CheckMDMConflicts(input ConfigInput, policy *mdm.Policy) error {
conflicts := MDMConflicts(input, policy)
if len(conflicts) == 0 {
return nil
}
return fmt.Errorf("%w: %v", ErrMDMManagedFields, conflicts)
}
+141 -68
View File
@@ -10,24 +10,58 @@ import (
"github.com/netbirdio/netbird/client/mdm"
)
// withMDMPolicy temporarily overrides the package-level loadMDMPolicy hook so
// apply() observes the supplied Policy. The original loader is restored at
// test cleanup.
func withMDMPolicy(t *testing.T, policy *mdm.Policy) {
// fakeFetcher implements mdm.PolicyFetcher returning a pre-set policy
// map. Test helper used to construct a Loader without touching the OS
// or any package-level state.
type fakeFetcher struct{ values map[string]any }
func (f *fakeFetcher) Fetch() map[string]any { return f.values }
// loaderFor builds an mdm.Loader whose loadPlatform returns the
// supplied Policy's underlying values.
func loaderFor(policy *mdm.Policy) *mdm.Loader {
if policy == nil || policy.IsEmpty() {
return mdm.NewLoader(&fakeFetcher{values: nil})
}
values := make(map[string]any)
for _, k := range policy.ManagedKeys() {
if v, ok := policy.GetString(k); ok {
values[k] = v
continue
}
if v, ok := policy.GetInt(k); ok {
values[k] = v
continue
}
if v, ok := policy.GetBool(k); ok {
values[k] = v
continue
}
if v, ok := policy.GetStringSlice(k); ok {
values[k] = v
}
}
return mdm.NewLoader(&fakeFetcher{values: values})
}
// configWithMDM is the test convenience that builds a Config via
// UpdateOrCreateConfig and overlays the supplied MDM policy on top —
// mirrors the production pattern (Server.getConfig / Client.applyMDMOverlay)
// where the Loader lives outside Config and the apply step is driven
// by the lifecycle owner.
func configWithMDM(t *testing.T, input ConfigInput, policy *mdm.Policy) *Config {
t.Helper()
prev := loadMDMPolicy
loadMDMPolicy = func() *mdm.Policy { return policy }
t.Cleanup(func() { loadMDMPolicy = prev })
cfg, err := UpdateOrCreateConfig(input)
require.NoError(t, err)
require.NotNil(t, cfg)
cfg.ApplyMDMPolicy(loaderFor(policy).Load())
return cfg
}
func TestApply_MDMEmpty_NoEnforcement(t *testing.T) {
withMDMPolicy(t, mdm.NewPolicy(nil))
cfg, err := UpdateOrCreateConfig(ConfigInput{
cfg := configWithMDM(t, ConfigInput{
ConfigPath: filepath.Join(t.TempDir(), "config.json"),
})
require.NoError(t, err)
require.NotNil(t, cfg)
}, mdm.NewPolicy(nil))
assert.True(t, cfg.Policy().IsEmpty(), "no MDM source ⇒ empty Policy")
assert.False(t, cfg.Policy().HasKey(mdm.KeyManagementURL))
@@ -39,18 +73,15 @@ func TestApply_MDMEmpty_NoEnforcement(t *testing.T) {
func TestApply_MDMOnly_OverridesDefaults(t *testing.T) {
const mdmURL = "https://corp.mdm.example.com:443"
withMDMPolicy(t, mdm.NewPolicy(map[string]any{
cfg := configWithMDM(t, ConfigInput{
ConfigPath: filepath.Join(t.TempDir(), "config.json"),
}, mdm.NewPolicy(map[string]any{
mdm.KeyManagementURL: mdmURL,
mdm.KeyDisableClientRoutes: true,
mdm.KeyBlockInbound: true,
}))
cfg, err := UpdateOrCreateConfig(ConfigInput{
ConfigPath: filepath.Join(t.TempDir(), "config.json"),
})
require.NoError(t, err)
require.NotNil(t, cfg)
assert.Equal(t, mdmURL, cfg.ManagementURL.String())
assert.True(t, cfg.DisableClientRoutes)
assert.True(t, cfg.BlockInbound)
@@ -65,16 +96,12 @@ func TestApply_MDMBeatsCLIInput(t *testing.T) {
const mdmURL = "https://mdm.example.com:443"
const cliURL = "https://cli.example.com:443"
withMDMPolicy(t, mdm.NewPolicy(map[string]any{
mdm.KeyManagementURL: mdmURL,
}))
cfg, err := UpdateOrCreateConfig(ConfigInput{
cfg := configWithMDM(t, ConfigInput{
ConfigPath: filepath.Join(t.TempDir(), "config.json"),
ManagementURL: cliURL,
})
require.NoError(t, err)
require.NotNil(t, cfg)
}, mdm.NewPolicy(map[string]any{
mdm.KeyManagementURL: mdmURL,
}))
// MDM wins over CLI-supplied management URL.
assert.Equal(t, mdmURL, cfg.ManagementURL.String())
@@ -82,16 +109,12 @@ func TestApply_MDMBeatsCLIInput(t *testing.T) {
}
func TestApply_MDMInvalidURL_KeepsPreviousValue(t *testing.T) {
withMDMPolicy(t, mdm.NewPolicy(map[string]any{
cfg := configWithMDM(t, ConfigInput{
ConfigPath: filepath.Join(t.TempDir(), "config.json"),
}, mdm.NewPolicy(map[string]any{
mdm.KeyManagementURL: "not-a-url",
}))
cfg, err := UpdateOrCreateConfig(ConfigInput{
ConfigPath: filepath.Join(t.TempDir(), "config.json"),
})
require.NoError(t, err)
require.NotNil(t, cfg)
// Invalid MDM URL is logged and skipped: default URL stays in place
// to keep the client functional.
assert.Equal(t, DefaultManagementURL, cfg.ManagementURL.String())
@@ -106,24 +129,20 @@ func TestApply_MDMBoolKeysOverrideOnDiskValue(t *testing.T) {
tmp := filepath.Join(t.TempDir(), "config.json")
// Seed without MDM.
withMDMPolicy(t, mdm.NewPolicy(nil))
_, err := UpdateOrCreateConfig(ConfigInput{
configWithMDM(t, ConfigInput{
ConfigPath: tmp,
DisableClientRoutes: boolPtr(false),
RosenpassEnabled: boolPtr(false),
})
require.NoError(t, err)
}, mdm.NewPolicy(nil))
// Now enable MDM enforcement for these keys.
withMDMPolicy(t, mdm.NewPolicy(map[string]any{
cfg := configWithMDM(t, ConfigInput{
ConfigPath: tmp,
}, mdm.NewPolicy(map[string]any{
mdm.KeyDisableClientRoutes: true,
mdm.KeyRosenpassEnabled: true,
}))
cfg, err := UpdateOrCreateConfig(ConfigInput{ConfigPath: tmp})
require.NoError(t, err)
require.NotNil(t, cfg)
assert.True(t, cfg.DisableClientRoutes, "MDM override should flip on-disk false to true")
assert.True(t, cfg.RosenpassEnabled)
assert.True(t, cfg.Policy().HasKey(mdm.KeyDisableClientRoutes))
@@ -134,22 +153,19 @@ func TestApply_MDMLocalMetrics(t *testing.T) {
tmp := filepath.Join(t.TempDir(), "config.json")
// Seed without MDM.
withMDMPolicy(t, mdm.NewPolicy(nil))
_, err := UpdateOrCreateConfig(ConfigInput{
configWithMDM(t, ConfigInput{
ConfigPath: tmp,
LocalMetricsEnabled: boolPtr(false),
})
require.NoError(t, err)
}, mdm.NewPolicy(nil))
withMDMPolicy(t, mdm.NewPolicy(map[string]any{
// Now enable MDM enforcement for these keys.
cfg := configWithMDM(t, ConfigInput{
ConfigPath: tmp,
}, mdm.NewPolicy(map[string]any{
mdm.KeyEnableLocalMetrics: true,
mdm.KeyLocalMetricsAddress: "127.0.0.1:9292",
}))
cfg, err := UpdateOrCreateConfig(ConfigInput{ConfigPath: tmp})
require.NoError(t, err)
require.NotNil(t, cfg)
assert.True(t, cfg.LocalMetricsEnabled, "MDM override should flip on-disk false to true")
assert.Equal(t, "127.0.0.1:9292", cfg.LocalMetricsAddress)
assert.True(t, cfg.Policy().HasKey(mdm.KeyEnableLocalMetrics))
@@ -171,16 +187,12 @@ func TestApply_MDMLazyConnection(t *testing.T) {
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
withMDMPolicy(t, mdm.NewPolicy(map[string]any{
cfg := configWithMDM(t, ConfigInput{
ConfigPath: filepath.Join(t.TempDir(), "config.json"),
}, mdm.NewPolicy(map[string]any{
mdm.KeyLazyConnection: c.raw,
}))
cfg, err := UpdateOrCreateConfig(ConfigInput{
ConfigPath: filepath.Join(t.TempDir(), "config.json"),
})
require.NoError(t, err)
require.NotNil(t, cfg)
assert.Equal(t, c.want, cfg.LazyConnection)
assert.True(t, cfg.Policy().HasKey(mdm.KeyLazyConnection))
})
@@ -188,22 +200,83 @@ func TestApply_MDMLazyConnection(t *testing.T) {
}
func TestApply_MDMPreSharedKeyRedactionSentinelRejected(t *testing.T) {
const maskSentinel = "**********"
const maskSentinel = mdm.PreSharedKeyRedactedSentinel
withMDMPolicy(t, mdm.NewPolicy(map[string]any{
cfg := configWithMDM(t, ConfigInput{
ConfigPath: filepath.Join(t.TempDir(), "config.json"),
}, mdm.NewPolicy(map[string]any{
mdm.KeyPreSharedKey: maskSentinel,
}))
cfg, err := UpdateOrCreateConfig(ConfigInput{
ConfigPath: filepath.Join(t.TempDir(), "config.json"),
})
require.NoError(t, err)
require.NotNil(t, cfg)
// Mask sentinel must not be persisted as the actual PSK.
assert.NotEqual(t, maskSentinel, cfg.PreSharedKey)
// Key still marked managed so user writes are still rejected.
assert.True(t, cfg.Policy().HasKey(mdm.KeyPreSharedKey))
}
func TestMDMConflicts_PreSharedKey(t *testing.T) {
policy := mdm.NewPolicy(map[string]any{
mdm.KeyPreSharedKey: "mdm-enforced-psk",
})
empty := ""
sentinel := mdm.PreSharedKeyRedactedSentinel
same := "mdm-enforced-psk"
other := "user-psk"
tests := []struct {
name string
psk *string
want []string
}{
{name: "unset", psk: nil, want: nil},
{name: "explicit empty", psk: &empty, want: []string{mdm.KeyPreSharedKey}},
{name: "sentinel echo", psk: &sentinel, want: nil},
{name: "same value", psk: &same, want: nil},
{name: "divergent", psk: &other, want: []string{mdm.KeyPreSharedKey}},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
assert.Equal(t, tc.want, MDMConflicts(ConfigInput{PreSharedKey: tc.psk}, policy))
})
}
}
func TestMDMConflicts_RemoteJobsAndLocalMetrics(t *testing.T) {
policy := mdm.NewPolicy(map[string]any{
mdm.KeyRemoteJobsAllowed: false,
mdm.KeyEnableLocalMetrics: true,
mdm.KeyLocalMetricsAddress: "127.0.0.1:9999",
})
sameAddr := "127.0.0.1:9999"
otherAddr := "0.0.0.0:9999"
emptyAddr := ""
tests := []struct {
name string
input ConfigInput
want []string
}{
{name: "unset", input: ConfigInput{}, want: nil},
{name: "echo", input: ConfigInput{
RemoteJobsAllowed: boolPtr(false),
LocalMetricsEnabled: boolPtr(true),
LocalMetricsAddress: &sameAddr,
}, want: nil},
{name: "remote jobs divergent", input: ConfigInput{RemoteJobsAllowed: boolPtr(true)}, want: []string{mdm.KeyRemoteJobsAllowed}},
{name: "metrics disabled", input: ConfigInput{LocalMetricsEnabled: boolPtr(false)}, want: []string{mdm.KeyEnableLocalMetrics}},
{name: "metrics address divergent", input: ConfigInput{LocalMetricsAddress: &otherAddr}, want: []string{mdm.KeyLocalMetricsAddress}},
{name: "metrics address explicit empty", input: ConfigInput{LocalMetricsAddress: &emptyAddr}, want: []string{mdm.KeyLocalMetricsAddress}},
{name: "all divergent", input: ConfigInput{
RemoteJobsAllowed: boolPtr(true),
LocalMetricsEnabled: boolPtr(false),
LocalMetricsAddress: &otherAddr,
}, want: []string{mdm.KeyRemoteJobsAllowed, mdm.KeyEnableLocalMetrics, mdm.KeyLocalMetricsAddress}},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
assert.Equal(t, tc.want, MDMConflicts(tc.input, policy))
})
}
}
func boolPtr(b bool) *bool { return &b }
@@ -6,6 +6,7 @@ import (
"os/user"
"path/filepath"
"runtime"
"strconv"
log "github.com/sirupsen/logrus"
)
@@ -13,17 +14,21 @@ import (
const envSudoUser = "SUDO_USER"
var (
geteuid = os.Geteuid
lookupUser = user.Lookup
currentUser = user.Current
getegid = os.Getegid
geteuid = os.Geteuid
lookupUser = user.Lookup
)
// InvokingUser returns the user a CLI invocation acts for. Under sudo that is
// the user who ran sudo, not root: privileged flags force commands through
// sudo, and resolving profiles as root would silently switch the daemon to
// root's (default) profile instead of the invoking user's. Privilege decisions
// are not made here — those stay on the kernel credentials of the daemon
// connection, which SUDO_USER (a plain environment variable) can never
// influence; a forged value only selects a profile root could select anyway.
// root's (default) profile instead of the invoking user's. An unmapped positive
// process UID uses its numeric kernel identity; root, sudo lookup failures, and
// unavailable platform identities still fail closed. Privilege decisions stay
// on the kernel credentials of the daemon connection, which SUDO_USER (a plain
// environment variable) can never influence; a forged value only selects a
// profile root could select anyway.
func InvokingUser() (*user.User, error) {
if u, ok := sudoInvokingUser(); ok {
return u, nil
@@ -35,7 +40,23 @@ func InvokingUser() (*user.User, error) {
if sudoActive() {
return nil, fmt.Errorf("resolve sudo invoking user %q: refusing to fall back to root", os.Getenv(envSudoUser))
}
return user.Current()
u, err := currentUser()
if err == nil {
return u, nil
}
uid := geteuid()
if uid <= 0 {
return nil, err
}
log.Debugf("current user lookup for UID %d: %v; using numeric UID", uid, err)
uidString := strconv.Itoa(uid)
return &user.User{
Username: uidString,
Uid: uidString,
Gid: strconv.Itoa(getegid()),
}, nil
}
// IsPlainRoot reports that the process runs as root with no usable sudo
@@ -2,6 +2,7 @@ package profilemanager
import (
"errors"
"fmt"
"io/fs"
"os"
"os/user"
@@ -21,7 +22,51 @@ func TestInvokingUserFallsBackToProcessUser(t *testing.T) {
current, err := user.Current()
require.NoError(t, err)
assert.Equal(t, current.Username, got.Username)
assert.Equal(t, current.Username, got.Username, "invoking user should match the process user without sudo")
}
func TestInvokingUserFailsClosedWithoutPositiveUID(t *testing.T) {
for _, uid := range []int{0, -1} {
t.Run(fmt.Sprintf("UID%d", uid), func(t *testing.T) {
t.Setenv(envSudoUser, "")
lookupErr := errors.New("current user unavailable")
fakeUnmappedUser(t, uid, 0, lookupErr)
got, err := InvokingUser()
require.ErrorIs(t, err, lookupErr)
assert.Nil(t, got, "root or unavailable UID must not become a synthetic identity")
})
}
}
func TestProfileFilePathUsesNumericIdentityForUnmappedNonRoot(t *testing.T) {
t.Setenv(envSudoUser, "")
fakeUnmappedUser(t, 1001230000, 0, errors.New("user: unknown userid 1001230000"))
profilesRoot := t.TempDir()
origDir := DefaultConfigPathDir
origOverride := ConfigDirOverride
DefaultConfigPathDir = profilesRoot
ConfigDirOverride = ""
t.Cleanup(func() {
DefaultConfigPathDir = origDir
ConfigDirOverride = origOverride
})
profileID := ID("0123456789abcdef0123456789abcdef")
got, err := (&Profile{ID: profileID}).FilePath()
require.NoError(t, err)
assert.Equal(t,
filepath.Join(profilesRoot, "1001230000", profileID.String()+".json"),
got,
"profile path should use the numeric UID namespace",
)
entries, err := os.ReadDir(profilesRoot)
require.NoError(t, err)
require.Len(t, entries, 1, "only the numeric UID directory should be created")
assert.Equal(t, "1001230000", entries[0].Name(), "profile namespace should be numeric")
assert.True(t, entries[0].IsDir(), "profile namespace should be a directory")
}
func TestSudoInvokingUserInactiveWithoutSudoContext(t *testing.T) {
@@ -60,6 +105,13 @@ func TestInvokingUserFailsClosedWhenSudoLookupFails(t *testing.T) {
fakeSudo(t, filepath.Join("/home", "misha"))
lookupUser = func(string) (*user.User, error) { return nil, errors.New("nss unavailable") }
origCurrentUser := currentUser
currentUser = func() (*user.User, error) {
t.Fatal("currentUser must not be called after a sudo lookup failure")
return nil, errors.New("currentUser called unexpectedly")
}
t.Cleanup(func() { currentUser = origCurrentUser })
got, err := InvokingUser()
require.Error(t, err)
assert.Nil(t, got, "must not resolve to the root process user")
@@ -215,6 +267,22 @@ func fakeSudo(t *testing.T, home string) {
})
}
func fakeUnmappedUser(t *testing.T, uid, gid int, lookupErr error) {
t.Helper()
origCurrentUser := currentUser
origEuid := geteuid
origEgid := getegid
currentUser = func() (*user.User, error) { return nil, lookupErr }
geteuid = func() int { return uid }
getegid = func() int { return gid }
t.Cleanup(func() {
currentUser = origCurrentUser
geteuid = origEuid
getegid = origEgid
})
}
func assertNoEntries(t *testing.T, root string) {
t.Helper()
err := filepath.WalkDir(root, func(path string, _ fs.DirEntry, err error) error {
+41 -18
View File
@@ -8,6 +8,7 @@ import (
"net/netip"
"net/url"
"runtime"
"slices"
"sort"
"strings"
"sync"
@@ -472,27 +473,13 @@ func (m *DefaultManager) CurrentRouteRange() []string {
m.mux.Lock()
defer m.mux.Unlock()
if m.disableClientRoutes {
return nil
}
filtered := m.routeSelector.FilterSelectedExitNodes(m.clientRoutes)
var nets []string
for _, routes := range filtered {
for _, r := range routes {
if r.IsDynamic() {
continue
}
nets = append(nets, r.NetString())
}
}
if m.fakeIPManager != nil {
nets = append(nets, m.fakeIPManager.GetFakeIPBlock().String(), m.fakeIPManager.GetFakeIPv6Block().String())
nets := m.overlayNetworks()
if !m.disableClientRoutes {
nets = append(nets, m.clientRouteRange()...)
}
sort.Strings(nets)
return nets
return slices.Compact(nets)
}
// GetRouteSelector returns the route selector
@@ -856,6 +843,42 @@ func (m *DefaultManager) logExitNodeUpdate(info exitNodeInfo, preferred route.Ne
len(info.allIDs), preferred, len(info.userSelected), len(info.userDeselected), len(info.selectedByManagement))
}
// overlayNetworks returns the v4 and v6 overlay networks of the WireGuard interface, each only when it is set.
func (m *DefaultManager) overlayNetworks() []string {
if m.wgInterface == nil {
return nil
}
addr := m.wgInterface.Address()
var nets []string
if addr.Network.IsValid() {
nets = append(nets, addr.Network.String())
}
if addr.IPv6Net.IsValid() {
nets = append(nets, addr.IPv6Net.String())
}
return nets
}
// clientRouteRange returns the static client route networks of the selected exit nodes together with the fake IP blocks.
func (m *DefaultManager) clientRouteRange() []string {
filtered := m.routeSelector.FilterSelectedExitNodes(m.clientRoutes)
var nets []string
for _, routes := range filtered {
for _, r := range routes {
if r.IsDynamic() {
continue
}
nets = append(nets, r.NetString())
}
}
if m.fakeIPManager != nil {
nets = append(nets, m.fakeIPManager.GetFakeIPBlock().String(), m.fakeIPManager.GetFakeIPv6Block().String())
}
return nets
}
// minNetID returns the lexicographically smallest NetID, for a deterministic
// default pick that stays stable across restarts.
func minNetID(ids []route.NetID) route.NetID {
@@ -4,8 +4,6 @@ package notifier
import (
"net/netip"
"slices"
"sort"
"sync"
"github.com/netbirdio/netbird/client/internal/listener"
@@ -75,19 +73,3 @@ func (n *Notifier) notifyLocked() {
func (n *Notifier) Close() {
// unused
}
func routesToStrings(routes []*route.Route) []string {
nets := make([]string, 0, len(routes))
for _, r := range routes {
nets = append(nets, r.NetString())
}
return nets
}
func hasRouteDiff(a []*route.Route, b []*route.Route) bool {
as := routesToStrings(a)
bs := routesToStrings(b)
sort.Strings(as)
sort.Strings(bs)
return !slices.Equal(as, bs)
}
@@ -0,0 +1,27 @@
package notifier
import (
"slices"
"sort"
"github.com/netbirdio/netbird/route"
)
// routePrefixes returns the distinct prefixes a route set covers, sorted.
// Duplicates are dropped deliberately: an HA group hands us one route per
// peer serving the same prefix, and the platform is given the prefix, not the
// candidates. Counting them would report a change every time a peer joins or
// leaves a group, and on Android each report renews the TUN.
func routePrefixes(routes []*route.Route) []string {
nets := make([]string, 0, len(routes))
for _, r := range routes {
nets = append(nets, r.NetString())
}
sort.Strings(nets)
return slices.Compact(nets)
}
// hasRouteDiff reports whether the prefixes the two route sets cover differ.
func hasRouteDiff(a []*route.Route, b []*route.Route) bool {
return !slices.Equal(routePrefixes(a), routePrefixes(b))
}
@@ -0,0 +1,88 @@
package notifier
import (
"net/netip"
"testing"
"github.com/stretchr/testify/assert"
"github.com/netbirdio/netbird/route"
)
func routeFor(id route.ID, prefix string) *route.Route {
return &route.Route{
ID: id,
NetID: "net",
Network: netip.MustParsePrefix(prefix),
}
}
// TestHasRouteDiff_IgnoresHACandidateCount is the reason the comparison
// deduplicates. Every notification renews the TUN, and a renewed TUN
// invalidates the sockets the embedded servers are listening on, so a peer
// joining or leaving an HA group must not count as a route change when the
// prefixes the TUN carries are identical.
func TestHasRouteDiff_IgnoresHACandidateCount(t *testing.T) {
onePeer := []*route.Route{routeFor("a", "10.0.0.0/24")}
twoPeers := []*route.Route{
routeFor("a", "10.0.0.0/24"),
routeFor("b", "10.0.0.0/24"),
}
assert.False(t, hasRouteDiff(onePeer, twoPeers),
"a second peer serving the same prefix is not a route change")
assert.False(t, hasRouteDiff(twoPeers, onePeer),
"losing one of two peers serving the same prefix is not a route change")
}
func TestHasRouteDiff_ReportsRealChanges(t *testing.T) {
tests := []struct {
name string
a []*route.Route
b []*route.Route
want bool
}{
{
name: "added prefix",
a: []*route.Route{routeFor("a", "10.0.0.0/24")},
b: []*route.Route{routeFor("a", "10.0.0.0/24"), routeFor("b", "10.0.1.0/24")},
want: true,
},
{
name: "removed prefix",
a: []*route.Route{routeFor("a", "10.0.0.0/24"), routeFor("b", "10.0.1.0/24")},
b: []*route.Route{routeFor("a", "10.0.0.0/24")},
want: true,
},
{
name: "replaced prefix",
a: []*route.Route{routeFor("a", "10.0.0.0/24")},
b: []*route.Route{routeFor("a", "10.0.1.0/24")},
want: true,
},
{
name: "same prefix, different order",
a: []*route.Route{routeFor("a", "10.0.1.0/24"), routeFor("b", "10.0.0.0/24")},
b: []*route.Route{routeFor("b", "10.0.0.0/24"), routeFor("a", "10.0.1.0/24")},
want: false,
},
{
name: "all routes gone",
a: []*route.Route{routeFor("a", "10.0.0.0/24")},
b: nil,
want: true,
},
{
name: "both empty",
a: nil,
b: nil,
want: false,
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
assert.Equal(t, tc.want, hasRouteDiff(tc.a, tc.b),
"route diff for %s", tc.name)
})
}
}
@@ -17,11 +17,12 @@ import (
"github.com/netbirdio/netbird/client/internal/routemanager/refcounter"
)
// reconcileWGMock is a minimal iface.WGIface that only records AddAllowedIP calls; every other
// method is an inert stub because ReconcilePeerAllowedIPs exercises none of them.
// reconcileWGMock is a minimal iface.WGIface that records AddAllowedIP calls and reports the
// configured address; every other method is an inert stub because the tests exercise none of them.
type reconcileWGMock struct {
mu sync.Mutex
adds map[string][]netip.Prefix
addr wgaddr.Address
}
func (m *reconcileWGMock) AddAllowedIP(peerKey string, allowedIP netip.Prefix) error {
@@ -42,7 +43,7 @@ func (m *reconcileWGMock) added(peerKey string) []netip.Prefix {
func (m *reconcileWGMock) RemoveAllowedIP(string, netip.Prefix) error { return nil }
func (m *reconcileWGMock) Name() string { return "utun-test" }
func (m *reconcileWGMock) Address() wgaddr.Address { return wgaddr.Address{} }
func (m *reconcileWGMock) Address() wgaddr.Address { return m.addr }
func (m *reconcileWGMock) ToInterface() *net.Interface { return nil }
func (m *reconcileWGMock) IsUserspaceBind() bool { return false }
func (m *reconcileWGMock) GetFilter() device.PacketFilter { return nil }
@@ -0,0 +1,95 @@
//go:build !windows
package routemanager
import (
"net/netip"
"testing"
"github.com/stretchr/testify/assert"
"github.com/netbirdio/netbird/client/iface/wgaddr"
"github.com/netbirdio/netbird/client/internal/routeselector"
"github.com/netbirdio/netbird/route"
)
func TestCurrentRouteRange_OverlayNetworkWithClientRoutesDisabled(t *testing.T) {
m := &DefaultManager{
wgInterface: &reconcileWGMock{addr: wgaddr.MustParseWGAddress("100.91.96.107/16")},
disableClientRoutes: true,
}
assert.Equal(t, []string{"100.91.0.0/16"}, m.CurrentRouteRange(), "overlay network must be routed even when client routes are disabled")
}
func TestCurrentRouteRange_OverlayNetworksAndClientRoutes(t *testing.T) {
addr := wgaddr.MustParseWGAddress("100.91.96.107/16")
addr.IPv6 = netip.MustParseAddr("fd00:1234::1")
addr.IPv6Net = netip.MustParsePrefix("fd00:1234::/64")
static := &route.Route{ID: "static", NetID: "lan", Network: netip.MustParsePrefix("192.168.50.0/24"), NetworkType: route.IPv4Network}
dynamic := &route.Route{ID: "dynamic", NetID: "dyn", NetworkType: route.DomainNetwork}
m := &DefaultManager{
wgInterface: &reconcileWGMock{addr: addr},
routeSelector: routeselector.NewRouteSelector(),
clientRoutes: route.HAMap{
static.GetHAUniqueID(): {static},
dynamic.GetHAUniqueID(): {dynamic},
},
}
assert.Equal(t, []string{"100.91.0.0/16", "192.168.50.0/24", "fd00:1234::/64"}, m.CurrentRouteRange(), "overlay networks and static client routes must be listed, dynamic routes skipped")
}
func TestCurrentRouteRange_NoInterfaceAddress(t *testing.T) {
m := &DefaultManager{
wgInterface: &reconcileWGMock{},
disableClientRoutes: true,
}
assert.Empty(t, m.CurrentRouteRange(), "an unset interface address must not produce a route entry")
}
func TestCurrentRouteRange_IPv6WithoutIPv4Network(t *testing.T) {
addr := wgaddr.Address{
IPv6: netip.MustParseAddr("fd00:1234::1"),
IPv6Net: netip.MustParsePrefix("fd00:1234::/64"),
}
m := &DefaultManager{
wgInterface: &reconcileWGMock{addr: addr},
disableClientRoutes: true,
}
assert.Equal(t, []string{"fd00:1234::/64"}, m.CurrentRouteRange(), "a v6 overlay network must not depend on a v4 network being set")
}
func TestCurrentRouteRange_IPv6AddressWithoutNetwork(t *testing.T) {
addr := wgaddr.MustParseWGAddress("100.91.96.107/16")
addr.IPv6 = netip.MustParseAddr("fd00:1234::1")
m := &DefaultManager{
wgInterface: &reconcileWGMock{addr: addr},
disableClientRoutes: true,
}
assert.Equal(t, []string{"100.91.0.0/16"}, m.CurrentRouteRange(), "a v6 address without a network must not produce a route entry")
}
func TestCurrentRouteRange_DeduplicatesPrefixes(t *testing.T) {
// Two HA peers serve the same prefix, and a client route announces the overlay network itself.
haPeerA := &route.Route{ID: "ha-a", NetID: "lan", Peer: "peer-a", Network: netip.MustParsePrefix("192.168.50.0/24"), NetworkType: route.IPv4Network}
haPeerB := &route.Route{ID: "ha-b", NetID: "lan", Peer: "peer-b", Network: netip.MustParsePrefix("192.168.50.0/24"), NetworkType: route.IPv4Network}
overlay := &route.Route{ID: "overlay", NetID: "overlay", Network: netip.MustParsePrefix("100.91.0.0/16"), NetworkType: route.IPv4Network}
m := &DefaultManager{
wgInterface: &reconcileWGMock{addr: wgaddr.MustParseWGAddress("100.91.96.107/16")},
routeSelector: routeselector.NewRouteSelector(),
clientRoutes: route.HAMap{
haPeerA.GetHAUniqueID(): {haPeerA, haPeerB},
overlay.GetHAUniqueID(): {overlay},
},
}
assert.Equal(t, []string{"100.91.0.0/16", "192.168.50.0/24"}, m.CurrentRouteRange(), "every prefix must be listed once regardless of how many routes carry it")
}
+44 -63
View File
@@ -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 {
+53 -50
View File
@@ -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)
}
+66
View File
@@ -0,0 +1,66 @@
//go:build ios
package NetBirdSDK
import (
"github.com/netbirdio/netbird/client/internal/profilemanager"
"github.com/netbirdio/netbird/client/mdm"
)
// PolicyFetcher is implemented by the native layer to return the current
// managed configuration as a JSON-encoded object string; "" means no MDM
// source is present.
type PolicyFetcher interface {
FetchJSON() string
}
type mdmSource struct {
loader *mdm.Loader
detector *mdm.ChangeDetector
}
// SetMDMPolicyFetcher registers the native-provided MDM policy fetcher on
// this Client; passing nil disables MDM enforcement.
func (c *Client) SetMDMPolicyFetcher(p PolicyFetcher) {
loader := loaderFor(p)
c.mdmSource.Store(&mdmSource{loader: loader, detector: mdm.NewChangeDetector(loader)})
}
// HasMDMPolicyChanged re-reads the managed configuration and reports whether
// it changed since the last observation; call it from the native OS-change
// notification and restart the engine only on true.
func (c *Client) HasMDMPolicyChanged() bool {
src := c.mdmSource.Load()
if src == nil {
return false
}
return src.detector.Changed()
}
// GetRestrictionsJSON returns the UI enforcement snapshot derived from the
// active MDM policy, in the JSON shape shared with the desktop frontend.
func (c *Client) GetRestrictionsJSON() (string, error) {
return mdm.BuildRestrictions(c.mdmLoader().Load()).JSON()
}
func (c *Client) applyMDMOverlay(cfg *profilemanager.Config) {
loader := c.mdmLoader()
if cfg == nil || loader == nil {
return
}
cfg.ApplyMDMPolicy(loader.Load())
}
func (c *Client) mdmLoader() *mdm.Loader {
if src := c.mdmSource.Load(); src != nil {
return src.loader
}
return nil
}
func loaderFor(p PolicyFetcher) *mdm.Loader {
if p == nil {
return mdm.NewJSONLoader(nil)
}
return mdm.NewJSONLoader(p.FetchJSON)
}
+66 -7
View File
@@ -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
}
@@ -128,8 +161,34 @@ func (p *Preferences) SetDisableIPv6(disable bool) {
p.configInput.DisableIPv6 = &disable
}
// GetRemoteJobsAllowed reads the remote jobs opt-in from config file
func (p *Preferences) GetRemoteJobsAllowed() (bool, error) {
policy := p.policy()
if !policy.HasKey(mdm.KeyRemoteJobsAllowed) && p.configInput.RemoteJobsAllowed != nil {
return *p.configInput.RemoteJobsAllowed, nil
}
cfg, err := profilemanager.ReadConfig(p.configInput.ConfigPath)
if err != nil {
return false, err
}
cfg.ApplyMDMPolicy(policy)
if cfg.RemoteJobsAllowed == nil {
return false, nil
}
return *cfg.RemoteJobsAllowed, nil
}
// SetRemoteJobsAllowed stores the given value and waits for commit
func (p *Preferences) SetRemoteJobsAllowed(allowed bool) {
p.configInput.RemoteJobsAllowed = &allowed
}
// Commit write out the changes into config file
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)
+12 -13
View File
@@ -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")
}
}
+6
View File
@@ -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) {
+34
View File
@@ -0,0 +1,34 @@
package mdm
import "sync"
// ChangeDetector tracks the last observed policy of a Loader so an
// OS-notification-driven caller can ask whether the managed configuration
// actually changed before restarting anything.
type ChangeDetector struct {
mu sync.Mutex
loader *Loader
prev *Policy
}
// NewChangeDetector constructs a ChangeDetector seeded with the loader's
// current policy, so only a later change reports as changed.
func NewChangeDetector(loader *Loader) *ChangeDetector {
return &ChangeDetector{
loader: loader,
prev: loader.Load(),
}
}
// Changed re-reads the policy, logs the per-key diff, and reports whether it
// diverged from the last observation; the new snapshot becomes the baseline.
func (d *ChangeDetector) Changed() bool {
d.mu.Lock()
defer d.mu.Unlock()
curr := d.loader.Load()
if !policyChanged(d.prev, curr) {
return false
}
d.prev = curr
return true
}
+116
View File
@@ -0,0 +1,116 @@
package mdm
import (
"net/url"
"github.com/netbirdio/netbird/util"
)
// PreSharedKeyRedactedSentinel is the redaction mask returned in place of a
// real pre-shared key; an incoming value equal to it is a round-trip echo,
// never an override.
const PreSharedKeyRedactedSentinel = "**********"
// ConflictCheck is a value-aware comparison between a single requested field
// and the corresponding MDM-enforced value.
type ConflictCheck struct {
Key string
Check func(*Policy) bool
}
// ConflictBool builds a ConflictCheck for a boolean MDM key.
func ConflictBool(key string, p *bool) ConflictCheck {
return ConflictCheck{
Key: key,
Check: func(pol *Policy) bool {
if p == nil {
return true
}
want, ok := pol.GetBool(key)
return ok && want == *p
},
}
}
// ConflictStringPtr builds a ConflictCheck for an optional string MDM key,
// where an explicit empty value is still a request to change the setting. A
// nil p means "field not set" (no override requested).
func ConflictStringPtr(key string, p *string) ConflictCheck {
return ConflictCheck{
Key: key,
Check: func(pol *Policy) bool {
if p == nil {
return true
}
want, ok := pol.GetString(key)
return ok && want == *p
},
}
}
// ConflictURL builds a ConflictCheck for a URL-typed MDM key. The two sides are
// compared as the endpoints they address, not as strings: see
// util.SameServiceURL.
func ConflictURL(key, got string) ConflictCheck {
return ConflictCheck{
Key: key,
Check: func(pol *Policy) bool {
if got == "" {
return true
}
want, ok := pol.GetString(key)
return ok && util.SameServiceURLStrings(want, got)
},
}
}
// ConflictInt64 builds a ConflictCheck for an integer MDM key.
func ConflictInt64(key string, p *int64) ConflictCheck {
return ConflictCheck{
Key: key,
Check: func(pol *Policy) bool {
if p == nil {
return true
}
want, ok := pol.GetInt(key)
return ok && want == *p
},
}
}
// ResolveConflicts returns the names of keys whose requested value diverges
// from the policy-enforced value; keys the policy does not manage are skipped,
// a managed key without a Check counts as a conflict.
func ResolveConflicts(policy *Policy, checks []ConflictCheck) []string {
if policy.IsEmpty() {
return nil
}
var conflicts []string
for _, c := range checks {
if !policy.HasKey(c.Key) {
continue
}
if c.Check == nil || !c.Check(policy) {
conflicts = append(conflicts, c.Key)
}
}
return conflicts
}
// CanonicalURL normalizes a service URL by appending the scheme default port
// when none is present; unparseable input is returned unchanged.
func CanonicalURL(s string) string {
u, err := url.ParseRequestURI(s)
if err != nil {
return s
}
if u.Port() == "" {
switch u.Scheme {
case "https":
u.Host += ":443"
case "http":
u.Host += ":80"
}
}
return u.String()
}
+40
View File
@@ -0,0 +1,40 @@
package mdm
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// The same spellings, through the conflict check that decides whether a request
// is refused. An enforced URL restated in another spelling addresses the very
// server the policy names, so it must not be reported as a conflict.
func TestConflictURLComparesEndpoints(t *testing.T) {
policy := NewPolicy(map[string]any{KeyManagementURL: "https://mgmt.example.com"})
require.True(t, policy.HasKey(KeyManagementURL))
for _, restated := range []string{
"https://mgmt.example.com",
"https://mgmt.example.com:443",
"https://mgmt.example.com/",
"https://MGMT.example.com",
"https://mgmt.example.com:0443",
} {
conflicts := ResolveConflicts(policy, []ConflictCheck{ConflictURL(KeyManagementURL, restated)})
assert.Empty(t, conflicts, "%q is the enforced endpoint written differently", restated)
}
for _, diverging := range []string{
"https://other.example.com",
"http://mgmt.example.com",
"https://mgmt.example.com:8443",
"https://mgmt.example.com/other",
} {
conflicts := ResolveConflicts(policy, []ConflictCheck{ConflictURL(KeyManagementURL, diverging)})
assert.Equal(t, []string{KeyManagementURL}, conflicts, "%q addresses another endpoint", diverging)
}
// An unset field is not a request to change anything.
assert.Empty(t, ResolveConflicts(policy, []ConflictCheck{ConflictURL(KeyManagementURL, "")}))
}
+34
View File
@@ -0,0 +1,34 @@
package mdm
import (
"encoding/json"
log "github.com/sirupsen/logrus"
)
type jsonPolicyFetcher struct {
fetch func() string
}
// NewJSONLoader constructs a Loader whose policy source is a JSON-encoded
// object string, as produced by the mobile native layers; a nil fetch
// disables MDM enforcement.
func NewJSONLoader(fetch func() string) *Loader {
if fetch == nil {
return NewLoader(nil)
}
return NewLoader(&jsonPolicyFetcher{fetch: fetch})
}
func (f *jsonPolicyFetcher) Fetch() map[string]any {
raw := f.fetch()
if raw == "" {
return nil
}
var out map[string]any
if err := json.Unmarshal([]byte(raw), &out); err != nil {
log.Warnf("MDM mobile fetcher: invalid JSON payload from native: %v", err)
return nil
}
return out
}
+38 -6
View File
@@ -119,16 +119,46 @@ func NewPolicy(values map[string]any) *Policy {
return &Policy{values: values}
}
// 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 {
+11 -4
View File
@@ -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)
+10 -9
View File
@@ -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
}
+12 -8
View File
@@ -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
}
+26 -5
View File
@@ -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())
+11 -4
View File
@@ -61,8 +61,9 @@ func readRegistryValue(k registry.Key, name, canonical string, out map[string]an
}
}
// loadPlatformPolicy reads the MDM-managed configuration from the
// 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)
+91
View File
@@ -0,0 +1,91 @@
package mdm
import "encoding/json"
// Fields carries the per-key MDM enforcement state for a UI: value-typed
// fields hold the enforced value (nil pointer = not managed), boolean
// fields report that the key is managed.
type Fields struct {
ManagementURL string `json:"managementURL"`
PreSharedKey bool `json:"preSharedKey"`
WireguardPort bool `json:"wireguardPort"`
RosenpassEnabled bool `json:"rosenpassEnabled"`
RosenpassPermissive bool `json:"rosenpassPermissive"`
DisableClientRoutes bool `json:"disableClientRoutes"`
DisableServerRoutes bool `json:"disableServerRoutes"`
AllowServerSSH *bool `json:"allowServerSSH"`
DisableAutoConnect bool `json:"disableAutoConnect"`
DisableAutostart bool `json:"disableAutostart"`
BlockInbound bool `json:"blockInbound"`
DisableMetricsCollection bool `json:"disableMetricsCollection"`
SplitTunnelMode bool `json:"splitTunnelMode"`
SplitTunnelApps bool `json:"splitTunnelApps"`
RemoteJobsAllowed bool `json:"allowRemoteJobs"`
DisableAdvancedView *bool `json:"disableAdvancedView"`
}
// Features carries the feature gates a UI must honor.
type Features struct {
DisableProfiles bool `json:"disableProfiles"`
DisableNetworks bool `json:"disableNetworks"`
DisableUpdateSettings bool `json:"disableUpdateSettings"`
}
// Restrictions is the UI-facing enforcement snapshot; the JSON shape is
// shared by the desktop frontend and the mobile bridges.
type Restrictions struct {
MDM Fields `json:"mdm"`
Features Features `json:"features"`
}
// BuildRestrictions derives the UI enforcement snapshot from the active
// policy.
func BuildRestrictions(policy *Policy) Restrictions {
var r Restrictions
if policy.IsEmpty() {
return r
}
if v, ok := policy.GetString(KeyManagementURL); ok {
r.MDM.ManagementURL = CanonicalURL(v)
}
r.MDM.PreSharedKey = policy.HasKey(KeyPreSharedKey)
r.MDM.WireguardPort = policy.HasKey(KeyWireguardPort)
r.MDM.RosenpassEnabled = policy.HasKey(KeyRosenpassEnabled)
r.MDM.RosenpassPermissive = policy.HasKey(KeyRosenpassPermissive)
r.MDM.DisableClientRoutes = policy.HasKey(KeyDisableClientRoutes)
r.MDM.DisableServerRoutes = policy.HasKey(KeyDisableServerRoutes)
r.MDM.DisableAutoConnect = policy.HasKey(KeyDisableAutoConnect)
r.MDM.DisableAutostart = policy.HasKey(KeyDisableAutostart)
r.MDM.BlockInbound = policy.HasKey(KeyBlockInbound)
r.MDM.DisableMetricsCollection = policy.HasKey(KeyDisableMetricsCollection)
r.MDM.SplitTunnelMode = policy.HasKey(KeySplitTunnelMode)
r.MDM.SplitTunnelApps = policy.HasKey(KeySplitTunnelApps)
r.MDM.RemoteJobsAllowed = policy.HasKey(KeyRemoteJobsAllowed)
if v, ok := policy.GetBool(KeyAllowServerSSH); ok {
r.MDM.AllowServerSSH = &v
}
if v, ok := policy.GetBool(KeyDisableAdvancedView); ok {
r.MDM.DisableAdvancedView = &v
}
if v, ok := policy.GetBool(KeyDisableProfiles); ok {
r.Features.DisableProfiles = v
}
if v, ok := policy.GetBool(KeyDisableNetworks); ok {
r.Features.DisableNetworks = v
}
if v, ok := policy.GetBool(KeyDisableUpdateSettings); ok {
r.Features.DisableUpdateSettings = v
}
return r
}
// JSON renders the snapshot in the shared UI JSON shape.
func (r Restrictions) JSON() (string, error) {
b, err := json.Marshal(r)
if err != nil {
return "", err
}
return string(b), nil
}
+26 -20
View File
@@ -15,33 +15,33 @@ import (
// instead, hence anticipating the ticker mechanism entirely.
const DefaultReloadInterval = 1 * time.Minute
// policyLoader is the indirection through which the ticker reads the
// OS-native policy, both for the initial observation and on every tick.
// Production points it at LoadPolicy; tests in this package override it to
// feed a scripted sequence of policies without touching the real OS store.
var policyLoader = LoadPolicy
// Ticker periodically re-reads the OS-native MDM policy via LoadPolicy and
// invokes the onChange callback (supplied to Run) whenever the observed
// Policy diverges from the last observation (added / removed / changed
// keys). Launch with Run from a goroutine; cancel the supplied context
// to stop.
// Ticker periodically re-reads the OS-native MDM policy via the
// injected Loader and invokes the onChange callback (supplied to Run)
// whenever the observed Policy diverges from the last observation
// (added / removed / changed keys). Launch with Run from a goroutine;
// cancel the supplied context to stop.
type Ticker struct {
interval time.Duration
loader *Loader
prev *Policy
}
// NewTicker constructs a Ticker that will re-read the OS-native policy
// every reloadInterval once Run is called.
// The initial snapshot is populated by calling policyLoader at
// every reloadInterval once Run is called. The Loader is injected so
// the ticker doesn't depend on any package-level state — production
// passes the daemon-owned Loader, tests pass a fake Loader (built with
// a fake PolicyFetcher).
//
// The initial snapshot is populated by calling loader.Load() at
// construction time so the first tick only fires
// onChange when the policy actually changed since boot — without
// this baseline the first tick would report every currently-managed
// key as "added" and trigger a spurious engine restart.
func NewTicker(reloadInterval time.Duration) *Ticker {
func NewTicker(reloadInterval time.Duration, loader *Loader) *Ticker {
return &Ticker{
interval: reloadInterval,
prev: policyLoader(),
loader: loader,
prev: loader.Load(),
}
}
@@ -58,13 +58,10 @@ func (t *Ticker) Run(ctx context.Context, onChange func(prev, curr *Policy) erro
log.Info("MDM policy reload ticker stopped")
return
case <-tk.C:
curr := policyLoader()
if policiesEqual(t.prev, curr) {
curr := t.loader.Load()
if !policyChanged(t.prev, curr) {
continue
}
added, removed, changed := diffPolicies(t.prev, curr)
log.Infof("MDM policy changed: added=%v removed=%v changed=%v",
added, removed, changed)
prev := t.prev
if err := onChange(prev, curr); err != nil {
log.Errorf("MDM policy change handler failed (retrying in 1 minute): %v", err)
@@ -127,3 +124,12 @@ func mapOf(p *Policy) map[string]any {
}
return out
}
func policyChanged(prev, curr *Policy) bool {
if policiesEqual(prev, curr) {
return false
}
added, removed, changed := diffPolicies(prev, curr)
log.Infof("MDM policy changed: added=%v removed=%v changed=%v", added, removed, changed)
return true
}
+38 -29
View File
@@ -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")
+42
View File
@@ -4,6 +4,7 @@
package mobile
import (
"errors"
"fmt"
"os"
"path/filepath"
@@ -11,6 +12,7 @@ import (
log "github.com/sirupsen/logrus"
"github.com/netbirdio/netbird/client/internal/profilemanager"
"github.com/netbirdio/netbird/client/mdm"
)
const (
@@ -22,6 +24,9 @@ const (
profilesSubdir = "profiles"
)
// ErrProfilesDisabled marks a profile mutation rejected by MDM policy.
var ErrProfilesDisabled = errors.New("profile management is disabled by MDM policy")
/*
<configDir>/ ← app-writable config root
@@ -55,6 +60,7 @@ type ProfileManager struct {
configDir string
username string
serviceMgr *profilemanager.ServiceManager
mdmLoader *mdm.Loader
}
// NewProfileManager creates a profile manager rooted at configDir, the
@@ -127,6 +133,9 @@ func (pm *ProfileManager) GetActiveProfile() (*Profile, error) {
// SwitchProfile records the given profile ID as the active profile. The caller
// must stop the VPN tunnel before switching.
func (pm *ProfileManager) SwitchProfile(id string) error {
if err := pm.checkProfilesAllowed(); err != nil {
return err
}
if err := pm.serviceMgr.SetActiveProfileState(&profilemanager.ActiveProfileState{
ID: profilemanager.ID(id),
Username: pm.username,
@@ -141,6 +150,9 @@ func (pm *ProfileManager) SwitchProfile(id string) error {
// AddProfile creates a new profile with the given display name and a
// generated ID. It returns the created profile so the caller learns the ID.
func (pm *ProfileManager) AddProfile(displayName string) (*Profile, error) {
if err := pm.checkProfilesAllowed(); err != nil {
return nil, err
}
profile, err := pm.serviceMgr.AddProfile(displayName, pm.username)
if err != nil {
return nil, fmt.Errorf("add profile: %w", err)
@@ -153,6 +165,9 @@ func (pm *ProfileManager) AddProfile(displayName string) (*Profile, error) {
// RenameProfile changes the display name of the profile identified by id. The
// on-disk filename (the ID) is left unchanged.
func (pm *ProfileManager) RenameProfile(id string, newName string) error {
if err := pm.checkProfilesAllowed(); err != nil {
return err
}
if err := pm.serviceMgr.RenameProfile(profilemanager.ID(id), pm.username, newName); err != nil {
return fmt.Errorf("rename profile: %w", err)
}
@@ -165,6 +180,9 @@ func (pm *ProfileManager) RenameProfile(id string, newName string) error {
// private key and SSH key from the config, forcing a re-login. The management
// URL and other settings are preserved.
func (pm *ProfileManager) LogoutProfile(id string) error {
if err := pm.checkProfileLogoutAllowed(id); err != nil {
return err
}
configPath, err := pm.getProfileConfigPath(id)
if err != nil {
return err
@@ -196,6 +214,9 @@ func (pm *ProfileManager) LogoutProfile(id string) error {
// RemoveProfile deletes a profile. The default profile and the active profile
// cannot be removed.
func (pm *ProfileManager) RemoveProfile(id string) error {
if err := pm.checkProfilesAllowed(); err != nil {
return err
}
configPath, err := pm.getProfileConfigPath(id)
if err != nil {
return err
@@ -267,6 +288,27 @@ func (pm *ProfileManager) GetActiveStateFilePath() (string, error) {
return pm.GetStateFilePath(activeProfile.ID)
}
// SetMDMLoader registers the MDM policy source consulted before profile
// mutations; a nil loader disables enforcement.
func (pm *ProfileManager) SetMDMLoader(loader *mdm.Loader) {
pm.mdmLoader = loader
}
func (pm *ProfileManager) checkProfilesAllowed() error {
if v, ok := pm.mdmLoader.Load().GetBool(mdm.KeyDisableProfiles); ok && v {
return ErrProfilesDisabled
}
return nil
}
func (pm *ProfileManager) checkProfileLogoutAllowed(id string) error {
active, err := pm.serviceMgr.GetActiveProfileState()
if err == nil && active.ID.String() == id {
return nil
}
return pm.checkProfilesAllowed()
}
// profileEmail returns the account email recorded for a profile. Display-only,
// so an unresolvable path degrades to "" rather than an error.
func (pm *ProfileManager) profileEmail(id string) string {
+83
View File
@@ -0,0 +1,83 @@
package mobile
import (
"encoding/json"
"os"
"path/filepath"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/netbirdio/netbird/client/internal/profilemanager"
"github.com/netbirdio/netbird/client/mdm"
)
type fakeFetcher struct{ values map[string]any }
func (f *fakeFetcher) Fetch() map[string]any { return f.values }
func newTestProfileManager(t *testing.T) *ProfileManager {
t.Helper()
origDir := profilemanager.DefaultConfigPathDir
origPath := profilemanager.DefaultConfigPath
origActive := profilemanager.ActiveProfileStatePath
t.Cleanup(func() {
profilemanager.DefaultConfigPathDir = origDir
profilemanager.DefaultConfigPath = origPath
profilemanager.ActiveProfileStatePath = origActive
})
configDir := t.TempDir()
pm := NewProfileManager(configDir, "mobile")
_, err := profilemanager.UpdateOrCreateConfig(profilemanager.ConfigInput{
ConfigPath: filepath.Join(configDir, defaultConfigFilename),
})
require.NoError(t, err)
return pm
}
func privateKeyOf(t *testing.T, pm *ProfileManager, id string) string {
t.Helper()
path, err := pm.getProfileConfigPath(id)
require.NoError(t, err)
raw, err := os.ReadFile(path)
require.NoError(t, err)
var cfg struct{ PrivateKey string }
require.NoError(t, json.Unmarshal(raw, &cfg))
return cfg.PrivateKey
}
func TestLogoutProfile_DisableProfiles(t *testing.T) {
pm := newTestProfileManager(t)
other, err := pm.AddProfile("work")
require.NoError(t, err)
require.NoError(t, pm.SwitchProfile(profilemanager.DefaultProfileName))
require.NotEmpty(t, privateKeyOf(t, pm, profilemanager.DefaultProfileName))
require.NotEmpty(t, privateKeyOf(t, pm, other.ID))
pm.SetMDMLoader(mdm.NewLoader(&fakeFetcher{values: map[string]any{
mdm.KeyDisableProfiles: true,
}}))
err = pm.LogoutProfile(other.ID)
assert.ErrorIs(t, err, ErrProfilesDisabled)
assert.NotEmpty(t, privateKeyOf(t, pm, other.ID))
require.NoError(t, pm.LogoutProfile(profilemanager.DefaultProfileName))
assert.Empty(t, privateKeyOf(t, pm, profilemanager.DefaultProfileName))
}
func TestLogoutProfile_ProfilesAllowed(t *testing.T) {
pm := newTestProfileManager(t)
other, err := pm.AddProfile("work")
require.NoError(t, err)
require.NoError(t, pm.SwitchProfile(profilemanager.DefaultProfileName))
pm.SetMDMLoader(mdm.NewLoader(&fakeFetcher{values: map[string]any{
mdm.KeyDisableProfiles: false,
}}))
require.NoError(t, pm.LogoutProfile(other.ID))
assert.Empty(t, privateKeyOf(t, pm, other.ID))
}
+1 -1
View File
@@ -1,4 +1,4 @@
#!/bin/bash
#!/usr/bin/env bash
set -e
if ! which realpath >/dev/null 2>&1; then
+36 -187
View File
@@ -3,7 +3,6 @@ package server
import (
"context"
"fmt"
"net/url"
"time"
log "github.com/sirupsen/logrus"
@@ -14,28 +13,6 @@ import (
"github.com/netbirdio/netbird/client/proto"
)
// preSharedKeyRedactedSentinel is the value GetConfig returns in place
// of an actual PSK, so a UI that round-trips the field back to the
// daemon (via SetConfig / Login) can be distinguished from a deliberate
// override. Any incoming PSK that equals this sentinel is treated as
// a no-op echo, never as a conflict with the policy.
const preSharedKeyRedactedSentinel = "**********"
// loadMDMPolicy is the indirection used by server handlers to read the
// active MDM policy. Tests override this to inject a fake policy.
var loadMDMPolicy = mdm.LoadPolicy
// conflictCheck is a value-aware comparison between a single field in
// the incoming request and the corresponding MDM-enforced value. It
// runs only when the field was actually set in the request (presence
// already filtered upstream); ok=true reports the policy value, ok=false
// means the policy is silent on the key — both are treated as conflicts
// to be safe (an MDM key declared as managed must hold a value).
type conflictCheck struct {
key string
check func(*mdm.Policy) (match bool)
}
// onMDMPolicyChange is invoked by the MDM reload ticker every time the
// OS-native managed-config store reports a diff vs the last observation.
//
@@ -168,126 +145,6 @@ func (s *Server) restartEngineForMDMLocked() error {
return nil
}
// conflictBool builds a conflictCheck for a boolean MDM key. If p is nil
// the field is treated as matching (no override requested); otherwise the
// check returns true only when the policy contains the key and its
// boolean value equals *p.
func conflictBool(key string, p *bool) conflictCheck {
return conflictCheck{
key: key,
check: func(pol *mdm.Policy) bool {
if p == nil {
return true // absent → match by definition
}
want, ok := pol.GetBool(key)
return ok && want == *p
},
}
}
func canonicalURL(s string) string {
u, err := url.ParseRequestURI(s)
if err != nil {
return s
}
if u.Port() == "" {
switch u.Scheme {
case "https":
u.Host += ":443"
case "http":
u.Host += ":80"
}
}
return u.String()
}
// conflictURL is conflictString for URL-typed keys: both sides are
// normalized via canonicalURL before comparison.
func conflictURL(key, got string) conflictCheck {
return conflictCheck{
key: key,
check: func(pol *mdm.Policy) bool {
if got == "" {
return true
}
want, ok := pol.GetString(key)
return ok && canonicalURL(want) == canonicalURL(got)
},
}
}
// conflictString builds a conflictCheck for a string MDM key. An empty
// `got` is treated as "field not set" (no override requested); otherwise
// the check returns true only when the policy contains the key and its
// value equals got.
func conflictString(key, got string) conflictCheck {
return conflictCheck{
key: key,
check: func(pol *mdm.Policy) bool {
if got == "" {
return true
}
want, ok := pol.GetString(key)
return ok && want == got
},
}
}
// conflictStringPtr is conflictString for optional proto fields, where an
// explicit empty value is still a request to change the setting. If p is
// nil the field is treated as matching (no override requested); otherwise
// the check returns true only when the policy contains the key and its
// value equals *p.
func conflictStringPtr(key string, p *string) conflictCheck {
return conflictCheck{
key: key,
check: func(pol *mdm.Policy) bool {
if p == nil {
return true
}
want, ok := pol.GetString(key)
return ok && want == *p
},
}
}
// conflictInt64 builds a conflictCheck for an integer MDM key. If p is
// nil the field is treated as matching; otherwise the check returns
// true only when the policy contains the key and its int value equals *p.
func conflictInt64(key string, p *int64) conflictCheck {
return conflictCheck{
key: key,
check: func(pol *mdm.Policy) bool {
if p == nil {
return true
}
want, ok := pol.GetInt(key)
return ok && want == *p
},
}
}
// resolveConflicts walks the per-field checks against the active MDM
// policy and returns the names of keys whose requested value diverges
// from the policy-enforced value. Keys not present in the policy are
// skipped silently (the gate fires only for keys the admin has
// actually pushed). Returns nil for an empty policy.
func resolveConflicts(policy *mdm.Policy, checks []conflictCheck) []string {
if policy.IsEmpty() {
return nil
}
var conflicts []string
for _, c := range checks {
if !policy.HasKey(c.key) {
continue
}
if !c.check(policy) {
conflicts = append(conflicts, c.key)
}
}
return conflicts
}
// mdmManagedFieldConflicts returns the names of MDM-managed keys whose
// requested value in the SetConfigRequest differs from the MDM-enforced
// value. A field set to the same value the policy already enforces is
@@ -301,27 +158,25 @@ func mdmManagedFieldConflicts(msg *proto.SetConfigRequest, policy *mdm.Policy) [
return nil
}
// PSK round-trip echo: collapse the sentinel to empty so the
// shared check treats it as "field not set".
pskGot := ""
if msg.OptionalPreSharedKey != nil && *msg.OptionalPreSharedKey != preSharedKeyRedactedSentinel {
pskGot = *msg.OptionalPreSharedKey
pskGot := msg.OptionalPreSharedKey
if pskGot != nil && *pskGot == mdm.PreSharedKeyRedactedSentinel {
pskGot = nil
}
return resolveConflicts(policy, []conflictCheck{
conflictURL(mdm.KeyManagementURL, msg.ManagementUrl),
conflictString(mdm.KeyPreSharedKey, pskGot),
conflictBool(mdm.KeyRosenpassEnabled, msg.RosenpassEnabled),
conflictBool(mdm.KeyRosenpassPermissive, msg.RosenpassPermissive),
conflictBool(mdm.KeyDisableAutoConnect, msg.DisableAutoConnect),
conflictBool(mdm.KeyAllowServerSSH, msg.ServerSSHAllowed),
conflictBool(mdm.KeyRemoteJobsAllowed, msg.RemoteJobsAllowed),
conflictBool(mdm.KeyDisableClientRoutes, msg.DisableClientRoutes),
conflictBool(mdm.KeyDisableServerRoutes, msg.DisableServerRoutes),
conflictBool(mdm.KeyBlockInbound, msg.BlockInbound),
conflictInt64(mdm.KeyWireguardPort, msg.WireguardPort),
conflictBool(mdm.KeyEnableLocalMetrics, msg.EnableLocalMetrics),
conflictStringPtr(mdm.KeyLocalMetricsAddress, msg.LocalMetricsAddress),
return mdm.ResolveConflicts(policy, []mdm.ConflictCheck{
mdm.ConflictURL(mdm.KeyManagementURL, msg.ManagementUrl),
mdm.ConflictStringPtr(mdm.KeyPreSharedKey, pskGot),
mdm.ConflictBool(mdm.KeyRosenpassEnabled, msg.RosenpassEnabled),
mdm.ConflictBool(mdm.KeyRosenpassPermissive, msg.RosenpassPermissive),
mdm.ConflictBool(mdm.KeyDisableAutoConnect, msg.DisableAutoConnect),
mdm.ConflictBool(mdm.KeyAllowServerSSH, msg.ServerSSHAllowed),
mdm.ConflictBool(mdm.KeyRemoteJobsAllowed, msg.RemoteJobsAllowed),
mdm.ConflictBool(mdm.KeyDisableClientRoutes, msg.DisableClientRoutes),
mdm.ConflictBool(mdm.KeyDisableServerRoutes, msg.DisableServerRoutes),
mdm.ConflictBool(mdm.KeyBlockInbound, msg.BlockInbound),
mdm.ConflictInt64(mdm.KeyWireguardPort, msg.WireguardPort),
mdm.ConflictBool(mdm.KeyEnableLocalMetrics, msg.EnableLocalMetrics),
mdm.ConflictStringPtr(mdm.KeyLocalMetricsAddress, msg.LocalMetricsAddress),
})
}
@@ -424,34 +279,28 @@ func loginRequestMDMConflicts(msg *proto.LoginRequest, policy *mdm.Policy) []str
return nil
}
// Collapse the two PSK fields + the redaction sentinel down to a
// single "got" string the shared check can compare against the
// policy: OptionalPreSharedKey wins if set; PreSharedKey (deprecated)
// is the fallback; sentinel echo is treated as "field not set".
pskGot := ""
if msg.OptionalPreSharedKey != nil {
pskGot = *msg.OptionalPreSharedKey
} else if msg.PreSharedKey != "" { //nolint:staticcheck // SA1019: legacy proto field still accepted by Login
pskGot = msg.PreSharedKey //nolint:staticcheck // SA1019
pskGot := msg.OptionalPreSharedKey
if pskGot == nil && msg.PreSharedKey != "" { //nolint:staticcheck // SA1019: legacy proto field still accepted by Login
pskGot = &msg.PreSharedKey //nolint:staticcheck // SA1019
}
if pskGot == preSharedKeyRedactedSentinel {
pskGot = ""
if pskGot != nil && *pskGot == mdm.PreSharedKeyRedactedSentinel {
pskGot = nil
}
return resolveConflicts(policy, []conflictCheck{
conflictURL(mdm.KeyManagementURL, msg.ManagementUrl),
conflictString(mdm.KeyPreSharedKey, pskGot),
conflictBool(mdm.KeyRosenpassEnabled, msg.RosenpassEnabled),
conflictBool(mdm.KeyRosenpassPermissive, msg.RosenpassPermissive),
conflictBool(mdm.KeyDisableAutoConnect, msg.DisableAutoConnect),
conflictBool(mdm.KeyAllowServerSSH, msg.ServerSSHAllowed),
conflictBool(mdm.KeyRemoteJobsAllowed, msg.RemoteJobsAllowed),
conflictBool(mdm.KeyDisableClientRoutes, msg.DisableClientRoutes),
conflictBool(mdm.KeyDisableServerRoutes, msg.DisableServerRoutes),
conflictBool(mdm.KeyBlockInbound, msg.BlockInbound),
conflictInt64(mdm.KeyWireguardPort, msg.WireguardPort),
conflictBool(mdm.KeyEnableLocalMetrics, msg.EnableLocalMetrics),
conflictStringPtr(mdm.KeyLocalMetricsAddress, msg.LocalMetricsAddress),
return mdm.ResolveConflicts(policy, []mdm.ConflictCheck{
mdm.ConflictURL(mdm.KeyManagementURL, msg.ManagementUrl),
mdm.ConflictStringPtr(mdm.KeyPreSharedKey, pskGot),
mdm.ConflictBool(mdm.KeyRosenpassEnabled, msg.RosenpassEnabled),
mdm.ConflictBool(mdm.KeyRosenpassPermissive, msg.RosenpassPermissive),
mdm.ConflictBool(mdm.KeyDisableAutoConnect, msg.DisableAutoConnect),
mdm.ConflictBool(mdm.KeyAllowServerSSH, msg.ServerSSHAllowed),
mdm.ConflictBool(mdm.KeyRemoteJobsAllowed, msg.RemoteJobsAllowed),
mdm.ConflictBool(mdm.KeyDisableClientRoutes, msg.DisableClientRoutes),
mdm.ConflictBool(mdm.KeyDisableServerRoutes, msg.DisableServerRoutes),
mdm.ConflictBool(mdm.KeyBlockInbound, msg.BlockInbound),
mdm.ConflictInt64(mdm.KeyWireguardPort, msg.WireguardPort),
mdm.ConflictBool(mdm.KeyEnableLocalMetrics, msg.EnableLocalMetrics),
mdm.ConflictStringPtr(mdm.KeyLocalMetricsAddress, msg.LocalMetricsAddress),
})
}
+32 -3
View File
@@ -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
+112 -30
View File
@@ -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,
+19
View File
@@ -3,6 +3,7 @@ package auth
import (
"errors"
"fmt"
"slices"
"sync"
log "github.com/sirupsen/logrus"
@@ -155,6 +156,24 @@ func (a *Authorizer) GetUserIDClaim() string {
return a.userIDClaim
}
// Config returns the authorization currently in force. The user list and the
// machine-user map are copies; the originals stay in use here.
func (a *Authorizer) Config() *Config {
a.mu.RLock()
defer a.mu.RUnlock()
machineUsers := make(map[string][]uint32, len(a.machineUsers))
for osUser, indexes := range a.machineUsers {
machineUsers[osUser] = slices.Clone(indexes)
}
return &Config{
UserIDClaim: a.userIDClaim,
AuthorizedUsers: slices.Clone(a.authorizedUsers),
MachineUsers: machineUsers,
}
}
// findUserIndex finds the index of a hashed user ID in the authorized users list
// Returns the index and true if found, 0 and false if not found
func (a *Authorizer) findUserIndex(hashedUserID sshuserhash.UserIDHash) (int, bool) {
+18 -15
View File
@@ -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)
}
+90
View File
@@ -0,0 +1,90 @@
package ssh
import (
"context"
"errors"
"net"
"testing"
"time"
"github.com/stretchr/testify/require"
"golang.org/x/crypto/ssh"
)
func TestHandshake_ContextDeadlineWrapped(t *testing.T) {
conn := dialSilentServer(t)
ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond)
defer cancel()
_, err := Handshake(ctx, conn, conn.RemoteAddr().String(), testClientConfig())
require.Error(t, err)
require.True(t, errors.Is(err, context.DeadlineExceeded), "expected context.DeadlineExceeded, got: %v", err)
}
func TestHandshake_ContextCancelUnblocks(t *testing.T) {
conn := dialSilentServer(t)
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
time.AfterFunc(50*time.Millisecond, cancel)
errCh := make(chan error, 1)
go func() {
_, err := Handshake(ctx, conn, conn.RemoteAddr().String(), testClientConfig())
errCh <- err
}()
select {
case err := <-errCh:
require.Error(t, err)
require.True(t, errors.Is(err, context.Canceled), "expected context.Canceled, got: %v", err)
case <-time.After(5 * time.Second):
t.Fatal("handshake did not return after context cancellation")
}
}
func TestHandshake_NonContextErrorNotWrapped(t *testing.T) {
conn := dialSilentServer(t)
require.NoError(t, conn.Close())
_, err := Handshake(context.Background(), conn, conn.RemoteAddr().String(), testClientConfig())
require.Error(t, err)
require.False(t, errors.Is(err, context.Canceled))
require.False(t, errors.Is(err, context.DeadlineExceeded))
}
func testClientConfig() *ssh.ClientConfig {
return &ssh.ClientConfig{
User: "test",
HostKeyCallback: ssh.InsecureIgnoreHostKey(),
}
}
// dialSilentServer returns a client conn to a server that accepts and never
// sends anything, so the SSH handshake blocks until the context is done.
func dialSilentServer(t *testing.T) net.Conn {
t.Helper()
listener, err := net.Listen("tcp", "127.0.0.1:0")
require.NoError(t, err)
t.Cleanup(func() { _ = listener.Close() })
done := make(chan struct{})
t.Cleanup(func() { close(done) })
go func() {
c, err := listener.Accept()
if err != nil {
return
}
defer func() { _ = c.Close() }()
<-done
}()
conn, err := net.Dial("tcp", listener.Addr().String())
require.NoError(t, err)
t.Cleanup(func() { _ = conn.Close() })
return conn
}
+32 -1
View File
@@ -197,6 +197,12 @@ type Config struct {
// HostKey is the SSH server host key in PEM format
HostKeyPEM []byte
// Auth is the fine-grained authorization to open with. Nil starts with an
// empty authorizer, which authorizes nobody until UpdateSSHAuth is called.
// Setting it here rather than afterwards means the server never accepts a
// login before it knows who is allowed.
Auth *sshauth.Config
}
// SessionInfo contains information about an active SSH session
@@ -220,7 +226,11 @@ func New(config *Config) *Server {
connections: make(map[connKey]*connState),
jwtEnabled: config.JWT != nil,
jwtConfig: config.JWT,
authorizer: sshauth.NewAuthorizer(), // Initialize with empty config
authorizer: sshauth.NewAuthorizer(),
}
if config.Auth != nil {
s.authorizer.Update(config.Auth)
}
return s
@@ -461,6 +471,27 @@ func (s *Server) UpdateSSHAuth(config *sshauth.Config) {
s.authorizer.Update(config)
}
// JWTConfig returns the JWT authentication this server was built with, or nil
// when JWT authentication is disabled.
func (s *Server) JWTConfig() *JWTConfig {
s.mu.RLock()
defer s.mu.RUnlock()
return s.jwtConfig
}
// AuthConfig returns the fine-grained authorization currently in force, or nil
// when the server has no authorizer.
func (s *Server) AuthConfig() *sshauth.Config {
s.mu.RLock()
authorizer := s.authorizer
s.mu.RUnlock()
if authorizer == nil {
return nil
}
return authorizer.Config()
}
// ensureJWTValidator initializes the JWT validator and extractor if not already initialized
func (s *Server) ensureJWTValidator() error {
s.mu.RLock()
+38
View File
@@ -0,0 +1,38 @@
package system
import (
"context"
"net/netip"
"slices"
"sync/atomic"
"time"
"github.com/netbirdio/netbird/shared/management/proto"
)
// InfoSource gathers the system info sent to management, keeping the posture
// check results from the last Refresh for the cheap Current snapshots.
type InfoSource struct {
files atomic.Pointer[[]File]
}
// Refresh gathers the info with the posture checks evaluated, bounded by timeout.
func (s *InfoSource) Refresh(ctx context.Context, timeout time.Duration, checks []*proto.Checks, excludeIPs ...netip.Addr) (*Info, bool) {
info, ok := GetInfoWithChecksTimeout(ctx, timeout, checks, excludeIPs...)
if !ok {
return nil, false
}
files := slices.Clone(info.Files)
s.files.Store(&files)
return info, true
}
// Current gathers the info without evaluating the checks, reusing the last Refresh results.
func (s *InfoSource) Current(ctx context.Context, excludeIPs ...netip.Addr) *Info {
info := GetInfo(ctx)
info.removeAddresses(excludeIPs...)
if files := s.files.Load(); files != nil {
info.Files = *files
}
return info
}
+59
View File
@@ -0,0 +1,59 @@
package system
import (
"context"
"os"
"path/filepath"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/netbirdio/netbird/shared/management/proto"
)
func TestInfoSource_CurrentBeforeRefresh(t *testing.T) {
var src InfoSource
info := src.Current(context.Background())
assert.Empty(t, info.Files)
}
func TestInfoSource_CurrentReusesRefreshedFiles(t *testing.T) {
path := filepath.Join(t.TempDir(), "agent")
require.NoError(t, os.WriteFile(path, nil, 0o600))
checks := []*proto.Checks{{Files: []string{path}}}
var src InfoSource
refreshed, ok := src.Refresh(context.Background(), 15*time.Second, checks)
require.True(t, ok)
require.Equal(t, []File{{Path: path, Exist: true}}, refreshed.Files)
info := src.Current(context.Background())
assert.Equal(t, refreshed.Files, info.Files)
}
func TestInfoSource_CurrentExcludesAddresses(t *testing.T) {
addrs := GetInfo(context.Background()).NetworkAddresses
if len(addrs) == 0 {
t.Skip("no network addresses on this host")
}
excluded := addrs[0].NetIP.Addr()
matching := 0
for _, addr := range addrs {
if addr.NetIP.Addr() == excluded {
matching++
}
}
var src InfoSource
info := src.Current(context.Background(), excluded)
assert.Len(t, info.NetworkAddresses, len(addrs)-matching)
for _, addr := range info.NetworkAddresses {
assert.NotEqual(t, excluded, addr.NetIP.Addr())
}
}
+1 -1
View File
@@ -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 {

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