diff --git a/.github/dependabot.yml b/.github/dependabot.yml
new file mode 100644
index 000000000..b78b1417a
--- /dev/null
+++ b/.github/dependabot.yml
@@ -0,0 +1,45 @@
+version: 2
+updates:
+ - package-ecosystem: "github-actions"
+ directory: "/"
+ schedule:
+ interval: "daily"
+ open-pull-requests-limit: 15
+ groups:
+ actions:
+ patterns:
+ - "*"
+ ignore:
+ # git-town/action v1.3.x crashes on cyclic PR graphs (self-loop main->main
+ # fork PRs) via its topological-sort visualization. Pinned to v1.2.1 in
+ # git-town.yml; block v1.3.x until upstream tolerates cyclic edges.
+ - dependency-name: "git-town/action"
+ update-types:
+ - "version-update:semver-minor"
+ - "version-update:semver-major"
+
+ - package-ecosystem: "gomod"
+ directories:
+ - "/"
+ schedule:
+ interval: "daily"
+ open-pull-requests-limit: 15
+ groups:
+ aws-sdk:
+ patterns:
+ - "github.com/aws/aws-sdk-go-v2/*"
+ pion:
+ patterns:
+ - "github.com/pion/*"
+ gorm:
+ patterns:
+ - "gorm.io/*"
+ otel:
+ patterns:
+ - "go.opentelemetry.io/*"
+ testcontainers:
+ patterns:
+ - "github.com/testcontainers/testcontainers-go/*"
+ wireguard:
+ patterns:
+ - "golang.zx2c4.com/wireguard*"
diff --git a/.github/workflows/check-license-dependencies.yml b/.github/workflows/check-license-dependencies.yml
index a721cb516..8acd645e2 100644
--- a/.github/workflows/check-license-dependencies.yml
+++ b/.github/workflows/check-license-dependencies.yml
@@ -2,16 +2,16 @@ name: Check License Dependencies
on:
push:
- branches: [ main ]
+ branches: [main]
paths:
- - 'go.mod'
- - 'go.sum'
- - '.github/workflows/check-license-dependencies.yml'
+ - "go.mod"
+ - "go.sum"
+ - ".github/workflows/check-license-dependencies.yml"
pull_request:
paths:
- - 'go.mod'
- - 'go.sum'
- - '.github/workflows/check-license-dependencies.yml'
+ - "go.mod"
+ - "go.sum"
+ - ".github/workflows/check-license-dependencies.yml"
jobs:
check-internal-dependencies:
@@ -19,7 +19,10 @@ jobs:
runs-on: ubuntu-latest
steps:
- - uses: actions/checkout@v4
+ - name: Checkout code
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ with:
+ persist-credentials: false
- name: Check for problematic license dependencies
run: |
@@ -56,55 +59,57 @@ jobs:
runs-on: ubuntu-latest
steps:
- - uses: actions/checkout@v4
+ - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ with:
+ persist-credentials: false
- - name: Set up Go
- uses: actions/setup-go@v5
- with:
- go-version-file: 'go.mod'
- cache: true
+ - name: Set up Go
+ uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0
+ with:
+ go-version-file: "go.mod"
+ cache: true
- - name: Install go-licenses
- run: go install github.com/google/go-licenses@v1.6.0
+ - name: Install go-licenses
+ run: go install github.com/google/go-licenses@v1.6.0
- - name: Check for GPL/AGPL licensed dependencies
- run: |
- echo "Checking for GPL/AGPL/LGPL licensed dependencies..."
- echo ""
-
- # Check all Go packages for copyleft licenses, excluding internal netbird packages
- COPYLEFT_DEPS=$(go-licenses report ./... 2>/dev/null | grep -E 'GPL|AGPL|LGPL' | grep -v 'github.com/netbirdio/netbird/' || true)
-
- if [ -n "$COPYLEFT_DEPS" ]; then
- echo "Found copyleft licensed dependencies:"
- echo "$COPYLEFT_DEPS"
+ - name: Check for GPL/AGPL licensed dependencies
+ run: |
+ echo "Checking for GPL/AGPL/LGPL licensed dependencies..."
echo ""
- # Filter out dependencies that are only pulled in by internal AGPL packages
- INCOMPATIBLE=""
- while IFS=',' read -r package url license; do
- if echo "$license" | grep -qE 'GPL-[0-9]|AGPL-[0-9]|LGPL-[0-9]'; then
- # Find ALL packages that import this GPL package using go list
- IMPORTERS=$(go list -json -deps ./... 2>/dev/null | jq -r "select(.Imports[]? == \"$package\") | .ImportPath")
+ # Check all Go packages for copyleft licenses, excluding internal netbird packages
+ COPYLEFT_DEPS=$(go-licenses report ./... 2>/dev/null | grep -E 'GPL|AGPL|LGPL' | grep -v 'github.com/netbirdio/netbird/' || true)
- # Check if any importer is NOT in management/signal/relay
- BSD_IMPORTER=$(echo "$IMPORTERS" | grep -v "github.com/netbirdio/netbird/\(management\|signal\|relay\|proxy\|combined\|tools/idp-migrate\)" | head -1)
-
- if [ -n "$BSD_IMPORTER" ]; then
- echo "❌ $package ($license) is imported by BSD-licensed code: $BSD_IMPORTER"
- INCOMPATIBLE="${INCOMPATIBLE}${package},${url},${license}\n"
- else
- echo "✓ $package ($license) is only used by internal AGPL packages - OK"
- fi
- fi
- done <<< "$COPYLEFT_DEPS"
-
- if [ -n "$INCOMPATIBLE" ]; then
+ if [ -n "$COPYLEFT_DEPS" ]; then
+ echo "Found copyleft licensed dependencies:"
+ echo "$COPYLEFT_DEPS"
echo ""
- echo "❌ INCOMPATIBLE licenses found that are used by BSD-licensed code:"
- echo -e "$INCOMPATIBLE"
- exit 1
- fi
- fi
- echo "✅ All external license dependencies are compatible with BSD-3-Clause"
+ # Filter out dependencies that are only pulled in by internal AGPL packages
+ INCOMPATIBLE=""
+ while IFS=',' read -r package url license; do
+ if echo "$license" | grep -qE 'GPL-[0-9]|AGPL-[0-9]|LGPL-[0-9]'; then
+ # Find ALL packages that import this GPL package using go list
+ IMPORTERS=$(go list -json -deps ./... 2>/dev/null | jq -r "select(.Imports[]? == \"$package\") | .ImportPath")
+
+ # Check if any importer is NOT in management/signal/relay
+ BSD_IMPORTER=$(echo "$IMPORTERS" | grep -v "github.com/netbirdio/netbird/\(management\|signal\|relay\|proxy\|combined\|tools/idp-migrate\)" | head -1)
+
+ if [ -n "$BSD_IMPORTER" ]; then
+ echo "❌ $package ($license) is imported by BSD-licensed code: $BSD_IMPORTER"
+ INCOMPATIBLE="${INCOMPATIBLE}${package},${url},${license}\n"
+ else
+ echo "✓ $package ($license) is only used by internal AGPL packages - OK"
+ fi
+ fi
+ done <<< "$COPYLEFT_DEPS"
+
+ if [ -n "$INCOMPATIBLE" ]; then
+ echo ""
+ echo "❌ INCOMPATIBLE licenses found that are used by BSD-licensed code:"
+ echo -e "$INCOMPATIBLE"
+ exit 1
+ fi
+ fi
+
+ echo "✅ All external license dependencies are compatible with BSD-3-Clause"
diff --git a/.github/workflows/docs-ack.yml b/.github/workflows/docs-ack.yml
index f11142a36..7e34e2f8a 100644
--- a/.github/workflows/docs-ack.yml
+++ b/.github/workflows/docs-ack.yml
@@ -83,7 +83,7 @@ jobs:
- name: Verify docs PR exists (and is open or merged)
if: steps.validate.outputs.mode == 'added'
- uses: actions/github-script@v7
+ uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
id: verify
with:
pr_number: ${{ steps.extract.outputs.pr_number }}
diff --git a/.github/workflows/forum.yml b/.github/workflows/forum.yml
index a26a72586..75543ef8b 100644
--- a/.github/workflows/forum.yml
+++ b/.github/workflows/forum.yml
@@ -8,11 +8,10 @@ jobs:
post:
runs-on: ubuntu-latest
steps:
- - uses: roots/discourse-topic-github-release-action@main
+ - uses: roots/discourse-topic-github-release-action@557d74ea05b6cc0c47f555c1d5d28a89d904005b # v1.1.0
with:
discourse-api-key: ${{ secrets.DISCOURSE_RELEASES_API_KEY }}
discourse-base-url: https://forum.netbird.io
discourse-author-username: NetBird
discourse-category: 17
- discourse-tags:
- releases
+ discourse-tags: releases
diff --git a/.github/workflows/git-town.yml b/.github/workflows/git-town.yml
index 699ed7d93..3f145020f 100644
--- a/.github/workflows/git-town.yml
+++ b/.github/workflows/git-town.yml
@@ -3,7 +3,7 @@ name: Git Town
on:
pull_request:
branches:
- - '**'
+ - "**"
jobs:
git-town:
@@ -15,7 +15,9 @@ jobs:
pull-requests: write
steps:
- - uses: actions/checkout@v4
- - uses: git-town/action@v1.2.1
+ - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ with:
+ persist-credentials: false
+ - uses: git-town/action@3d8b878379abb1ee393fb49865a28b4a6c2cd3b0 # v1.2.1
with:
skip-single-stacks: true
diff --git a/.github/workflows/golang-test-darwin.yml b/.github/workflows/golang-test-darwin.yml
index 0528ed086..ad84840a2 100644
--- a/.github/workflows/golang-test-darwin.yml
+++ b/.github/workflows/golang-test-darwin.yml
@@ -16,16 +16,18 @@ jobs:
runs-on: macos-latest
steps:
- name: Checkout code
- uses: actions/checkout@v4
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ with:
+ persist-credentials: false
- name: Install Go
- uses: actions/setup-go@v5
+ uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0
with:
go-version-file: "go.mod"
cache: false
- name: Cache Go modules
- uses: actions/cache@v4
+ uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
path: ~/go/pkg/mod
key: macos-gotest-${{ hashFiles('**/go.sum') }}
@@ -43,5 +45,11 @@ jobs:
run: git --no-pager diff --exit-code
- name: Test
- run: NETBIRD_STORE_ENGINE=${{ matrix.store }} CI=true go test -tags=devcert -exec 'sudo --preserve-env=CI,NETBIRD_STORE_ENGINE' -timeout 5m -p 1 $(go list ./... | grep -v -e /management -e /signal -e /relay -e /proxy -e /combined)
+ run: NETBIRD_STORE_ENGINE=${{ matrix.store }} CI=true go test -coverprofile=coverage.txt -tags=devcert -exec 'sudo --preserve-env=CI,NETBIRD_STORE_ENGINE' -timeout 5m -p 1 $(go list ./... | grep -v -e /management -e /signal -e /relay -e /proxy -e /combined)
+ - name: Upload coverage reports to Codecov
+ uses: codecov/codecov-action@e79a6962e0d4c0c17b229090214935d2e33f8354 #v6.0.1
+ with:
+ token: ${{ secrets.CODECOV_TOKEN }}
+ slug: netbirdio/netbird
+ flags: unit,client
diff --git a/.github/workflows/golang-test-freebsd.yml b/.github/workflows/golang-test-freebsd.yml
index 2c029b117..9a81d3e4c 100644
--- a/.github/workflows/golang-test-freebsd.yml
+++ b/.github/workflows/golang-test-freebsd.yml
@@ -15,20 +15,31 @@ jobs:
name: "Client / Unit"
runs-on: ubuntu-22.04
steps:
- - uses: actions/checkout@v4
+ - name: Checkout code
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ with:
+ persist-credentials: false
+
+ - name: Read Go version from go.mod
+ id: goversion
+ run: echo "version=$(awk '/^go / {print $2}' go.mod)" >> "$GITHUB_OUTPUT"
+
- name: Test in FreeBSD
id: test
- uses: vmactions/freebsd-vm@v1
+ env:
+ GO_VERSION: ${{ steps.goversion.outputs.version }}
+ uses: vmactions/freebsd-vm@d1e65811565151536c0c894fff74f06351ed26e6 # v1.4.5
with:
usesh: true
copyback: false
- release: "14.2"
+ release: "15.0"
+ envs: "GO_VERSION"
prepare: |
pkg install -y curl pkgconf xorg
- GO_TARBALL="go1.25.3.freebsd-amd64.tar.gz"
+ GO_TARBALL="go${GO_VERSION}.freebsd-amd64.tar.gz"
GO_URL="https://go.dev/dl/$GO_TARBALL"
curl -vLO "$GO_URL"
- tar -C /usr/local -vxzf "$GO_TARBALL"
+ tar -C /usr/local -vxzf "$GO_TARBALL"
# -x - to print all executed commands
# -e - to faile on first error
diff --git a/.github/workflows/golang-test-linux.yml b/.github/workflows/golang-test-linux.yml
index 450c44aea..c17f83222 100644
--- a/.github/workflows/golang-test-linux.yml
+++ b/.github/workflows/golang-test-linux.yml
@@ -18,9 +18,11 @@ jobs:
management: ${{ steps.filter.outputs.management }}
steps:
- name: Checkout code
- uses: actions/checkout@v4
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ with:
+ persist-credentials: false
- - uses: dorny/paths-filter@v3
+ - uses: dorny/paths-filter@fbd0ab8f3e69293af611ebaee6363fc25e6d187d # v4.0.1
id: filter
with:
filters: |
@@ -28,7 +30,7 @@ jobs:
- 'management/**'
- name: Install Go
- uses: actions/setup-go@v5
+ uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0
with:
go-version-file: "go.mod"
cache: false
@@ -36,10 +38,10 @@ jobs:
- name: Get Go environment
run: |
echo "cache=$(go env GOCACHE)" >> $GITHUB_ENV
- echo "modcache=$(go env GOMODCACHE)" >> $GITHUB_ENV
+ echo "modcache=$(go env GOMODCACHE)" >> $GITHUB_ENV
- name: Cache Go modules
- uses: actions/cache@v4
+ uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
id: cache
with:
path: |
@@ -113,14 +115,16 @@ jobs:
strategy:
fail-fast: false
matrix:
- arch: [ '386','amd64' ]
+ arch: ["386", "amd64"]
runs-on: ubuntu-22.04
steps:
- name: Checkout code
- uses: actions/checkout@v4
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ with:
+ persist-credentials: false
- name: Install Go
- uses: actions/setup-go@v5
+ uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0
with:
go-version-file: "go.mod"
cache: false
@@ -128,10 +132,10 @@ jobs:
- name: Get Go environment
run: |
echo "cache=$(go env GOCACHE)" >> $GITHUB_ENV
- echo "modcache=$(go env GOMODCACHE)" >> $GITHUB_ENV
+ echo "modcache=$(go env GOMODCACHE)" >> $GITHUB_ENV
- name: Cache Go modules
- uses: actions/cache/restore@v4
+ uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
path: |
${{ env.cache }}
@@ -154,18 +158,29 @@ jobs:
run: git --no-pager diff --exit-code
- name: Test
- run: CGO_ENABLED=1 GOARCH=${{ matrix.arch }} CI=true go test -tags devcert -exec 'sudo' -timeout 10m -p 1 $(go list ./... | grep -v -e /management -e /signal -e /relay -e /proxy -e /combined)
+ run: CGO_ENABLED=1 GOARCH=${{ matrix.arch }} CI=true go test -coverprofile=coverage.txt -tags devcert -exec 'sudo' -timeout 10m -p 1 $(go list ./... | grep -v -e /management -e /signal -e /relay -e /proxy -e /combined)
+
+ - name: Upload coverage reports to Codecov
+ if: matrix.arch == 'amd64'
+ uses: codecov/codecov-action@e79a6962e0d4c0c17b229090214935d2e33f8354 #v6.0.1
+ with:
+ token: ${{ secrets.CODECOV_TOKEN }}
+ slug: netbirdio/netbird
+ flags: unit,client
+
test_client_on_docker:
name: "Client (Docker) / Unit"
- needs: [ build-cache ]
+ needs: [build-cache]
runs-on: ubuntu-22.04
steps:
- name: Checkout code
- uses: actions/checkout@v4
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ with:
+ persist-credentials: false
- name: Install Go
- uses: actions/setup-go@v5
+ uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0
with:
go-version-file: "go.mod"
cache: false
@@ -177,7 +192,7 @@ jobs:
echo "modcache_dir=$(go env GOMODCACHE)" >> $GITHUB_OUTPUT
- name: Cache Go modules
- uses: actions/cache/restore@v4
+ uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
id: cache-restore
with:
path: |
@@ -231,10 +246,12 @@ jobs:
runs-on: ubuntu-22.04
steps:
- name: Checkout code
- uses: actions/checkout@v4
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ with:
+ persist-credentials: false
- name: Install Go
- uses: actions/setup-go@v5
+ uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0
with:
go-version-file: "go.mod"
cache: false
@@ -246,10 +263,10 @@ jobs:
- name: Get Go environment
run: |
echo "cache=$(go env GOCACHE)" >> $GITHUB_ENV
- echo "modcache=$(go env GOMODCACHE)" >> $GITHUB_ENV
+ echo "modcache=$(go env GOMODCACHE)" >> $GITHUB_ENV
- name: Cache Go modules
- uses: actions/cache/restore@v4
+ uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
path: |
${{ env.cache }}
@@ -268,23 +285,33 @@ jobs:
run: |
CGO_ENABLED=1 GOARCH=${{ matrix.arch }} \
go test ${{ matrix.raceFlag }} \
- -exec 'sudo' \
+ -exec 'sudo' -coverprofile=coverage.txt \
-timeout 10m -p 1 ./relay/... ./shared/relay/...
+ - name: Upload coverage reports to Codecov
+ if: matrix.arch == 'amd64'
+ uses: codecov/codecov-action@e79a6962e0d4c0c17b229090214935d2e33f8354 #v6.0.1
+ with:
+ token: ${{ secrets.CODECOV_TOKEN }}
+ slug: netbirdio/netbird
+ flags: unit,relay
+
test_proxy:
name: "Proxy / Unit"
needs: [build-cache]
strategy:
fail-fast: false
matrix:
- arch: [ '386','amd64' ]
+ arch: ["386", "amd64"]
runs-on: ubuntu-22.04
steps:
- name: Checkout code
- uses: actions/checkout@v4
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ with:
+ persist-credentials: false
- name: Install Go
- uses: actions/setup-go@v5
+ uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0
with:
go-version-file: "go.mod"
cache: false
@@ -298,7 +325,7 @@ jobs:
echo "modcache=$(go env GOMODCACHE)" >> $GITHUB_ENV
- name: Cache Go modules
- uses: actions/cache/restore@v4
+ uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
path: |
${{ env.cache }}
@@ -316,7 +343,15 @@ jobs:
- name: Test
run: |
CGO_ENABLED=1 GOARCH=${{ matrix.arch }} \
- go test -timeout 10m -p 1 ./proxy/...
+ go test -timeout 10m -p 1 -coverprofile=coverage.txt ./proxy/...
+
+ - name: Upload coverage reports to Codecov
+ if: matrix.arch == 'amd64'
+ uses: codecov/codecov-action@e79a6962e0d4c0c17b229090214935d2e33f8354 #v6.0.1
+ with:
+ token: ${{ secrets.CODECOV_TOKEN }}
+ slug: netbirdio/netbird
+ flags: unit,proxy
test_signal:
name: "Signal / Unit"
@@ -324,14 +359,16 @@ jobs:
strategy:
fail-fast: false
matrix:
- arch: [ '386','amd64' ]
+ arch: ["386", "amd64"]
runs-on: ubuntu-22.04
steps:
- name: Checkout code
- uses: actions/checkout@v4
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ with:
+ persist-credentials: false
- name: Install Go
- uses: actions/setup-go@v5
+ uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0
with:
go-version-file: "go.mod"
cache: false
@@ -343,10 +380,10 @@ jobs:
- name: Get Go environment
run: |
echo "cache=$(go env GOCACHE)" >> $GITHUB_ENV
- echo "modcache=$(go env GOMODCACHE)" >> $GITHUB_ENV
+ echo "modcache=$(go env GOMODCACHE)" >> $GITHUB_ENV
- name: Cache Go modules
- uses: actions/cache/restore@v4
+ uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
path: |
${{ env.cache }}
@@ -365,24 +402,34 @@ jobs:
run: |
CGO_ENABLED=1 GOARCH=${{ matrix.arch }} \
go test \
- -exec 'sudo' \
+ -exec 'sudo' -coverprofile=coverage.txt \
-timeout 10m ./signal/... ./shared/signal/...
+ - name: Upload coverage reports to Codecov
+ if: matrix.arch == 'amd64'
+ uses: codecov/codecov-action@e79a6962e0d4c0c17b229090214935d2e33f8354 #v6.0.1
+ with:
+ token: ${{ secrets.CODECOV_TOKEN }}
+ slug: netbirdio/netbird
+ flags: unit,signal
+
test_management:
name: "Management / Unit"
- needs: [ build-cache ]
+ needs: [build-cache]
strategy:
fail-fast: false
matrix:
- arch: [ 'amd64' ]
- store: [ 'sqlite', 'postgres', 'mysql' ]
+ arch: ["amd64"]
+ store: ["sqlite", "postgres", "mysql"]
runs-on: ubuntu-22.04
steps:
- name: Checkout code
- uses: actions/checkout@v4
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ with:
+ persist-credentials: false
- name: Install Go
- uses: actions/setup-go@v5
+ uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0
with:
go-version-file: "go.mod"
cache: false
@@ -390,10 +437,10 @@ jobs:
- name: Get Go environment
run: |
echo "cache=$(go env GOCACHE)" >> $GITHUB_ENV
- echo "modcache=$(go env GOMODCACHE)" >> $GITHUB_ENV
+ echo "modcache=$(go env GOMODCACHE)" >> $GITHUB_ENV
- name: Cache Go modules
- uses: actions/cache/restore@v4
+ uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
path: |
${{ env.cache }}
@@ -410,7 +457,7 @@ jobs:
- name: Login to Docker hub
if: github.event.pull_request && github.event.pull_request.head.repo && github.event.pull_request.head.repo.full_name == '' || github.repository == github.event.pull_request.head.repo.full_name || !github.head_ref
- uses: docker/login-action@v3
+ uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
with:
username: ${{ secrets.DOCKER_USER }}
password: ${{ secrets.DOCKER_TOKEN }}
@@ -427,23 +474,31 @@ jobs:
run: docker pull mlsmaycon/warmed-mysql:8
- name: Test
- run: |
+ run: |
CGO_ENABLED=1 GOARCH=${{ matrix.arch }} \
NETBIRD_STORE_ENGINE=${{ matrix.store }} \
CI=true \
- go test -tags=devcert \
+ go test -tags=devcert -coverprofile=coverage.txt \
-exec "sudo --preserve-env=CI,NETBIRD_STORE_ENGINE" \
-timeout 20m ./management/... ./shared/management/...
+ - name: Upload coverage reports to Codecov
+ if: matrix.arch == 'amd64'
+ uses: codecov/codecov-action@e79a6962e0d4c0c17b229090214935d2e33f8354 #v6.0.1
+ with:
+ token: ${{ secrets.CODECOV_TOKEN }}
+ slug: netbirdio/netbird
+ flags: unit,management
+
benchmark:
name: "Management / Benchmark"
- needs: [ build-cache ]
+ needs: [build-cache]
if: ${{ needs.build-cache.outputs.management == 'true' || github.event_name != 'pull_request' }}
strategy:
fail-fast: false
matrix:
- arch: [ 'amd64' ]
- store: [ 'sqlite', 'postgres' ]
+ arch: ["amd64"]
+ store: ["sqlite", "postgres"]
runs-on: ubuntu-22.04
steps:
- name: Create Docker network
@@ -474,10 +529,12 @@ jobs:
prom/prometheus
- name: Checkout code
- uses: actions/checkout@v4
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ with:
+ persist-credentials: false
- name: Install Go
- uses: actions/setup-go@v5
+ uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0
with:
go-version-file: "go.mod"
cache: false
@@ -485,10 +542,10 @@ jobs:
- name: Get Go environment
run: |
echo "cache=$(go env GOCACHE)" >> $GITHUB_ENV
- echo "modcache=$(go env GOMODCACHE)" >> $GITHUB_ENV
+ echo "modcache=$(go env GOMODCACHE)" >> $GITHUB_ENV
- name: Cache Go modules
- uses: actions/cache/restore@v4
+ uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
path: |
${{ env.cache }}
@@ -505,7 +562,7 @@ jobs:
- name: Login to Docker hub
if: github.event.pull_request && github.event.pull_request.head.repo && github.event.pull_request.head.repo.full_name == '' || github.repository == github.event.pull_request.head.repo.full_name || !github.head_ref
- uses: docker/login-action@v3
+ uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
with:
username: ${{ secrets.DOCKER_USER }}
password: ${{ secrets.DOCKER_TOKEN }}
@@ -529,13 +586,13 @@ jobs:
api_benchmark:
name: "Management / Benchmark (API)"
- needs: [ build-cache ]
+ needs: [build-cache]
if: ${{ needs.build-cache.outputs.management == 'true' || github.event_name != 'pull_request' }}
strategy:
fail-fast: false
matrix:
- arch: [ 'amd64' ]
- store: [ 'sqlite', 'postgres' ]
+ arch: ["amd64"]
+ store: ["sqlite", "postgres"]
runs-on: ubuntu-22.04
steps:
- name: Create Docker network
@@ -566,10 +623,12 @@ jobs:
prom/prometheus
- name: Checkout code
- uses: actions/checkout@v4
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ with:
+ persist-credentials: false
- name: Install Go
- uses: actions/setup-go@v5
+ uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0
with:
go-version-file: "go.mod"
cache: false
@@ -577,10 +636,10 @@ jobs:
- name: Get Go environment
run: |
echo "cache=$(go env GOCACHE)" >> $GITHUB_ENV
- echo "modcache=$(go env GOMODCACHE)" >> $GITHUB_ENV
+ echo "modcache=$(go env GOMODCACHE)" >> $GITHUB_ENV
- name: Cache Go modules
- uses: actions/cache/restore@v4
+ uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
path: |
${{ env.cache }}
@@ -597,7 +656,7 @@ jobs:
- name: Login to Docker hub
if: github.event.pull_request && github.event.pull_request.head.repo && github.event.pull_request.head.repo.full_name == '' || github.repository == github.event.pull_request.head.repo.full_name || !github.head_ref
- uses: docker/login-action@v3
+ uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
with:
username: ${{ secrets.DOCKER_USER }}
password: ${{ secrets.DOCKER_TOKEN }}
@@ -623,20 +682,22 @@ jobs:
api_integration_test:
name: "Management / Integration"
- needs: [ build-cache ]
+ needs: [build-cache]
if: ${{ needs.build-cache.outputs.management == 'true' || github.event_name != 'pull_request' }}
strategy:
fail-fast: false
matrix:
- arch: [ 'amd64' ]
- store: [ 'sqlite', 'postgres']
+ arch: ["amd64"]
+ store: ["sqlite", "postgres"]
runs-on: ubuntu-22.04
steps:
- name: Checkout code
- uses: actions/checkout@v4
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ with:
+ persist-credentials: false
- name: Install Go
- uses: actions/setup-go@v5
+ uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0
with:
go-version-file: "go.mod"
cache: false
@@ -644,10 +705,10 @@ jobs:
- name: Get Go environment
run: |
echo "cache=$(go env GOCACHE)" >> $GITHUB_ENV
- echo "modcache=$(go env GOMODCACHE)" >> $GITHUB_ENV
+ echo "modcache=$(go env GOMODCACHE)" >> $GITHUB_ENV
- name: Cache Go modules
- uses: actions/cache/restore@v4
+ uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
path: |
${{ env.cache }}
@@ -667,6 +728,14 @@ jobs:
CGO_ENABLED=1 GOARCH=${{ matrix.arch }} \
NETBIRD_STORE_ENGINE=${{ matrix.store }} \
CI=true \
- go test -tags=integration \
+ go test -tags=integration -coverprofile=coverage.txt \
-exec 'sudo --preserve-env=CI,NETBIRD_STORE_ENGINE' \
-timeout 20m ./management/server/http/...
+
+ - name: Upload coverage reports to Codecov
+ if: matrix.arch == 'amd64'
+ uses: codecov/codecov-action@e79a6962e0d4c0c17b229090214935d2e33f8354 #v6.0.1
+ with:
+ token: ${{ secrets.CODECOV_TOKEN }}
+ slug: netbirdio/netbird
+ flags: integration,management
diff --git a/.github/workflows/golang-test-windows.yml b/.github/workflows/golang-test-windows.yml
index 8e672043d..8712cc879 100644
--- a/.github/workflows/golang-test-windows.yml
+++ b/.github/workflows/golang-test-windows.yml
@@ -18,10 +18,12 @@ jobs:
runs-on: windows-latest
steps:
- name: Checkout code
- uses: actions/checkout@v4
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ with:
+ persist-credentials: false
- name: Install Go
- uses: actions/setup-go@v5
+ uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0
id: go
with:
go-version-file: "go.mod"
@@ -33,7 +35,7 @@ jobs:
echo "modcache=$(go env GOMODCACHE)" >> $env:GITHUB_ENV
- name: Cache Go modules
- uses: actions/cache@v4
+ uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
path: |
${{ env.cache }}
@@ -44,16 +46,15 @@ jobs:
${{ runner.os }}-go-
- name: Download wintun
- uses: carlosperate/download-file-action@v2
id: download-wintun
+ uses: netbirdio/shared-actions/actions/win-download-and-verify@be5df6047383da2236e02243cceb857d8567c27e # v0.0.2
with:
- file-url: https://pkgs.netbird.io/wintun/wintun-0.14.1.zip
- file-name: wintun.zip
- location: ${{ env.downloadPath }}
- sha256: '07c256185d6ee3652e09fa55c0b673e2624b565e02c4b9091c79ca7d2f24ef51'
+ url: https://pkgs.netbird.io/wintun/wintun-0.14.1.zip
+ destination: ${{ env.downloadPath }}\wintun.zip
+ sha256: 07c256185d6ee3652e09fa55c0b673e2624b565e02c4b9091c79ca7d2f24ef51
- name: Decompressing wintun files
- run: tar -zvxf "${{ steps.download-wintun.outputs.file-path }}" -C ${{ env.downloadPath }}
+ run: tar -xvf "${{ steps.download-wintun.outputs.file-path }}" -C ${{ env.downloadPath }}
- run: mv ${{ env.downloadPath }}/wintun/bin/amd64/wintun.dll 'C:\Windows\System32\'
diff --git a/.github/workflows/golangci-lint.yml b/.github/workflows/golangci-lint.yml
index 7842530ce..9adf2268f 100644
--- a/.github/workflows/golangci-lint.yml
+++ b/.github/workflows/golangci-lint.yml
@@ -15,9 +15,11 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout code
- uses: actions/checkout@v4
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ with:
+ persist-credentials: false
- name: codespell
- uses: codespell-project/actions-codespell@v2
+ uses: codespell-project/actions-codespell@8f01853be192eb0f849a5c7d721450e7a467c579 # v2.2
with:
ignore_words_list: erro,clienta,hastable,iif,groupd,testin,groupe,cros,ans,deriver,te,userA,ede,additionals
skip: go.mod,go.sum,**/proxy/web/**
@@ -38,13 +40,15 @@ jobs:
timeout-minutes: 25
steps:
- name: Checkout code
- uses: actions/checkout@v4
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ with:
+ persist-credentials: false
- name: Check for duplicate constants
if: matrix.os == 'ubuntu-latest'
run: |
! awk '/const \(/,/)/{print $0}' management/server/activity/codes.go | grep -o '= [0-9]*' | sort | uniq -d | grep .
- name: Install Go
- uses: actions/setup-go@v5
+ uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0
with:
go-version-file: "go.mod"
cache: false
@@ -52,7 +56,7 @@ jobs:
if: matrix.os == 'ubuntu-latest'
run: sudo apt update && sudo apt install -y -q libgtk-3-dev libayatana-appindicator3-dev libgl1-mesa-dev xorg-dev libpcap-dev
- name: golangci-lint
- uses: golangci/golangci-lint-action@4afd733a84b1f43292c63897423277bb7f4313a9 # v8.0.0
+ uses: golangci/golangci-lint-action@82606bf257cbaff209d206a39f5134f0cfbfd2ee #v9.2.1
with:
version: latest
skip-cache: true
diff --git a/.github/workflows/install-script-test.yml b/.github/workflows/install-script-test.yml
index 22d002a48..aec9f6300 100644
--- a/.github/workflows/install-script-test.yml
+++ b/.github/workflows/install-script-test.yml
@@ -22,7 +22,9 @@ jobs:
runs-on: ${{ matrix.os }}
steps:
- name: Checkout code
- uses: actions/checkout@v4
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ with:
+ persist-credentials: false
- name: run install script
env:
diff --git a/.github/workflows/mobile-build-validation.yml b/.github/workflows/mobile-build-validation.yml
index 8325fbf2d..8e0538104 100644
--- a/.github/workflows/mobile-build-validation.yml
+++ b/.github/workflows/mobile-build-validation.yml
@@ -16,23 +16,25 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
- uses: actions/checkout@v4
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ with:
+ persist-credentials: false
- name: Install Go
- uses: actions/setup-go@v5
+ uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0
with:
go-version-file: "go.mod"
- name: Setup Android SDK
- uses: android-actions/setup-android@v3
+ uses: android-actions/setup-android@40fd30fb8d7440372e1316f5d1809ec01dcd3699 # v4.0.1
with:
cmdline-tools-version: 8512546
- name: Setup Java
- uses: actions/setup-java@v4
+ uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654
with:
java-version: "11"
distribution: "adopt"
- name: NDK Cache
id: ndk-cache
- uses: actions/cache@v4
+ uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
path: /usr/local/lib/android/sdk/ndk
key: ndk-cache-23.1.7779620
@@ -52,9 +54,11 @@ jobs:
runs-on: macos-latest
steps:
- name: Checkout repository
- uses: actions/checkout@v4
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ with:
+ persist-credentials: false
- name: Install Go
- uses: actions/setup-go@v5
+ uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0
with:
go-version-file: "go.mod"
- name: install gomobile
diff --git a/.github/workflows/pr-title-check.yml b/.github/workflows/pr-title-check.yml
index a2e6ce219..67d65356c 100644
--- a/.github/workflows/pr-title-check.yml
+++ b/.github/workflows/pr-title-check.yml
@@ -9,7 +9,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Validate PR title prefix
- uses: actions/github-script@v7
+ uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with:
script: |
const title = context.payload.pull_request.title;
diff --git a/.github/workflows/proto-version-check.yml b/.github/workflows/proto-version-check.yml
index bec503b36..fd2c2c908 100644
--- a/.github/workflows/proto-version-check.yml
+++ b/.github/workflows/proto-version-check.yml
@@ -10,7 +10,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Check for proto tool version changes
- uses: actions/github-script@v7
+ uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with:
script: |
const files = await github.paginate(github.rest.pulls.listFiles, {
@@ -20,15 +20,30 @@ jobs:
per_page: 100,
});
- const modifiedPbFiles = files.filter(
- f => f.filename.endsWith('.pb.go') && f.status === 'modified'
- );
- if (modifiedPbFiles.length === 0) {
- console.log('No modified .pb.go files to check');
+ // Cover renamed .pb.go files in addition to plain edits.
+ // Renamed entries land under the new path with previous_filename
+ // pointing at the base-side name, so we read the base content
+ // from the old path when present.
+ const changedPbFiles = files
+ .filter(f => (f.status === 'modified' || f.status === 'renamed')
+ && f.filename.endsWith('.pb.go'))
+ .map(f => ({
+ headPath: f.filename,
+ basePath: f.previous_filename || f.filename,
+ }));
+ if (changedPbFiles.length === 0) {
+ console.log('No modified or renamed .pb.go files to check');
return;
}
- const versionPattern = /^\s*\/\/\s+protoc(?:-gen-go)?\s+v[\d.]+/;
+ // Matches the generator version headers protoc writes at the top
+ // of generated files:
+ // // protoc v3.21.12
+ // // protoc-gen-go v1.26.0
+ // // - protoc-gen-go-grpc v1.6.1 (grpc files prefix with "- ")
+ // The optional "- " prefix and the optional -gen-go / -gen-go-grpc
+ // suffixes keep the *_grpc.pb.go headers in scope.
+ const versionPattern = /^\s*\/\/\s+(?:-\s+)?protoc(?:-gen-go(?:-grpc)?)?\s+v[\d.]+/;
const baseSha = context.payload.pull_request.base.sha;
const headSha = context.payload.pull_request.head.sha;
@@ -55,20 +70,22 @@ jobs:
}
const violations = [];
- for (const file of modifiedPbFiles) {
+ for (const file of changedPbFiles) {
const [base, head] = await Promise.all([
- getVersionHeader(file.filename, baseSha),
- getVersionHeader(file.filename, headSha),
+ getVersionHeader(file.basePath, baseSha),
+ getVersionHeader(file.headPath, headSha),
]);
if (!base.ok || !head.ok) {
core.warning(
- `Skipping ${file.filename}: base=${base.ok ? 'ok' : base.reason}, head=${head.ok ? 'ok' : head.reason}`
+ `Skipping ${file.headPath}: base=${base.ok ? 'ok' : base.reason}, head=${head.ok ? 'ok' : head.reason}`
);
continue;
}
if (base.lines.join('\n') !== head.lines.join('\n')) {
violations.push({
- file: file.filename,
+ file: file.basePath === file.headPath
+ ? file.headPath
+ : `${file.basePath} → ${file.headPath}`,
base: base.lines,
head: head.lines,
});
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index c1ae01a98..b335aad72 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -9,7 +9,7 @@ on:
pull_request:
env:
- SIGN_PIPE_VER: "v0.1.4"
+ SIGN_PIPE_VER: "v0.1.5"
GORELEASER_VER: "v2.14.3"
PRODUCT_NAME: "NetBird"
COPYRIGHT: "NetBird GmbH"
@@ -24,13 +24,15 @@ jobs:
runs-on: ubuntu-22.04
steps:
- name: Checkout
- uses: actions/checkout@v4
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ with:
+ persist-credentials: false
- name: Generate FreeBSD port diff
- run: bash release_files/freebsd-port-diff.sh
+ run: bash -x release_files/freebsd-port-diff.sh
- name: Generate FreeBSD port issue body
- run: bash release_files/freebsd-port-issue-body.sh
+ run: bash -x release_files/freebsd-port-issue-body.sh
- name: Check if diff was generated
id: check_diff
@@ -51,19 +53,26 @@ jobs:
echo "Generated files for version: $VERSION"
cat netbird-*.diff
+ - name: Read Go version from go.mod
+ id: goversion
+ run: echo "version=$(awk '/^go / {print $2}' go.mod)" >> "$GITHUB_OUTPUT"
+
- name: Test FreeBSD port
if: steps.check_diff.outputs.diff_exists == 'true'
- uses: vmactions/freebsd-vm@v1
+ env:
+ GO_VERSION: ${{ steps.goversion.outputs.version }}
+ uses: vmactions/freebsd-vm@d1e65811565151536c0c894fff74f06351ed26e6 # v1.4.5
with:
usesh: true
copyback: false
release: "15.0"
+ envs: "GO_VERSION"
prepare: |
# Install required packages
- pkg install -y git curl portlint go
+ pkg install -y git curl portlint
# Install Go for building
- GO_TARBALL="go1.25.5.freebsd-amd64.tar.gz"
+ GO_TARBALL="go${GO_VERSION}.freebsd-amd64.tar.gz"
GO_URL="https://go.dev/dl/$GO_TARBALL"
curl -LO "$GO_URL"
tar -C /usr/local -xzf "$GO_TARBALL"
@@ -93,19 +102,19 @@ jobs:
# Show patched Makefile
version=$(cat security/netbird/Makefile | grep -E '^DISTVERSION=' | awk '{print $NF}')
-
+
cd /usr/ports/security/netbird
export BATCH=yes
make package
pkg add ./work/pkg/netbird-*.pkg
-
+
netbird version | grep "$version"
echo "FreeBSD port test completed successfully!"
- name: Upload FreeBSD port files
if: steps.check_diff.outputs.diff_exists == 'true'
- uses: actions/upload-artifact@v4
+ uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a #v7.0.1
with:
name: freebsd-port-files
path: |
@@ -124,26 +133,25 @@ jobs:
env:
flags: ""
steps:
+ - name: Checkout
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ with:
+ fetch-depth: 0 # It is required for GoReleaser to work properly
+ persist-credentials: false
+
- name: Parse semver string
id: semver_parser
- uses: booxmedialtd/ws-action-parse-semver@v1
- with:
- input_string: ${{ (startsWith(github.ref, 'refs/tags/v') && github.ref) || 'refs/tags/v0.0.0' }}
- version_extractor_regex: '\/v(.*)$'
+ uses: netbirdio/shared-actions/actions/parse-semver@be5df6047383da2236e02243cceb857d8567c27e # v0.0.2
- if: ${{ !startsWith(github.ref, 'refs/tags/v') }}
run: echo "flags=--snapshot" >> $GITHUB_ENV
- - name: Checkout
- uses: actions/checkout@v4
- with:
- fetch-depth: 0 # It is required for GoReleaser to work properly
- name: Set up Go
- uses: actions/setup-go@v5
+ uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0
with:
go-version-file: "go.mod"
cache: false
- name: Cache Go modules
- uses: actions/cache@v4
+ uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
path: |
~/go/pkg/mod
@@ -153,21 +161,23 @@ jobs:
${{ runner.os }}-go-releaser-
- name: Install modules
run: go mod tidy
+ - name: run openapi generator
+ run: bash shared/management/http/api/generate.sh
- name: check git status
run: git --no-pager diff --exit-code
- name: Set up QEMU
- uses: docker/setup-qemu-action@v2
+ uses: docker/setup-qemu-action@ce360397dd3f832beb865e1373c09c0e9f86d70a #v4.0.0
- name: Set up Docker Buildx
- uses: docker/setup-buildx-action@v2
+ uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd #v4.0.0
- name: Login to Docker hub
if: github.event_name != 'pull_request'
- uses: docker/login-action@v1
+ uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
with:
username: ${{ secrets.DOCKER_USER }}
password: ${{ secrets.DOCKER_TOKEN }}
- name: Log in to the GitHub container registry
if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository
- uses: docker/login-action@v3
+ uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
with:
registry: ghcr.io
username: ${{ github.actor }}
@@ -191,7 +201,7 @@ jobs:
run: goversioninfo -arm -64 -icon client/ui/assets/netbird.ico -manifest client/manifest.xml -product-name ${{ env.PRODUCT_NAME }} -copyright "${{ env.COPYRIGHT }}" -ver-major ${{ steps.semver_parser.outputs.major }} -ver-minor ${{ steps.semver_parser.outputs.minor }} -ver-patch ${{ steps.semver_parser.outputs.patch }} -ver-build 0 -file-version ${{ steps.semver_parser.outputs.fullversion }}.0 -product-version ${{ steps.semver_parser.outputs.fullversion }}.0 -o client/resources_windows_arm64.syso
- name: Run GoReleaser
id: goreleaser
- uses: goreleaser/goreleaser-action@v4
+ uses: goreleaser/goreleaser-action@4c6ab561adb47e50c45ef534e2155934e91c40c1 # v7.2.0
with:
version: ${{ env.GORELEASER_VER }}
args: release --clean ${{ env.flags }}
@@ -282,28 +292,28 @@ jobs:
} >> "$GITHUB_OUTPUT"
- name: upload non tags for debug purposes
id: upload_release
- uses: actions/upload-artifact@v4
+ uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a #v7.0.1
with:
name: release
path: dist/
retention-days: 7
- name: upload linux packages
id: upload_linux_packages
- uses: actions/upload-artifact@v4
+ uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a #v7.0.1
with:
name: linux-packages
path: dist/netbird_linux**
retention-days: 7
- name: upload windows packages
id: upload_windows_packages
- uses: actions/upload-artifact@v4
+ uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a #v7.0.1
with:
name: windows-packages
path: dist/netbird_windows**
retention-days: 7
- name: upload macos packages
id: upload_macos_packages
- uses: actions/upload-artifact@v4
+ uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a #v7.0.1
with:
name: macos-packages
path: dist/netbird_darwin**
@@ -314,27 +324,26 @@ jobs:
outputs:
release_ui_artifact_url: ${{ steps.upload_release_ui.outputs.artifact-url }}
steps:
+ - name: Checkout
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ with:
+ fetch-depth: 0 # It is required for GoReleaser to work properly
+ persist-credentials: false
+
- name: Parse semver string
id: semver_parser
- uses: booxmedialtd/ws-action-parse-semver@v1
- with:
- input_string: ${{ (startsWith(github.ref, 'refs/tags/v') && github.ref) || 'refs/tags/v0.0.0' }}
- version_extractor_regex: '\/v(.*)$'
+ uses: netbirdio/shared-actions/actions/parse-semver@be5df6047383da2236e02243cceb857d8567c27e # v0.0.2
- if: ${{ !startsWith(github.ref, 'refs/tags/v') }}
run: echo "flags=--snapshot" >> $GITHUB_ENV
- - name: Checkout
- uses: actions/checkout@v4
- with:
- fetch-depth: 0 # It is required for GoReleaser to work properly
- name: Set up Go
- uses: actions/setup-go@v5
+ uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0
with:
go-version-file: "go.mod"
cache: false
- name: Cache Go modules
- uses: actions/cache@v4
+ uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
path: |
~/go/pkg/mod
@@ -375,7 +384,7 @@ jobs:
run: goversioninfo -arm -64 -icon client/ui/assets/netbird.ico -manifest client/ui/manifest.xml -product-name ${{ env.PRODUCT_NAME }}-"UI" -copyright "${{ env.COPYRIGHT }}" -ver-major ${{ steps.semver_parser.outputs.major }} -ver-minor ${{ steps.semver_parser.outputs.minor }} -ver-patch ${{ steps.semver_parser.outputs.patch }} -ver-build 0 -file-version ${{ steps.semver_parser.outputs.fullversion }}.0 -product-version ${{ steps.semver_parser.outputs.fullversion }}.0 -o client/ui/resources_windows_arm64.syso
- name: Run GoReleaser
- uses: goreleaser/goreleaser-action@v4
+ uses: goreleaser/goreleaser-action@4c6ab561adb47e50c45ef534e2155934e91c40c1 # v7.2.0
with:
version: ${{ env.GORELEASER_VER }}
args: release --config .goreleaser_ui.yaml --clean ${{ env.flags }}
@@ -404,7 +413,7 @@ jobs:
run: rm -f /tmp/gpg-rpm-signing-key.asc
- name: upload non tags for debug purposes
id: upload_release_ui
- uses: actions/upload-artifact@v4
+ uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a #v7.0.1
with:
name: release-ui
path: dist/
@@ -418,16 +427,17 @@ jobs:
- if: ${{ !startsWith(github.ref, 'refs/tags/v') }}
run: echo "flags=--snapshot" >> $GITHUB_ENV
- name: Checkout
- uses: actions/checkout@v4
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
fetch-depth: 0 # It is required for GoReleaser to work properly
+ persist-credentials: false
- name: Set up Go
- uses: actions/setup-go@v5
+ uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0
with:
go-version-file: "go.mod"
cache: false
- name: Cache Go modules
- uses: actions/cache@v4
+ uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
path: |
~/go/pkg/mod
@@ -441,7 +451,7 @@ jobs:
run: git --no-pager diff --exit-code
- name: Run GoReleaser
id: goreleaser
- uses: goreleaser/goreleaser-action@v4
+ uses: goreleaser/goreleaser-action@4c6ab561adb47e50c45ef534e2155934e91c40c1 # v7.2.0
with:
version: ${{ env.GORELEASER_VER }}
args: release --config .goreleaser_ui_darwin.yaml --clean ${{ env.flags }}
@@ -449,7 +459,7 @@ jobs:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: upload non tags for debug purposes
id: upload_release_ui_darwin
- uses: actions/upload-artifact@v4
+ uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a #v7.0.1
with:
name: release-ui-darwin
path: dist/
@@ -474,27 +484,26 @@ jobs:
PackageWorkdir: netbird_windows_${{ matrix.arch }}
downloadPath: '${{ github.workspace }}\temp'
steps:
+ - name: Checkout
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ with:
+ persist-credentials: false
+
- name: Parse semver string
id: semver_parser
- uses: booxmedialtd/ws-action-parse-semver@v1
- with:
- input_string: ${{ (startsWith(github.ref, 'refs/tags/v') && github.ref) || 'refs/tags/v0.0.0' }}
- version_extractor_regex: '\/v(.*)$'
-
- - name: Checkout
- uses: actions/checkout@v4
+ uses: netbirdio/shared-actions/actions/parse-semver@be5df6047383da2236e02243cceb857d8567c27e # v0.0.2
- name: Add 7-Zip to PATH
run: echo "C:\Program Files\7-Zip" | Out-File -FilePath $env:GITHUB_PATH -Encoding utf8 -Append
- name: Download release artifacts
- uses: actions/download-artifact@v4
+ uses: actions/download-artifact@70fc10c6e5e1ce46ad2ea6f2b72d43f7d47b13c3 # v8.0.1
with:
name: release
path: release
- name: Download UI release artifacts
- uses: actions/download-artifact@v4
+ uses: actions/download-artifact@70fc10c6e5e1ce46ad2ea6f2b72d43f7d47b13c3 # v8.0.1
with:
name: release-ui
path: release-ui
@@ -514,29 +523,27 @@ jobs:
Get-ChildItem $workdir
- name: Download wintun
- uses: carlosperate/download-file-action@v2
id: download-wintun
+ uses: netbirdio/shared-actions/actions/win-download-and-verify@be5df6047383da2236e02243cceb857d8567c27e # v0.0.2
with:
- file-url: https://pkgs.netbird.io/wintun/wintun-0.14.1.zip
- file-name: wintun.zip
- location: ${{ env.downloadPath }}
- sha256: '07c256185d6ee3652e09fa55c0b673e2624b565e02c4b9091c79ca7d2f24ef51'
+ url: https://pkgs.netbird.io/wintun/wintun-0.14.1.zip
+ destination: ${{ env.downloadPath }}\wintun.zip
+ sha256: 07c256185d6ee3652e09fa55c0b673e2624b565e02c4b9091c79ca7d2f24ef51
- name: Decompress wintun files
- run: tar -zvxf "${{ steps.download-wintun.outputs.file-path }}" -C ${{ env.downloadPath }}
+ run: tar -xvf "${{ env.downloadPath }}\wintun.zip" -C ${{ env.downloadPath }}
- name: Move wintun.dll into dist
run: mv ${{ env.downloadPath }}\wintun\bin\${{ matrix.wintun_arch }}\wintun.dll ${{ github.workspace }}\dist\${{ env.PackageWorkdir }}\
- name: Download Mesa3D (amd64 only)
- uses: carlosperate/download-file-action@v2
id: download-mesa3d
if: matrix.arch == 'amd64'
+ uses: netbirdio/shared-actions/actions/win-download-and-verify@be5df6047383da2236e02243cceb857d8567c27e # v0.0.2
with:
- file-url: https://downloads.fdossena.com/Projects/Mesa3D/Builds/MesaForWindows-x64-20.1.8.7z
- file-name: mesa3d.7z
- location: ${{ env.downloadPath }}
- sha256: '71c7cb64ec229a1d6b8d62fa08e1889ed2bd17c0eeede8689daf0f25cb31d6b9'
+ url: https://pkgs.netbird.io/mesa3d/MesaForWindows-x64-20.1.8.7z
+ destination: ${{ env.downloadPath }}\mesa3d.7z
+ sha256: 71c7cb64ec229a1d6b8d62fa08e1889ed2bd17c0eeede8689daf0f25cb31d6b9
- name: Extract Mesa3D driver (amd64 only)
if: matrix.arch == 'amd64'
@@ -547,35 +554,38 @@ jobs:
run: mv ${{ env.downloadPath }}\opengl32.dll ${{ github.workspace }}\dist\${{ env.PackageWorkdir }}\
- name: Download EnVar plugin for NSIS
- uses: carlosperate/download-file-action@v2
+ uses: netbirdio/shared-actions/actions/win-download-and-verify@be5df6047383da2236e02243cceb857d8567c27e # v0.0.2
with:
- file-url: https://nsis.sourceforge.io/mediawiki/images/7/7f/EnVar_plugin.zip
- file-name: envar_plugin.zip
- location: ${{ github.workspace }}
+ url: https://pkgs.netbird.io/nsis/EnVar_plugin.zip
+ destination: ${{ github.workspace }}\envar_plugin.zip
+ sha256: e9aa92de351345ed82795251d838f1ae9041ba35af9d381a5780c7843b01f56a
- name: Extract EnVar plugin
run: 7z x -o"${{ github.workspace }}/NSIS_Plugins" "${{ github.workspace }}/envar_plugin.zip"
- name: Download ShellExecAsUser plugin for NSIS (amd64 only)
- uses: carlosperate/download-file-action@v2
if: matrix.arch == 'amd64'
+ uses: netbirdio/shared-actions/actions/win-download-and-verify@be5df6047383da2236e02243cceb857d8567c27e # v0.0.2
with:
- file-url: https://nsis.sourceforge.io/mediawiki/images/6/68/ShellExecAsUser_amd64-Unicode.7z
- file-name: ShellExecAsUser_amd64-Unicode.7z
- location: ${{ github.workspace }}
+ url: https://pkgs.netbird.io/nsis/ShellExecAsUser_amd64-Unicode.7z
+ destination: ${{ github.workspace }}\ShellExecAsUser_amd64-Unicode.7z
+ sha256: 0a55ea25c7330a92cec028eda8afcaf1b1a7092e0dfb77c21c8f654564b4ff9d
- name: Extract ShellExecAsUser plugin (amd64 only)
if: matrix.arch == 'amd64'
run: 7z x -o"${{ github.workspace }}/NSIS_Plugins" "${{ github.workspace }}/ShellExecAsUser_amd64-Unicode.7z"
- name: Build NSIS installer
- uses: joncloud/makensis-action@v3.3
- with:
- additional-plugin-paths: ${{ github.workspace }}/NSIS_Plugins/Plugins
- script-file: client/installer.nsis
- arguments: "/V4 /DARCH=${{ matrix.arch }}"
+ shell: pwsh
env:
APPVER: ${{ steps.semver_parser.outputs.major }}.${{ steps.semver_parser.outputs.minor }}.${{ steps.semver_parser.outputs.patch }}.${{ github.run_id }}
+ run: |
+ $nsisPluginDir = "C:\Program Files (x86)\NSIS\Plugins\x86-unicode"
+ $srcPlugins = "${{ github.workspace }}\NSIS_Plugins\Plugins"
+ Get-ChildItem -Path $srcPlugins -Recurse -Filter *.dll |
+ Copy-Item -Destination $nsisPluginDir -Force
+ & "C:\Program Files (x86)\NSIS\makensis.exe" /V4 "/DARCH=${{ matrix.arch }}" client\installer.nsis
+ if ($LASTEXITCODE -ne 0) { throw "makensis failed with exit code $LASTEXITCODE" }
- name: Rename NSIS installer
run: mv netbird-installer.exe netbird_installer_test_windows_${{ matrix.arch }}.exe
@@ -592,7 +602,7 @@ jobs:
- name: Upload installer artifacts
if: always()
- uses: actions/upload-artifact@v4
+ uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a #v7.0.1
with:
name: windows-installer-test-${{ matrix.arch }}
path: |
@@ -611,7 +621,7 @@ jobs:
pull-requests: write
steps:
- name: Create or update PR comment
- uses: actions/github-script@v7
+ uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
env:
RELEASE_RESULT: ${{ needs.release.result }}
RELEASE_UI_RESULT: ${{ needs.release_ui.result }}
@@ -703,7 +713,7 @@ jobs:
if: startsWith(github.ref, 'refs/tags/')
steps:
- name: Trigger binaries sign pipelines
- uses: benc-uk/workflow-dispatch@v1
+ uses: benc-uk/workflow-dispatch@31e2b3319479a63f0ab15bf800eff9e913504e26 # v1.3.2
with:
workflow: Sign bin and installer
repo: netbirdio/sign-pipelines
diff --git a/.github/workflows/sync-main.yml b/.github/workflows/sync-main.yml
index e36e35a2d..5805fcf57 100644
--- a/.github/workflows/sync-main.yml
+++ b/.github/workflows/sync-main.yml
@@ -14,9 +14,9 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Trigger main branch sync
- uses: benc-uk/workflow-dispatch@v1
+ uses: benc-uk/workflow-dispatch@31e2b3319479a63f0ab15bf800eff9e913504e26 # v1.3.2
with:
workflow: sync-main.yml
repo: ${{ secrets.UPSTREAM_REPO }}
token: ${{ secrets.NC_GITHUB_TOKEN }}
- inputs: '{ "sha": "${{ github.sha }}" }'
\ No newline at end of file
+ inputs: '{ "sha": "${{ github.sha }}" }'
diff --git a/.github/workflows/sync-tag.yml b/.github/workflows/sync-tag.yml
index a75d9a9d5..d99f88b54 100644
--- a/.github/workflows/sync-tag.yml
+++ b/.github/workflows/sync-tag.yml
@@ -3,7 +3,7 @@ name: sync tag
on:
push:
tags:
- - 'v*'
+ - "v*"
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}-${{ github.head_ref || github.actor_id }}
@@ -16,7 +16,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Trigger release tag sync
- uses: benc-uk/workflow-dispatch@v1
+ uses: benc-uk/workflow-dispatch@31e2b3319479a63f0ab15bf800eff9e913504e26 # v1.3.2
with:
workflow: sync-tag.yml
ref: main
@@ -29,7 +29,7 @@ jobs:
if: github.event.created && !github.event.deleted && startsWith(github.ref, 'refs/tags/v') && !contains(github.ref_name, '-')
steps:
- name: Trigger android-client submodule bump
- uses: benc-uk/workflow-dispatch@7a027648b88c2413826b6ddd6c76114894dc5ec4 # v1.3.1
+ uses: benc-uk/workflow-dispatch@31e2b3319479a63f0ab15bf800eff9e913504e26 # v1.3.2
with:
workflow: bump-netbird.yml
ref: main
@@ -42,10 +42,10 @@ jobs:
if: github.event.created && !github.event.deleted && startsWith(github.ref, 'refs/tags/v') && !contains(github.ref_name, '-')
steps:
- name: Trigger ios-client submodule bump
- uses: benc-uk/workflow-dispatch@7a027648b88c2413826b6ddd6c76114894dc5ec4 # v1.3.1
+ uses: benc-uk/workflow-dispatch@31e2b3319479a63f0ab15bf800eff9e913504e26 # v1.3.2
with:
workflow: bump-netbird.yml
ref: main
repo: netbirdio/ios-client
token: ${{ secrets.NC_GITHUB_TOKEN }}
- inputs: '{ "tag": "${{ github.ref_name }}" }'
\ No newline at end of file
+ inputs: '{ "tag": "${{ github.ref_name }}" }'
diff --git a/.github/workflows/test-infrastructure-files.yml b/.github/workflows/test-infrastructure-files.yml
index e2f950731..9ad1f2f67 100644
--- a/.github/workflows/test-infrastructure-files.yml
+++ b/.github/workflows/test-infrastructure-files.yml
@@ -6,10 +6,10 @@ on:
- main
pull_request:
paths:
- - 'infrastructure_files/**'
- - '.github/workflows/test-infrastructure-files.yml'
- - 'management/cmd/**'
- - 'signal/cmd/**'
+ - "infrastructure_files/**"
+ - ".github/workflows/test-infrastructure-files.yml"
+ - "management/cmd/**"
+ - "signal/cmd/**"
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}-${{ github.head_ref || github.actor_id }}
@@ -20,7 +20,7 @@ jobs:
runs-on: ubuntu-latest
strategy:
matrix:
- store: [ 'sqlite', 'postgres', 'mysql' ]
+ store: ["sqlite", "postgres", "mysql"]
services:
postgres:
image: ${{ (matrix.store == 'postgres') && 'postgres' || '' }}
@@ -68,15 +68,17 @@ jobs:
run: sudo apt-get install -y curl
- name: Checkout code
- uses: actions/checkout@v4
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ with:
+ persist-credentials: false
- name: Install Go
- uses: actions/setup-go@v5
+ uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0
with:
go-version-file: "go.mod"
- name: Cache Go modules
- uses: actions/cache@v4
+ uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
path: ~/go/pkg/mod
key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }}
@@ -139,8 +141,8 @@ jobs:
CI_NETBIRD_IDP_MGMT_CLIENT_SECRET: testing.client.secret
CI_NETBIRD_SIGNAL_PORT: 12345
CI_NETBIRD_STORE_CONFIG_ENGINE: ${{ matrix.store }}
- NETBIRD_STORE_ENGINE_POSTGRES_DSN: '${{ env.NETBIRD_STORE_ENGINE_POSTGRES_DSN }}$'
- NETBIRD_STORE_ENGINE_MYSQL_DSN: '${{ env.NETBIRD_STORE_ENGINE_MYSQL_DSN }}$'
+ NETBIRD_STORE_ENGINE_POSTGRES_DSN: "${{ env.NETBIRD_STORE_ENGINE_POSTGRES_DSN }}$"
+ NETBIRD_STORE_ENGINE_MYSQL_DSN: "${{ env.NETBIRD_STORE_ENGINE_MYSQL_DSN }}$"
CI_NETBIRD_MGMT_IDP_SIGNKEY_REFRESH: false
CI_NETBIRD_TURN_EXTERNAL_IP: "1.2.3.4"
CI_NETBIRD_MGMT_DISABLE_DEFAULT_POLICY: false
@@ -254,7 +256,9 @@ jobs:
run: sudo apt-get install -y jq
- name: Checkout code
- uses: actions/checkout@v4
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ with:
+ persist-credentials: false
- name: run script with Zitadel PostgreSQL
run: NETBIRD_DOMAIN=use-ip bash -x infrastructure_files/getting-started-with-zitadel.sh
diff --git a/.github/workflows/update-docs.yml b/.github/workflows/update-docs.yml
index 26f3b8f02..ff4f0a86a 100644
--- a/.github/workflows/update-docs.yml
+++ b/.github/workflows/update-docs.yml
@@ -3,9 +3,9 @@ name: update docs
on:
push:
tags:
- - 'v*'
+ - "v*"
paths:
- - 'shared/management/http/api/openapi.yml'
+ - "shared/management/http/api/openapi.yml"
jobs:
trigger_docs_api_update:
@@ -13,10 +13,10 @@ jobs:
if: startsWith(github.ref, 'refs/tags/')
steps:
- name: Trigger API pages generation
- uses: benc-uk/workflow-dispatch@v1
+ uses: benc-uk/workflow-dispatch@31e2b3319479a63f0ab15bf800eff9e913504e26 # v1.3.2
with:
workflow: generate api pages
repo: netbirdio/docs
ref: "refs/heads/main"
token: ${{ secrets.SIGN_GITHUB_TOKEN }}
- inputs: '{ "tag": "${{ github.ref }}" }'
\ No newline at end of file
+ inputs: '{ "tag": "${{ github.ref }}" }'
diff --git a/.github/workflows/wasm-build-validation.yml b/.github/workflows/wasm-build-validation.yml
index 81ae36e78..318a127dd 100644
--- a/.github/workflows/wasm-build-validation.yml
+++ b/.github/workflows/wasm-build-validation.yml
@@ -19,15 +19,17 @@ jobs:
GOARCH: wasm
steps:
- name: Checkout repository
- uses: actions/checkout@v4
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ with:
+ persist-credentials: false
- name: Install Go
- uses: actions/setup-go@v5
+ uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0
with:
go-version-file: "go.mod"
- name: Install dependencies
run: sudo apt update && sudo apt install -y -q libgtk-3-dev libayatana-appindicator3-dev libgl1-mesa-dev xorg-dev libpcap-dev
- name: Install golangci-lint
- uses: golangci/golangci-lint-action@4afd733a84b1f43292c63897423277bb7f4313a9 # v8.0.0
+ uses: golangci/golangci-lint-action@82606bf257cbaff209d206a39f5134f0cfbfd2ee #v9.2.1
with:
version: latest
install-mode: binary
@@ -42,9 +44,11 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
- uses: actions/checkout@v4
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ with:
+ persist-credentials: false
- name: Install Go
- uses: actions/setup-go@v5
+ uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0
with:
go-version-file: "go.mod"
- name: Build Wasm client
@@ -61,8 +65,7 @@ jobs:
echo "Size: ${SIZE} bytes (${SIZE_MB} MB)"
- if [ ${SIZE} -gt 58720256 ]; then
- echo "Wasm binary size (${SIZE_MB}MB) exceeds 56MB limit!"
+ if [ ${SIZE} -gt 62914560 ]; then
+ echo "Wasm binary size (${SIZE_MB}MB) exceeds 60MB limit!"
exit 1
fi
-
diff --git a/client/cmd/debug.go b/client/cmd/debug.go
index 2a8cdc887..bc7b0e98c 100644
--- a/client/cmd/debug.go
+++ b/client/cmd/debug.go
@@ -3,12 +3,14 @@ package cmd
import (
"context"
"fmt"
+ "os/user"
"strings"
"time"
log "github.com/sirupsen/logrus"
"github.com/spf13/cobra"
"google.golang.org/grpc/status"
+ "google.golang.org/protobuf/encoding/protojson"
"google.golang.org/protobuf/types/known/durationpb"
"github.com/netbirdio/netbird/client/internal"
@@ -19,6 +21,7 @@ import (
"github.com/netbirdio/netbird/client/server"
mgmProto "github.com/netbirdio/netbird/shared/management/proto"
"github.com/netbirdio/netbird/upload-server/types"
+ "github.com/netbirdio/netbird/version"
)
const errCloseConnection = "Failed to close connection: %v"
@@ -84,6 +87,73 @@ var persistenceCmd = &cobra.Command{
RunE: setSyncResponsePersistence,
}
+var debugConfigCmd = &cobra.Command{
+ Use: "config",
+ Example: " netbird debug config",
+ Short: "Dump the effective configuration",
+ Long: "Prints the daemon's resolved configuration (after applying defaults, file, env, CLI input, and MDM policy overrides) as JSON. Includes the list of MDM-managed fields.",
+ RunE: debugConfigDump,
+}
+
+// debugConfigDump implements `netbird debug config`. It resolves the
+// active profile, queries the daemon for the effective configuration
+// via GetConfig, and prints the resulting GetConfigResponse as JSON
+// (via protojson with EmitUnpopulated=true so the output is stable
+// across runs and includes zero-valued fields).
+//
+// Useful for verifying MDM enforcement end-to-end: the response's
+// mDMManagedFields array is the single source of truth for "which
+// fields is the daemon currently enforcing from the MDM source", and
+// every config field side-by-side with that list confirms the merge
+// result. Secrets in the response (e.g. PreSharedKey) are already
+// redacted by the daemon-side handler.
+func debugConfigDump(cmd *cobra.Command, _ []string) error {
+ pm := profilemanager.NewProfileManager()
+ activeProf, err := pm.GetActiveProfile()
+ if err != nil {
+ return fmt.Errorf("get active profile: %v", err)
+ }
+ currUser, err := user.Current()
+ if err != nil {
+ return fmt.Errorf("get current user: %v", err)
+ }
+
+ conn, err := getClient(cmd)
+ if err != nil {
+ return err
+ }
+ defer func() {
+ if err := conn.Close(); err != nil {
+ log.Errorf(errCloseConnection, err)
+ }
+ }()
+
+ client := proto.NewDaemonServiceClient(conn)
+ resp, err := client.GetConfig(cmd.Context(), &proto.GetConfigRequest{
+ ProfileName: activeProf.Name,
+ Username: currUser.Username,
+ })
+ if err != nil {
+ return fmt.Errorf("failed to get config: %v", status.Convert(err).Message())
+ }
+
+ // Use protojson so well-known fields render correctly; emit defaults so
+ // the operator sees every field even when zero/empty.
+ m := protojson.MarshalOptions{Multiline: true, Indent: " ", EmitUnpopulated: true}
+ out, err := m.Marshal(resp)
+ if err != nil {
+ return fmt.Errorf("marshal config: %w", err)
+ }
+ cmd.Println(string(out))
+ return nil
+}
+
+// debugBundle requests the daemon to create a debug bundle and prints
+// the resulting local file path and, if uploaded, the uploaded file
+// key. It uses the package flags (anonymize, system info, log file
+// count, CLI version, optional upload URL) to configure the bundle
+// request. Returns an error if the RPC fails or if the daemon reports
+// an upload failure reason.
func debugBundle(cmd *cobra.Command, _ []string) error {
conn, err := getClient(cmd)
if err != nil {
@@ -100,6 +170,7 @@ func debugBundle(cmd *cobra.Command, _ []string) error {
Anonymize: anonymizeFlag,
SystemInfo: systemInfoFlag,
LogFileCount: logFileCount,
+ CliVersion: version.NetbirdVersion(),
}
if uploadBundleFlag {
request.UploadURL = uploadBundleURLFlag
@@ -298,6 +369,7 @@ func runForDuration(cmd *cobra.Command, args []string) error {
Anonymize: anonymizeFlag,
SystemInfo: systemInfoFlag,
LogFileCount: logFileCount,
+ CliVersion: version.NetbirdVersion(),
}
if uploadBundleFlag {
request.UploadURL = uploadBundleURLFlag
@@ -432,6 +504,7 @@ func generateDebugBundle(config *profilemanager.Config, recorder *peer.Status, c
SyncResponse: syncResponse,
LogPath: logFilePath,
CPUProfile: nil,
+ DaemonVersion: version.NetbirdVersion(), // acting as daemon
},
debug.BundleConfig{
IncludeSystemInfo: true,
diff --git a/client/cmd/kubernetes.go b/client/cmd/kubernetes.go
new file mode 100644
index 000000000..cc91477c6
--- /dev/null
+++ b/client/cmd/kubernetes.go
@@ -0,0 +1,301 @@
+package cmd
+
+import (
+ "context"
+ "crypto/tls"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "io"
+ "net"
+ "net/http"
+ "net/url"
+ "os"
+ "path/filepath"
+ "slices"
+ "strings"
+
+ "github.com/goccy/go-yaml"
+ log "github.com/sirupsen/logrus"
+ "github.com/spf13/cobra"
+
+ "github.com/netbirdio/netbird/client/proto"
+)
+
+const (
+ KubernetesDNSSuffix = "netbird-kubeapi-proxy"
+)
+
+var kubernetesCmd = &cobra.Command{
+ Use: "kubernetes",
+ Short: "Kubernetes cluster commands.",
+ Long: "Kubernetes cluster commands.",
+}
+
+var kubernetesListCmd = &cobra.Command{
+ Use: "list",
+ RunE: kubernetesList,
+ Short: "List Kubernetes clusters.",
+ Long: "List Kubernetes clusters by discovering NetBird peers running netbird-kubeapi-proxy.",
+}
+
+var kubernetesWriteKubeconfigCmd = &cobra.Command{
+ Use: "write-kubeconfig",
+ RunE: kubernetesWriteKubeconfig,
+ Args: cobra.ExactArgs(1),
+ Short: "Write kubeconfig for a Kubernetes cluster.",
+ Long: "Updates kubeconfig in place to allow token-less access to the Kubernetes cluster through NetBird.",
+}
+
+func init() {
+ kubernetesWriteKubeconfigCmd.Flags().String("kubeconfig", "", "path to kubeconfig file")
+}
+
+func kubernetesList(cmd *cobra.Command, _ []string) error {
+ conn, err := getClient(cmd)
+ if err != nil {
+ return err
+ }
+ defer conn.Close()
+ client := proto.NewDaemonServiceClient(conn)
+ statusResp, err := client.Status(cmd.Context(), &proto.StatusRequest{GetFullPeerStatus: true})
+ if err != nil {
+ return err
+ }
+
+ kcs, err := getKubernetesClusters(cmd.Context(), statusResp.FullStatus.Peers, "")
+ if err != nil {
+ return err
+ }
+ if len(kcs) == 0 {
+ cmd.Println("No Kubernetes clusters available.")
+ return nil
+ }
+ cmd.Println("Available Kubernetes clusters:")
+ for _, k := range kcs {
+ cmd.Printf("\n - Name: %s\n FQDN: %s\n Version: %s\n", k.name, k.url.Host, k.version)
+ }
+ return nil
+}
+
+func kubernetesWriteKubeconfig(cmd *cobra.Command, args []string) error {
+ kubeconfigPath, err := resolveKubeconfigPath(cmd)
+ if err != nil {
+ return err
+ }
+
+ conn, err := getClient(cmd)
+ if err != nil {
+ return err
+ }
+ defer conn.Close()
+ client := proto.NewDaemonServiceClient(conn)
+ statusResp, err := client.Status(cmd.Context(), &proto.StatusRequest{GetFullPeerStatus: true})
+ if err != nil {
+ return err
+ }
+
+ clusterName := args[0]
+ kcs, err := getKubernetesClusters(cmd.Context(), statusResp.FullStatus.Peers, clusterName)
+ if err != nil {
+ return err
+ }
+ if len(kcs) == 0 {
+ return fmt.Errorf("kubernetes cluster named %s not found", clusterName)
+ }
+ if len(kcs) > 1 {
+ return fmt.Errorf("too many Kubernetes clusters returned")
+ }
+ err = writeKubeconfig(kubeconfigPath, kcs[0])
+ if err != nil {
+ return err
+ }
+ return nil
+}
+
+type kubernetesCluster struct {
+ name string
+ url *url.URL
+ version string
+}
+
+func getKubernetesClusters(ctx context.Context, peers []*proto.PeerState, nameFilter string) ([]kubernetesCluster, error) {
+ transport := http.DefaultTransport.(*http.Transport).Clone()
+ transport.TLSClientConfig = &tls.Config{
+ InsecureSkipVerify: true,
+ }
+ httpClient := &http.Client{
+ Transport: transport,
+ }
+ resolver := net.Resolver{
+ // Required so both DNS records are returned.
+ // https://github.com/golang/go/issues/17093
+ PreferGo: true,
+ }
+
+ kcs := []kubernetesCluster{}
+ attempted := map[string]struct{}{}
+ for _, peer := range peers {
+ fqdns, err := resolver.LookupAddr(ctx, peer.IP)
+ if err != nil {
+ return nil, err
+ }
+ for _, fqdn := range fqdns {
+ if _, ok := attempted[fqdn]; ok {
+ continue
+ }
+ attempted[fqdn] = struct{}{}
+ comps := strings.Split(fqdn, ".")
+ if len(comps) < 2 {
+ continue
+ }
+ if comps[1] != KubernetesDNSSuffix {
+ continue
+ }
+ if nameFilter != "" && nameFilter != comps[0] {
+ continue
+ }
+ clusterURL, clusterVersion, err := fingerprintClusters(ctx, httpClient, fqdn)
+ if err != nil {
+ log.Debugf("could not fingerprint Kubernetes cluster %s %q", fqdn, err)
+ continue
+ }
+ kc := kubernetesCluster{
+ name: comps[0],
+ url: clusterURL,
+ version: clusterVersion,
+ }
+ if nameFilter != "" {
+ return []kubernetesCluster{kc}, nil
+ }
+ kcs = append(kcs, kc)
+ }
+ }
+ return kcs, nil
+}
+
+func fingerprintClusters(ctx context.Context, httpClient *http.Client, fqdn string) (*url.URL, string, error) {
+ clusterURL, err := url.Parse("https://" + fqdn)
+ if err != nil {
+ return nil, "", err
+ }
+ versionURL, err := clusterURL.Parse("/version")
+ if err != nil {
+ return nil, "", err
+ }
+ req, err := http.NewRequestWithContext(ctx, http.MethodGet, versionURL.String(), nil)
+ if err != nil {
+ return nil, "", err
+ }
+ resp, err := httpClient.Do(req)
+ if err != nil {
+ return nil, "", err
+ }
+ defer resp.Body.Close()
+ if resp.StatusCode != http.StatusOK {
+ return nil, "", fmt.Errorf("expected %d response but got %s", http.StatusOK, resp.Status)
+ }
+ b, err := io.ReadAll(resp.Body)
+ if err != nil {
+ return nil, "", err
+ }
+ versionData := map[string]string{}
+ err = json.Unmarshal(b, &versionData)
+ if err != nil {
+ return nil, "", err
+ }
+ version, ok := versionData["gitVersion"]
+ if !ok {
+ return nil, "", errors.New("no version found in response")
+ }
+ return clusterURL, version, nil
+}
+
+func resolveKubeconfigPath(cmd *cobra.Command) (string, error) {
+ if cmd.Flags().Changed("kubeconfig") {
+ path, err := cmd.Flags().GetString("kubeconfig")
+ if err != nil {
+ return "", err
+ }
+ return path, nil
+ }
+ if env := os.Getenv("KUBECONFIG"); env != "" {
+ return env, nil
+ }
+ home, err := os.UserHomeDir()
+ if err != nil {
+ return "", fmt.Errorf("could not determine home directory: %w", err)
+ }
+ return filepath.Join(home, ".kube", "config"), nil
+}
+
+func writeKubeconfig(kubeconfigPath string, kc kubernetesCluster) error {
+ b, err := os.ReadFile(kubeconfigPath)
+ if err != nil && !errors.Is(err, os.ErrNotExist) {
+ return err
+ }
+ var cfg map[string]any
+ if err := yaml.Unmarshal(b, &cfg); err != nil {
+ return err
+ }
+ if cfg == nil {
+ cfg = map[string]any{
+ "apiVersion": "v1",
+ "kind": "Config",
+ }
+ }
+
+ cfg["clusters"] = appendWithName(cfg["clusters"], map[string]any{
+ "name": kc.name,
+ "cluster": map[string]any{
+ "server": kc.url.String(),
+ "insecure-skip-tls-verify": true,
+ },
+ })
+ cfg["users"] = appendWithName(cfg["users"], map[string]any{
+ "name": "netbird",
+ "user": map[string]any{
+ "token": "none",
+ },
+ })
+ cfg["contexts"] = appendWithName(cfg["contexts"], map[string]any{
+ "name": kc.name,
+ "context": map[string]any{
+ "cluster": kc.name,
+ "user": "netbird",
+ "namespace": "default",
+ },
+ })
+ cfg["current-context"] = kc.name
+
+ out, err := yaml.Marshal(cfg)
+ if err != nil {
+ return err
+ }
+ if err := os.WriteFile(kubeconfigPath, out, 0o600); err != nil {
+ return err
+ }
+ return nil
+}
+
+func appendWithName(data any, add map[string]any) any {
+ if data == nil {
+ return []any{add}
+ }
+ v, ok := data.([]any)
+ if !ok {
+ return []any{add}
+ }
+ i := slices.IndexFunc(v, func(item any) bool {
+ m, ok := item.(map[string]any)
+ if !ok {
+ return false
+ }
+ return m["name"] == add["name"]
+ })
+ if i == -1 {
+ return append(v, add)
+ }
+ v[i] = add
+ return v
+}
diff --git a/client/cmd/kubernetes_test.go b/client/cmd/kubernetes_test.go
new file mode 100644
index 000000000..c40d20996
--- /dev/null
+++ b/client/cmd/kubernetes_test.go
@@ -0,0 +1,120 @@
+package cmd
+
+import (
+ "net/http"
+ "net/http/httptest"
+ "net/url"
+ "os"
+ "path/filepath"
+ "testing"
+
+ "github.com/spf13/cobra"
+ "github.com/stretchr/testify/require"
+)
+
+func TestFingerprintClusters(t *testing.T) {
+ t.Parallel()
+
+ srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ //nolint: errcheck
+ w.Write([]byte(`{"gitVersion": "foobar"}`))
+ }))
+ defer srv.Close()
+
+ clusterURL, clusterVersion, err := fingerprintClusters(t.Context(), srv.Client(), srv.Listener.Addr().String())
+ require.NoError(t, err)
+ require.Equal(t, srv.URL, clusterURL.String())
+ require.Equal(t, "foobar", clusterVersion)
+}
+
+func TestResolveKubeconfigPath(t *testing.T) {
+ home, err := os.UserHomeDir()
+ if err != nil {
+ t.Fatalf("could not determine home directory: %v", err)
+ }
+ defaultPath := filepath.Join(home, ".kube", "config")
+ path, err := resolveKubeconfigPath(&cobra.Command{})
+ require.NoError(t, err)
+ require.Equal(t, defaultPath, path)
+
+ flagPath := "flag-path"
+ cmd := &cobra.Command{}
+ cmd.Flags().String("kubeconfig", "", "")
+ err = cmd.Flags().Set("kubeconfig", flagPath)
+ require.NoError(t, err)
+ path, err = resolveKubeconfigPath(cmd)
+ require.NoError(t, err)
+ require.Equal(t, flagPath, path)
+
+ envPath := "env-path"
+ t.Setenv("KUBECONFIG", envPath)
+ path, err = resolveKubeconfigPath(&cobra.Command{})
+ require.NoError(t, err)
+ require.Equal(t, envPath, path)
+}
+
+func TestWriteKubeconfig(t *testing.T) {
+ t.Parallel()
+
+ tests := []struct {
+ name string
+ existing string
+ }{
+ {
+ name: "empty file",
+ },
+ {
+ name: "existing content",
+ existing: `apiVersion: v1
+clusters:
+- cluster:
+ insecure-skip-tls-verify: true
+ server: https://foobar.com
+ name: foo
+current-context: test
+kind: Config
+users: []
+`,
+ },
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ t.Parallel()
+
+ kubeconfigPath := filepath.Join(t.TempDir(), "config")
+ err := os.WriteFile(kubeconfigPath, []byte(tt.existing), 0o644)
+ require.NoError(t, err)
+
+ kc := kubernetesCluster{
+ name: "foo",
+ url: &url.URL{Scheme: "https", Host: "example.com"},
+ }
+ err = writeKubeconfig(kubeconfigPath, kc)
+ require.NoError(t, err)
+
+ b, err := os.ReadFile(kubeconfigPath)
+ require.NoError(t, err)
+ expected := `apiVersion: v1
+clusters:
+- cluster:
+ insecure-skip-tls-verify: true
+ server: https://example.com
+ name: foo
+contexts:
+- context:
+ cluster: foo
+ namespace: default
+ user: netbird
+ name: foo
+current-context: foo
+kind: Config
+users:
+- name: netbird
+ user:
+ token: none
+`
+ require.Equal(t, expected, string(b))
+ })
+ }
+
+}
diff --git a/client/cmd/root.go b/client/cmd/root.go
index 0a0aa4197..b1d960bec 100644
--- a/client/cmd/root.go
+++ b/client/cmd/root.go
@@ -95,7 +95,9 @@ var (
}
)
-// Execute executes the root command.
+// Execute runs the appropriate Cobra command for the CLI.
+// If the process is the update binary it delegates to updateCmd; otherwise it runs the root command.
+// It returns any error produced during command execution.
func Execute() error {
if isUpdateBinary() {
return updateCmd.Execute()
@@ -103,6 +105,16 @@ func Execute() error {
return rootCmd.Execute()
}
+// init initialises package-level defaults and configures the root
+// Cobra command tree. Sets platform-specific config / log directory
+// paths (including legacy Wiretrustee fallbacks) and a default daemon
+// address; registers persistent CLI flags (daemon address,
+// management / admin URLs, logging, setup key (file and inline,
+// mutually exclusive), preshared key, hostname, anonymise, config
+// path); attaches top-level and nested subcommands to the root
+// command; and registers `up`-specific persistent flags (external IP
+// maps, custom DNS resolver address, Rosenpass options, auto-connect
+// disabling, lazy connection).
func init() {
defaultConfigPathDir = "/etc/netbird/"
defaultLogFileDir = "/var/log/netbird/"
@@ -168,6 +180,12 @@ func init() {
logCmd.AddCommand(logLevelCmd)
debugCmd.AddCommand(forCmd)
debugCmd.AddCommand(persistenceCmd)
+ debugCmd.AddCommand(debugConfigCmd)
+
+ // kubernetes commands
+ rootCmd.AddCommand(kubernetesCmd)
+ kubernetesCmd.AddCommand(kubernetesListCmd)
+ kubernetesCmd.AddCommand(kubernetesWriteKubeconfigCmd)
// profile commands
profileCmd.AddCommand(profileListCmd)
diff --git a/client/cmd/service_controller.go b/client/cmd/service_controller.go
index 88121c067..8de147946 100644
--- a/client/cmd/service_controller.go
+++ b/client/cmd/service_controller.go
@@ -102,7 +102,7 @@ func (p *program) Stop(srv service.Service) error {
}
// Common setup for service control commands
-func setupServiceControlCommand(cmd *cobra.Command, ctx context.Context, cancel context.CancelFunc) (service.Service, error) {
+func setupServiceControlCommand(cmd *cobra.Command, ctx context.Context, cancel context.CancelFunc, consoleLog bool) (service.Service, error) {
// rootCmd env vars are already applied by PersistentPreRunE.
SetFlagsFromEnvVars(serviceCmd)
@@ -112,8 +112,14 @@ func setupServiceControlCommand(cmd *cobra.Command, ctx context.Context, cancel
return nil, err
}
- if err := util.InitLog(logLevel, logFiles...); err != nil {
- return nil, fmt.Errorf("init log: %w", err)
+ if consoleLog {
+ if err := util.InitLog(logLevel, util.LogConsole); err != nil {
+ return nil, fmt.Errorf("init log: %w", err)
+ }
+ } else {
+ if err := util.InitLog(logLevel, logFiles...); err != nil {
+ return nil, fmt.Errorf("init log: %w", err)
+ }
}
cfg, err := newSVCConfig()
@@ -138,7 +144,7 @@ var runCmd = &cobra.Command{
SetupCloseHandler(ctx, cancel)
SetupDebugHandler(ctx, nil, nil, nil, util.FindFirstLogPath(logFiles))
- s, err := setupServiceControlCommand(cmd, ctx, cancel)
+ s, err := setupServiceControlCommand(cmd, ctx, cancel, false)
if err != nil {
return err
}
@@ -152,7 +158,7 @@ var startCmd = &cobra.Command{
Short: "starts NetBird service",
RunE: func(cmd *cobra.Command, args []string) error {
ctx, cancel := context.WithCancel(cmd.Context())
- s, err := setupServiceControlCommand(cmd, ctx, cancel)
+ s, err := setupServiceControlCommand(cmd, ctx, cancel, false)
if err != nil {
return err
}
@@ -170,7 +176,7 @@ var stopCmd = &cobra.Command{
Short: "stops NetBird service",
RunE: func(cmd *cobra.Command, args []string) error {
ctx, cancel := context.WithCancel(cmd.Context())
- s, err := setupServiceControlCommand(cmd, ctx, cancel)
+ s, err := setupServiceControlCommand(cmd, ctx, cancel, false)
if err != nil {
return err
}
@@ -188,7 +194,7 @@ var restartCmd = &cobra.Command{
Short: "restarts NetBird service",
RunE: func(cmd *cobra.Command, args []string) error {
ctx, cancel := context.WithCancel(cmd.Context())
- s, err := setupServiceControlCommand(cmd, ctx, cancel)
+ s, err := setupServiceControlCommand(cmd, ctx, cancel, false)
if err != nil {
return err
}
@@ -206,7 +212,7 @@ var svcStatusCmd = &cobra.Command{
Short: "shows NetBird service status",
RunE: func(cmd *cobra.Command, args []string) error {
ctx, cancel := context.WithCancel(cmd.Context())
- s, err := setupServiceControlCommand(cmd, ctx, cancel)
+ s, err := setupServiceControlCommand(cmd, ctx, cancel, true)
if err != nil {
return err
}
diff --git a/client/cmd/version.go b/client/cmd/version.go
index 249854444..5deeae1a0 100644
--- a/client/cmd/version.go
+++ b/client/cmd/version.go
@@ -12,7 +12,13 @@ var (
Short: "Print the NetBird's client application version",
Run: func(cmd *cobra.Command, args []string) {
cmd.SetOut(cmd.OutOrStdout())
- cmd.Println(version.NetbirdVersion())
+ out := version.NetbirdVersion()
+ if version.IsDevelopmentVersion(out) {
+ if commit := version.NetbirdCommit(); commit != "" {
+ out += "-" + commit
+ }
+ }
+ cmd.Println(out)
},
}
)
diff --git a/client/embed/embed.go b/client/embed/embed.go
index 04bc60fb8..0e8991be2 100644
--- a/client/embed/embed.go
+++ b/client/embed/embed.go
@@ -279,6 +279,10 @@ func (c *Client) Start(startCtx context.Context) error {
select {
case <-startCtx.Done():
+ // Cancel the client context before stopping: Engine.Start blocks on the
+ // signal stream while holding the engine mutex and only unblocks on
+ // cancellation. Stopping first would deadlock on that mutex.
+ cancel()
if stopErr := client.Stop(); stopErr != nil {
return fmt.Errorf("stop error after context done. Stop error: %w. Context done: %w", stopErr, startCtx.Err())
}
@@ -442,8 +446,8 @@ func (c *Client) Expose(ctx context.Context, req ExposeRequest) (*ExposeSession,
// IdentityForIP looks up a remote peer by its tunnel IP using the
// embedded client's status recorder. Returns the peer's WireGuard public
-// key and FQDN. ok=false means the IP isn't in this client's peer
-// roster — callers should treat that as "unknown peer".
+// key and FQDN. ok=false means the IP doesn't belong to an active peer
+// — offline roster peers are treated as unknown, same as foreign IPs.
func (c *Client) IdentityForIP(ip netip.Addr) (pubKey, fqdn string, ok bool) {
if !ip.IsValid() || c.recorder == nil {
return "", "", false
diff --git a/client/embed/embed_test.go b/client/embed/embed_test.go
new file mode 100644
index 000000000..a2f438975
--- /dev/null
+++ b/client/embed/embed_test.go
@@ -0,0 +1,168 @@
+package embed
+
+import (
+ "context"
+ "net"
+ "testing"
+ "time"
+
+ "github.com/golang/mock/gomock"
+ "github.com/stretchr/testify/require"
+ "google.golang.org/grpc"
+
+ "github.com/netbirdio/netbird/management/internals/controllers/network_map/controller"
+ "github.com/netbirdio/netbird/management/internals/controllers/network_map/update_channel"
+ "github.com/netbirdio/netbird/management/internals/modules/peers"
+ "github.com/netbirdio/netbird/management/internals/modules/peers/ephemeral/manager"
+ "github.com/netbirdio/netbird/management/internals/server/config"
+ nbgrpc "github.com/netbirdio/netbird/management/internals/shared/grpc"
+ mgmt "github.com/netbirdio/netbird/management/server"
+ "github.com/netbirdio/netbird/management/server/activity"
+ nbcache "github.com/netbirdio/netbird/management/server/cache"
+ "github.com/netbirdio/netbird/management/server/groups"
+ "github.com/netbirdio/netbird/management/server/integrations/integrated_validator/validator"
+ "github.com/netbirdio/netbird/management/server/integrations/port_forwarding"
+ "github.com/netbirdio/netbird/management/server/job"
+ "github.com/netbirdio/netbird/management/server/permissions"
+ "github.com/netbirdio/netbird/management/server/settings"
+ "github.com/netbirdio/netbird/management/server/store"
+ "github.com/netbirdio/netbird/management/server/telemetry"
+ "github.com/netbirdio/netbird/management/server/types"
+ mgmtProto "github.com/netbirdio/netbird/shared/management/proto"
+ "github.com/netbirdio/netbird/util"
+)
+
+const testSetupKey = "A2C8E62B-38F5-4553-B31E-DD66C696CEBB"
+
+// TestClientStartTimeoutRollback reproduces a deadlock between Engine.Start and
+// Engine.Stop. The signal endpoint accepts gRPC connections but never serves the
+// SignalExchange service, so Engine.Start parks in WaitStreamConnected while
+// holding the engine mutex. When the Start context expires, the rollback path
+// calls ConnectClient.Stop, which must not block forever acquiring that mutex.
+func TestClientStartTimeoutRollback(t *testing.T) {
+ signalAddr := startBlackholeSignal(t)
+ mgmAddr := startManagement(t, signalAddr)
+
+ wgPort := 0
+ client, err := New(Options{
+ DeviceName: "embed-rollback-test",
+ SetupKey: testSetupKey,
+ ManagementURL: "http://" + mgmAddr,
+ WireguardPort: &wgPort,
+ })
+ require.NoError(t, err, "embed client creation must succeed")
+
+ startCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
+ defer cancel()
+
+ startErr := make(chan error, 1)
+ go func() {
+ startErr <- client.Start(startCtx)
+ }()
+
+ select {
+ case err := <-startErr:
+ require.ErrorIs(t, err, context.DeadlineExceeded)
+ case <-time.After(60 * time.Second):
+ t.Fatal("client.Start did not return after its context expired: Engine.Stop deadlocked against Engine.Start waiting for the signal stream")
+ }
+}
+
+// startBlackholeSignal starts a gRPC server without the SignalExchange service
+// registered. Connections succeed, but the signal stream can never be
+// established, which keeps Engine.Start parked in WaitStreamConnected.
+func startBlackholeSignal(t *testing.T) string {
+ t.Helper()
+
+ lis, err := net.Listen("tcp", "localhost:0")
+ require.NoError(t, err)
+
+ s := grpc.NewServer()
+ go func() {
+ if err := s.Serve(lis); err != nil {
+ t.Error(err)
+ }
+ }()
+ t.Cleanup(s.Stop)
+
+ return lis.Addr().String()
+}
+
+func startManagement(t *testing.T, signalAddr string) string {
+ t.Helper()
+
+ cfg := &config.Config{
+ Stuns: []*config.Host{},
+ TURNConfig: &config.TURNConfig{},
+ Relay: &config.Relay{
+ Addresses: []string{"127.0.0.1:1234"},
+ CredentialsTTL: util.Duration{Duration: time.Hour},
+ Secret: "222222222222222222",
+ },
+ Signal: &config.Host{
+ Proto: "http",
+ URI: signalAddr,
+ },
+ Datadir: t.TempDir(),
+ HttpConfig: nil,
+ }
+
+ lis, err := net.Listen("tcp", "localhost:0")
+ require.NoError(t, err)
+
+ s := grpc.NewServer()
+
+ testStore, cleanUp, err := store.NewTestStoreFromSQL(context.Background(), "../testdata/store.sql", cfg.Datadir)
+ require.NoError(t, err)
+ t.Cleanup(cleanUp)
+
+ eventStore := &activity.InMemoryEventStore{}
+
+ permissionsManager := permissions.NewManager(testStore)
+ peersManager := peers.NewManager(testStore, permissionsManager)
+ jobManager := job.NewJobManager(nil, testStore, peersManager)
+
+ cacheStore, err := nbcache.NewStore(context.Background(), 100*time.Millisecond, 300*time.Millisecond, 100)
+ require.NoError(t, err)
+
+ iv, err := validator.NewIntegratedValidator(context.Background(), peersManager, nil, eventStore, cacheStore)
+ require.NoError(t, err)
+ metrics, err := telemetry.NewDefaultAppMetrics(context.Background())
+ require.NoError(t, err)
+
+ ctrl := gomock.NewController(t)
+ t.Cleanup(ctrl.Finish)
+ settingsMockManager := settings.NewMockManager(ctrl)
+ settingsMockManager.EXPECT().
+ GetSettings(gomock.Any(), gomock.Any(), gomock.Any()).
+ Return(&types.Settings{}, nil).
+ AnyTimes()
+ settingsMockManager.EXPECT().
+ GetExtraSettings(gomock.Any(), gomock.Any()).
+ Return(&types.ExtraSettings{}, nil).
+ AnyTimes()
+
+ groupsManager := groups.NewManagerMock()
+
+ updateManager := update_channel.NewPeersUpdateManager(metrics)
+ requestBuffer := mgmt.NewAccountRequestBuffer(context.Background(), testStore)
+ networkMapController := controller.NewController(context.Background(), testStore, metrics, updateManager, requestBuffer, mgmt.MockIntegratedValidator{}, settingsMockManager, "netbird.selfhosted", port_forwarding.NewControllerMock(), manager.NewEphemeralManager(testStore, peersManager), cfg)
+ accountManager, err := mgmt.BuildManager(context.Background(), cfg, testStore, networkMapController, jobManager, nil, "", eventStore, nil, false, iv, metrics, port_forwarding.NewControllerMock(), settingsMockManager, permissionsManager, false, cacheStore)
+ require.NoError(t, err)
+
+ secretsManager, err := nbgrpc.NewTimeBasedAuthSecretsManager(updateManager, cfg.TURNConfig, cfg.Relay, settingsMockManager, groupsManager)
+ require.NoError(t, err)
+
+ mgmtServer, err := nbgrpc.NewServer(cfg, accountManager, settingsMockManager, jobManager, secretsManager, nil, nil, &mgmt.MockIntegratedValidator{}, networkMapController, nil, nil)
+ require.NoError(t, err)
+ mgmtProto.RegisterManagementServiceServer(s, mgmtServer)
+
+ go func() {
+ if err := s.Serve(lis); err != nil {
+ t.Error(err)
+ }
+ }()
+ t.Cleanup(s.Stop)
+
+ return lis.Addr().String()
+}
diff --git a/client/firewall/iptables/acl_linux.go b/client/firewall/iptables/acl_linux.go
index e5e19cec9..4b4cebf9c 100644
--- a/client/firewall/iptables/acl_linux.go
+++ b/client/firewall/iptables/acl_linux.go
@@ -3,6 +3,7 @@ package iptables
import (
"errors"
"fmt"
+ "maps"
"net"
"slices"
@@ -421,12 +422,17 @@ func (m *aclManager) updateState() {
currentState.Lock()
defer currentState.Unlock()
+ // Clone the maps so the persisted state holds a private snapshot. The
+ // live maps keep being mutated by subsequent rule operations while the
+ // state manager marshals the state from its periodic-save goroutine.
+ // Sharing them by reference races the two and aborts the process with a
+ // concurrent map iteration and write.
if m.v6 {
- currentState.ACLEntries6 = m.entries
- currentState.ACLIPsetStore6 = m.ipsetStore
+ currentState.ACLEntries6 = maps.Clone(m.entries)
+ currentState.ACLIPsetStore6 = m.ipsetStore.clone()
} else {
- currentState.ACLEntries = m.entries
- currentState.ACLIPsetStore = m.ipsetStore
+ currentState.ACLEntries = maps.Clone(m.entries)
+ currentState.ACLIPsetStore = m.ipsetStore.clone()
}
if err := m.stateManager.UpdateState(currentState); err != nil {
diff --git a/client/firewall/iptables/router_linux.go b/client/firewall/iptables/router_linux.go
index 290e5da1e..42d305f5c 100644
--- a/client/firewall/iptables/router_linux.go
+++ b/client/firewall/iptables/router_linux.go
@@ -4,6 +4,7 @@ package iptables
import (
"fmt"
+ "maps"
"net/netip"
"strconv"
"strings"
@@ -749,11 +750,17 @@ func (r *router) updateState() {
currentState.Lock()
defer currentState.Unlock()
+ // Clone the rule map so the persisted state holds a private snapshot. The
+ // live map keeps being mutated by subsequent rule operations while the
+ // state manager marshals the state from its periodic-save goroutine.
+ // Sharing it by reference races the two and aborts the process with a
+ // concurrent map iteration and write. The ipset counter guards itself
+ // during marshaling, so it can be shared directly.
if r.v6 {
- currentState.RouteRules6 = r.rules
+ currentState.RouteRules6 = maps.Clone(r.rules)
currentState.RouteIPsetCounter6 = r.ipsetCounter
} else {
- currentState.RouteRules = r.rules
+ currentState.RouteRules = maps.Clone(r.rules)
currentState.RouteIPsetCounter = r.ipsetCounter
}
diff --git a/client/firewall/iptables/rulestore_linux.go b/client/firewall/iptables/rulestore_linux.go
index 004c512a4..a6d36540e 100644
--- a/client/firewall/iptables/rulestore_linux.go
+++ b/client/firewall/iptables/rulestore_linux.go
@@ -1,6 +1,9 @@
package iptables
-import "encoding/json"
+import (
+ "encoding/json"
+ "maps"
+)
type ipList struct {
ips map[string]struct{}
@@ -19,6 +22,14 @@ func (s *ipList) addIP(ip string) {
s.ips[ip] = struct{}{}
}
+// clone returns a deep copy of the ipList with its own ips map.
+func (s *ipList) clone() *ipList {
+ if s == nil {
+ return nil
+ }
+ return &ipList{ips: maps.Clone(s.ips)}
+}
+
// MarshalJSON implements json.Marshaler
func (s *ipList) MarshalJSON() ([]byte, error) {
return json.Marshal(struct {
@@ -55,6 +66,19 @@ func newIpsetStore() *ipsetStore {
}
}
+// clone returns a deep copy of the ipsetStore with its own ipsets map and
+// independent ipList entries.
+func (s *ipsetStore) clone() *ipsetStore {
+ if s == nil {
+ return nil
+ }
+ cloned := &ipsetStore{ipsets: make(map[string]*ipList, len(s.ipsets))}
+ for name, list := range s.ipsets {
+ cloned.ipsets[name] = list.clone()
+ }
+ return cloned
+}
+
func (s *ipsetStore) ipset(ipsetName string) (*ipList, bool) {
r, ok := s.ipsets[ipsetName]
return r, ok
diff --git a/client/firewall/uspfilter/forwarder/icmp.go b/client/firewall/uspfilter/forwarder/icmp.go
index d6d4e705e..94a50570f 100644
--- a/client/firewall/uspfilter/forwarder/icmp.go
+++ b/client/firewall/uspfilter/forwarder/icmp.go
@@ -362,6 +362,10 @@ func (f *Forwarder) injectICMPv6Reply(id stack.TransportEndpointID, icmpPayload
return 0
}
+ if pc := f.endpoint.capture.Load(); pc != nil {
+ (*pc).Offer(fullPacket, true)
+ }
+
return len(fullPacket)
}
diff --git a/client/iface/bind/ice_bind.go b/client/iface/bind/ice_bind.go
index bf79ecd79..156450c61 100644
--- a/client/iface/bind/ice_bind.go
+++ b/client/iface/bind/ice_bind.go
@@ -41,7 +41,6 @@ type ICEBind struct {
*wgConn.StdNetBind
transportNet transport.Net
- filterFn udpmux.FilterFn
address wgaddr.Address
mtu uint16
@@ -61,12 +60,11 @@ type ICEBind struct {
ipv6Conn *net.UDPConn
}
-func NewICEBind(transportNet transport.Net, filterFn udpmux.FilterFn, address wgaddr.Address, mtu uint16) *ICEBind {
+func NewICEBind(transportNet transport.Net, address wgaddr.Address, mtu uint16) *ICEBind {
b, _ := wgConn.NewStdNetBind().(*wgConn.StdNetBind)
ib := &ICEBind{
StdNetBind: b,
transportNet: transportNet,
- filterFn: filterFn,
address: address,
mtu: mtu,
endpoints: make(map[netip.Addr]net.Conn),
@@ -265,7 +263,6 @@ func (s *ICEBind) createOrUpdateMux() {
udpmux.UniversalUDPMuxParams{
UDPConn: muxConn,
Net: s.transportNet,
- FilterFn: s.filterFn,
WGAddress: s.address,
MTU: s.mtu,
},
diff --git a/client/iface/bind/ice_bind_test.go b/client/iface/bind/ice_bind_test.go
index f49e68508..0b8db7640 100644
--- a/client/iface/bind/ice_bind_test.go
+++ b/client/iface/bind/ice_bind_test.go
@@ -289,7 +289,7 @@ func setupICEBind(t *testing.T) *ICEBind {
IP: netip.MustParseAddr("100.64.0.1"),
Network: netip.MustParsePrefix("100.64.0.0/10"),
}
- return NewICEBind(transportNet, nil, address, 1280)
+ return NewICEBind(transportNet, address, 1280)
}
func createDualStackConns(t *testing.T) (*net.UDPConn, *net.UDPConn) {
diff --git a/client/iface/device/device_filter.go b/client/iface/device/device_filter.go
index fc1c65efa..7d7493835 100644
--- a/client/iface/device/device_filter.go
+++ b/client/iface/device/device_filter.go
@@ -1,10 +1,13 @@
package device
import (
+ "fmt"
"net/netip"
+ "runtime/debug"
"sync"
"sync/atomic"
+ log "github.com/sirupsen/logrus"
"golang.zx2c4.com/wireguard/tun"
)
@@ -41,10 +44,13 @@ type PacketCapture interface {
type FilteredDevice struct {
tun.Device
- filter PacketFilter
- capture atomic.Pointer[PacketCapture]
- mutex sync.RWMutex
- closeOnce sync.Once
+ filter PacketFilter
+ capture atomic.Pointer[PacketCapture]
+ // panicHandler is invoked after a panic in the underlying device is
+ // recovered in Read or Write.
+ panicHandler atomic.Pointer[func()]
+ mutex sync.RWMutex
+ closeOnce sync.Once
}
// newDeviceFilter constructor function
@@ -70,7 +76,7 @@ func (d *FilteredDevice) Close() error {
// Read wraps read method with filtering feature
func (d *FilteredDevice) Read(bufs [][]byte, sizes []int, offset int) (n int, err error) {
- if n, err = d.Device.Read(bufs, sizes, offset); err != nil {
+ if n, err = d.deviceRead(bufs, sizes, offset); err != nil {
return 0, err
}
@@ -112,7 +118,7 @@ func (d *FilteredDevice) Write(bufs [][]byte, offset int) (int, error) {
d.mutex.RUnlock()
if filter == nil {
- return d.Device.Write(bufs, offset)
+ return d.deviceWrite(bufs, offset)
}
filteredBufs := make([][]byte, 0, len(bufs))
@@ -125,9 +131,44 @@ func (d *FilteredDevice) Write(bufs [][]byte, offset int) (int, error) {
}
}
- n, err := d.Device.Write(filteredBufs, offset)
- n += dropped
- return n, err
+ n, err := d.deviceWrite(filteredBufs, offset)
+ if err != nil {
+ return n, err
+ }
+ return n + dropped, nil
+}
+
+// deviceRead calls the underlying device Read, recovering from panics in the
+// wintun read path and converting them into errors.
+func (d *FilteredDevice) deviceRead(bufs [][]byte, sizes []int, offset int) (n int, err error) {
+ defer d.recoverFromPanic("read", &n, &err)
+ return d.Device.Read(bufs, sizes, offset)
+}
+
+// deviceWrite calls the underlying device Write, recovering from panics in the
+// wintun write path and converting them into errors.
+func (d *FilteredDevice) deviceWrite(bufs [][]byte, offset int) (n int, err error) {
+ defer d.recoverFromPanic("write", &n, &err)
+ return d.Device.Write(bufs, offset)
+}
+
+// recoverFromPanic converts a panic in the underlying device into a regular
+// error and invokes the registered panic handler. The wintun read path is
+// known to panic on zero-length packets that third-party filter drivers can
+// place in the ring.
+func (d *FilteredDevice) recoverFromPanic(op string, n *int, err *error) {
+ r := recover()
+ if r == nil {
+ return
+ }
+
+ log.Errorf("recovered panic in tun device %s: %v\n%s", op, r, debug.Stack())
+ *n = 0
+ *err = fmt.Errorf("tun device %s panic: %v", op, r)
+
+ if handler := d.panicHandler.Load(); handler != nil {
+ (*handler)()
+ }
}
// SetFilter sets packet filter to device
@@ -137,6 +178,17 @@ func (d *FilteredDevice) SetFilter(filter PacketFilter) {
d.mutex.Unlock()
}
+// SetPanicHandler registers a handler invoked after a recovered panic in Read
+// or Write. The device is unusable after such a panic; the handler should
+// trigger recreation of the interface. Pass nil to remove.
+func (d *FilteredDevice) SetPanicHandler(handler func()) {
+ if handler == nil {
+ d.panicHandler.Store(nil)
+ return
+ }
+ d.panicHandler.Store(&handler)
+}
+
// SetCapture sets or clears the packet capture sink. Pass nil to disable.
// Uses atomic store so the hot path (Read/Write) is a single pointer load
// with no locking overhead when capture is off.
diff --git a/client/iface/device/device_filter_test.go b/client/iface/device/device_filter_test.go
index 8fb16ca8d..0d86c9323 100644
--- a/client/iface/device/device_filter_test.go
+++ b/client/iface/device/device_filter_test.go
@@ -221,3 +221,60 @@ func TestDeviceWrapperRead(t *testing.T) {
}
})
}
+
+func TestDeviceWrapperReadPanic(t *testing.T) {
+ ctrl := gomock.NewController(t)
+ defer ctrl.Finish()
+
+ tun := mocks.NewMockDevice(ctrl)
+ tun.EXPECT().Read(gomock.Any(), gomock.Any(), gomock.Any()).
+ DoAndReturn(func(bufs [][]byte, sizes []int, offset int) (int, error) {
+ // Reproduce the wintun zero-length packet panic (index out of range).
+ packet := make([]byte, 0)
+ return int(packet[0]), nil
+ })
+
+ wrapped := newDeviceFilter(tun)
+
+ handlerCalled := false
+ wrapped.SetPanicHandler(func() { handlerCalled = true })
+
+ n, err := wrapped.Read([][]byte{{}}, []int{0}, 0)
+ if err == nil {
+ t.Errorf("expected error from recovered panic, got nil")
+ }
+ if n != 0 {
+ t.Errorf("expected n=0, got %d", n)
+ }
+ if !handlerCalled {
+ t.Errorf("expected panic handler to be called")
+ }
+}
+
+func TestDeviceWrapperWritePanic(t *testing.T) {
+ ctrl := gomock.NewController(t)
+ defer ctrl.Finish()
+
+ tun := mocks.NewMockDevice(ctrl)
+ tun.EXPECT().Write(gomock.Any(), gomock.Any()).
+ DoAndReturn(func(bufs [][]byte, offset int) (int, error) {
+ packet := make([]byte, 0)
+ return int(packet[0]), nil
+ })
+
+ wrapped := newDeviceFilter(tun)
+
+ handlerCalled := false
+ wrapped.SetPanicHandler(func() { handlerCalled = true })
+
+ n, err := wrapped.Write([][]byte{{0x45, 0x00}}, 0)
+ if err == nil {
+ t.Errorf("expected error from recovered panic, got nil")
+ }
+ if n != 0 {
+ t.Errorf("expected n=0, got %d", n)
+ }
+ if !handlerCalled {
+ t.Errorf("expected panic handler to be called")
+ }
+}
diff --git a/client/iface/device/device_kernel_unix.go b/client/iface/device/device_kernel_unix.go
index 25c4148a6..3c429fb96 100644
--- a/client/iface/device/device_kernel_unix.go
+++ b/client/iface/device/device_kernel_unix.go
@@ -32,8 +32,6 @@ type TunKernelDevice struct {
link *wgLink
udpMuxConn net.PacketConn
udpMux *udpmux.UniversalUDPMuxDefault
-
- filterFn udpmux.FilterFn
}
func NewKernelDevice(name string, address wgaddr.Address, wgPort int, key string, mtu uint16, transportNet transport.Net) *TunKernelDevice {
@@ -104,7 +102,6 @@ func (t *TunKernelDevice) Up() (*udpmux.UniversalUDPMuxDefault, error) {
bindParams := udpmux.UniversalUDPMuxParams{
UDPConn: nbnet.WrapPacketConn(rawSock),
Net: t.transportNet,
- FilterFn: t.filterFn,
WGAddress: t.address,
MTU: t.mtu,
}
diff --git a/client/iface/iface.go b/client/iface/iface.go
index 78c5080e7..247f421a2 100644
--- a/client/iface/iface.go
+++ b/client/iface/iface.go
@@ -63,7 +63,6 @@ type WGIFaceOpts struct {
MTU uint16
MobileArgs *device.MobileIFaceArguments
TransportNet transport.Net
- FilterFn udpmux.FilterFn
DisableDNS bool
}
diff --git a/client/iface/iface_new.go b/client/iface/iface_new.go
index 28f350e3f..96a0e670f 100644
--- a/client/iface/iface_new.go
+++ b/client/iface/iface_new.go
@@ -11,7 +11,7 @@ import (
// NewWGIFace Creates a new WireGuard interface instance
func NewWGIFace(opts WGIFaceOpts) (*WGIface, error) {
- iceBind := bind.NewICEBind(opts.TransportNet, opts.FilterFn, opts.Address, opts.MTU)
+ iceBind := bind.NewICEBind(opts.TransportNet, opts.Address, opts.MTU)
var tun WGTunDevice
if netstack.IsEnabled() {
diff --git a/client/iface/iface_new_android.go b/client/iface/iface_new_android.go
index e28dcc0de..ce8b4da23 100644
--- a/client/iface/iface_new_android.go
+++ b/client/iface/iface_new_android.go
@@ -9,7 +9,7 @@ import (
// NewWGIFace Creates a new WireGuard interface instance
func NewWGIFace(opts WGIFaceOpts) (*WGIface, error) {
- iceBind := bind.NewICEBind(opts.TransportNet, opts.FilterFn, opts.Address, opts.MTU)
+ iceBind := bind.NewICEBind(opts.TransportNet, opts.Address, opts.MTU)
if netstack.IsEnabled() {
wgIFace := &WGIface{
diff --git a/client/iface/iface_new_ios.go b/client/iface/iface_new_ios.go
index 41e0022b2..cedd55ce2 100644
--- a/client/iface/iface_new_ios.go
+++ b/client/iface/iface_new_ios.go
@@ -10,7 +10,7 @@ import (
// NewWGIFace Creates a new WireGuard interface instance
func NewWGIFace(opts WGIFaceOpts) (*WGIface, error) {
- iceBind := bind.NewICEBind(opts.TransportNet, opts.FilterFn, opts.Address, opts.MTU)
+ iceBind := bind.NewICEBind(opts.TransportNet, opts.Address, opts.MTU)
wgIFace := &WGIface{
tun: device.NewTunDevice(opts.IFaceName, opts.Address, opts.WGPort, opts.WGPrivKey, opts.MTU, iceBind, opts.MobileArgs.TunFd),
diff --git a/client/iface/iface_new_linux.go b/client/iface/iface_new_linux.go
index 65ce67e88..2465130e6 100644
--- a/client/iface/iface_new_linux.go
+++ b/client/iface/iface_new_linux.go
@@ -14,7 +14,7 @@ import (
// NewWGIFace Creates a new WireGuard interface instance
func NewWGIFace(opts WGIFaceOpts) (*WGIface, error) {
if netstack.IsEnabled() {
- iceBind := bind.NewICEBind(opts.TransportNet, opts.FilterFn, opts.Address, opts.MTU)
+ iceBind := bind.NewICEBind(opts.TransportNet, opts.Address, opts.MTU)
return &WGIface{
tun: device.NewNetstackDevice(opts.IFaceName, opts.Address, opts.WGPort, opts.WGPrivKey, opts.MTU, iceBind, netstack.ListenAddr()),
userspaceBind: true,
@@ -30,7 +30,7 @@ func NewWGIFace(opts WGIFaceOpts) (*WGIface, error) {
}
if device.ModuleTunIsLoaded() {
- iceBind := bind.NewICEBind(opts.TransportNet, opts.FilterFn, opts.Address, opts.MTU)
+ iceBind := bind.NewICEBind(opts.TransportNet, opts.Address, opts.MTU)
return &WGIface{
tun: device.NewTunDevice(opts.IFaceName, opts.Address, opts.WGPort, opts.WGPrivKey, opts.MTU, iceBind),
userspaceBind: true,
diff --git a/client/iface/udpmux/universal.go b/client/iface/udpmux/universal.go
index 89a7eefb9..77e1b1b35 100644
--- a/client/iface/udpmux/universal.go
+++ b/client/iface/udpmux/universal.go
@@ -8,8 +8,6 @@ import (
"context"
"fmt"
"net"
- "net/netip"
- "sync"
"time"
log "github.com/sirupsen/logrus"
@@ -22,10 +20,6 @@ import (
"github.com/netbirdio/netbird/client/iface/wgaddr"
)
-// FilterFn is a function that filters out candidates based on the address.
-// If it returns true, the address is to be filtered. It also returns the prefix of matching route.
-type FilterFn func(address netip.Addr) (bool, netip.Prefix, error)
-
// UniversalUDPMuxDefault handles STUN and TURN servers packets by wrapping the original UDPConn
// It then passes packets to the UDPMux that does the actual connection muxing.
type UniversalUDPMuxDefault struct {
@@ -43,7 +37,6 @@ type UniversalUDPMuxParams struct {
UDPConn net.PacketConn
XORMappedAddrCacheTTL time.Duration
Net transport.Net
- FilterFn FilterFn
WGAddress wgaddr.Address
MTU uint16
}
@@ -68,7 +61,6 @@ func NewUniversalUDPMuxDefault(params UniversalUDPMuxParams) *UniversalUDPMuxDef
PacketConn: params.UDPConn,
mux: m,
logger: params.Logger,
- filterFn: params.FilterFn,
address: params.WGAddress,
}
@@ -115,15 +107,12 @@ func (m *UniversalUDPMuxDefault) ReadFromConn(ctx context.Context) {
}
}
-// UDPConn is a wrapper around UDPMux conn that overrides ReadFrom and handles STUN/TURN packets
+// UDPConn is a wrapper around UDPMux conn that overrides WriteTo to drop packets destined for the overlay subnet.
type UDPConn struct {
net.PacketConn
- mux *UniversalUDPMuxDefault
- logger logging.LeveledLogger
- filterFn FilterFn
- // TODO: reset cache on route changes
- addrCache sync.Map
- address wgaddr.Address
+ mux *UniversalUDPMuxDefault
+ logger logging.LeveledLogger
+ address wgaddr.Address
}
// GetPacketConn returns the underlying PacketConn
@@ -132,65 +121,16 @@ func (u *UDPConn) GetPacketConn() net.PacketConn {
}
func (u *UDPConn) WriteTo(b []byte, addr net.Addr) (int, error) {
- if u.filterFn == nil {
+ udpAddr, ok := addr.(*net.UDPAddr)
+ if !ok {
return u.PacketConn.WriteTo(b, addr)
}
-
- if isRouted, found := u.addrCache.Load(addr.String()); found {
- return u.handleCachedAddress(isRouted.(bool), b, addr)
- }
-
- return u.handleUncachedAddress(b, addr)
-}
-
-func (u *UDPConn) handleCachedAddress(isRouted bool, b []byte, addr net.Addr) (int, error) {
- if isRouted {
- return 0, fmt.Errorf("address %s is part of a routed network, refusing to write", addr)
- }
- return u.PacketConn.WriteTo(b, addr)
-}
-
-func (u *UDPConn) handleUncachedAddress(b []byte, addr net.Addr) (int, error) {
- if err := u.performFilterCheck(addr); err != nil {
- return 0, err
- }
- return u.PacketConn.WriteTo(b, addr)
-}
-
-func (u *UDPConn) performFilterCheck(addr net.Addr) error {
- host, err := getHostFromAddr(addr)
- if err != nil {
- log.Errorf("Failed to get host from address %s: %v", addr, err)
- return nil
- }
-
- a, err := netip.ParseAddr(host)
- if err != nil {
- log.Errorf("Failed to parse address %s: %v", addr, err)
- return nil
- }
-
- if u.address.Network.Contains(a) {
+ dst := udpAddr.AddrPort().Addr().Unmap()
+ if (u.address.Network.IsValid() && u.address.Network.Contains(dst)) || (u.address.IPv6Net.IsValid() && u.address.IPv6Net.Contains(dst)) {
log.Warnf("address %s is part of the NetBird network %s, refusing to write", addr, u.address)
- return fmt.Errorf("address %s is part of the NetBird network %s, refusing to write", addr, u.address)
+ return 0, fmt.Errorf("address %s is part of the NetBird network %s, refusing to write", addr, u.address)
}
-
- if isRouted, prefix, err := u.filterFn(a); err != nil {
- log.Errorf("Failed to check if address %s is routed: %v", addr, err)
- } else {
- u.addrCache.Store(addr.String(), isRouted)
- if isRouted {
- // Extra log, as the error only shows up with ICE logging enabled
- log.Infof("address %s is part of routed network %s, refusing to write", addr, prefix)
- return fmt.Errorf("address %s is part of routed network %s, refusing to write", addr, prefix)
- }
- }
- return nil
-}
-
-func getHostFromAddr(addr net.Addr) (string, error) {
- host, _, err := net.SplitHostPort(addr.String())
- return host, err
+ return u.PacketConn.WriteTo(b, addr)
}
// GetSharedConn returns the shared udp conn
@@ -225,6 +165,13 @@ func (m *UniversalUDPMuxDefault) HandleSTUNMessage(msg *stun.Message, addr net.A
return nil
}
+ src := udpAddr.AddrPort().Addr().Unmap()
+ wg := m.params.WGAddress
+ if (wg.Network.IsValid() && wg.Network.Contains(src)) || (wg.IPv6Net.IsValid() && wg.IPv6Net.Contains(src)) {
+ log.Debugf("dropping STUN message from overlay source %s", udpAddr)
+ return nil
+ }
+
if m.isXORMappedResponse(msg, udpAddr.String()) {
err := m.handleXORMappedResponse(udpAddr, msg)
if err != nil {
diff --git a/client/iface/wgproxy/proxy_linux_test.go b/client/iface/wgproxy/proxy_linux_test.go
index dd24d1cdc..7f7abcb4a 100644
--- a/client/iface/wgproxy/proxy_linux_test.go
+++ b/client/iface/wgproxy/proxy_linux_test.go
@@ -66,7 +66,7 @@ func seedProxyForProxyCloseByRemoteConn() ([]proxyInstance, error) {
if err != nil {
return nil, err
}
- iceBind := bind.NewICEBind(nil, nil, wgAddress, 1280)
+ iceBind := bind.NewICEBind(nil, wgAddress, 1280)
endpointAddress := &net.UDPAddr{
IP: net.IPv4(10, 0, 0, 1),
Port: 1234,
diff --git a/client/iface/wgproxy/proxy_seed_test.go b/client/iface/wgproxy/proxy_seed_test.go
index ad375ccde..9278029a5 100644
--- a/client/iface/wgproxy/proxy_seed_test.go
+++ b/client/iface/wgproxy/proxy_seed_test.go
@@ -22,7 +22,7 @@ func seedProxyForProxyCloseByRemoteConn() ([]proxyInstance, error) {
if err != nil {
return nil, err
}
- iceBind := bind.NewICEBind(nil, nil, wgAddress, 1280)
+ iceBind := bind.NewICEBind(nil, wgAddress, 1280)
endpointAddress := &net.UDPAddr{
IP: net.IPv4(10, 0, 0, 1),
Port: 1234,
diff --git a/client/internal/auth/pkce_flow.go b/client/internal/auth/pkce_flow.go
index 2e16836d8..84fa8a214 100644
--- a/client/internal/auth/pkce_flow.go
+++ b/client/internal/auth/pkce_flow.go
@@ -360,7 +360,13 @@ func isRedirectURLPortUsed(redirectURL string, excludedRanges []excludedPortRang
return true
}
- addr := fmt.Sprintf(":%s", port)
+ // FreeBSD 15 disables connecting to INADDR_ANY (0.0.0.0) as a localhost
+ // alias by default, ensure explicit ip for localhost.
+ host := parsedURL.Hostname()
+ if host == "" {
+ host = "127.0.0.1"
+ }
+ addr := net.JoinHostPort(host, port)
conn, err := net.DialTimeout("tcp", addr, 3*time.Second)
if err != nil {
return false
diff --git a/client/internal/connect.go b/client/internal/connect.go
index ea884818f..d93b62bb5 100644
--- a/client/internal/connect.go
+++ b/client/internal/connect.go
@@ -6,6 +6,7 @@ import (
"fmt"
"net"
"net/netip"
+ "path/filepath"
"runtime"
"runtime/debug"
"strings"
@@ -117,6 +118,8 @@ func (c *ConnectClient) RunOniOS(
networkChangeListener listener.NetworkChangeListener,
dnsManager dns.IosDnsManager,
stateFilePath string,
+ cacheDir string,
+ logFilePath string,
) error {
// Set GC percent to 5% to reduce memory usage as iOS only allows 50MB of memory for the extension.
debug.SetGCPercent(5)
@@ -126,8 +129,9 @@ func (c *ConnectClient) RunOniOS(
NetworkChangeListener: networkChangeListener,
DnsManager: dnsManager,
StateFilePath: stateFilePath,
+ TempDir: cacheDir,
}
- return c.run(mobileDependency, nil, "")
+ return c.run(mobileDependency, nil, logFilePath)
}
func (c *ConnectClient) run(mobileDependency MobileDependency, runningChan chan struct{}, logPath string) error {
@@ -346,6 +350,11 @@ func (c *ConnectClient) run(mobileDependency MobileDependency, runningChan chan
return wrapErr(err)
}
engineConfig.TempDir = mobileDependency.TempDir
+ // Leave StateDir empty when there is no state path so a disk-backed
+ // syncstore falls back to os.TempDir() instead of filepath.Dir("") == ".".
+ if path != "" {
+ engineConfig.StateDir = filepath.Dir(path)
+ }
relayManager := relayClient.NewManager(engineCtx, relayURLs, myPrivateKey.PublicKey().String(), engineConfig.MTU)
c.statusRecorder.SetRelayMgr(relayManager)
diff --git a/client/internal/debug/debug.go b/client/internal/debug/debug.go
index ebaf71b21..a65d8bd05 100644
--- a/client/internal/debug/debug.go
+++ b/client/internal/debug/debug.go
@@ -250,10 +250,13 @@ type BundleGenerator struct {
syncResponse *mgmProto.SyncResponse
logPath string
tempDir string
+ statePath string
cpuProfile []byte
capturePath string
refreshStatus func() // Optional callback to refresh status before bundle generation
clientMetrics MetricsExporter
+ daemonVersion string
+ cliVersion string
anonymize bool
includeSystemInfo bool
@@ -274,10 +277,13 @@ type GeneratorDependencies struct {
SyncResponse *mgmProto.SyncResponse
LogPath string
TempDir string // Directory for temporary bundle zip files. If empty, os.TempDir() is used.
+ StatePath string // Path to the state file. If empty, the ServiceManager default path is used.
CPUProfile []byte
CapturePath string
RefreshStatus func()
ClientMetrics MetricsExporter
+ DaemonVersion string
+ CliVersion string
}
func NewBundleGenerator(deps GeneratorDependencies, cfg BundleConfig) *BundleGenerator {
@@ -295,10 +301,13 @@ func NewBundleGenerator(deps GeneratorDependencies, cfg BundleConfig) *BundleGen
syncResponse: deps.SyncResponse,
logPath: deps.LogPath,
tempDir: deps.TempDir,
+ statePath: deps.StatePath,
cpuProfile: deps.CPUProfile,
capturePath: deps.CapturePath,
refreshStatus: deps.RefreshStatus,
clientMetrics: deps.ClientMetrics,
+ daemonVersion: deps.DaemonVersion,
+ cliVersion: deps.CliVersion,
anonymize: cfg.Anonymize,
includeSystemInfo: cfg.IncludeSystemInfo,
@@ -459,9 +468,11 @@ func (g *BundleGenerator) addStatus() error {
protoFullStatus := nbstatus.ToProtoFullStatus(fullStatus)
protoFullStatus.Events = g.statusRecorder.GetEventHistory()
overview := nbstatus.ConvertToStatusOutputOverview(protoFullStatus, nbstatus.ConvertOptions{
- Anonymize: g.anonymize,
- ProfileName: profName,
+ Anonymize: g.anonymize,
+ ProfileName: profName,
+ DaemonVersion: g.daemonVersion,
})
+ overview.CliVersion = g.cliVersion
statusOutput := overview.FullDetailSummary()
statusReader := strings.NewReader(statusOutput)
@@ -508,6 +519,14 @@ func (g *BundleGenerator) addConfig() error {
}
}
+ // Surface the set of MDM-enforced keys so a support engineer reading
+ // the bundle can tell which field values are user-set vs MDM-overridden.
+ // Same semantics as the mDMManagedFields list returned by the
+ // GetConfig RPC consumed by `netbird debug config`.
+ if managed := g.internalConfig.Policy().ManagedKeys(); len(managed) > 0 {
+ configContent.WriteString(fmt.Sprintf("MDMManagedFields: %v\n", managed))
+ }
+
configReader := strings.NewReader(configContent.String())
if err := g.addFileToZip(configReader, "config.txt"); err != nil {
return fmt.Errorf("add config file to zip: %w", err)
@@ -798,6 +817,8 @@ func (g *BundleGenerator) addSyncResponse() error {
AllowPartial: true,
}
+ g.maskSecrets()
+
jsonBytes, err := options.Marshal(g.syncResponse)
if err != nil {
return fmt.Errorf("generate json: %w", err)
@@ -810,9 +831,33 @@ func (g *BundleGenerator) addSyncResponse() error {
return nil
}
+func (g *BundleGenerator) maskSecrets() {
+ if g.syncResponse == nil || g.syncResponse.NetbirdConfig == nil {
+ return
+ }
+
+ if g.syncResponse.NetbirdConfig.Flow != nil {
+ g.syncResponse.NetbirdConfig.Flow.TokenPayload = maskedValue
+
+ }
+
+ if g.syncResponse.NetbirdConfig.Relay != nil {
+ g.syncResponse.NetbirdConfig.Relay.TokenPayload = maskedValue
+ }
+
+ for i := range g.syncResponse.NetbirdConfig.Turns {
+ if g.syncResponse.NetbirdConfig.Turns[i] != nil {
+ g.syncResponse.NetbirdConfig.Turns[i].Password = maskedValue
+ }
+ }
+}
+
func (g *BundleGenerator) addStateFile() error {
- sm := profilemanager.NewServiceManager("")
- path := sm.GetStatePath()
+ path := g.statePath
+ if path == "" {
+ sm := profilemanager.NewServiceManager("")
+ path = sm.GetStatePath()
+ }
if path == "" {
return nil
}
@@ -1039,7 +1084,8 @@ func (g *BundleGenerator) addRotatedLogFiles(logDir string) {
return
}
- pattern := filepath.Join(logDir, "client-*.log.gz")
+ // This regex will match both logs rotated by us and logrotate on linux
+ pattern := filepath.Join(logDir, "client*.log.*")
files, err := filepath.Glob(pattern)
if err != nil {
log.Warnf("failed to glob rotated logs: %v", err)
@@ -1072,7 +1118,12 @@ func (g *BundleGenerator) addRotatedLogFiles(logDir string) {
for i := 0; i < maxFiles; i++ {
name := filepath.Base(files[i])
- if err := g.addSingleLogFileGz(files[i], name); err != nil {
+ if strings.HasSuffix(name, ".gz") {
+ err = g.addSingleLogFileGz(files[i], name)
+ } else {
+ err = g.addSingleLogfile(files[i], name)
+ }
+ if err != nil {
log.Warnf("failed to add rotated log %s: %v", name, err)
}
}
diff --git a/client/internal/debug/debug_ios.go b/client/internal/debug/debug_ios.go
new file mode 100644
index 000000000..a07c23dbd
--- /dev/null
+++ b/client/internal/debug/debug_ios.go
@@ -0,0 +1,36 @@
+//go:build ios
+
+package debug
+
+import (
+ "path/filepath"
+
+ log "github.com/sirupsen/logrus"
+)
+
+// swiftLogFile is the Swift app log written by the iOS app into the same log
+// directory as the Go client log, so it can be collected into the bundle.
+const swiftLogFile = "swift-log.log"
+
+// addPlatformLog collects logs for the iOS debug bundle. iOS has no logcat or
+// systemd journal, so we rely on file-based logs. addLogfile handles the Go
+// client log (logPath) with rotation, the stderr/stdout companions and
+// anonymization. The iOS app writes its own Swift log into the same directory,
+// so we add it alongside the Go log.
+func (g *BundleGenerator) addPlatformLog() error {
+ if err := g.addLogfile(); err != nil {
+ return err
+ }
+
+ if g.logPath == "" {
+ return nil
+ }
+
+ swiftLogPath := filepath.Join(filepath.Dir(g.logPath), swiftLogFile)
+ if err := g.addSingleLogfile(swiftLogPath, swiftLogFile); err != nil {
+ // The Swift log is best-effort: the app may not have written it yet.
+ log.Warnf("failed to add %s to debug bundle: %v", swiftLogFile, err)
+ }
+
+ return nil
+}
diff --git a/client/internal/debug/debug_logfiles_test.go b/client/internal/debug/debug_logfiles_test.go
new file mode 100644
index 000000000..f6473f979
--- /dev/null
+++ b/client/internal/debug/debug_logfiles_test.go
@@ -0,0 +1,103 @@
+package debug
+
+import (
+ "archive/zip"
+ "bytes"
+ "compress/gzip"
+ "io"
+ "os"
+ "path/filepath"
+ "testing"
+ "time"
+
+ "github.com/stretchr/testify/require"
+)
+
+// TestAddRotatedLogFiles_PicksUpAllVariants asserts that the rotated-log
+// glob picks up logs rotated by timberjack (gzipped) and by logrotate (plain
+// and gzipped), and skips unrelated files.
+func TestAddRotatedLogFiles_PicksUpAllVariants(t *testing.T) {
+ dir := t.TempDir()
+
+ writeFile(t, filepath.Join(dir, "client.log"), "active log\n")
+ writeFile(t, filepath.Join(dir, "other.log"), "unrelated\n")
+
+ timberjackRotated := "client-2026-05-21T10-30-45.000.log.gz"
+ writeGzFile(t, filepath.Join(dir, timberjackRotated), "timberjack rotated content\n")
+
+ logrotatePlain := "client.log.1"
+ writeFile(t, filepath.Join(dir, logrotatePlain), "logrotate plain content\n")
+
+ logrotateGz := "client.log.2.gz"
+ writeGzFile(t, filepath.Join(dir, logrotateGz), "logrotate gz content\n")
+
+ names := runAddRotatedLogFiles(t, dir, 10)
+
+ require.Contains(t, names, timberjackRotated, "timberjack rotated file should be in bundle")
+ require.Contains(t, names, logrotatePlain, "logrotate plain rotated file should be in bundle")
+ require.Contains(t, names, logrotateGz, "logrotate gzipped rotated file should be in bundle")
+ require.NotContains(t, names, "client.log", "active log should not be added by addRotatedLogFiles")
+ require.NotContains(t, names, "other.log", "unrelated files should not be in bundle")
+}
+
+// TestAddRotatedLogFiles_RespectsLogFileCount asserts that only the newest
+// logFileCount rotated files are bundled, ordered by mtime.
+func TestAddRotatedLogFiles_RespectsLogFileCount(t *testing.T) {
+ dir := t.TempDir()
+
+ oldest := filepath.Join(dir, "client.log.3")
+ middle := filepath.Join(dir, "client.log.2")
+ newest := filepath.Join(dir, "client.log.1")
+ writeFile(t, oldest, "old\n")
+ writeFile(t, middle, "mid\n")
+ writeFile(t, newest, "new\n")
+
+ now := time.Now()
+ require.NoError(t, os.Chtimes(oldest, now.Add(-2*time.Hour), now.Add(-2*time.Hour)))
+ require.NoError(t, os.Chtimes(middle, now.Add(-1*time.Hour), now.Add(-1*time.Hour)))
+ require.NoError(t, os.Chtimes(newest, now, now))
+
+ names := runAddRotatedLogFiles(t, dir, 2)
+
+ require.Contains(t, names, "client.log.1")
+ require.Contains(t, names, "client.log.2")
+ require.NotContains(t, names, "client.log.3", "oldest file should be dropped when logFileCount=2")
+}
+
+// runAddRotatedLogFiles calls addRotatedLogFiles against a fresh in-memory
+// zip writer and returns the set of entry names that ended up in the archive.
+func runAddRotatedLogFiles(t *testing.T, dir string, logFileCount uint32) map[string]struct{} {
+ t.Helper()
+
+ var buf bytes.Buffer
+ g := &BundleGenerator{
+ archive: zip.NewWriter(&buf),
+ logFileCount: logFileCount,
+ }
+ g.addRotatedLogFiles(dir)
+ require.NoError(t, g.archive.Close())
+
+ zr, err := zip.NewReader(bytes.NewReader(buf.Bytes()), int64(buf.Len()))
+ require.NoError(t, err)
+
+ names := make(map[string]struct{}, len(zr.File))
+ for _, f := range zr.File {
+ names[f.Name] = struct{}{}
+ }
+ return names
+}
+
+func writeFile(t *testing.T, path, content string) {
+ t.Helper()
+ require.NoError(t, os.WriteFile(path, []byte(content), 0o644))
+}
+
+func writeGzFile(t *testing.T, path, content string) {
+ t.Helper()
+ var buf bytes.Buffer
+ gw := gzip.NewWriter(&buf)
+ _, err := io.WriteString(gw, content)
+ require.NoError(t, err)
+ require.NoError(t, gw.Close())
+ require.NoError(t, os.WriteFile(path, buf.Bytes(), 0o644))
+}
diff --git a/client/internal/debug/debug_nonandroid.go b/client/internal/debug/debug_nonandroid.go
index 117238dec..2dfca6ddc 100644
--- a/client/internal/debug/debug_nonandroid.go
+++ b/client/internal/debug/debug_nonandroid.go
@@ -1,4 +1,4 @@
-//go:build !android
+//go:build !android && !ios
package debug
diff --git a/client/internal/debug/debug_test.go b/client/internal/debug/debug_test.go
index 39b972244..76df588a5 100644
--- a/client/internal/debug/debug_test.go
+++ b/client/internal/debug/debug_test.go
@@ -843,6 +843,7 @@ func TestAddConfig_AllFieldsCovered(t *testing.T) {
"PreSharedKey": "sensitive: WireGuard pre-shared key",
"SSHKey": "sensitive: SSH private key",
"ClientCertKeyPair": "non-config: parsed cert pair, not serialized",
+ "policy": "non-config: in-memory MDM policy snapshot, surfaced via Config.Policy() / GetConfigResponse.MDMManagedFields",
}
mURL, _ := url.Parse("https://api.example.com:443")
diff --git a/client/internal/dns/local/local.go b/client/internal/dns/local/local.go
index d13aa672e..d0268186c 100644
--- a/client/internal/dns/local/local.go
+++ b/client/internal/dns/local/local.go
@@ -482,7 +482,7 @@ func (d *Resolver) logDNSError(logger *log.Entry, hostname string, qtype uint16,
// completely when every proxy peer is offline (the upstream may still
// be reachable some other way, or the peerstore may be stale).
func (d *Resolver) filterDisconnectedPeerAnswers(logger *log.Entry, question dns.Question, records []dns.RR) []dns.RR {
- if len(records) == 0 {
+ if len(records) < 2 {
return records
}
d.mu.RLock()
diff --git a/client/internal/dns/local/local_test.go b/client/internal/dns/local/local_test.go
index fdf7f2659..9b7dac231 100644
--- a/client/internal/dns/local/local_test.go
+++ b/client/internal/dns/local/local_test.go
@@ -2738,6 +2738,17 @@ func TestLocalResolver_FilterDisconnectedPeerAnswers(t *testing.T) {
connByIP: nil,
wantInOrder: []string{"100.64.0.10", "100.64.0.11"},
},
+ {
+ // A single answer is never filtered: dropping it would only
+ // trigger the empty-answer escape hatch, so the fast path
+ // returns it untouched.
+ name: "single disconnected answer passes through",
+ records: []nbdns.SimpleRecord{disconnectedRec},
+ connByIP: map[string]ipState{
+ "100.64.0.11": {known: true, connected: false},
+ },
+ wantInOrder: []string{"100.64.0.11"},
+ },
}
for _, tc := range tests {
diff --git a/client/internal/dns/resutil/resolve.go b/client/internal/dns/resutil/resolve.go
index 5a3744719..07a70d6d1 100644
--- a/client/internal/dns/resutil/resolve.go
+++ b/client/internal/dns/resutil/resolve.go
@@ -14,6 +14,10 @@ import (
log "github.com/sirupsen/logrus"
)
+// errNoSuitableAddress mirrors the unexported error string the net package
+// uses when a resolved host has no addresses of the requested family.
+const errNoSuitableAddress = "no suitable address found"
+
// GenerateRequestID creates a random 8-character hex string for request tracing.
func GenerateRequestID() string {
bytes := make([]byte, 4)
@@ -126,6 +130,14 @@ func LookupIP(ctx context.Context, r resolver, network, host string, qtype uint1
}
func getRcodeForError(ctx context.Context, r resolver, host string, qtype uint16, err error) int {
+ // The net package returns this AddrError when the host resolves but has
+ // no addresses of the requested family. The domain exists, so answer
+ // NODATA instead of SERVFAIL.
+ var addrErr *net.AddrError
+ if errors.As(err, &addrErr) && addrErr.Err == errNoSuitableAddress {
+ return dns.RcodeSuccess
+ }
+
var dnsErr *net.DNSError
if !errors.As(err, &dnsErr) {
return dns.RcodeServerFailure
diff --git a/client/internal/dns/resutil/resolve_test.go b/client/internal/dns/resutil/resolve_test.go
new file mode 100644
index 000000000..432367c22
--- /dev/null
+++ b/client/internal/dns/resutil/resolve_test.go
@@ -0,0 +1,122 @@
+package resutil
+
+import (
+ "context"
+ "errors"
+ "net"
+ "net/netip"
+ "testing"
+
+ "github.com/miekg/dns"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+type mockResolver struct {
+ // results maps network ("ip4"/"ip6") to the lookup outcome.
+ results map[string]mockLookup
+}
+
+type mockLookup struct {
+ ips []netip.Addr
+ err error
+}
+
+func (m *mockResolver) LookupNetIP(_ context.Context, network, _ string) ([]netip.Addr, error) {
+ res, ok := m.results[network]
+ if !ok {
+ return nil, errors.New("unexpected network: " + network)
+ }
+ return res.ips, res.err
+}
+
+func TestLookupIP_Success(t *testing.T) {
+ r := &mockResolver{results: map[string]mockLookup{
+ "ip4": {ips: []netip.Addr{netip.MustParseAddr("::ffff:192.0.2.1")}},
+ }}
+
+ result := LookupIP(context.Background(), r, "ip4", "example.com.", dns.TypeA)
+
+ assert.Equal(t, dns.RcodeSuccess, result.Rcode, "successful lookup should return NOERROR")
+ require.Len(t, result.IPs, 1, "should return the resolved address")
+ assert.Equal(t, netip.MustParseAddr("192.0.2.1"), result.IPs[0], "v4-mapped address should be unmapped")
+}
+
+func TestLookupIP_NoSuitableAddress(t *testing.T) {
+ // The net package returns this AddrError when the host resolves but has
+ // no addresses of the requested family (e.g. AAAA query for a v4-only
+ // hosts file entry). The domain exists, so this is NODATA, not SERVFAIL.
+ r := &mockResolver{results: map[string]mockLookup{
+ "ip6": {err: &net.AddrError{Err: "no suitable address found", Addr: "example.com."}},
+ }}
+
+ result := LookupIP(context.Background(), r, "ip6", "example.com.", dns.TypeAAAA)
+
+ assert.Equal(t, dns.RcodeSuccess, result.Rcode, "no suitable address should map to NODATA")
+ assert.Empty(t, result.IPs, "NODATA response should carry no addresses")
+}
+
+// TestErrNoSuitableAddressMatchesNetPackage pins our copy of the error string
+// to what the net package actually emits. A literal IP of the wrong family
+// takes the same filterAddrList path as a resolved hostname, without network
+// access.
+func TestErrNoSuitableAddressMatchesNetPackage(t *testing.T) {
+ _, err := (&net.Resolver{}).LookupNetIP(context.Background(), "ip6", "192.0.2.1")
+ require.Error(t, err)
+
+ var addrErr *net.AddrError
+ require.ErrorAs(t, err, &addrErr, "wrong-family lookup should return AddrError")
+ assert.Equal(t, errNoSuitableAddress, addrErr.Err, "net package error string should match our constant")
+}
+
+func TestLookupIP_OtherAddrError(t *testing.T) {
+ r := &mockResolver{results: map[string]mockLookup{
+ "ip4": {err: &net.AddrError{Err: "some other address problem", Addr: "example.com."}},
+ }}
+
+ result := LookupIP(context.Background(), r, "ip4", "example.com.", dns.TypeA)
+
+ assert.Equal(t, dns.RcodeServerFailure, result.Rcode, "unrecognized AddrError should map to SERVFAIL")
+}
+
+func TestLookupIP_NotFoundNXDomain(t *testing.T) {
+ r := &mockResolver{results: map[string]mockLookup{
+ "ip4": {err: &net.DNSError{Err: "no such host", Name: "example.com.", IsNotFound: true}},
+ "ip6": {err: &net.DNSError{Err: "no such host", Name: "example.com.", IsNotFound: true}},
+ }}
+
+ result := LookupIP(context.Background(), r, "ip4", "example.com.", dns.TypeA)
+
+ assert.Equal(t, dns.RcodeNameError, result.Rcode, "not found for both families should map to NXDOMAIN")
+}
+
+func TestLookupIP_NotFoundNoData(t *testing.T) {
+ r := &mockResolver{results: map[string]mockLookup{
+ "ip6": {err: &net.DNSError{Err: "no such host", Name: "example.com.", IsNotFound: true}},
+ "ip4": {ips: []netip.Addr{netip.MustParseAddr("192.0.2.1")}},
+ }}
+
+ result := LookupIP(context.Background(), r, "ip6", "example.com.", dns.TypeAAAA)
+
+ assert.Equal(t, dns.RcodeSuccess, result.Rcode, "not found with the other family present should map to NODATA")
+}
+
+func TestLookupIP_GenericError(t *testing.T) {
+ r := &mockResolver{results: map[string]mockLookup{
+ "ip4": {err: errors.New("connection refused")},
+ }}
+
+ result := LookupIP(context.Background(), r, "ip4", "example.com.", dns.TypeA)
+
+ assert.Equal(t, dns.RcodeServerFailure, result.Rcode, "generic error should map to SERVFAIL")
+}
+
+func TestLookupIP_DNSErrorNotIsNotFound(t *testing.T) {
+ r := &mockResolver{results: map[string]mockLookup{
+ "ip4": {err: &net.DNSError{Err: "server misbehaving", Name: "example.com.", IsTemporary: true}},
+ }}
+
+ result := LookupIP(context.Background(), r, "ip4", "example.com.", dns.TypeA)
+
+ assert.Equal(t, dns.RcodeServerFailure, result.Rcode, "upstream failure should map to SERVFAIL")
+}
diff --git a/client/internal/dns/server.go b/client/internal/dns/server.go
index 7a35e56d8..dcd4cb9d0 100644
--- a/client/internal/dns/server.go
+++ b/client/internal/dns/server.go
@@ -777,13 +777,24 @@ func (s *DefaultServer) applyHostConfig() {
// context is released rather than leaked until GC.
func (s *DefaultServer) registerFallback() {
originalNameservers := s.hostManager.getOriginalNameservers()
- if len(originalNameservers) == 0 {
+
+ serverIP := s.service.RuntimeIP()
+ var servers []netip.AddrPort
+ for _, ns := range originalNameservers {
+ if ns == serverIP {
+ log.Debugf("skipping original nameserver %s as it is the same as the server IP %s", ns, serverIP)
+ continue
+ }
+ servers = append(servers, netip.AddrPortFrom(ns, DefaultPort))
+ }
+
+ if len(servers) == 0 {
log.Debugf("no fallback upstreams to register; clearing PriorityFallback handler")
s.clearFallback()
return
}
- log.Infof("registering original nameservers %v as upstream handlers with priority %d", originalNameservers, PriorityFallback)
+ log.Infof("registering original nameservers %v as upstream handlers with priority %d", servers, PriorityFallback)
handler, err := newUpstreamResolver(
s.ctx,
@@ -797,11 +808,6 @@ func (s *DefaultServer) registerFallback() {
return
}
handler.selectedRoutes = s.selectedRoutes
-
- var servers []netip.AddrPort
- for _, ns := range originalNameservers {
- servers = append(servers, netip.AddrPortFrom(ns, DefaultPort))
- }
handler.addRace(servers)
prev := s.fallbackHandler
diff --git a/client/internal/engine.go b/client/internal/engine.go
index fa7f3730d..80a7fa343 100644
--- a/client/internal/engine.go
+++ b/client/internal/engine.go
@@ -22,7 +22,6 @@ import (
log "github.com/sirupsen/logrus"
"golang.zx2c4.com/wireguard/tun/netstack"
"golang.zx2c4.com/wireguard/wgctrl/wgtypes"
- "google.golang.org/protobuf/proto"
nberrors "github.com/netbirdio/netbird/client/errors"
"github.com/netbirdio/netbird/client/firewall"
@@ -54,19 +53,19 @@ import (
"github.com/netbirdio/netbird/client/internal/relay"
"github.com/netbirdio/netbird/client/internal/rosenpass"
"github.com/netbirdio/netbird/client/internal/routemanager"
- "github.com/netbirdio/netbird/client/internal/routemanager/systemops"
"github.com/netbirdio/netbird/client/internal/statemanager"
+ "github.com/netbirdio/netbird/client/internal/syncstore"
"github.com/netbirdio/netbird/client/internal/updater"
"github.com/netbirdio/netbird/client/jobexec"
cProto "github.com/netbirdio/netbird/client/proto"
"github.com/netbirdio/netbird/client/system"
nbdns "github.com/netbirdio/netbird/dns"
- types "github.com/netbirdio/netbird/shared/management/types"
"github.com/netbirdio/netbird/route"
mgm "github.com/netbirdio/netbird/shared/management/client"
"github.com/netbirdio/netbird/shared/management/domain"
nbnetworkmap "github.com/netbirdio/netbird/shared/management/networkmap"
mgmProto "github.com/netbirdio/netbird/shared/management/proto"
+ types "github.com/netbirdio/netbird/shared/management/types"
"github.com/netbirdio/netbird/shared/netiputil"
auth "github.com/netbirdio/netbird/shared/relay/auth/hmac"
relayClient "github.com/netbirdio/netbird/shared/relay/client"
@@ -74,6 +73,7 @@ import (
sProto "github.com/netbirdio/netbird/shared/signal/proto"
"github.com/netbirdio/netbird/util"
"github.com/netbirdio/netbird/util/capture"
+ "github.com/netbirdio/netbird/version"
)
// PeerConnectionTimeoutMax is a timeout of an initial connection attempt to a remote peer.
@@ -150,6 +150,10 @@ type EngineConfig struct {
LogPath string
TempDir string
+
+ // StateDir is the directory holding the state file. The sync response
+ // (network map) is serialized here on platforms that persist it to disk.
+ StateDir string
}
// EngineServices holds the external service dependencies required by the Engine.
@@ -235,11 +239,16 @@ type Engine struct {
afpacketCapture *capture.AFPacketCapture
- // Sync response persistence (protected by syncRespMux)
- syncRespMux sync.RWMutex
- persistSyncResponse bool
- latestSyncResponse *mgmProto.SyncResponse
- flowManager nftypes.FlowManager
+ // Sync response persistence (protected by syncRespMux).
+ // syncStore is nil unless persistence has been enabled; its presence is
+ // what marks persistence as active. The backend (disk or memory) is
+ // selected per-platform; see the syncstore package. syncStoreDir is where
+ // a disk-backed store serializes to.
+ syncRespMux sync.RWMutex
+ syncStore syncstore.Store
+ syncStoreDir string
+
+ flowManager nftypes.FlowManager
// auto-update
updateManager *updater.Manager
@@ -301,6 +310,7 @@ func NewEngine(
jobExecutor: jobexec.NewExecutor(),
clientMetrics: services.ClientMetrics,
updateManager: services.UpdateManager,
+ syncStoreDir: config.StateDir,
}
log.Infof("I am: %s", config.WgPrivateKey.PublicKey().String())
@@ -529,6 +539,10 @@ func (e *Engine) Start(netbirdConfig *mgmProto.NetbirdConfig, mgmtURL *url.URL)
return fmt.Errorf("create wg interface: %w", err)
}
+ if filteredDevice := e.wgInterface.GetDevice(); filteredDevice != nil {
+ filteredDevice.SetPanicHandler(e.triggerClientRestart)
+ }
+
if err := e.createFirewall(); err != nil {
e.close()
return err
@@ -882,44 +896,13 @@ func (e *Engine) handleSync(update *mgmProto.SyncResponse) error {
e.handleAutoUpdateVersion(nm.GetPeerConfig().GetAutoUpdate())
}
- if update.GetNetbirdConfig() != nil {
- wCfg := update.GetNetbirdConfig()
- err := e.updateTURNs(wCfg.GetTurns())
- if err != nil {
- return fmt.Errorf("update TURNs: %w", err)
- }
-
- err = e.updateSTUNs(wCfg.GetStuns())
- if err != nil {
- return fmt.Errorf("update STUNs: %w", err)
- }
-
- var stunTurn []*stun.URI
- stunTurn = append(stunTurn, e.STUNs...)
- stunTurn = append(stunTurn, e.TURNs...)
- e.stunTurn.Store(stunTurn)
-
- err = e.handleRelayUpdate(wCfg.GetRelay())
- if err != nil {
- return err
- }
-
- err = e.handleFlowUpdate(wCfg.GetFlow())
- if err != nil {
- return fmt.Errorf("handle the flow configuration: %w", err)
- }
-
- if err := e.PopulateNetbirdConfig(wCfg, nil); err != nil {
- log.Warnf("Failed to update DNS server config: %v", err)
- }
-
- // todo update signal
- }
-
- if err := e.updateChecksIfNew(update.Checks); err != nil {
+ if err := e.updateNetbirdConfig(update.GetNetbirdConfig()); err != nil {
return err
}
+ // Decode the network map from either the components envelope or the
+ // legacy proto.NetworkMap before the posture-check gating below, so the
+ // "is there a network map" decision covers both wire shapes.
var (
nm *mgmProto.NetworkMap
components *types.NetworkMapComponents
@@ -947,10 +930,20 @@ func (e *Engine) handleSync(update *mgmProto.SyncResponse) error {
} else {
nm = update.GetNetworkMap()
}
+
+ // Posture checks are bound to the network map presence:
+ // NetworkMap != nil, checks present -> apply the received checks
+ // NetworkMap != nil, checks nil -> posture checks were removed, clear them
+ // NetworkMap == nil -> config-only update (e.g. relay token rotation),
+ // leave the previously applied checks untouched
if nm == nil {
return nil
}
+ if err := e.updateChecksIfNew(update.Checks); err != nil {
+ return err
+ }
+
// Only retain the components view when the server sent the envelope
// path. A legacy proto.NetworkMap means components == nil; writing it
// here would clobber a previously-cached snapshot, breaking the
@@ -959,20 +952,7 @@ func (e *Engine) handleSync(update *mgmProto.SyncResponse) error {
e.latestComponents = components
}
- // Persist sync response under the dedicated lock (syncRespMux), not under syncMsgMux.
- // Read the storage-enabled flag under the syncRespMux too.
- e.syncRespMux.RLock()
- enabled := e.persistSyncResponse
- e.syncRespMux.RUnlock()
-
- // Store sync response if persistence is enabled
- if enabled {
- e.syncRespMux.Lock()
- e.latestSyncResponse = update
- e.syncRespMux.Unlock()
-
- log.Debugf("sync response persisted with serial %d", nm.GetSerial())
- }
+ e.persistSyncResponse(update)
// only apply new changes and ignore old ones
if err := e.updateNetworkMap(nm); err != nil {
@@ -997,6 +977,64 @@ func extractDNSDomainFromFQDN(fqdn string) string {
return ""
}
+// updateNetbirdConfig applies the management-provided NetBird configuration:
+// STUN/TURN and relay servers, flow logging and DNS settings. A nil config is a no-op,
+// which is the case for sync updates carrying only a network map.
+func (e *Engine) updateNetbirdConfig(wCfg *mgmProto.NetbirdConfig) error {
+ if wCfg == nil {
+ return nil
+ }
+
+ if err := e.updateTURNs(wCfg.GetTurns()); err != nil {
+ return fmt.Errorf("update TURNs: %w", err)
+ }
+
+ if err := e.updateSTUNs(wCfg.GetStuns()); err != nil {
+ return fmt.Errorf("update STUNs: %w", err)
+ }
+
+ var stunTurn []*stun.URI
+ stunTurn = append(stunTurn, e.STUNs...)
+ stunTurn = append(stunTurn, e.TURNs...)
+ e.stunTurn.Store(stunTurn)
+
+ if err := e.handleRelayUpdate(wCfg.GetRelay()); err != nil {
+ return err
+ }
+
+ if err := e.handleFlowUpdate(wCfg.GetFlow()); err != nil {
+ return fmt.Errorf("handle the flow configuration: %w", err)
+ }
+
+ if err := e.PopulateNetbirdConfig(wCfg, nil); err != nil {
+ log.Warnf("Failed to update DNS server config: %v", err)
+ }
+
+ // todo update signal
+
+ return nil
+}
+
+// persistSyncResponse stores the full sync response so it can be restored on the next
+// startup. Persistence is enabled only when syncStore is set. The dedicated syncRespMux
+// (not syncMsgMux) is held for the whole Set so the store cannot be cleared (disabled /
+// engine close) mid-call and have this write resurrect a file that was just removed.
+func (e *Engine) persistSyncResponse(update *mgmProto.SyncResponse) {
+ e.syncRespMux.RLock()
+ defer e.syncRespMux.RUnlock()
+
+ if e.syncStore == nil {
+ return
+ }
+
+ if err := e.syncStore.Set(update); err != nil {
+ log.Errorf("failed to persist sync response: %v", err)
+ return
+ }
+
+ log.Debugf("sync response persisted with serial %d", update.GetNetworkMap().GetSerial())
+}
+
func (e *Engine) handleRelayUpdate(update *mgmProto.RelayConfig) error {
if update != nil {
// when we receive token we expect valid address list too
@@ -1123,6 +1161,7 @@ func (e *Engine) updateConfig(conf *mgmProto.PeerConfig) error {
state.PubKey = e.config.WgPrivateKey.PublicKey().String()
state.KernelInterface = !e.wgInterface.IsUserspaceBind()
state.FQDN = conf.GetFqdn()
+ state.WgPort = e.config.WgPort
e.statusRecorder.UpdateLocalPeerState(state)
@@ -1201,6 +1240,7 @@ func (e *Engine) handleBundle(params *mgmProto.BundleParameters) (*mgmProto.JobR
LogPath: e.config.LogPath,
TempDir: e.config.TempDir,
ClientMetrics: e.clientMetrics,
+ DaemonVersion: version.NetbirdVersion(),
RefreshStatus: func() {
e.RunHealthProbes(true)
},
@@ -1873,6 +1913,18 @@ func (e *Engine) close() {
if err := e.portForwardManager.GracefullyStop(ctx); err != nil {
log.Warnf("failed to gracefully stop port forwarding manager: %s", err)
}
+
+ // Drop any persisted sync response so its network map does not linger on
+ // disk after the engine stops (and cannot leak into a later run).
+ e.syncRespMux.Lock()
+ store := e.syncStore
+ e.syncStore = nil
+ e.syncRespMux.Unlock()
+ if store != nil {
+ if err := store.Clear(); err != nil {
+ log.Warnf("failed to clear persisted sync response on close: %v", err)
+ }
+ }
}
func (e *Engine) readInitialSettings() ([]*route.Route, *nbdns.Config, bool, error) {
@@ -1924,7 +1976,6 @@ func (e *Engine) newWgIface() (*iface.WGIface, error) {
WGPrivKey: e.config.WgPrivateKey.String(),
MTU: e.config.MTU,
TransportNet: transportNet,
- FilterFn: e.addrViaRoutes,
DisableDNS: e.config.DisableDNS,
}
@@ -2172,21 +2223,6 @@ func (e *Engine) startNetworkMonitor() {
}()
}
-func (e *Engine) addrViaRoutes(addr netip.Addr) (bool, netip.Prefix, error) {
- var vpnRoutes []netip.Prefix
- for _, routes := range e.routeManager.GetClientRoutes() {
- if len(routes) > 0 && routes[0] != nil {
- vpnRoutes = append(vpnRoutes, routes[0].Network)
- }
- }
-
- if isVpn, prefix := systemops.IsAddrRouted(addr, vpnRoutes); isVpn {
- return true, prefix, nil
- }
-
- return false, netip.Prefix{}, nil
-}
-
func (e *Engine) stopDNSServer() {
if e.dnsServer == nil {
return
@@ -2202,45 +2238,42 @@ func (e *Engine) stopDNSServer() {
e.statusRecorder.UpdateDNSStates(nsGroupStates)
}
-// SetSyncResponsePersistence enables or disables sync response persistence
+// SetSyncResponsePersistence enables or disables sync response persistence.
+// The store is only instantiated while persistence is enabled; construction
+// itself drops any stale data left over from an earlier run (see syncstore).
func (e *Engine) SetSyncResponsePersistence(enabled bool) {
e.syncRespMux.Lock()
defer e.syncRespMux.Unlock()
- if enabled == e.persistSyncResponse {
+ if enabled == (e.syncStore != nil) {
return
}
- e.persistSyncResponse = enabled
log.Debugf("Sync response persistence is set to %t", enabled)
if !enabled {
- e.latestSyncResponse = nil
+ if err := e.syncStore.Clear(); err != nil {
+ log.Warnf("failed to clear persisted sync response: %v", err)
+ }
+ e.syncStore = nil
+ return
}
+
+ e.syncStore = syncstore.New(e.syncStoreDir)
}
// GetLatestSyncResponse returns the stored sync response if persistence is enabled
func (e *Engine) GetLatestSyncResponse() (*mgmProto.SyncResponse, error) {
+ // Hold the lock for the whole Get so the store cannot be cleared
+ // (disabled / engine close) mid-call.
e.syncRespMux.RLock()
- enabled := e.persistSyncResponse
- latest := e.latestSyncResponse
- e.syncRespMux.RUnlock()
+ defer e.syncRespMux.RUnlock()
- if !enabled {
+ if e.syncStore == nil {
return nil, errors.New("sync response persistence is disabled")
}
- if latest == nil {
- //nolint:nilnil
- return nil, nil
- }
-
- log.Debugf("Retrieving latest sync response with size %d bytes", proto.Size(latest))
- sr, ok := proto.Clone(latest).(*mgmProto.SyncResponse)
- if !ok {
- return nil, fmt.Errorf("failed to clone sync response")
- }
-
- return sr, nil
+ //nolint:nilnil
+ return e.syncStore.Get()
}
// GetWgAddr returns the wireguard address
@@ -2276,7 +2309,7 @@ func (e *Engine) updateDNSForwarder(
enabled bool,
fwdEntries []*dnsfwd.ForwarderEntry,
) {
- if e.config.DisableServerRoutes {
+ if e.config.DisableServerRoutes || e.config.BlockInbound {
return
}
diff --git a/client/internal/lazyconn/support.go b/client/internal/lazyconn/support.go
index 5e765c2d6..cc0e95e53 100644
--- a/client/internal/lazyconn/support.go
+++ b/client/internal/lazyconn/support.go
@@ -4,6 +4,8 @@ import (
"strings"
"github.com/hashicorp/go-version"
+
+ nbversion "github.com/netbirdio/netbird/version"
)
var (
@@ -11,7 +13,7 @@ var (
)
func IsSupported(agentVersion string) bool {
- if agentVersion == "development" {
+ if nbversion.IsDevelopmentVersion(agentVersion) {
return true
}
diff --git a/client/internal/peer/conn_status.go b/client/internal/peer/conn_status.go
index b43e245f3..d6ad37b70 100644
--- a/client/internal/peer/conn_status.go
+++ b/client/internal/peer/conn_status.go
@@ -26,7 +26,6 @@ type connStatusInputs struct {
iceInProgress bool // a negotiation is currently in flight
}
-
// ConnStatus describe the status of a peer's connection
type ConnStatus int32
diff --git a/client/internal/peer/status.go b/client/internal/peer/status.go
index f9eb9adf5..3e5c56dd2 100644
--- a/client/internal/peer/status.go
+++ b/client/internal/peer/status.go
@@ -111,6 +111,7 @@ type LocalPeerState struct {
PubKey string
KernelInterface bool
FQDN string
+ WgPort int
Routes map[string]struct{}
}
@@ -192,6 +193,7 @@ func (s *StatusChangeSubscription) Events() chan map[string]RouterState {
type Status struct {
mux sync.RWMutex
peers map[string]State
+ ipToKey map[string]string
changeNotify map[string]map[string]*StatusChangeSubscription // map[peerID]map[subscriptionID]*StatusChangeSubscription
signalState bool
signalError error
@@ -230,6 +232,7 @@ type Status struct {
func NewRecorder(mgmAddress string) *Status {
return &Status{
peers: make(map[string]State),
+ ipToKey: make(map[string]string),
changeNotify: make(map[string]map[string]*StatusChangeSubscription),
eventStreams: make(map[string]chan *proto.SystemEvent),
eventQueue: NewEventQueue(eventQueueSize),
@@ -281,6 +284,12 @@ func (d *Status) AddPeer(peerPubKey string, fqdn string, ip string, ipv6 string)
Mux: new(sync.RWMutex),
}
d.peerListChangedForNotification = true
+ if ipv6 != "" {
+ d.ipToKey[ipv6] = peerPubKey
+ }
+ if ip != "" {
+ d.ipToKey[ip] = peerPubKey
+ }
return nil
}
@@ -310,19 +319,22 @@ func (d *Status) PeerByIP(ip string) (string, bool) {
// PeerStateByIP returns the full peer State for the given tunnel IP.
// Matches against either the IPv4 (State.IP) or IPv6 (State.IPv6) tunnel
-// address so dual-stack peers are reachable on either family. Returns the
-// zero State and false when no peer matches or the input is empty.
+// address so dual-stack peers are reachable on either family. Only
+// active peers are matched; peers moved into the offline slice by
+// ReplaceOfflinePeers are intentionally treated as unknown.
func (d *Status) PeerStateByIP(ip string) (State, bool) {
if ip == "" {
return State{}, false
}
d.mux.RLock()
defer d.mux.RUnlock()
-
- for _, state := range d.peers {
- if (state.IP != "" && state.IP == ip) || (state.IPv6 != "" && state.IPv6 == ip) {
- return state, true
- }
+ key, ok := d.ipToKey[ip]
+ if !ok {
+ return State{}, false
+ }
+ state, ok := d.peers[key]
+ if ok {
+ return state, true
}
return State{}, false
}
@@ -332,12 +344,18 @@ func (d *Status) RemovePeer(peerPubKey string) error {
d.mux.Lock()
defer d.mux.Unlock()
- _, ok := d.peers[peerPubKey]
+ p, ok := d.peers[peerPubKey]
if !ok {
return errors.New("no peer with to remove")
}
delete(d.peers, peerPubKey)
+ if mappedKey, exists := d.ipToKey[p.IP]; exists && mappedKey == peerPubKey {
+ delete(d.ipToKey, p.IP)
+ }
+ if mappedKey, exists := d.ipToKey[p.IPv6]; exists && mappedKey == peerPubKey {
+ delete(d.ipToKey, p.IPv6)
+ }
d.peerListChangedForNotification = true
return nil
}
@@ -1006,14 +1024,17 @@ func (d *Status) GetRelayStates() []relay.ProbeResult {
return d.relayStates
}
- // extend the list of stun, turn servers with relay address
+ // extend the list of stun, turn servers with the relay server connections
relayStates := slices.Clone(d.relayStates)
- // if the server connection is not established then we will use the general address
- // in case of connection we will use the instance specific address
- instanceAddr, _, err := d.relayMgr.RelayInstanceAddress()
- if err != nil {
- // TODO add their status
+ states := d.relayMgr.RelayStates()
+ if len(states) == 0 {
+ // no relay connection tracked yet; surface configured servers as
+ // unavailable with the real reconnect error when known
+ err := relayClient.ErrRelayClientNotConnected
+ if connErr := d.relayMgr.RelayConnectError(); connErr != nil {
+ err = connErr
+ }
for _, r := range d.relayMgr.ServerURLs() {
relayStates = append(relayStates, relay.ProbeResult{
URI: r,
@@ -1023,10 +1044,14 @@ func (d *Status) GetRelayStates() []relay.ProbeResult {
return relayStates
}
- relayState := relay.ProbeResult{
- URI: instanceAddr,
+ for _, rs := range states {
+ relayStates = append(relayStates, relay.ProbeResult{
+ URI: rs.URL,
+ Err: rs.Err,
+ Transport: rs.Transport,
+ })
}
- return append(relayStates, relayState)
+ return relayStates
}
func (d *Status) ForwardingRules() []firewall.ForwardRule {
@@ -1348,6 +1373,7 @@ func (fs FullStatus) ToProto() *proto.FullStatus {
pbFullStatus.LocalPeerState.PubKey = fs.LocalPeerState.PubKey
pbFullStatus.LocalPeerState.KernelInterface = fs.LocalPeerState.KernelInterface
pbFullStatus.LocalPeerState.Fqdn = fs.LocalPeerState.FQDN
+ pbFullStatus.LocalPeerState.WgPort = int32(fs.LocalPeerState.WgPort)
pbFullStatus.LocalPeerState.RosenpassPermissive = fs.RosenpassState.Permissive
pbFullStatus.LocalPeerState.RosenpassEnabled = fs.RosenpassState.Enabled
pbFullStatus.NumberOfForwardingRules = int32(fs.NumOfForwardingRules)
@@ -1386,6 +1412,7 @@ func (fs FullStatus) ToProto() *proto.FullStatus {
pbRelayState := &proto.RelayState{
URI: relayState.URI,
Available: relayState.Err == nil,
+ Transport: relayState.Transport,
}
if err := relayState.Err; err != nil {
pbRelayState.Error = err.Error()
diff --git a/client/internal/peer/status_test.go b/client/internal/peer/status_test.go
index 8d889b0ae..17ed47cd3 100644
--- a/client/internal/peer/status_test.go
+++ b/client/internal/peer/status_test.go
@@ -90,6 +90,45 @@ func TestStatus_PeerStateByIP_MatchesIPv6(t *testing.T) {
req.Equal("pk-1", state.PubKey, "matching state must carry the right pub key")
}
+// TestStatus_PeerStateByIP_IgnoresOfflinePeers documents that peers
+// moved into the offline slice via ReplaceOfflinePeers are intentionally
+// not resolvable by IP: only active peers can carry traffic, so callers
+// (DNS filter, embed.Client.IdentityForIP) treat them as unknown.
+func TestStatus_PeerStateByIP_IgnoresOfflinePeers(t *testing.T) {
+ status := NewRecorder("https://mgm")
+ req := require.New(t)
+
+ status.ReplaceOfflinePeers([]State{
+ {PubKey: "pk-offline", FQDN: "offline.netbird", IP: "100.64.0.20", IPv6: "fd00::20"},
+ })
+
+ _, ok := status.PeerStateByIP("100.64.0.20")
+ req.False(ok, "offline peer must not resolve by IPv4 tunnel address")
+
+ _, ok = status.PeerStateByIP("fd00::20")
+ req.False(ok, "offline peer must not resolve by IPv6 tunnel address")
+}
+
+// TestStatus_PeerStateByIP_RemovedPeer verifies RemovePeer drops the
+// IP index entries for both address families.
+func TestStatus_PeerStateByIP_RemovedPeer(t *testing.T) {
+ status := NewRecorder("https://mgm")
+ req := require.New(t)
+
+ req.NoError(status.AddPeer("pk-1", "peer-1.netbird", "100.64.0.10", "fd00::1"))
+
+ _, ok := status.PeerStateByIP("100.64.0.10")
+ req.True(ok, "active peer must resolve before removal")
+
+ req.NoError(status.RemovePeer("pk-1"))
+
+ _, ok = status.PeerStateByIP("100.64.0.10")
+ req.False(ok, "removed peer must not resolve by IPv4 tunnel address")
+
+ _, ok = status.PeerStateByIP("fd00::1")
+ req.False(ok, "removed peer must not resolve by IPv6 tunnel address")
+}
+
func TestStatus_UpdatePeerFQDN(t *testing.T) {
key := "abc"
fqdn := "peer-a.netbird.local"
diff --git a/client/internal/peer/worker_ice.go b/client/internal/peer/worker_ice.go
index 29bf5aaaa..b1aa3e0f9 100644
--- a/client/internal/peer/worker_ice.go
+++ b/client/internal/peer/worker_ice.go
@@ -4,7 +4,6 @@ import (
"context"
"fmt"
"net"
- "net/netip"
"strconv"
"sync"
"time"
@@ -165,10 +164,6 @@ func (w *WorkerICE) OnRemoteCandidate(candidate ice.Candidate, haRoutes route.HA
return
}
- if candidateViaRoutes(candidate, haRoutes) {
- return
- }
-
if err := w.agent.AddRemoteCandidate(candidate); err != nil {
w.log.Errorf("error while handling remote candidate")
return
@@ -589,34 +584,6 @@ func extraSrflxCandidate(candidate ice.Candidate) (*ice.CandidateServerReflexive
return ec, nil
}
-func candidateViaRoutes(candidate ice.Candidate, clientRoutes route.HAMap) bool {
- addr, err := netip.ParseAddr(candidate.Address())
- if err != nil {
- log.Errorf("Failed to parse IP address %s: %v", candidate.Address(), err)
- return false
- }
-
- var routePrefixes []netip.Prefix
- for _, routes := range clientRoutes {
- if len(routes) > 0 && routes[0] != nil {
- routePrefixes = append(routePrefixes, routes[0].Network)
- }
- }
-
- for _, prefix := range routePrefixes {
- // default route is handled by route exclusion / ip rules
- if prefix.Bits() == 0 {
- continue
- }
-
- if prefix.Contains(addr) {
- log.Debugf("Ignoring candidate [%s], its address is part of routed network %s", candidate.String(), prefix)
- return true
- }
- }
- return false
-}
-
func isRelayCandidate(candidate ice.Candidate) bool {
return candidate.Type() == ice.CandidateTypeRelay
}
diff --git a/client/internal/portforward/pcp/nat.go b/client/internal/portforward/pcp/nat.go
index 6491e7367..0e635b6c8 100644
--- a/client/internal/portforward/pcp/nat.go
+++ b/client/internal/portforward/pcp/nat.go
@@ -179,8 +179,10 @@ func getDefaultGateway() (gateway net.IP, localIP net.IP, err error) {
}
dst := net.IPv4zero
- if runtime.GOOS == "linux" {
- // go-netroute v0.4.0 rejects unspecified destinations client-side on Linux.
+ if runtime.GOOS == "linux" || runtime.GOOS == "android" {
+ // go-netroute v0.4.0 rejects unspecified destinations client-side on Linux/Android.
+ // TODO: on android/ios, use platform APIs (ConnectivityManager.getLinkProperties /
+ // NWPathMonitor) when netlink-based lookup is restricted or unavailable.
dst = net.IPv4(0, 0, 0, 1)
}
_, gateway, localIP, err = router.Route(dst)
@@ -203,7 +205,7 @@ func getDefaultGateway6() (gateway net.IP, localIP net.IP, err error) {
}
dst := net.IPv6zero
- if runtime.GOOS == "linux" {
+ if runtime.GOOS == "linux" || runtime.GOOS == "android" {
// ::2
dst = net.IP{0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 2}
}
diff --git a/client/internal/profilemanager/config.go b/client/internal/profilemanager/config.go
index cd5bc0680..b0c7fd470 100644
--- a/client/internal/profilemanager/config.go
+++ b/client/internal/profilemanager/config.go
@@ -22,6 +22,7 @@ import (
"github.com/netbirdio/netbird/client/iface"
"github.com/netbirdio/netbird/client/internal/routemanager/dynamic"
+ "github.com/netbirdio/netbird/client/mdm"
"github.com/netbirdio/netbird/client/ssh"
mgm "github.com/netbirdio/netbird/shared/management/client"
"github.com/netbirdio/netbird/shared/management/domain"
@@ -57,6 +58,10 @@ 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
@@ -174,6 +179,23 @@ type Config struct {
LazyConnectionEnabled bool
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 *mdm.Policy `json:"-"`
+}
+
+// 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.
+func (config *Config) Policy() *mdm.Policy {
+ if config == nil || config.policy == nil {
+ return mdm.NewPolicy(nil)
+ }
+ return config.policy
}
var ConfigDirOverride string
@@ -612,10 +634,93 @@ 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())
+
return updated, nil
}
-// parseURL parses and validates a service URL
+// applyMDMPolicy overlays MDM-supplied values on top of the resolved Config.
+// The provided Policy is also stored on the Config so callers can later query
+// which fields are enforced. Invalid values (e.g. malformed URLs) are logged
+// and skipped to avoid bricking the client; the field keeps its previous
+// resolved value but is still marked as managed (Policy.HasKey returns true
+// for the key, so per-field rejection of user writes still applies).
+func (config *Config) applyMDMPolicy(policy *mdm.Policy) {
+ config.policy = policy
+ if policy.IsEmpty() {
+ return
+ }
+
+ // Helper: log the application of a single MDM-managed key. Values for
+ // keys in mdm.SecretKeys are redacted.
+ logApplied := func(key string, displayValue any) {
+ if _, secret := mdm.SecretKeys[key]; secret {
+ log.Infof("MDM override %s = ********** (secret)", key)
+ return
+ }
+ log.Infof("MDM override %s = %v", key, displayValue)
+ }
+
+ if v, ok := policy.GetString(mdm.KeyManagementURL); ok {
+ if u, err := parseURL("Management URL", v); err != nil {
+ log.Warnf("MDM management URL %q invalid: %v; keeping previous value", v, err)
+ } else {
+ config.ManagementURL = u
+ logApplied(mdm.KeyManagementURL, u.String())
+ }
+ }
+
+ if v, ok := policy.GetString(mdm.KeyPreSharedKey); ok {
+ // Defensive: refuse the redaction mask in case it round-tripped
+ // through a manifest by mistake.
+ if !isPreSharedKeyHidden(&v) {
+ config.PreSharedKey = v
+ logApplied(mdm.KeyPreSharedKey, "")
+ }
+ }
+
+ // applyBool collapses the per-key "read + set + log" boilerplate
+ // for every plain bool MDM key into a single helper. Keeps the
+ // outer function's cognitive complexity below SonarCube's
+ // threshold; functional behaviour is identical to the inlined
+ // branches it replaces.
+ applyBool := func(key string, setter func(bool)) {
+ v, ok := policy.GetBool(key)
+ if !ok {
+ return
+ }
+ setter(v)
+ logApplied(key, v)
+ }
+
+ applyBool(mdm.KeyAllowServerSSH, func(v bool) { bv := v; config.ServerSSHAllowed = &bv })
+ applyBool(mdm.KeyDisableClientRoutes, func(v bool) { config.DisableClientRoutes = v })
+ applyBool(mdm.KeyDisableServerRoutes, func(v bool) { config.DisableServerRoutes = v })
+ applyBool(mdm.KeyBlockInbound, func(v bool) { config.BlockInbound = v })
+ applyBool(mdm.KeyDisableAutoConnect, func(v bool) { config.DisableAutoConnect = v })
+ applyBool(mdm.KeyRosenpassEnabled, func(v bool) { config.RosenpassEnabled = v })
+ applyBool(mdm.KeyRosenpassPermissive, func(v bool) { config.RosenpassPermissive = v })
+
+ if v, ok := policy.GetInt(mdm.KeyWireguardPort); ok {
+ // REG_DWORD is 32-bit; UDP port range is 1-65535. Clamp at the
+ // upper bound and reject obviously-invalid values to avoid the
+ // engine binding to an unusable port if the admin pushes garbage.
+ if v >= 1 && v <= 65535 {
+ config.WgPort = int(v)
+ logApplied(mdm.KeyWireguardPort, v)
+ } else {
+ log.Warnf("MDM wireguard port %d out of range [1,65535]; keeping previous value", v)
+ }
+ }
+}
+
+// parseURL parses and validates the URL for the named service. The URL
+// must use the http or https scheme; if no port is present, ":443" is
+// appended for https or ":80" for http. The serviceName parameter is
+// used to contextualise error messages. On success returns the parsed
+// *url.URL; on failure returns a non-nil error.
func parseURL(serviceName, serviceURL string) (*url.URL, error) {
parsedMgmtURL, err := url.ParseRequestURI(serviceURL)
if err != nil {
diff --git a/client/internal/profilemanager/config_mdm_test.go b/client/internal/profilemanager/config_mdm_test.go
new file mode 100644
index 000000000..6a201235e
--- /dev/null
+++ b/client/internal/profilemanager/config_mdm_test.go
@@ -0,0 +1,152 @@
+package profilemanager
+
+import (
+ "path/filepath"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+
+ "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) {
+ t.Helper()
+ prev := loadMDMPolicy
+ loadMDMPolicy = func() *mdm.Policy { return policy }
+ t.Cleanup(func() { loadMDMPolicy = prev })
+}
+
+func TestApply_MDMEmpty_NoEnforcement(t *testing.T) {
+ withMDMPolicy(t, mdm.NewPolicy(nil))
+
+ cfg, err := UpdateOrCreateConfig(ConfigInput{
+ ConfigPath: filepath.Join(t.TempDir(), "config.json"),
+ })
+ require.NoError(t, err)
+ require.NotNil(t, cfg)
+
+ assert.True(t, cfg.Policy().IsEmpty(), "no MDM source ⇒ empty Policy")
+ assert.False(t, cfg.Policy().HasKey(mdm.KeyManagementURL))
+ assert.Empty(t, cfg.Policy().ManagedKeys())
+
+ // Default management URL still resolves.
+ assert.Equal(t, DefaultManagementURL, cfg.ManagementURL.String())
+}
+
+func TestApply_MDMOnly_OverridesDefaults(t *testing.T) {
+ const mdmURL = "https://corp.mdm.example.com:443"
+ withMDMPolicy(t, 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)
+
+ assert.True(t, cfg.Policy().HasKey(mdm.KeyManagementURL))
+ assert.True(t, cfg.Policy().HasKey(mdm.KeyDisableClientRoutes))
+ assert.True(t, cfg.Policy().HasKey(mdm.KeyBlockInbound))
+ assert.False(t, cfg.Policy().HasKey(mdm.KeyAllowServerSSH))
+}
+
+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{
+ ConfigPath: filepath.Join(t.TempDir(), "config.json"),
+ ManagementURL: cliURL,
+ })
+ require.NoError(t, err)
+ require.NotNil(t, cfg)
+
+ // MDM wins over CLI-supplied management URL.
+ assert.Equal(t, mdmURL, cfg.ManagementURL.String())
+ assert.True(t, cfg.Policy().HasKey(mdm.KeyManagementURL))
+}
+
+func TestApply_MDMInvalidURL_KeepsPreviousValue(t *testing.T) {
+ withMDMPolicy(t, 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())
+
+ // But the key is still considered MDM-managed (admin intent is to
+ // enforce, daemon rejects user writes to this field — phase-1 scaffolding
+ // reflects this by keeping Policy.HasKey true even on parse failure).
+ assert.True(t, cfg.Policy().HasKey(mdm.KeyManagementURL))
+}
+
+func TestApply_MDMBoolKeysOverrideOnDiskValue(t *testing.T) {
+ tmp := filepath.Join(t.TempDir(), "config.json")
+
+ // Seed without MDM.
+ withMDMPolicy(t, mdm.NewPolicy(nil))
+ _, err := UpdateOrCreateConfig(ConfigInput{
+ ConfigPath: tmp,
+ DisableClientRoutes: boolPtr(false),
+ RosenpassEnabled: boolPtr(false),
+ })
+ require.NoError(t, err)
+
+ // Now enable MDM enforcement for these keys.
+ withMDMPolicy(t, 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))
+ assert.True(t, cfg.Policy().HasKey(mdm.KeyRosenpassEnabled))
+}
+
+func TestApply_MDMPreSharedKeyRedactionSentinelRejected(t *testing.T) {
+ const maskSentinel = "**********"
+
+ withMDMPolicy(t, 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 boolPtr(b bool) *bool { return &b }
diff --git a/client/internal/relay/relay.go b/client/internal/relay/relay.go
index f00a8d93a..051717608 100644
--- a/client/internal/relay/relay.go
+++ b/client/internal/relay/relay.go
@@ -32,6 +32,9 @@ type ProbeResult struct {
URI string
Err error
Addr string
+ // Transport is the negotiated relay transport, empty
+ // for stun/turn probes or when not connected.
+ Transport string
}
type StunTurnProbe struct {
diff --git a/client/internal/rosenpass/manager_test.go b/client/internal/rosenpass/manager_test.go
index ace6f88da..d74960d0d 100644
--- a/client/internal/rosenpass/manager_test.go
+++ b/client/internal/rosenpass/manager_test.go
@@ -22,14 +22,14 @@ type removePeerCall struct {
}
type mockServer struct {
- mu sync.Mutex
- addCalls []addPeerCall
- removed []removePeerCall
- nextID rp.PeerID
- addErr error
- removeErr error
- closed bool
- ran bool
+ mu sync.Mutex
+ addCalls []addPeerCall
+ removed []removePeerCall
+ nextID rp.PeerID
+ addErr error
+ removeErr error
+ closed bool
+ ran bool
}
func (m *mockServer) AddPeer(cfg rp.PeerConfig) (rp.PeerID, error) {
@@ -51,7 +51,7 @@ func (m *mockServer) RemovePeer(id rp.PeerID) error {
return m.removeErr
}
-func (m *mockServer) Run() error { m.ran = true; return nil }
+func (m *mockServer) Run() error { m.ran = true; return nil }
func (m *mockServer) Close() error { m.closed = true; return nil }
type setPSKCall struct {
diff --git a/client/internal/rosenpass/seed_test.go b/client/internal/rosenpass/seed_test.go
index 0dfa478c7..b6a9a5991 100644
--- a/client/internal/rosenpass/seed_test.go
+++ b/client/internal/rosenpass/seed_test.go
@@ -41,4 +41,3 @@ func TestDeterministicSeedKey_TooShortKey_ReturnsError(t *testing.T) {
_, err = DeterministicSeedKey(long, short)
require.Error(t, err)
}
-
diff --git a/client/internal/routemanager/manager.go b/client/internal/routemanager/manager.go
index 839ec14c0..0edf4607f 100644
--- a/client/internal/routemanager/manager.go
+++ b/client/internal/routemanager/manager.go
@@ -9,6 +9,7 @@ import (
"net/url"
"runtime"
"slices"
+ "strings"
"sync"
"sync/atomic"
"time"
@@ -700,6 +701,15 @@ func resolveURLsToIPs(urls []string) []net.IP {
// updateRouteSelectorFromManagement updates the route selector based on the isSelected status from the management server
func (m *DefaultManager) updateRouteSelectorFromManagement(clientRoutes route.HAMap) {
+ m.mirrorV6ExitPairSelections(clientRoutes)
+
+ // An explicit user "deselect all" must not be overridden by management auto-apply.
+ // Auto-applying an exit node here would call SelectRoutes, which clears the
+ // deselect-all flag and re-enables every route the user turned off.
+ if m.routeSelector.IsDeselectAll() {
+ return
+ }
+
exitNodeInfo := m.collectExitNodeInfo(clientRoutes)
if len(exitNodeInfo.allIDs) == 0 {
return
@@ -709,6 +719,24 @@ func (m *DefaultManager) updateRouteSelectorFromManagement(clientRoutes route.HA
m.logExitNodeUpdate(exitNodeInfo)
}
+// mirrorV6ExitPairSelections keeps every synthesized "-v6" exit route's selection
+// consistent with its v4 base. The v4/v6 exit pair is a single toggle, so the v6
+// entry always follows the base: deselecting the v4 exit node also drops its ::/0
+// pair, and any stale (orphaned) explicit selection on the v6 entry is reset. This
+// runs before selection is read so both collectExitNodeInfo and FilterSelectedExitNodes
+// see consistent state, including pairs loaded from persisted selector state.
+func (m *DefaultManager) mirrorV6ExitPairSelections(clientRoutes route.HAMap) {
+ routesByNetID := make(map[route.NetID][]*route.Route, len(clientRoutes))
+ for haID, routes := range clientRoutes {
+ routesByNetID[haID.NetID()] = routes
+ }
+
+ for v6ID := range route.V6ExitMergeSet(routesByNetID) {
+ baseID := route.NetID(strings.TrimSuffix(string(v6ID), route.V6ExitSuffix))
+ m.routeSelector.SyncPairedSelection(baseID, v6ID)
+ }
+}
+
type exitNodeInfo struct {
allIDs []route.NetID
selectedByManagement []route.NetID
diff --git a/client/internal/routemanager/manager_v6exit_test.go b/client/internal/routemanager/manager_v6exit_test.go
new file mode 100644
index 000000000..15ab99cbd
--- /dev/null
+++ b/client/internal/routemanager/manager_v6exit_test.go
@@ -0,0 +1,47 @@
+package routemanager
+
+import (
+ "net/netip"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+
+ "github.com/netbirdio/netbird/client/internal/routeselector"
+ "github.com/netbirdio/netbird/route"
+)
+
+// TestUpdateRouteSelectorFromManagement_MirrorsV6ExitPair reproduces the bug seen
+// in netbird-engine.log: persisted selector state has the v4 exit node deselected
+// but its synthesized "-v6" pair explicitly selected (orphaned), so the ::/0 route
+// leaked onto the tunnel. The management update must mirror the v4 deselect onto the
+// v6 pair so FilterSelectedExitNodes drops it.
+func TestUpdateRouteSelectorFromManagement_MirrorsV6ExitPair(t *testing.T) {
+ const (
+ v4ID = route.NetID("Exit Node (raspberrypi)")
+ v6ID = route.NetID("Exit Node (raspberrypi)-v6")
+ )
+ all := []route.NetID{v4ID, v6ID}
+
+ rs := routeselector.NewRouteSelector()
+ // Orphan the v6 selection: select the pair, then deselect only the v4 base.
+ require.NoError(t, rs.SelectRoutes([]route.NetID{v4ID, v6ID}, true, all))
+ require.NoError(t, rs.DeselectRoutes([]route.NetID{v4ID}, all))
+ require.True(t, rs.IsSelected(v6ID), "precondition: orphaned v6 selection survives v4 deselect")
+
+ m := &DefaultManager{routeSelector: rs}
+
+ v4Route := &route.Route{NetID: v4ID, Network: netip.MustParsePrefix("0.0.0.0/0")}
+ v6Route := &route.Route{NetID: v6ID, Network: netip.MustParsePrefix("::/0")}
+ clientRoutes := route.HAMap{
+ "Exit Node (raspberrypi)|0.0.0.0/0": {v4Route},
+ "Exit Node (raspberrypi)-v6|::/0": {v6Route},
+ }
+
+ m.updateRouteSelectorFromManagement(clientRoutes)
+
+ assert.False(t, rs.IsSelected(v6ID), "v6 pair must follow the v4 base deselect after the management update")
+
+ filtered := rs.FilterSelectedExitNodes(clientRoutes)
+ assert.Empty(t, filtered, "deselected v4 exit node must not leak its ::/0 pair onto the tunnel")
+}
diff --git a/client/internal/routemanager/selector_management_test.go b/client/internal/routemanager/selector_management_test.go
new file mode 100644
index 000000000..04659db65
--- /dev/null
+++ b/client/internal/routemanager/selector_management_test.go
@@ -0,0 +1,71 @@
+package routemanager
+
+import (
+ "net/netip"
+ "testing"
+
+ "github.com/stretchr/testify/require"
+
+ "github.com/netbirdio/netbird/client/internal/routeselector"
+ "github.com/netbirdio/netbird/route"
+)
+
+func exitNodeRoutes(netID route.NetID, skipAutoApply bool) route.HAMap {
+ haID := route.HAUniqueID(string(netID) + "|0.0.0.0/0")
+ return route.HAMap{
+ haID: []*route.Route{
+ {
+ ID: "r-" + route.ID(netID),
+ NetID: netID,
+ Network: netip.MustParsePrefix("0.0.0.0/0"),
+ NetworkType: route.IPv4Network,
+ Enabled: true,
+ SkipAutoApply: skipAutoApply,
+ },
+ },
+ }
+}
+
+func TestUpdateRouteSelectorFromManagement(t *testing.T) {
+ t.Run("management auto-apply selects exit node without user selection", func(t *testing.T) {
+ m := &DefaultManager{routeSelector: routeselector.NewRouteSelector()}
+ routes := exitNodeRoutes("exit1", false)
+
+ m.updateRouteSelectorFromManagement(routes)
+
+ require.True(t, m.routeSelector.IsSelected("exit1"), "auto-apply exit node should be selected")
+ require.Len(t, m.routeSelector.FilterSelectedExitNodes(routes), 1, "selected exit node should pass the filter")
+ })
+
+ t.Run("management SkipAutoApply leaves exit node deselected", func(t *testing.T) {
+ m := &DefaultManager{routeSelector: routeselector.NewRouteSelector()}
+ routes := exitNodeRoutes("exit1", true)
+
+ m.updateRouteSelectorFromManagement(routes)
+
+ require.False(t, m.routeSelector.IsSelected("exit1"), "SkipAutoApply exit node should not be selected")
+ require.Empty(t, m.routeSelector.FilterSelectedExitNodes(routes), "deselected exit node should be filtered out")
+ })
+
+ t.Run("user selection is not overridden by management", func(t *testing.T) {
+ m := &DefaultManager{routeSelector: routeselector.NewRouteSelector()}
+ require.NoError(t, m.routeSelector.SelectRoutes([]route.NetID{"exit1"}, true, []route.NetID{"exit1"}))
+ routes := exitNodeRoutes("exit1", true)
+
+ m.updateRouteSelectorFromManagement(routes)
+
+ require.True(t, m.routeSelector.IsSelected("exit1"), "explicit user selection must survive a management sync that wants to skip auto-apply")
+ require.Len(t, m.routeSelector.FilterSelectedExitNodes(routes), 1, "user-selected exit node should pass the filter")
+ })
+
+ t.Run("deselect-all is preserved across a management sync", func(t *testing.T) {
+ m := &DefaultManager{routeSelector: routeselector.NewRouteSelector()}
+ m.routeSelector.DeselectAllRoutes()
+ routes := exitNodeRoutes("exit1", false)
+
+ m.updateRouteSelectorFromManagement(routes)
+
+ require.True(t, m.routeSelector.IsDeselectAll(), "an explicit deselect-all must not be cleared by management auto-apply")
+ require.Empty(t, m.routeSelector.FilterSelectedExitNodes(routes), "no routes should be selected while deselect-all is set")
+ })
+}
diff --git a/client/internal/routemanager/systemops/systemops_generic.go b/client/internal/routemanager/systemops/systemops_generic.go
index 2b96c14dc..bb9ac494d 100644
--- a/client/internal/routemanager/systemops/systemops_generic.go
+++ b/client/internal/routemanager/systemops/systemops_generic.go
@@ -121,9 +121,12 @@ func (r *SysOps) addRouteToNonVPNIntf(prefix netip.Prefix, vpnIntf wgIface, init
return Nexthop{}, vars.ErrRouteNotAllowed
}
- // Check if the prefix is part of any local subnets
- if isLocal, subnet := r.isPrefixInLocalSubnets(prefix); isLocal {
- return Nexthop{}, fmt.Errorf("prefix %s is part of local subnet %s: %w", prefix, subnet, vars.ErrRouteNotAllowed)
+ // BSDs blackhole a /32 added inside a directly-connected subnet; Linux/Windows need it to beat the wt0 route.
+ switch runtime.GOOS {
+ case "darwin", "freebsd", "netbsd", "openbsd", "dragonfly":
+ if isLocal, subnet := r.isPrefixInLocalSubnets(prefix); isLocal {
+ return Nexthop{}, fmt.Errorf("prefix %s is part of local subnet %s: %w", prefix, subnet, vars.ErrRouteNotAllowed)
+ }
}
// Determine the exit interface and next hop for the prefix, so we can add a specific route
diff --git a/client/internal/routeselector/routeselector.go b/client/internal/routeselector/routeselector.go
index 2ddc24bf2..232baf746 100644
--- a/client/internal/routeselector/routeselector.go
+++ b/client/internal/routeselector/routeselector.go
@@ -4,7 +4,6 @@ import (
"encoding/json"
"fmt"
"slices"
- "strings"
"sync"
"github.com/hashicorp/go-multierror"
@@ -116,6 +115,14 @@ func (rs *RouteSelector) DeselectAllRoutes() {
clear(rs.selectedRoutes)
}
+// IsDeselectAll reports whether the user has explicitly deselected all routes.
+func (rs *RouteSelector) IsDeselectAll() bool {
+ rs.mu.RLock()
+ defer rs.mu.RUnlock()
+
+ return rs.deselectAll
+}
+
// IsSelected checks if a specific route is selected.
func (rs *RouteSelector) IsSelected(routeID route.NetID) bool {
rs.mu.RLock()
@@ -124,6 +131,33 @@ func (rs *RouteSelector) IsSelected(routeID route.NetID) bool {
return rs.isSelectedLocked(routeID)
}
+// SyncPairedSelection forces pairedID's explicit selection state to match baseID's,
+// so a synthesized "-v6" exit route always follows its v4 base: selecting or
+// deselecting the v4 exit node governs the ::/0 pair, and any stale (orphaned)
+// explicit state on the v6 entry is reset. The v4/v6 exit pair is treated as a single
+// toggle, so the v6 entry carries no independent selection of its own.
+func (rs *RouteSelector) SyncPairedSelection(baseID, pairedID route.NetID) {
+ rs.mu.Lock()
+ defer rs.mu.Unlock()
+
+ if rs.deselectAll {
+ return
+ }
+
+ _, baseSelected := rs.selectedRoutes[baseID]
+ _, baseDeselected := rs.deselectedRoutes[baseID]
+
+ delete(rs.selectedRoutes, pairedID)
+ delete(rs.deselectedRoutes, pairedID)
+
+ switch {
+ case baseSelected:
+ rs.selectedRoutes[pairedID] = struct{}{}
+ case baseDeselected:
+ rs.deselectedRoutes[pairedID] = struct{}{}
+ }
+}
+
// FilterSelected removes unselected routes from the provided map.
func (rs *RouteSelector) FilterSelected(routes route.HAMap) route.HAMap {
rs.mu.RLock()
@@ -143,14 +177,13 @@ func (rs *RouteSelector) FilterSelected(routes route.HAMap) route.HAMap {
}
// HasUserSelectionForRoute returns true if the user has explicitly selected or deselected this route.
-// Intended for exit-node code paths: a v6 exit-node pair (e.g. "MyExit-v6") with no explicit state of
-// its own inherits its v4 base's state, so legacy persisted selections that predate v6 pairing
-// transparently apply to the synthesized v6 entry.
+// The lookup is literal; v4/v6 exit pairs are kept consistent at write time via SyncPairedSelection,
+// so a synthesized "-v6" entry carries the same explicit state as its v4 base.
func (rs *RouteSelector) HasUserSelectionForRoute(routeID route.NetID) bool {
rs.mu.RLock()
defer rs.mu.RUnlock()
- return rs.hasUserSelectionForRouteLocked(rs.effectiveNetID(routeID))
+ return rs.hasUserSelectionForRouteLocked(routeID)
}
func (rs *RouteSelector) FilterSelectedExitNodes(routes route.HAMap) route.HAMap {
@@ -179,83 +212,6 @@ func (rs *RouteSelector) FilterSelectedExitNodes(routes route.HAMap) route.HAMap
return filtered
}
-// effectiveNetID returns the v4 base for a "-v6" exit pair entry that has no explicit
-// state of its own, so selections made on the v4 entry govern the v6 entry automatically.
-// Only call this from exit-node-specific code paths: applying it to a non-exit "-v6" route
-// would make it inherit unrelated v4 state. Must be called with rs.mu held.
-func (rs *RouteSelector) effectiveNetID(id route.NetID) route.NetID {
- name := string(id)
- if !strings.HasSuffix(name, route.V6ExitSuffix) {
- return id
- }
- if _, ok := rs.selectedRoutes[id]; ok {
- return id
- }
- if _, ok := rs.deselectedRoutes[id]; ok {
- return id
- }
- return route.NetID(strings.TrimSuffix(name, route.V6ExitSuffix))
-}
-
-func (rs *RouteSelector) isSelectedLocked(routeID route.NetID) bool {
- if rs.deselectAll {
- return false
- }
- _, deselected := rs.deselectedRoutes[routeID]
- return !deselected
-}
-
-func (rs *RouteSelector) isDeselectedLocked(netID route.NetID) bool {
- if rs.deselectAll {
- return true
- }
- _, deselected := rs.deselectedRoutes[netID]
- return deselected
-}
-
-func (rs *RouteSelector) hasUserSelectionForRouteLocked(routeID route.NetID) bool {
- _, selected := rs.selectedRoutes[routeID]
- _, deselected := rs.deselectedRoutes[routeID]
- return selected || deselected
-}
-
-func isExitNode(rt []*route.Route) bool {
- return len(rt) > 0 && (route.IsV4DefaultRoute(rt[0].Network) || route.IsV6DefaultRoute(rt[0].Network))
-}
-
-func (rs *RouteSelector) applyExitNodeFilter(
- id route.HAUniqueID,
- netID route.NetID,
- rt []*route.Route,
- out route.HAMap,
-) {
- // Exit-node path: apply the v4/v6 pair mirror so a deselect on the v4 base also
- // drops the synthesized v6 entry that lacks its own explicit state.
- effective := rs.effectiveNetID(netID)
- if rs.hasUserSelectionForRouteLocked(effective) {
- if rs.isSelectedLocked(effective) {
- out[id] = rt
- }
- return
- }
-
- // no explicit selection for this route: defer to management's SkipAutoApply flag
- sel := collectSelected(rt)
- if len(sel) > 0 {
- out[id] = sel
- }
-}
-
-func collectSelected(rt []*route.Route) []*route.Route {
- var sel []*route.Route
- for _, r := range rt {
- if !r.SkipAutoApply {
- sel = append(sel, r)
- }
- }
- return sel
-}
-
// MarshalJSON implements the json.Marshaler interface
func (rs *RouteSelector) MarshalJSON() ([]byte, error) {
rs.mu.RLock()
@@ -309,3 +265,59 @@ func (rs *RouteSelector) UnmarshalJSON(data []byte) error {
return nil
}
+
+func (rs *RouteSelector) isSelectedLocked(routeID route.NetID) bool {
+ if rs.deselectAll {
+ return false
+ }
+ _, deselected := rs.deselectedRoutes[routeID]
+ return !deselected
+}
+
+func (rs *RouteSelector) isDeselectedLocked(netID route.NetID) bool {
+ if rs.deselectAll {
+ return true
+ }
+ _, deselected := rs.deselectedRoutes[netID]
+ return deselected
+}
+
+func (rs *RouteSelector) hasUserSelectionForRouteLocked(routeID route.NetID) bool {
+ _, selected := rs.selectedRoutes[routeID]
+ _, deselected := rs.deselectedRoutes[routeID]
+ return selected || deselected
+}
+
+func (rs *RouteSelector) applyExitNodeFilter(
+ id route.HAUniqueID,
+ netID route.NetID,
+ rt []*route.Route,
+ out route.HAMap,
+) {
+ if rs.hasUserSelectionForRouteLocked(netID) {
+ if rs.isSelectedLocked(netID) {
+ out[id] = rt
+ }
+ return
+ }
+
+ // no explicit selection for this route: defer to management's SkipAutoApply flag
+ sel := collectSelected(rt)
+ if len(sel) > 0 {
+ out[id] = sel
+ }
+}
+
+func isExitNode(rt []*route.Route) bool {
+ return len(rt) > 0 && (route.IsV4DefaultRoute(rt[0].Network) || route.IsV6DefaultRoute(rt[0].Network))
+}
+
+func collectSelected(rt []*route.Route) []*route.Route {
+ var sel []*route.Route
+ for _, r := range rt {
+ if !r.SkipAutoApply {
+ sel = append(sel, r)
+ }
+ }
+ return sel
+}
diff --git a/client/internal/routeselector/routeselector_test.go b/client/internal/routeselector/routeselector_test.go
index 3f0d9f120..c9d6acb4d 100644
--- a/client/internal/routeselector/routeselector_test.go
+++ b/client/internal/routeselector/routeselector_test.go
@@ -330,39 +330,73 @@ func TestRouteSelector_FilterSelectedExitNodes(t *testing.T) {
assert.Len(t, filtered, 0) // No routes should be selected
}
-// TestRouteSelector_V6ExitPairInherits covers the v4/v6 exit-node pair selection
-// mirror. The mirror is scoped to exit-node code paths: HasUserSelectionForRoute
-// and FilterSelectedExitNodes resolve a "-v6" entry without explicit state to its
-// v4 base, so legacy persisted selections that predate v6 pairing transparently
-// apply to the synthesized v6 entry. General lookups (IsSelected, FilterSelected)
-// stay literal so unrelated routes named "*-v6" don't inherit unrelated state.
-func TestRouteSelector_V6ExitPairInherits(t *testing.T) {
+// TestRouteSelector_V6ExitPairSync covers SyncPairedSelection, which keeps a v4
+// exit node and its synthesized "-v6" counterpart consistent. The selector itself
+// is literal and never infers a v6 entry's state from its v4 base; callers that know
+// the pairing (exit-node code paths) call SyncPairedSelection to force the v6 entry
+// to follow the base, treating the pair as a single toggle.
+func TestRouteSelector_V6ExitPairSync(t *testing.T) {
all := []route.NetID{"exit1", "exit1-v6", "exit2", "exit2-v6", "corp", "corp-v6"}
- t.Run("HasUserSelectionForRoute mirrors deselected v4 base", func(t *testing.T) {
+ t.Run("selector lookups stay literal without sync", func(t *testing.T) {
rs := routeselector.NewRouteSelector()
require.NoError(t, rs.DeselectRoutes([]route.NetID{"exit1"}, all))
- assert.True(t, rs.HasUserSelectionForRoute("exit1-v6"), "v6 pair sees v4 base's user selection")
+ // The selector does not pair-resolve: the v6 entry is independent until synced.
+ assert.False(t, rs.HasUserSelectionForRoute("exit1-v6"), "v6 entry has no state of its own")
+ assert.True(t, rs.IsSelected("exit1-v6"), "unsynced v6 entry stays selected by default")
- // unrelated v6 with no v4 base touched is unaffected
- assert.False(t, rs.HasUserSelectionForRoute("exit2-v6"))
+ // A route literally named "exit1-something" must never pair-resolve either.
+ assert.False(t, rs.HasUserSelectionForRoute("exit1-something"))
})
- t.Run("IsSelected stays literal for non-exit lookups", func(t *testing.T) {
- rs := routeselector.NewRouteSelector()
- require.NoError(t, rs.DeselectRoutes([]route.NetID{"corp"}, all))
-
- // A non-exit route literally named "corp-v6" must not inherit "corp"'s state
- // via the mirror; the mirror only applies in exit-node code paths.
- assert.False(t, rs.IsSelected("corp"))
- assert.True(t, rs.IsSelected("corp-v6"), "non-exit *-v6 routes must not inherit unrelated v4 state")
- })
-
- t.Run("explicit v6 state overrides v4 base in filter", func(t *testing.T) {
+ t.Run("sync mirrors deselected v4 base onto v6", func(t *testing.T) {
rs := routeselector.NewRouteSelector()
require.NoError(t, rs.DeselectRoutes([]route.NetID{"exit1"}, all))
+
+ rs.SyncPairedSelection("exit1", "exit1-v6")
+
+ assert.False(t, rs.IsSelected("exit1"))
+ assert.False(t, rs.IsSelected("exit1-v6"), "v6 pair follows v4 base deselect")
+ assert.True(t, rs.HasUserSelectionForRoute("exit1-v6"), "v6 carries explicit deselect after sync")
+ })
+
+ t.Run("sync mirrors selected v4 base onto v6", func(t *testing.T) {
+ rs := routeselector.NewRouteSelector()
+ require.NoError(t, rs.SelectRoutes([]route.NetID{"exit1"}, false, all))
+
+ rs.SyncPairedSelection("exit1", "exit1-v6")
+
+ assert.True(t, rs.IsSelected("exit1"))
+ assert.True(t, rs.IsSelected("exit1-v6"), "v6 pair follows v4 base select")
+ })
+
+ t.Run("sync clears v6 state when base has no explicit selection", func(t *testing.T) {
+ rs := routeselector.NewRouteSelector()
require.NoError(t, rs.SelectRoutes([]route.NetID{"exit1-v6"}, true, all))
+ require.True(t, rs.HasUserSelectionForRoute("exit1-v6"))
+
+ rs.SyncPairedSelection("exit1", "exit1-v6")
+
+ assert.False(t, rs.HasUserSelectionForRoute("exit1-v6"),
+ "v6 explicit state is cleared so it follows management like its base")
+ })
+
+ // Regression for the observed bug (see netbird-engine.log): persisted state has
+ // the v4 base deselected but the v6 sibling explicitly selected (orphaned). The
+ // sync must reset the orphan so the ::/0 route does not leak onto the tunnel.
+ t.Run("sync clears orphaned explicit v6 selection on deselected base", func(t *testing.T) {
+ rs := routeselector.NewRouteSelector()
+
+ // Prior state: both explicitly selected, then only the v4 base deselected,
+ // leaving the v6 entry as a stale explicit selection.
+ require.NoError(t, rs.SelectRoutes([]route.NetID{"exit1", "exit1-v6"}, true, all))
+ require.NoError(t, rs.DeselectRoutes([]route.NetID{"exit1"}, all))
+ require.True(t, rs.IsSelected("exit1-v6"), "precondition: orphaned v6 selection")
+
+ rs.SyncPairedSelection("exit1", "exit1-v6")
+
+ assert.False(t, rs.IsSelected("exit1-v6"), "orphaned v6 selection reset to follow v4 deselect")
v4Route := &route.Route{NetID: "exit1", Network: netip.MustParsePrefix("0.0.0.0/0")}
v6Route := &route.Route{NetID: "exit1-v6", Network: netip.MustParsePrefix("::/0")}
@@ -370,23 +404,14 @@ func TestRouteSelector_V6ExitPairInherits(t *testing.T) {
"exit1|0.0.0.0/0": {v4Route},
"exit1-v6|::/0": {v6Route},
}
-
filtered := rs.FilterSelectedExitNodes(routes)
- assert.NotContains(t, filtered, route.HAUniqueID("exit1|0.0.0.0/0"))
- assert.Contains(t, filtered, route.HAUniqueID("exit1-v6|::/0"), "explicit v6 select wins over v4 base")
+ assert.Empty(t, filtered, "deselecting v4 base must drop the v6 pair even if it was explicitly selected before")
})
- t.Run("non-v6-suffix routes unaffected", func(t *testing.T) {
- rs := routeselector.NewRouteSelector()
- require.NoError(t, rs.DeselectRoutes([]route.NetID{"exit1"}, all))
-
- // A route literally named "exit1-something" must not pair-resolve.
- assert.False(t, rs.HasUserSelectionForRoute("exit1-something"))
- })
-
- t.Run("filter v6 paired with deselected v4 base", func(t *testing.T) {
+ t.Run("filter drops synced v6 pair of deselected v4 base", func(t *testing.T) {
rs := routeselector.NewRouteSelector()
require.NoError(t, rs.DeselectRoutes([]route.NetID{"exit1"}, all))
+ rs.SyncPairedSelection("exit1", "exit1-v6")
v4Route := &route.Route{NetID: "exit1", Network: netip.MustParsePrefix("0.0.0.0/0")}
v6Route := &route.Route{NetID: "exit1-v6", Network: netip.MustParsePrefix("::/0")}
@@ -399,6 +424,15 @@ func TestRouteSelector_V6ExitPairInherits(t *testing.T) {
assert.Empty(t, filtered, "deselecting v4 base must also drop the v6 pair")
})
+ t.Run("deselectAll makes sync a no-op", func(t *testing.T) {
+ rs := routeselector.NewRouteSelector()
+ rs.DeselectAllRoutes()
+
+ rs.SyncPairedSelection("exit1", "exit1-v6")
+
+ assert.False(t, rs.HasUserSelectionForRoute("exit1-v6"), "sync must not write explicit state under deselectAll")
+ })
+
t.Run("non-exit *-v6 routes pass through FilterSelectedExitNodes", func(t *testing.T) {
rs := routeselector.NewRouteSelector()
require.NoError(t, rs.DeselectRoutes([]route.NetID{"corp"}, all))
diff --git a/client/internal/syncstore/disk.go b/client/internal/syncstore/disk.go
new file mode 100644
index 000000000..eb24e87a7
--- /dev/null
+++ b/client/internal/syncstore/disk.go
@@ -0,0 +1,99 @@
+package syncstore
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "os"
+ "path/filepath"
+ "sync"
+
+ log "github.com/sirupsen/logrus"
+ "google.golang.org/protobuf/proto"
+
+ mgmProto "github.com/netbirdio/netbird/shared/management/proto"
+ "github.com/netbirdio/netbird/util"
+)
+
+// syncResponseFileName is the name of the file the sync response is serialized
+// to, placed inside the configured directory (the state directory).
+const syncResponseFileName = "networkmap.pb"
+
+// diskStore serializes the latest sync response to a file on disk instead of
+// keeping it in memory. This trades disk I/O for a much smaller memory
+// footprint, which matters on memory-constrained platforms (iOS).
+type diskStore struct {
+ mu sync.Mutex
+ path string
+}
+
+// NewDiskStore returns a Store that serializes the sync response to a file in
+// the given directory. If dir is empty it falls back to the OS temp directory.
+//
+// Any file left over from a previous run is removed on construction so a fresh
+// store never reads stale data (e.g. another profile's network map).
+func NewDiskStore(dir string) Store {
+ if dir == "" {
+ dir = os.TempDir()
+ }
+ s := &diskStore{
+ path: filepath.Join(dir, syncResponseFileName),
+ }
+ if err := s.Clear(); err != nil {
+ log.Warnf("failed to clear stale sync response file: %v", err)
+ }
+ return s
+}
+
+func (s *diskStore) Set(resp *mgmProto.SyncResponse) error {
+ if resp == nil {
+ return s.Clear()
+ }
+
+ bs, err := proto.Marshal(resp)
+ if err != nil {
+ return fmt.Errorf("marshal sync response: %w", err)
+ }
+
+ s.mu.Lock()
+ defer s.mu.Unlock()
+
+ if err := util.WriteBytesWithRestrictedPermission(context.Background(), s.path, bs); err != nil {
+ return fmt.Errorf("write sync response to %s: %w", s.path, err)
+ }
+
+ log.Debugf("sync response persisted to %s (%d bytes)", s.path, len(bs))
+ return nil
+}
+
+func (s *diskStore) Get() (*mgmProto.SyncResponse, error) {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+
+ bs, err := os.ReadFile(s.path)
+ if err != nil {
+ if errors.Is(err, os.ErrNotExist) {
+ //nolint:nilnil // nil,nil means "nothing stored", per the Store contract; preserve the original behaviour
+ return nil, nil
+ }
+ return nil, fmt.Errorf("read sync response from %s: %w", s.path, err)
+ }
+
+ resp := &mgmProto.SyncResponse{}
+ if err := proto.Unmarshal(bs, resp); err != nil {
+ return nil, fmt.Errorf("unmarshal sync response: %w", err)
+ }
+
+ log.Debugf("retrieving latest sync response from %s (%d bytes)", s.path, len(bs))
+ return resp, nil
+}
+
+func (s *diskStore) Clear() error {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+
+ if err := os.Remove(s.path); err != nil && !errors.Is(err, os.ErrNotExist) {
+ return fmt.Errorf("remove sync response file %s: %w", s.path, err)
+ }
+ return nil
+}
diff --git a/client/internal/syncstore/factory_ios.go b/client/internal/syncstore/factory_ios.go
new file mode 100644
index 000000000..f19ab5e5c
--- /dev/null
+++ b/client/internal/syncstore/factory_ios.go
@@ -0,0 +1,9 @@
+//go:build ios
+
+package syncstore
+
+// New returns the platform default store. On iOS the sync response is
+// serialized to disk (in dir) to keep it out of the constrained process memory.
+func New(dir string) Store {
+ return NewDiskStore(dir)
+}
diff --git a/client/internal/syncstore/factory_other.go b/client/internal/syncstore/factory_other.go
new file mode 100644
index 000000000..79ea46116
--- /dev/null
+++ b/client/internal/syncstore/factory_other.go
@@ -0,0 +1,9 @@
+//go:build !ios
+
+package syncstore
+
+// New returns the platform default store. On all non-iOS platforms the sync
+// response is kept in memory; dir is unused.
+func New(_ string) Store {
+ return NewMemoryStore()
+}
diff --git a/client/internal/syncstore/memory.go b/client/internal/syncstore/memory.go
new file mode 100644
index 000000000..8fc069069
--- /dev/null
+++ b/client/internal/syncstore/memory.go
@@ -0,0 +1,56 @@
+package syncstore
+
+import (
+ "fmt"
+ "sync"
+
+ log "github.com/sirupsen/logrus"
+ "google.golang.org/protobuf/proto"
+
+ mgmProto "github.com/netbirdio/netbird/shared/management/proto"
+)
+
+// memoryStore keeps the latest sync response in memory.
+type memoryStore struct {
+ mu sync.RWMutex
+ latest *mgmProto.SyncResponse
+}
+
+// NewMemoryStore returns a Store that keeps the sync response in memory.
+func NewMemoryStore() Store {
+ return &memoryStore{}
+}
+
+func (s *memoryStore) Set(resp *mgmProto.SyncResponse) error {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+
+ s.latest = resp
+ return nil
+}
+
+func (s *memoryStore) Get() (*mgmProto.SyncResponse, error) {
+ s.mu.RLock()
+ latest := s.latest
+ s.mu.RUnlock()
+
+ if latest == nil {
+ //nolint:nilnil // nil,nil means "nothing stored", per the Store contract; preserve the original behaviour
+ return nil, nil
+ }
+
+ log.Debugf("retrieving latest sync response with size %d bytes", proto.Size(latest))
+ sr, ok := proto.Clone(latest).(*mgmProto.SyncResponse)
+ if !ok {
+ return nil, fmt.Errorf("clone sync response")
+ }
+ return sr, nil
+}
+
+func (s *memoryStore) Clear() error {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+
+ s.latest = nil
+ return nil
+}
diff --git a/client/internal/syncstore/syncstore.go b/client/internal/syncstore/syncstore.go
new file mode 100644
index 000000000..ba24b9c57
--- /dev/null
+++ b/client/internal/syncstore/syncstore.go
@@ -0,0 +1,29 @@
+// Package syncstore stores the latest Management sync response (which carries
+// the network map) for debug bundle generation.
+//
+// The storage backend is selected at build time per operating system: on iOS
+// the response is serialized to disk to keep it out of the (tightly
+// constrained) process memory, while on all other platforms it is kept in
+// memory. The backend is chosen by the New constructor; see factory_ios.go and
+// factory_other.go.
+package syncstore
+
+import (
+ mgmProto "github.com/netbirdio/netbird/shared/management/proto"
+)
+
+// Store persists the latest sync response and returns it on demand.
+//
+// Implementations must be safe for concurrent use.
+type Store interface {
+ // Set stores the given sync response, replacing any previously stored one.
+ Set(resp *mgmProto.SyncResponse) error
+
+ // Get returns the stored sync response, or nil if none is stored.
+ // The returned value is an independent copy that the caller may retain.
+ Get() (*mgmProto.SyncResponse, error)
+
+ // Clear removes any stored sync response. It is safe to call when nothing
+ // is stored.
+ Clear() error
+}
diff --git a/client/internal/updater/manager.go b/client/internal/updater/manager.go
index dfcb93177..7fc300739 100644
--- a/client/internal/updater/manager.go
+++ b/client/internal/updater/manager.go
@@ -19,8 +19,6 @@ import (
const (
latestVersion = "latest"
- // this version will be ignored
- developmentVersion = "development"
)
var errNoUpdateState = errors.New("no update state found")
@@ -483,7 +481,7 @@ func (m *Manager) loadAndDeleteUpdateState(ctx context.Context) (*UpdateState, e
}
func (m *Manager) shouldUpdate(updateVersion *v.Version, forceUpdate bool) bool {
- if m.currentVersion == developmentVersion {
+ if version.IsDevelopmentVersion(m.currentVersion) {
log.Debugf("skipping auto-update, running development version")
return false
}
diff --git a/client/ios/NetBirdSDK/client.go b/client/ios/NetBirdSDK/client.go
index bafbb0031..359a83556 100644
--- a/client/ios/NetBirdSDK/client.go
+++ b/client/ios/NetBirdSDK/client.go
@@ -17,6 +17,7 @@ import (
"github.com/netbirdio/netbird/client/internal"
"github.com/netbirdio/netbird/client/internal/auth"
+ "github.com/netbirdio/netbird/client/internal/debug"
"github.com/netbirdio/netbird/client/internal/dns"
"github.com/netbirdio/netbird/client/internal/listener"
"github.com/netbirdio/netbird/client/internal/peer"
@@ -25,6 +26,7 @@ import (
"github.com/netbirdio/netbird/formatter"
"github.com/netbirdio/netbird/route"
"github.com/netbirdio/netbird/shared/management/domain"
+ types "github.com/netbirdio/netbird/upload-server/types"
)
// ConnectionListener export internal Listener for mobile
@@ -54,6 +56,7 @@ type selectRoute struct {
Network netip.Prefix
Domains domain.List
Selected bool
+ Status string
extraNetworks []netip.Prefix
}
@@ -65,6 +68,8 @@ func init() {
type Client struct {
cfgFile string
stateFile string
+ cacheDir string
+ logFilePath string
recorder *peer.Status
ctxCancel context.CancelFunc
ctxCancelLock *sync.Mutex
@@ -75,16 +80,21 @@ type Client struct {
onHostDnsFn func([]string)
dnsManager dns.IosDnsManager
loginComplete bool
- connectClient *internal.ConnectClient
// preloadedConfig holds config loaded from JSON (used on tvOS where file writes are blocked)
preloadedConfig *profilemanager.Config
+
+ stateMu sync.RWMutex
+ connectClient *internal.ConnectClient
+ config *profilemanager.Config
}
// NewClient instantiate a new Client
-func NewClient(cfgFile, stateFile, deviceName string, osVersion string, osName string, networkChangeListener NetworkChangeListener, dnsManager DnsManager) *Client {
+func NewClient(cfgFile, stateFile, cacheDir, logFilePath, deviceName string, osVersion string, osName string, networkChangeListener NetworkChangeListener, dnsManager DnsManager) *Client {
return &Client{
cfgFile: cfgFile,
stateFile: stateFile,
+ cacheDir: cacheDir,
+ logFilePath: logFilePath,
deviceName: deviceName,
osName: osName,
osVersion: osVersion,
@@ -161,8 +171,13 @@ func (c *Client) Run(fd int32, interfaceName string, envList *EnvList) error {
c.onHostDnsFn = func([]string) {}
cfg.WgIface = interfaceName
- c.connectClient = internal.NewConnectClient(ctx, cfg, c.recorder)
- return c.connectClient.RunOniOS(fd, c.networkChangeListener, c.dnsManager, c.stateFile)
+ connectClient := internal.NewConnectClient(ctx, cfg, c.recorder)
+ c.setState(cfg, connectClient)
+ // Persist the latest sync response so DebugBundle can include the network
+ // map. On iOS this is backed by disk to keep it out of the constrained
+ // process memory (see the syncstore package).
+ connectClient.SetSyncResponsePersistence(true)
+ return connectClient.RunOniOS(fd, c.networkChangeListener, c.dnsManager, c.stateFile, c.cacheDir, c.logFilePath)
}
// Stop the internal client and free the resources
@@ -174,6 +189,84 @@ func (c *Client) Stop() {
}
c.ctxCancel()
+ c.setState(nil, nil)
+}
+
+// DebugBundle generates a debug bundle, uploads it and returns the upload key.
+// It works with or without a running engine: when the engine is up it reuses
+// the live config, sync response and client metrics; otherwise it loads the
+// config from disk (or the preloaded tvOS config).
+func (c *Client) DebugBundle(anonymize bool) (string, error) {
+ cfg, cc := c.stateSnapshot()
+
+ // 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)
+ }
+ }
+ }
+
+ deps := debug.GeneratorDependencies{
+ InternalConfig: cfg,
+ StatusRecorder: c.recorder,
+ TempDir: c.cacheDir,
+ StatePath: c.stateFile,
+ LogPath: c.logFilePath,
+ }
+
+ if cc != nil {
+ resp, err := cc.GetLatestSyncResponse()
+ if err != nil {
+ log.Warnf("get latest sync response: %v", err)
+ }
+ deps.SyncResponse = resp
+
+ if e := cc.Engine(); e != nil {
+ if cm := e.GetClientMetrics(); cm != nil {
+ deps.ClientMetrics = cm
+ }
+ }
+ }
+
+ bundleGenerator := debug.NewBundleGenerator(
+ deps,
+ debug.BundleConfig{
+ Anonymize: anonymize,
+ IncludeSystemInfo: true,
+ },
+ )
+
+ path, err := bundleGenerator.Generate()
+ if err != nil {
+ return "", fmt.Errorf("generate debug bundle: %w", err)
+ }
+ defer func() {
+ if err := os.Remove(path); err != nil {
+ log.Errorf("failed to remove debug bundle file: %v", err)
+ }
+ }()
+
+ uploadCtx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
+ defer cancel()
+
+ key, err := debug.UploadDebugBundle(uploadCtx, types.DefaultBundleURL, cfg.ManagementURL.String(), path)
+ if err != nil {
+ return "", fmt.Errorf("upload debug bundle: %w", err)
+ }
+
+ log.Infof("debug bundle uploaded with key %s", key)
+ return key, nil
}
// SetTraceLogLevel configure the logger to trace level
@@ -227,6 +320,16 @@ func (c *Client) RemoveConnectionListener() {
c.recorder.RemoveConnectionListener()
}
+// IsLoginRequiredCached reports whether the LAST observed management error was an
+// auth failure (PermissionDenied/InvalidArgument), using the in-memory status
+// recorder. Unlike IsLoginRequired() it performs NO network call, so it is safe to
+// call from the connection listener during teardown (e.g. onDisconnected) without
+// blocking on a slow or unavailable network. Returns false while connected to
+// management or when the last error was not auth-related.
+func (c *Client) IsLoginRequiredCached() bool {
+ return c.recorder.IsLoginRequired()
+}
+
func (c *Client) IsLoginRequired() bool {
var ctx context.Context
//nolint
@@ -354,11 +457,12 @@ func (c *Client) ClearLoginComplete() {
}
func (c *Client) GetRoutesSelectionDetails() (*RoutesSelectionDetails, error) {
- if c.connectClient == nil {
+ _, connectClient := c.stateSnapshot()
+ if connectClient == nil {
return nil, fmt.Errorf("not connected")
}
- engine := c.connectClient.Engine()
+ engine := connectClient.Engine()
if engine == nil {
return nil, fmt.Errorf("not connected")
}
@@ -377,9 +481,57 @@ func (c *Client) GetRoutesSelectionDetails() (*RoutesSelectionDetails, error) {
routes := buildSelectRoutes(routesMap, routeSelector.IsSelected, v6ExitMerged)
resolvedDomains := c.recorder.GetResolvedDomainsStates()
+ // Compute each route's connection status in the core (mirroring the Android
+ // bridge), so the UI doesn't have to infer it by string-matching the joined
+ // Network value against peer routes. For a merged exit node the status reflects
+ // whichever of the v4/v6 prefixes is served by a connected peer; for dynamic
+ // (DNS) routes the peer route key is the domain pattern (see dynamic.Route.String).
+ connectedRoutes := c.connectedRouteSet()
+ for _, r := range routes {
+ r.Status = routeStatus(r, connectedRoutes)
+ }
+
return prepareRouteSelectionDetails(routes, resolvedDomains), nil
}
+// connectedRouteSet returns the set of route keys (as strings) currently served by a
+// connected peer, gathered across all connected peers' route tables. The keys match
+// what the route manager records: a prefix string for static routes (e.g. "0.0.0.0/0")
+// and the domain pattern for dynamic routes (e.g. "*.example.com").
+func (c *Client) connectedRouteSet() map[string]struct{} {
+ connected := map[string]struct{}{}
+ for _, p := range c.recorder.GetFullStatus().Peers {
+ if p.ConnStatus != peer.StatusConnected {
+ continue
+ }
+ for r := range p.GetRoutes() {
+ connected[r] = struct{}{}
+ }
+ }
+ return connected
+}
+
+// routeStatus reports "Connected" if any of the route's keys is served by a connected
+// peer: the primary Network prefix, an extra v6 network of a merged exit node, or the
+// domain pattern for a dynamic DNS route. Otherwise "Idle".
+func routeStatus(r *selectRoute, connectedRoutes map[string]struct{}) string {
+ keys := make([]string, 0, 1+len(r.extraNetworks))
+ if len(r.Domains) > 0 {
+ keys = append(keys, r.Domains.SafeString())
+ } else {
+ keys = append(keys, r.Network.String())
+ }
+ for _, extra := range r.extraNetworks {
+ keys = append(keys, extra.String())
+ }
+ for _, k := range keys {
+ if _, ok := connectedRoutes[k]; ok {
+ return peer.StatusConnected.String()
+ }
+ }
+ return peer.StatusIdle.String()
+}
+
func buildSelectRoutes(routesMap map[route.NetID][]*route.Route, isSelected func(route.NetID) bool, v6Merged map[route.NetID]struct{}) []*selectRoute {
var routes []*selectRoute
for id, rt := range routesMap {
@@ -462,6 +614,7 @@ func prepareRouteSelectionDetails(routes []*selectRoute, resolvedDomains map[dom
Network: netStr,
Domains: &domainDetails,
Selected: r.Selected,
+ Status: r.Status,
})
}
@@ -470,11 +623,12 @@ func prepareRouteSelectionDetails(routes []*selectRoute, resolvedDomains map[dom
}
func (c *Client) SelectRoute(id string) error {
- if c.connectClient == nil {
+ _, connectClient := c.stateSnapshot()
+ if connectClient == nil {
return fmt.Errorf("not connected")
}
- engine := c.connectClient.Engine()
+ engine := connectClient.Engine()
if engine == nil {
return fmt.Errorf("not connected")
}
@@ -500,10 +654,11 @@ func (c *Client) SelectRoute(id string) error {
}
func (c *Client) DeselectRoute(id string) error {
- if c.connectClient == nil {
+ _, connectClient := c.stateSnapshot()
+ if connectClient == nil {
return fmt.Errorf("not connected")
}
- engine := c.connectClient.Engine()
+ engine := connectClient.Engine()
if engine == nil {
return fmt.Errorf("not connected")
}
@@ -527,6 +682,22 @@ func (c *Client) DeselectRoute(id string) error {
return nil
}
+// setState stores the running engine state so DebugBundle can reuse the live
+// config and ConnectClient. It is cleared on Stop.
+func (c *Client) setState(cfg *profilemanager.Config, cc *internal.ConnectClient) {
+ c.stateMu.Lock()
+ defer c.stateMu.Unlock()
+ c.config = cfg
+ c.connectClient = cc
+}
+
+// stateSnapshot returns the current config and ConnectClient under the lock.
+func (c *Client) stateSnapshot() (*profilemanager.Config, *internal.ConnectClient) {
+ c.stateMu.RLock()
+ defer c.stateMu.RUnlock()
+ return c.config, c.connectClient
+}
+
func formatDuration(d time.Duration) string {
ds := d.String()
dotIndex := strings.Index(ds, ".")
diff --git a/client/ios/NetBirdSDK/routes.go b/client/ios/NetBirdSDK/routes.go
index 025313bfa..56af2a1ad 100644
--- a/client/ios/NetBirdSDK/routes.go
+++ b/client/ios/NetBirdSDK/routes.go
@@ -20,6 +20,7 @@ type RoutesSelectionInfo struct {
Network string
Domains *DomainDetails
Selected bool
+ Status string
}
type DomainCollection interface {
diff --git a/client/mdm/canonical_loaders.go b/client/mdm/canonical_loaders.go
new file mode 100644
index 000000000..6e7ab19cb
--- /dev/null
+++ b/client/mdm/canonical_loaders.go
@@ -0,0 +1,50 @@
+//go:build windows || darwin
+
+package mdm
+
+import "strings"
+
+// allKeys is the set of recognised MDM keys. Unknown keys in a managed
+// configuration are ignored but logged. Lives in this build-tagged file
+// (windows || darwin) because only desktop loaders need the
+// canonicalisation table that consumes it; including it unconditionally
+// would trigger the `unused` golangci-lint check on platforms that
+// don't import canonical_loaders.go.
+var allKeys = []string{
+ KeyManagementURL,
+ KeyDisableUpdateSettings,
+ KeyDisableProfiles,
+ KeyDisableNetworks,
+ KeyDisableClientRoutes,
+ KeyDisableServerRoutes,
+ KeyBlockInbound,
+ KeyDisableMetricsCollection,
+ KeyAllowServerSSH,
+ KeyDisableAutoConnect,
+ KeyPreSharedKey,
+ KeyRosenpassEnabled,
+ KeyRosenpassPermissive,
+ KeyWireguardPort,
+ KeySplitTunnelMode,
+ KeySplitTunnelApps,
+}
+
+// canonicalKey maps the lowercase form of a managed-config value name to
+// its canonical mdm.Key* form. Admins commonly write PascalCase value
+// names in ADMX / Group Policy ("ManagementURL"); the iOS/AppConfig and
+// macOS plist conventions are camelCase ("managementURL"); both must
+// resolve to the same Policy lookup.
+//
+// Lives in a desktop-loader-only file (build tag `windows || darwin`)
+// because no other build path consumes it. Linux / FreeBSD / mobile
+// builds don't ship a platform loader that reads arbitrary-case key
+// names, so they don't need the canonicalisation table — and including
+// the var unconditionally would trigger the `unused` golangci-lint
+// check on those platforms.
+var canonicalKey = func() map[string]string {
+ m := make(map[string]string, len(allKeys))
+ for _, k := range allKeys {
+ m[strings.ToLower(k)] = k
+ }
+ return m
+}()
diff --git a/client/mdm/policy.go b/client/mdm/policy.go
new file mode 100644
index 000000000..3f64ba58a
--- /dev/null
+++ b/client/mdm/policy.go
@@ -0,0 +1,246 @@
+// Package mdm reads MDM-managed configuration from platform-native sources
+// (plist on macOS, registry on Windows, UserDefaults on iOS,
+// RestrictionsManager on Android). The returned Policy is consumed by
+// profilemanager.Config.apply() as the highest-priority override layer.
+//
+// An empty Policy (no source present, or source present with zero keys)
+// means no MDM enforcement is active and the client behaves as if the
+// feature did not exist.
+package mdm
+
+import (
+ "sort"
+ "strconv"
+
+ log "github.com/sirupsen/logrus"
+)
+
+// Well-known policy keys. Names mirror the corresponding ConfigInput Go field
+// names (lowerCamelCase) so the daemon can map a Policy key directly to a
+// configuration field.
+const (
+ KeyManagementURL = "managementURL"
+ KeyDisableUpdateSettings = "disableUpdateSettings"
+ KeyDisableProfiles = "disableProfiles"
+ KeyDisableNetworks = "disableNetworks"
+ KeyDisableClientRoutes = "disableClientRoutes"
+ KeyDisableServerRoutes = "disableServerRoutes"
+ KeyBlockInbound = "blockInbound"
+ KeyDisableMetricsCollection = "disableMetricsCollection"
+ KeyAllowServerSSH = "allowServerSSH"
+ KeyDisableAutoConnect = "disableAutoConnect"
+ KeyPreSharedKey = "preSharedKey"
+ KeyRosenpassEnabled = "rosenpassEnabled"
+ KeyRosenpassPermissive = "rosenpassPermissive"
+ KeyWireguardPort = "wireguardPort"
+
+ // Split tunnel is modeled as a single conceptual policy with two
+ // registry/plist values. KeySplitTunnelMode is the discriminator
+ // ("allow" or "disallow"); KeySplitTunnelApps is a comma-separated
+ // list of package names. The values are mutually exclusive by
+ // construction — only one mode can be set at a time.
+ KeySplitTunnelMode = "splitTunnelMode"
+ KeySplitTunnelApps = "splitTunnelApps"
+)
+
+// Split-tunnel mode literals (KeySplitTunnelMode values).
+const (
+ SplitTunnelModeAllow = "allow"
+ SplitTunnelModeDisallow = "disallow"
+)
+
+// SecretKeys lists keys whose values must be redacted in logs.
+var SecretKeys = map[string]struct{}{
+ KeyPreSharedKey: {},
+}
+
+// boolStringLiterals enumerates the textual boolean encodings the
+// platform loaders may produce (Windows REG_SZ "true", iOS / Android
+// managed-config booleans-as-strings, etc.). Lookup keeps GetBool flat
+// (no nested switch on the string case).
+var boolStringLiterals = map[string]bool{
+ "true": true,
+ "1": true,
+ "yes": true,
+ "false": false,
+ "0": false,
+ "no": false,
+}
+
+// Policy holds MDM-managed settings read from the platform source. A nil or
+// empty Policy means no enforcement is active.
+type Policy struct {
+ values map[string]any
+}
+
+// NewPolicy constructs a Policy from a key→value map. Pass nil or an
+// empty map to construct an empty (no-enforcement) Policy. The returned
+// *Policy is always non-nil.
+func NewPolicy(values map[string]any) *Policy {
+ if values == nil {
+ values = map[string]any{}
+ }
+ 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.
+//
+// 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()
+ if err != nil {
+ log.Tracef("MDM policy load: %v", err)
+ return &Policy{values: map[string]any{}}
+ }
+ if values == nil {
+ return &Policy{values: map[string]any{}}
+ }
+ if len(values) == 0 {
+ log.Info("MDM enrolled (no managed keys)")
+ } else {
+ log.Infof("MDM enrolled with %d managed key(s): %v", len(values), sortedKeys(values))
+ }
+ return &Policy{values: values}
+}
+
+// IsEmpty reports whether the Policy has no managed keys.
+func (p *Policy) IsEmpty() bool {
+ return p == nil || len(p.values) == 0
+}
+
+// HasKey reports whether the given key is MDM-managed.
+func (p *Policy) HasKey(key string) bool {
+ if p == nil {
+ return false
+ }
+ _, ok := p.values[key]
+ return ok
+}
+
+// ManagedKeys returns the sorted list of managed key names. Returns an empty
+// slice (not nil) on an empty Policy.
+func (p *Policy) ManagedKeys() []string {
+ if p == nil {
+ return []string{}
+ }
+ return sortedKeys(p.values)
+}
+
+// GetString returns the managed value for key coerced to string, and whether
+// the key was set. A non-string value returns ("", false).
+func (p *Policy) GetString(key string) (string, bool) {
+ if p == nil {
+ return "", false
+ }
+ v, ok := p.values[key]
+ if !ok {
+ return "", false
+ }
+ s, ok := v.(string)
+ if !ok || s == "" {
+ return "", false
+ }
+ return s, true
+}
+
+// GetBool returns the managed value for key coerced to bool, and whether the
+// key was set. Accepts native bool and string literals "true"/"false"/"1"/"0".
+func (p *Policy) GetBool(key string) (bool, bool) {
+ if p == nil {
+ return false, false
+ }
+ v, ok := p.values[key]
+ if !ok {
+ return false, false
+ }
+ switch t := v.(type) {
+ case bool:
+ return t, true
+ case string:
+ b, known := boolStringLiterals[t]
+ return b, known
+ case int:
+ return t != 0, true
+ case int64:
+ return t != 0, true
+ }
+ return false, false
+}
+
+// GetInt returns the managed value for key as int64, and whether the key
+// was set. Accepts native int / int64 (as produced by the Windows registry
+// loader for REG_DWORD/REG_QWORD) and numeric strings (decimal).
+func (p *Policy) GetInt(key string) (int64, bool) {
+ if p == nil {
+ return 0, false
+ }
+ v, ok := p.values[key]
+ if !ok {
+ return 0, false
+ }
+ switch t := v.(type) {
+ case int64:
+ return t, true
+ case int:
+ return int64(t), true
+ case int32:
+ return int64(t), true
+ case uint64:
+ return int64(t), true
+ case float64:
+ return int64(t), true
+ case string:
+ if n, err := strconv.ParseInt(t, 10, 64); err == nil {
+ return n, true
+ }
+ }
+ return 0, false
+}
+
+// GetStringSlice returns the managed value for key as []string, and whether
+// the key was set. Accepts []string, []any (of strings), and a single string
+// (treated as a one-element list).
+func (p *Policy) GetStringSlice(key string) ([]string, bool) {
+ if p == nil {
+ return nil, false
+ }
+ v, ok := p.values[key]
+ if !ok {
+ return nil, false
+ }
+ switch t := v.(type) {
+ case []string:
+ return append([]string(nil), t...), true
+ case []any:
+ out := make([]string, 0, len(t))
+ for _, item := range t {
+ s, ok := item.(string)
+ if !ok {
+ return nil, false
+ }
+ out = append(out, s)
+ }
+ return out, true
+ case string:
+ return []string{t}, true
+ }
+ return nil, false
+}
+
+// sortedKeys returns the keys of m as a deterministic, lexicographically
+// sorted slice. Used internally by Policy.ManagedKeys and LoadPolicy'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 {
+ out := make([]string, 0, len(m))
+ for k := range m {
+ out = append(out, k)
+ }
+ sort.Strings(out)
+ return out
+}
diff --git a/client/mdm/policy_darwin.go b/client/mdm/policy_darwin.go
new file mode 100644
index 000000000..57aa1168c
--- /dev/null
+++ b/client/mdm/policy_darwin.go
@@ -0,0 +1,90 @@
+//go:build darwin && !ios
+
+package mdm
+
+import (
+ "errors"
+ "fmt"
+ "io/fs"
+ "os"
+ "strings"
+
+ log "github.com/sirupsen/logrus"
+ "howett.net/plist"
+)
+
+// policyPlistPath is the well-known location where macOS writes the
+// device-level mandatory MDM payload for NetBird. The path is fixed by
+// Apple convention: when an MDM provider (Jamf / Kandji / Mosyle /
+// Intune for Mac / Workspace ONE) pushes a Configuration Profile that
+// contains a com.apple.ManagedClient.preferences payload targeting the
+// bundle id io.netbird.client, the OS materializes the payload here.
+//
+// Read-only — only the OS (root) is supposed to write this file. The
+// loader sanity-checks the file mode and refuses to honour a world-
+// 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:
+// - (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
+// (N may be 0 — empty plist still signals enrollment to the caller)
+// - (nil, err) on permission / parse / safety errors (including
+// refusal to read a world-writable plist)
+//
+// Top-level plist keys are canonicalised case-insensitively to the
+// package's internal mdm.Key* names; unknown keys are logged and
+// 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) {
+ 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.
+ return nil, nil
+ }
+ return nil, fmt.Errorf("open %s: %w", policyPlistPath, err)
+ }
+ defer func() {
+ if closeErr := f.Close(); closeErr != nil {
+ log.Warnf("MDM close plist %s: %v", policyPlistPath, closeErr)
+ }
+ }()
+
+ info, err := f.Stat()
+ if err != nil {
+ return nil, fmt.Errorf("stat %s: %w", policyPlistPath, err)
+ }
+ // World-writable plist => tampered install. Refuse rather than
+ // honour potentially attacker-controlled policy values.
+ if info.Mode().Perm()&0o002 != 0 {
+ return nil, fmt.Errorf("refusing to read world-writable MDM source %s (mode %o)",
+ policyPlistPath, info.Mode().Perm())
+ }
+
+ raw := make(map[string]any)
+ if err := plist.NewDecoder(f).Decode(&raw); err != nil {
+ return nil, fmt.Errorf("decode plist %s: %w", policyPlistPath, err)
+ }
+
+ out := make(map[string]any, len(raw))
+ for name, val := range raw {
+ // macOS / AppConfig conventions both use camelCase for managed
+ // preferences keys; canonicalize to the mdm.Key* form so a key
+ // written as "ManagementURL" (PascalCase, rare on macOS but
+ // possible if the admin reused an ADMX-style name) still
+ // resolves.
+ canonical, known := canonicalKey[strings.ToLower(name)]
+ if !known {
+ log.Warnf("MDM ignoring unknown plist key %s: %s", policyPlistPath, name)
+ continue
+ }
+ out[canonical] = val
+ }
+ return out, nil
+}
diff --git a/client/mdm/policy_mobile.go b/client/mdm/policy_mobile.go
new file mode 100644
index 000000000..ec25d4bb1
--- /dev/null
+++ b/client/mdm/policy_mobile.go
@@ -0,0 +1,14 @@
+//go:build ios || android
+
+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
+}
diff --git a/client/mdm/policy_other.go b/client/mdm/policy_other.go
new file mode 100644
index 000000000..f4263afa2
--- /dev/null
+++ b/client/mdm/policy_other.go
@@ -0,0 +1,14 @@
+//go:build !windows && !darwin && !ios && !android
+
+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.
+ return nil, nil
+}
diff --git a/client/mdm/policy_test.go b/client/mdm/policy_test.go
new file mode 100644
index 000000000..42724765e
--- /dev/null
+++ b/client/mdm/policy_test.go
@@ -0,0 +1,160 @@
+package mdm
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestPolicy_NilSafe(t *testing.T) {
+ var p *Policy
+ assert.True(t, p.IsEmpty())
+ assert.False(t, p.HasKey(KeyManagementURL))
+ assert.Empty(t, p.ManagedKeys())
+
+ _, ok := p.GetString(KeyManagementURL)
+ assert.False(t, ok)
+ _, ok = p.GetBool(KeyDisableProfiles)
+ assert.False(t, ok)
+ _, ok = p.GetStringSlice(KeySplitTunnelApps)
+ assert.False(t, ok)
+}
+
+func TestPolicy_Empty(t *testing.T) {
+ p := NewPolicy(nil)
+ require.NotNil(t, p)
+ assert.True(t, p.IsEmpty())
+ assert.False(t, p.HasKey(KeyManagementURL))
+ assert.Empty(t, p.ManagedKeys())
+}
+
+func TestPolicy_HasKey(t *testing.T) {
+ p := NewPolicy(map[string]any{
+ KeyManagementURL: "https://corp.example.com",
+ KeyDisableProfiles: true,
+ })
+ assert.False(t, p.IsEmpty())
+ assert.True(t, p.HasKey(KeyManagementURL))
+ assert.True(t, p.HasKey(KeyDisableProfiles))
+ assert.False(t, p.HasKey(KeyPreSharedKey))
+}
+
+func TestPolicy_ManagedKeysSorted(t *testing.T) {
+ p := NewPolicy(map[string]any{
+ KeyDisableProfiles: true,
+ KeyManagementURL: "https://x",
+ KeyAllowServerSSH: false,
+ })
+ got := p.ManagedKeys()
+ assert.Equal(t, []string{KeyAllowServerSSH, KeyDisableProfiles, KeyManagementURL}, got)
+}
+
+func TestPolicy_GetString(t *testing.T) {
+ p := NewPolicy(map[string]any{
+ KeyManagementURL: "https://corp.example.com",
+ KeyDisableProfiles: true, // wrong type for GetString
+ KeyPreSharedKey: "", // empty rejected
+ })
+ v, ok := p.GetString(KeyManagementURL)
+ assert.True(t, ok)
+ assert.Equal(t, "https://corp.example.com", v)
+
+ _, ok = p.GetString(KeyDisableProfiles)
+ assert.False(t, ok, "non-string value must not be reported as string")
+
+ _, ok = p.GetString(KeyPreSharedKey)
+ assert.False(t, ok, "empty string treated as unset")
+
+ _, ok = p.GetString("nonexistent")
+ assert.False(t, ok)
+}
+
+func TestPolicy_GetBool(t *testing.T) {
+ cases := []struct {
+ name string
+ raw any
+ want bool
+ ok bool
+ }{
+ {"native true", true, true, true},
+ {"native false", false, false, true},
+ {"string true", "true", true, true},
+ {"string false", "false", false, true},
+ {"string 1", "1", true, true},
+ {"string 0", "0", false, true},
+ {"string yes", "yes", true, true},
+ {"string no", "no", false, true},
+ {"int nonzero", 1, true, true},
+ {"int zero", 0, false, true},
+ {"int64 nonzero", int64(2), true, true},
+ {"int64 zero", int64(0), false, true},
+ {"string garbage", "maybe", false, false},
+ {"float unsupported", 1.0, false, false},
+ }
+ for _, c := range cases {
+ t.Run(c.name, func(t *testing.T) {
+ p := NewPolicy(map[string]any{KeyDisableProfiles: c.raw})
+ got, ok := p.GetBool(KeyDisableProfiles)
+ assert.Equal(t, c.ok, ok)
+ if c.ok {
+ assert.Equal(t, c.want, got)
+ }
+ })
+ }
+
+ _, ok := NewPolicy(nil).GetBool(KeyDisableProfiles)
+ assert.False(t, ok)
+}
+
+func TestPolicy_GetStringSlice(t *testing.T) {
+ t.Run("native string slice", func(t *testing.T) {
+ p := NewPolicy(map[string]any{
+ KeySplitTunnelApps: []string{"com.a", "com.b"},
+ })
+ got, ok := p.GetStringSlice(KeySplitTunnelApps)
+ assert.True(t, ok)
+ assert.Equal(t, []string{"com.a", "com.b"}, got)
+ })
+
+ t.Run("any slice of strings", func(t *testing.T) {
+ p := NewPolicy(map[string]any{
+ KeySplitTunnelApps: []any{"com.a", "com.b"},
+ })
+ got, ok := p.GetStringSlice(KeySplitTunnelApps)
+ assert.True(t, ok)
+ assert.Equal(t, []string{"com.a", "com.b"}, got)
+ })
+
+ t.Run("single string lifts to one-element slice", func(t *testing.T) {
+ p := NewPolicy(map[string]any{
+ KeySplitTunnelApps: "com.a",
+ })
+ got, ok := p.GetStringSlice(KeySplitTunnelApps)
+ assert.True(t, ok)
+ assert.Equal(t, []string{"com.a"}, got)
+ })
+
+ t.Run("mixed any slice rejected", func(t *testing.T) {
+ p := NewPolicy(map[string]any{
+ KeySplitTunnelApps: []any{"com.a", 1},
+ })
+ _, ok := p.GetStringSlice(KeySplitTunnelApps)
+ assert.False(t, ok)
+ })
+
+ t.Run("missing key", func(t *testing.T) {
+ p := NewPolicy(nil)
+ _, ok := p.GetStringSlice(KeySplitTunnelApps)
+ assert.False(t, ok)
+ })
+}
+
+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()
+ require.NotNil(t, p)
+ assert.True(t, p.IsEmpty())
+ assert.Empty(t, p.ManagedKeys())
+}
diff --git a/client/mdm/policy_windows.go b/client/mdm/policy_windows.go
new file mode 100644
index 000000000..0c2629f98
--- /dev/null
+++ b/client/mdm/policy_windows.go
@@ -0,0 +1,108 @@
+//go:build windows
+
+package mdm
+
+import (
+ "errors"
+ "fmt"
+ "strings"
+
+ log "github.com/sirupsen/logrus"
+ "golang.org/x/sys/windows/registry"
+)
+
+// policyRegistryPath is the well-known MDM policy registry key for NetBird.
+// Admins push values here through Group Policy, Intune ADMX ingestion, an
+// Intune custom Registry CSP profile, or `reg add` during MSI deployment.
+// Listed in the project's docs/mdm/netbird.admx schema.
+const policyRegistryPath = `Software\Policies\NetBird`
+
+// readRegistryValue reads a single value under policyRegistryPath and,
+// on success, stores the type-coerced result in out[canonical]. Type
+// coercion mirrors loadPlatformPolicy's documented mapping:
+// - REG_SZ / REG_EXPAND_SZ -> string (REG_EXPAND_SZ is expanded by the API)
+// - REG_DWORD / REG_QWORD -> int64
+// - REG_MULTI_SZ -> []string
+//
+// Unsupported value types and per-value read failures are logged at
+// warn level and skipped — one malformed value must not block the
+// surrounding loop. Extracted from loadPlatformPolicy to keep that
+// function's cognitive complexity in check.
+func readRegistryValue(k registry.Key, name, canonical string, out map[string]any) {
+ _, valType, err := k.GetValue(name, nil)
+ if err != nil {
+ log.Warnf("MDM stat %s\\%s: %v", policyRegistryPath, name, err)
+ return
+ }
+ switch valType {
+ case registry.SZ, registry.EXPAND_SZ:
+ if v, _, err := k.GetStringValue(name); err == nil {
+ out[canonical] = v
+ } else {
+ log.Warnf("MDM read string %s\\%s: %v", policyRegistryPath, name, err)
+ }
+ case registry.DWORD, registry.QWORD:
+ if v, _, err := k.GetIntegerValue(name); err == nil {
+ // uint64 from the registry API; Policy.GetBool / GetInt
+ // helpers consume int64, so narrow safely.
+ out[canonical] = int64(v)
+ } else {
+ log.Warnf("MDM read int %s\\%s: %v", policyRegistryPath, name, err)
+ }
+ case registry.MULTI_SZ:
+ if v, _, err := k.GetStringsValue(name); err == nil {
+ out[canonical] = v
+ } else {
+ log.Warnf("MDM read multi-string %s\\%s: %v", policyRegistryPath, name, err)
+ }
+ default:
+ log.Warnf("MDM ignoring unsupported registry value type %d at %s\\%s",
+ valType, policyRegistryPath, name)
+ }
+}
+
+// loadPlatformPolicy reads the MDM-managed configuration from the
+// Windows registry under HKLM\Software\Policies\NetBird. 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
+//
+// 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) {
+ 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.
+ return nil, nil
+ }
+ return nil, fmt.Errorf("open %s: %w", policyRegistryPath, err)
+ }
+ defer func() {
+ if closeErr := k.Close(); closeErr != nil {
+ log.Warnf("MDM close registry key %s: %v", policyRegistryPath, closeErr)
+ }
+ }()
+
+ names, err := k.ReadValueNames(-1)
+ if err != nil {
+ return nil, fmt.Errorf("enumerate values of %s: %w", policyRegistryPath, err)
+ }
+
+ out := make(map[string]any, len(names))
+ for _, name := range names {
+ // Canonicalize the registry value name against the known MDM key
+ // set so Policy.HasKey lookups (which use the canonical names)
+ // succeed regardless of the casing used by the admin's ADMX or
+ // `reg add` command.
+ canonical, known := canonicalKey[strings.ToLower(name)]
+ if !known {
+ log.Warnf("MDM ignoring unknown registry value %s\\%s", policyRegistryPath, name)
+ continue
+ }
+ readRegistryValue(k, name, canonical, out)
+ }
+ return out, nil
+}
diff --git a/client/mdm/ticker.go b/client/mdm/ticker.go
new file mode 100644
index 000000000..abd6ae233
--- /dev/null
+++ b/client/mdm/ticker.go
@@ -0,0 +1,129 @@
+package mdm
+
+import (
+ "context"
+ "reflect"
+ "sort"
+ "time"
+
+ log "github.com/sirupsen/logrus"
+)
+
+// DefaultReloadInterval is the production cadence at which the desktop daemon
+// re-reads the OS-native MDM policy. Picked to balance responsiveness against
+// registry/plist I/O overhead. Mobile builds use OS-side notifications
+// 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.
+type Ticker struct {
+ interval time.Duration
+ 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
+// 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 {
+ return &Ticker{
+ interval: reloadInterval,
+ prev: policyLoader(),
+ }
+}
+
+// Run blocks until ctx is cancelled, polling the OS-native policy store at
+// the configured cadence and emitting log lines + onChange callback on
+// every observed diff. onChange must be non-nil.
+func (t *Ticker) Run(ctx context.Context, onChange func(prev, curr *Policy) error) {
+ tk := time.NewTicker(t.interval)
+ defer tk.Stop()
+ log.Infof("MDM policy reload ticker started (interval=%s)", t.interval)
+ for {
+ select {
+ case <-ctx.Done():
+ log.Info("MDM policy reload ticker stopped")
+ return
+ case <-tk.C:
+ curr := policyLoader()
+ if policiesEqual(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)
+ continue
+ }
+ t.prev = curr
+ }
+ }
+}
+
+// policiesEqual reports whether two Policy instances carry the same
+// managed key set with identical values. Nil and empty policies
+// compare equal; one-nil/one-non-empty compare not equal; otherwise
+// the underlying values maps are compared with reflect.DeepEqual.
+func policiesEqual(a, b *Policy) bool {
+ if a.IsEmpty() && b.IsEmpty() {
+ return true
+ }
+ if a == nil || b == nil {
+ return false
+ }
+ return reflect.DeepEqual(a.values, b.values)
+}
+
+// diffPolicies returns the keys added in curr, removed from prev, and
+// whose values changed between prev and curr. Each slice is sorted
+// lexicographically for stable log output; value differences are
+// determined with reflect.DeepEqual.
+func diffPolicies(prev, curr *Policy) (added, removed, changed []string) {
+ prevKVs := mapOf(prev)
+ currKVs := mapOf(curr)
+ for k := range currKVs {
+ if _, ok := prevKVs[k]; !ok {
+ added = append(added, k)
+ } else if !reflect.DeepEqual(prevKVs[k], currKVs[k]) {
+ changed = append(changed, k)
+ }
+ }
+ for k := range prevKVs {
+ if _, ok := currKVs[k]; !ok {
+ removed = append(removed, k)
+ }
+ }
+ sort.Strings(added)
+ sort.Strings(removed)
+ sort.Strings(changed)
+ return added, removed, changed
+}
+
+// mapOf returns a (possibly empty, never nil) copy of the underlying
+// values map of a Policy so callers outside this package can compare
+// keys/values across the type boundary. Returns an empty map on nil p.
+func mapOf(p *Policy) map[string]any {
+ if p == nil {
+ return map[string]any{}
+ }
+ out := make(map[string]any, len(p.values))
+ for k, v := range p.values {
+ out[k] = v
+ }
+ return out
+}
diff --git a/client/mdm/ticker_test.go b/client/mdm/ticker_test.go
new file mode 100644
index 000000000..17f3cfc2f
--- /dev/null
+++ b/client/mdm/ticker_test.go
@@ -0,0 +1,100 @@
+package mdm
+
+import (
+ "context"
+ "sync"
+ "testing"
+ "time"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+// 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 })
+}
+
+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
+ })
+
+ type change struct{ prev, curr *Policy }
+ changes := make(chan change, 1)
+ tk := NewTicker(testReloadInterval)
+ require.Equal(t, testReloadInterval, tk.interval)
+
+ ctx, cancel := context.WithCancel(context.Background())
+ done := make(chan struct{})
+ go func() {
+ tk.Run(ctx, func(prev, curr *Policy) error {
+ select {
+ case changes <- change{prev, curr}:
+ default:
+ }
+ return nil
+ })
+ 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.
+ 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()
+
+ select {
+ case c := <-changes:
+ assert.True(t, c.prev.IsEmpty(), "prev should be the initial empty policy")
+ assert.True(t, c.curr.HasKey(KeyManagementURL), "curr should carry the newly-pushed managed key")
+ case <-time.After(5 * time.Second):
+ t.Fatal("onChange not invoked within 5s; ticker should fire every 1s under test")
+ }
+}
+
+func TestTicker_NoCallbackWhenPolicyUnchanged(t *testing.T) {
+ withPolicyLoader(t, func() *Policy {
+ return NewPolicy(map[string]any{KeyBlockInbound: true})
+ })
+
+ fired := make(chan struct{}, 1)
+ tk := NewTicker(testReloadInterval)
+
+ ctx, cancel := context.WithCancel(context.Background())
+ done := make(chan struct{})
+ go func() {
+ tk.Run(ctx, func(_, _ *Policy) error {
+ select {
+ case fired <- struct{}{}:
+ default:
+ }
+ return nil
+ })
+ close(done)
+ }()
+ 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.
+ select {
+ case <-fired:
+ t.Fatal("onChange fired despite an unchanged policy")
+ case <-time.After(2500 * time.Millisecond):
+ }
+}
diff --git a/client/proto/daemon.pb.go b/client/proto/daemon.pb.go
index 2c054c99a..6b5a37658 100644
--- a/client/proto/daemon.pb.go
+++ b/client/proto/daemon.pb.go
@@ -1191,8 +1191,14 @@ type GetConfigResponse struct {
DisableSSHAuth bool `protobuf:"varint,25,opt,name=disableSSHAuth,proto3" json:"disableSSHAuth,omitempty"`
SshJWTCacheTTL int32 `protobuf:"varint,26,opt,name=sshJWTCacheTTL,proto3" json:"sshJWTCacheTTL,omitempty"`
DisableIpv6 bool `protobuf:"varint,27,opt,name=disable_ipv6,json=disableIpv6,proto3" json:"disable_ipv6,omitempty"`
- unknownFields protoimpl.UnknownFields
- sizeCache protoimpl.SizeCache
+ // mDMManagedFields lists the names of configuration keys whose value is
+ // currently enforced by an MDM policy. Names match mdm.Key* constants
+ // (e.g. "managementURL", "disableClientRoutes"). UI/CLI clients should
+ // render the corresponding inputs as read-only and display a "managed
+ // by MDM" indicator.
+ MDMManagedFields []string `protobuf:"bytes,28,rep,name=mDMManagedFields,proto3" json:"mDMManagedFields,omitempty"`
+ unknownFields protoimpl.UnknownFields
+ sizeCache protoimpl.SizeCache
}
func (x *GetConfigResponse) Reset() {
@@ -1414,6 +1420,13 @@ func (x *GetConfigResponse) GetDisableIpv6() bool {
return false
}
+func (x *GetConfigResponse) GetMDMManagedFields() []string {
+ if x != nil {
+ return x.MDMManagedFields
+ }
+ return nil
+}
+
// PeerState contains the latest state of a peer
type PeerState struct {
state protoimpl.MessageState `protogen:"open.v1"`
@@ -1614,6 +1627,7 @@ type LocalPeerState struct {
RosenpassPermissive bool `protobuf:"varint,6,opt,name=rosenpassPermissive,proto3" json:"rosenpassPermissive,omitempty"`
Networks []string `protobuf:"bytes,7,rep,name=networks,proto3" json:"networks,omitempty"`
Ipv6 string `protobuf:"bytes,8,opt,name=ipv6,proto3" json:"ipv6,omitempty"`
+ WgPort int32 `protobuf:"varint,9,opt,name=wgPort,proto3" json:"wgPort,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
@@ -1704,6 +1718,13 @@ func (x *LocalPeerState) GetIpv6() string {
return ""
}
+func (x *LocalPeerState) GetWgPort() int32 {
+ if x != nil {
+ return x.WgPort
+ }
+ return 0
+}
+
// SignalState contains the latest state of a signal connection
type SignalState struct {
state protoimpl.MessageState `protogen:"open.v1"`
@@ -1828,10 +1849,13 @@ func (x *ManagementState) GetError() string {
// RelayState contains the latest state of the relay
type RelayState struct {
- state protoimpl.MessageState `protogen:"open.v1"`
- URI string `protobuf:"bytes,1,opt,name=URI,proto3" json:"URI,omitempty"`
- Available bool `protobuf:"varint,2,opt,name=available,proto3" json:"available,omitempty"`
- Error string `protobuf:"bytes,3,opt,name=error,proto3" json:"error,omitempty"`
+ state protoimpl.MessageState `protogen:"open.v1"`
+ URI string `protobuf:"bytes,1,opt,name=URI,proto3" json:"URI,omitempty"`
+ Available bool `protobuf:"varint,2,opt,name=available,proto3" json:"available,omitempty"`
+ Error string `protobuf:"bytes,3,opt,name=error,proto3" json:"error,omitempty"`
+ // transport is the negotiated relay transport (e.g. "ws", "quic"),
+ // empty for stun/turn probes or when not connected.
+ Transport string `protobuf:"bytes,4,opt,name=transport,proto3" json:"transport,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
@@ -1887,6 +1911,13 @@ func (x *RelayState) GetError() string {
return ""
}
+func (x *RelayState) GetTransport() string {
+ if x != nil {
+ return x.Transport
+ }
+ return ""
+}
+
type NSGroupState struct {
state protoimpl.MessageState `protogen:"open.v1"`
Servers []string `protobuf:"bytes,1,rep,name=servers,proto3" json:"servers,omitempty"`
@@ -2709,6 +2740,7 @@ type DebugBundleRequest struct {
SystemInfo bool `protobuf:"varint,3,opt,name=systemInfo,proto3" json:"systemInfo,omitempty"`
UploadURL string `protobuf:"bytes,4,opt,name=uploadURL,proto3" json:"uploadURL,omitempty"`
LogFileCount uint32 `protobuf:"varint,5,opt,name=logFileCount,proto3" json:"logFileCount,omitempty"`
+ CliVersion string `protobuf:"bytes,6,opt,name=cliVersion,proto3" json:"cliVersion,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
@@ -2771,6 +2803,13 @@ func (x *DebugBundleRequest) GetLogFileCount() uint32 {
return 0
}
+func (x *DebugBundleRequest) GetCliVersion() string {
+ if x != nil {
+ return x.CliVersion
+ }
+ return ""
+}
+
type DebugBundleResponse struct {
state protoimpl.MessageState `protogen:"open.v1"`
Path string `protobuf:"bytes,1,opt,name=path,proto3" json:"path,omitempty"`
@@ -4945,6 +4984,55 @@ func (x *GetFeaturesResponse) GetDisableNetworks() bool {
return false
}
+// MDMManagedFieldsViolation is attached as a gRPC error detail on a
+// FailedPrecondition status returned from SetConfig (and similar mutating
+// RPCs) when the caller tries to modify one or more MDM-enforced fields.
+// The fields list contains the offending key names; the entire request is
+// rejected (no partial apply).
+type MDMManagedFieldsViolation struct {
+ state protoimpl.MessageState `protogen:"open.v1"`
+ Fields []string `protobuf:"bytes,1,rep,name=fields,proto3" json:"fields,omitempty"`
+ unknownFields protoimpl.UnknownFields
+ sizeCache protoimpl.SizeCache
+}
+
+func (x *MDMManagedFieldsViolation) Reset() {
+ *x = MDMManagedFieldsViolation{}
+ mi := &file_daemon_proto_msgTypes[71]
+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+ ms.StoreMessageInfo(mi)
+}
+
+func (x *MDMManagedFieldsViolation) String() string {
+ return protoimpl.X.MessageStringOf(x)
+}
+
+func (*MDMManagedFieldsViolation) ProtoMessage() {}
+
+func (x *MDMManagedFieldsViolation) ProtoReflect() protoreflect.Message {
+ mi := &file_daemon_proto_msgTypes[71]
+ if x != nil {
+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+ if ms.LoadMessageInfo() == nil {
+ ms.StoreMessageInfo(mi)
+ }
+ return ms
+ }
+ return mi.MessageOf(x)
+}
+
+// Deprecated: Use MDMManagedFieldsViolation.ProtoReflect.Descriptor instead.
+func (*MDMManagedFieldsViolation) Descriptor() ([]byte, []int) {
+ return file_daemon_proto_rawDescGZIP(), []int{71}
+}
+
+func (x *MDMManagedFieldsViolation) GetFields() []string {
+ if x != nil {
+ return x.Fields
+ }
+ return nil
+}
+
type TriggerUpdateRequest struct {
state protoimpl.MessageState `protogen:"open.v1"`
unknownFields protoimpl.UnknownFields
@@ -4953,7 +5041,7 @@ type TriggerUpdateRequest struct {
func (x *TriggerUpdateRequest) Reset() {
*x = TriggerUpdateRequest{}
- mi := &file_daemon_proto_msgTypes[71]
+ mi := &file_daemon_proto_msgTypes[72]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -4965,7 +5053,7 @@ func (x *TriggerUpdateRequest) String() string {
func (*TriggerUpdateRequest) ProtoMessage() {}
func (x *TriggerUpdateRequest) ProtoReflect() protoreflect.Message {
- mi := &file_daemon_proto_msgTypes[71]
+ mi := &file_daemon_proto_msgTypes[72]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -4978,7 +5066,7 @@ func (x *TriggerUpdateRequest) ProtoReflect() protoreflect.Message {
// Deprecated: Use TriggerUpdateRequest.ProtoReflect.Descriptor instead.
func (*TriggerUpdateRequest) Descriptor() ([]byte, []int) {
- return file_daemon_proto_rawDescGZIP(), []int{71}
+ return file_daemon_proto_rawDescGZIP(), []int{72}
}
type TriggerUpdateResponse struct {
@@ -4991,7 +5079,7 @@ type TriggerUpdateResponse struct {
func (x *TriggerUpdateResponse) Reset() {
*x = TriggerUpdateResponse{}
- mi := &file_daemon_proto_msgTypes[72]
+ mi := &file_daemon_proto_msgTypes[73]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -5003,7 +5091,7 @@ func (x *TriggerUpdateResponse) String() string {
func (*TriggerUpdateResponse) ProtoMessage() {}
func (x *TriggerUpdateResponse) ProtoReflect() protoreflect.Message {
- mi := &file_daemon_proto_msgTypes[72]
+ mi := &file_daemon_proto_msgTypes[73]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -5016,7 +5104,7 @@ func (x *TriggerUpdateResponse) ProtoReflect() protoreflect.Message {
// Deprecated: Use TriggerUpdateResponse.ProtoReflect.Descriptor instead.
func (*TriggerUpdateResponse) Descriptor() ([]byte, []int) {
- return file_daemon_proto_rawDescGZIP(), []int{72}
+ return file_daemon_proto_rawDescGZIP(), []int{73}
}
func (x *TriggerUpdateResponse) GetSuccess() bool {
@@ -5044,7 +5132,7 @@ type GetPeerSSHHostKeyRequest struct {
func (x *GetPeerSSHHostKeyRequest) Reset() {
*x = GetPeerSSHHostKeyRequest{}
- mi := &file_daemon_proto_msgTypes[73]
+ mi := &file_daemon_proto_msgTypes[74]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -5056,7 +5144,7 @@ func (x *GetPeerSSHHostKeyRequest) String() string {
func (*GetPeerSSHHostKeyRequest) ProtoMessage() {}
func (x *GetPeerSSHHostKeyRequest) ProtoReflect() protoreflect.Message {
- mi := &file_daemon_proto_msgTypes[73]
+ mi := &file_daemon_proto_msgTypes[74]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -5069,7 +5157,7 @@ func (x *GetPeerSSHHostKeyRequest) ProtoReflect() protoreflect.Message {
// Deprecated: Use GetPeerSSHHostKeyRequest.ProtoReflect.Descriptor instead.
func (*GetPeerSSHHostKeyRequest) Descriptor() ([]byte, []int) {
- return file_daemon_proto_rawDescGZIP(), []int{73}
+ return file_daemon_proto_rawDescGZIP(), []int{74}
}
func (x *GetPeerSSHHostKeyRequest) GetPeerAddress() string {
@@ -5096,7 +5184,7 @@ type GetPeerSSHHostKeyResponse struct {
func (x *GetPeerSSHHostKeyResponse) Reset() {
*x = GetPeerSSHHostKeyResponse{}
- mi := &file_daemon_proto_msgTypes[74]
+ mi := &file_daemon_proto_msgTypes[75]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -5108,7 +5196,7 @@ func (x *GetPeerSSHHostKeyResponse) String() string {
func (*GetPeerSSHHostKeyResponse) ProtoMessage() {}
func (x *GetPeerSSHHostKeyResponse) ProtoReflect() protoreflect.Message {
- mi := &file_daemon_proto_msgTypes[74]
+ mi := &file_daemon_proto_msgTypes[75]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -5121,7 +5209,7 @@ func (x *GetPeerSSHHostKeyResponse) ProtoReflect() protoreflect.Message {
// Deprecated: Use GetPeerSSHHostKeyResponse.ProtoReflect.Descriptor instead.
func (*GetPeerSSHHostKeyResponse) Descriptor() ([]byte, []int) {
- return file_daemon_proto_rawDescGZIP(), []int{74}
+ return file_daemon_proto_rawDescGZIP(), []int{75}
}
func (x *GetPeerSSHHostKeyResponse) GetSshHostKey() []byte {
@@ -5163,7 +5251,7 @@ type RequestJWTAuthRequest struct {
func (x *RequestJWTAuthRequest) Reset() {
*x = RequestJWTAuthRequest{}
- mi := &file_daemon_proto_msgTypes[75]
+ mi := &file_daemon_proto_msgTypes[76]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -5175,7 +5263,7 @@ func (x *RequestJWTAuthRequest) String() string {
func (*RequestJWTAuthRequest) ProtoMessage() {}
func (x *RequestJWTAuthRequest) ProtoReflect() protoreflect.Message {
- mi := &file_daemon_proto_msgTypes[75]
+ mi := &file_daemon_proto_msgTypes[76]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -5188,7 +5276,7 @@ func (x *RequestJWTAuthRequest) ProtoReflect() protoreflect.Message {
// Deprecated: Use RequestJWTAuthRequest.ProtoReflect.Descriptor instead.
func (*RequestJWTAuthRequest) Descriptor() ([]byte, []int) {
- return file_daemon_proto_rawDescGZIP(), []int{75}
+ return file_daemon_proto_rawDescGZIP(), []int{76}
}
func (x *RequestJWTAuthRequest) GetHint() string {
@@ -5221,7 +5309,7 @@ type RequestJWTAuthResponse struct {
func (x *RequestJWTAuthResponse) Reset() {
*x = RequestJWTAuthResponse{}
- mi := &file_daemon_proto_msgTypes[76]
+ mi := &file_daemon_proto_msgTypes[77]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -5233,7 +5321,7 @@ func (x *RequestJWTAuthResponse) String() string {
func (*RequestJWTAuthResponse) ProtoMessage() {}
func (x *RequestJWTAuthResponse) ProtoReflect() protoreflect.Message {
- mi := &file_daemon_proto_msgTypes[76]
+ mi := &file_daemon_proto_msgTypes[77]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -5246,7 +5334,7 @@ func (x *RequestJWTAuthResponse) ProtoReflect() protoreflect.Message {
// Deprecated: Use RequestJWTAuthResponse.ProtoReflect.Descriptor instead.
func (*RequestJWTAuthResponse) Descriptor() ([]byte, []int) {
- return file_daemon_proto_rawDescGZIP(), []int{76}
+ return file_daemon_proto_rawDescGZIP(), []int{77}
}
func (x *RequestJWTAuthResponse) GetVerificationURI() string {
@@ -5311,7 +5399,7 @@ type WaitJWTTokenRequest struct {
func (x *WaitJWTTokenRequest) Reset() {
*x = WaitJWTTokenRequest{}
- mi := &file_daemon_proto_msgTypes[77]
+ mi := &file_daemon_proto_msgTypes[78]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -5323,7 +5411,7 @@ func (x *WaitJWTTokenRequest) String() string {
func (*WaitJWTTokenRequest) ProtoMessage() {}
func (x *WaitJWTTokenRequest) ProtoReflect() protoreflect.Message {
- mi := &file_daemon_proto_msgTypes[77]
+ mi := &file_daemon_proto_msgTypes[78]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -5336,7 +5424,7 @@ func (x *WaitJWTTokenRequest) ProtoReflect() protoreflect.Message {
// Deprecated: Use WaitJWTTokenRequest.ProtoReflect.Descriptor instead.
func (*WaitJWTTokenRequest) Descriptor() ([]byte, []int) {
- return file_daemon_proto_rawDescGZIP(), []int{77}
+ return file_daemon_proto_rawDescGZIP(), []int{78}
}
func (x *WaitJWTTokenRequest) GetDeviceCode() string {
@@ -5368,7 +5456,7 @@ type WaitJWTTokenResponse struct {
func (x *WaitJWTTokenResponse) Reset() {
*x = WaitJWTTokenResponse{}
- mi := &file_daemon_proto_msgTypes[78]
+ mi := &file_daemon_proto_msgTypes[79]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -5380,7 +5468,7 @@ func (x *WaitJWTTokenResponse) String() string {
func (*WaitJWTTokenResponse) ProtoMessage() {}
func (x *WaitJWTTokenResponse) ProtoReflect() protoreflect.Message {
- mi := &file_daemon_proto_msgTypes[78]
+ mi := &file_daemon_proto_msgTypes[79]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -5393,7 +5481,7 @@ func (x *WaitJWTTokenResponse) ProtoReflect() protoreflect.Message {
// Deprecated: Use WaitJWTTokenResponse.ProtoReflect.Descriptor instead.
func (*WaitJWTTokenResponse) Descriptor() ([]byte, []int) {
- return file_daemon_proto_rawDescGZIP(), []int{78}
+ return file_daemon_proto_rawDescGZIP(), []int{79}
}
func (x *WaitJWTTokenResponse) GetToken() string {
@@ -5426,7 +5514,7 @@ type StartCPUProfileRequest struct {
func (x *StartCPUProfileRequest) Reset() {
*x = StartCPUProfileRequest{}
- mi := &file_daemon_proto_msgTypes[79]
+ mi := &file_daemon_proto_msgTypes[80]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -5438,7 +5526,7 @@ func (x *StartCPUProfileRequest) String() string {
func (*StartCPUProfileRequest) ProtoMessage() {}
func (x *StartCPUProfileRequest) ProtoReflect() protoreflect.Message {
- mi := &file_daemon_proto_msgTypes[79]
+ mi := &file_daemon_proto_msgTypes[80]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -5451,7 +5539,7 @@ func (x *StartCPUProfileRequest) ProtoReflect() protoreflect.Message {
// Deprecated: Use StartCPUProfileRequest.ProtoReflect.Descriptor instead.
func (*StartCPUProfileRequest) Descriptor() ([]byte, []int) {
- return file_daemon_proto_rawDescGZIP(), []int{79}
+ return file_daemon_proto_rawDescGZIP(), []int{80}
}
// StartCPUProfileResponse confirms CPU profiling has started
@@ -5463,7 +5551,7 @@ type StartCPUProfileResponse struct {
func (x *StartCPUProfileResponse) Reset() {
*x = StartCPUProfileResponse{}
- mi := &file_daemon_proto_msgTypes[80]
+ mi := &file_daemon_proto_msgTypes[81]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -5475,7 +5563,7 @@ func (x *StartCPUProfileResponse) String() string {
func (*StartCPUProfileResponse) ProtoMessage() {}
func (x *StartCPUProfileResponse) ProtoReflect() protoreflect.Message {
- mi := &file_daemon_proto_msgTypes[80]
+ mi := &file_daemon_proto_msgTypes[81]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -5488,7 +5576,7 @@ func (x *StartCPUProfileResponse) ProtoReflect() protoreflect.Message {
// Deprecated: Use StartCPUProfileResponse.ProtoReflect.Descriptor instead.
func (*StartCPUProfileResponse) Descriptor() ([]byte, []int) {
- return file_daemon_proto_rawDescGZIP(), []int{80}
+ return file_daemon_proto_rawDescGZIP(), []int{81}
}
// StopCPUProfileRequest for stopping CPU profiling
@@ -5500,7 +5588,7 @@ type StopCPUProfileRequest struct {
func (x *StopCPUProfileRequest) Reset() {
*x = StopCPUProfileRequest{}
- mi := &file_daemon_proto_msgTypes[81]
+ mi := &file_daemon_proto_msgTypes[82]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -5512,7 +5600,7 @@ func (x *StopCPUProfileRequest) String() string {
func (*StopCPUProfileRequest) ProtoMessage() {}
func (x *StopCPUProfileRequest) ProtoReflect() protoreflect.Message {
- mi := &file_daemon_proto_msgTypes[81]
+ mi := &file_daemon_proto_msgTypes[82]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -5525,7 +5613,7 @@ func (x *StopCPUProfileRequest) ProtoReflect() protoreflect.Message {
// Deprecated: Use StopCPUProfileRequest.ProtoReflect.Descriptor instead.
func (*StopCPUProfileRequest) Descriptor() ([]byte, []int) {
- return file_daemon_proto_rawDescGZIP(), []int{81}
+ return file_daemon_proto_rawDescGZIP(), []int{82}
}
// StopCPUProfileResponse confirms CPU profiling has stopped
@@ -5537,7 +5625,7 @@ type StopCPUProfileResponse struct {
func (x *StopCPUProfileResponse) Reset() {
*x = StopCPUProfileResponse{}
- mi := &file_daemon_proto_msgTypes[82]
+ mi := &file_daemon_proto_msgTypes[83]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -5549,7 +5637,7 @@ func (x *StopCPUProfileResponse) String() string {
func (*StopCPUProfileResponse) ProtoMessage() {}
func (x *StopCPUProfileResponse) ProtoReflect() protoreflect.Message {
- mi := &file_daemon_proto_msgTypes[82]
+ mi := &file_daemon_proto_msgTypes[83]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -5562,7 +5650,7 @@ func (x *StopCPUProfileResponse) ProtoReflect() protoreflect.Message {
// Deprecated: Use StopCPUProfileResponse.ProtoReflect.Descriptor instead.
func (*StopCPUProfileResponse) Descriptor() ([]byte, []int) {
- return file_daemon_proto_rawDescGZIP(), []int{82}
+ return file_daemon_proto_rawDescGZIP(), []int{83}
}
type InstallerResultRequest struct {
@@ -5573,7 +5661,7 @@ type InstallerResultRequest struct {
func (x *InstallerResultRequest) Reset() {
*x = InstallerResultRequest{}
- mi := &file_daemon_proto_msgTypes[83]
+ mi := &file_daemon_proto_msgTypes[84]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -5585,7 +5673,7 @@ func (x *InstallerResultRequest) String() string {
func (*InstallerResultRequest) ProtoMessage() {}
func (x *InstallerResultRequest) ProtoReflect() protoreflect.Message {
- mi := &file_daemon_proto_msgTypes[83]
+ mi := &file_daemon_proto_msgTypes[84]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -5598,7 +5686,7 @@ func (x *InstallerResultRequest) ProtoReflect() protoreflect.Message {
// Deprecated: Use InstallerResultRequest.ProtoReflect.Descriptor instead.
func (*InstallerResultRequest) Descriptor() ([]byte, []int) {
- return file_daemon_proto_rawDescGZIP(), []int{83}
+ return file_daemon_proto_rawDescGZIP(), []int{84}
}
type InstallerResultResponse struct {
@@ -5611,7 +5699,7 @@ type InstallerResultResponse struct {
func (x *InstallerResultResponse) Reset() {
*x = InstallerResultResponse{}
- mi := &file_daemon_proto_msgTypes[84]
+ mi := &file_daemon_proto_msgTypes[85]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -5623,7 +5711,7 @@ func (x *InstallerResultResponse) String() string {
func (*InstallerResultResponse) ProtoMessage() {}
func (x *InstallerResultResponse) ProtoReflect() protoreflect.Message {
- mi := &file_daemon_proto_msgTypes[84]
+ mi := &file_daemon_proto_msgTypes[85]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -5636,7 +5724,7 @@ func (x *InstallerResultResponse) ProtoReflect() protoreflect.Message {
// Deprecated: Use InstallerResultResponse.ProtoReflect.Descriptor instead.
func (*InstallerResultResponse) Descriptor() ([]byte, []int) {
- return file_daemon_proto_rawDescGZIP(), []int{84}
+ return file_daemon_proto_rawDescGZIP(), []int{85}
}
func (x *InstallerResultResponse) GetSuccess() bool {
@@ -5669,7 +5757,7 @@ type ExposeServiceRequest struct {
func (x *ExposeServiceRequest) Reset() {
*x = ExposeServiceRequest{}
- mi := &file_daemon_proto_msgTypes[85]
+ mi := &file_daemon_proto_msgTypes[86]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -5681,7 +5769,7 @@ func (x *ExposeServiceRequest) String() string {
func (*ExposeServiceRequest) ProtoMessage() {}
func (x *ExposeServiceRequest) ProtoReflect() protoreflect.Message {
- mi := &file_daemon_proto_msgTypes[85]
+ mi := &file_daemon_proto_msgTypes[86]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -5694,7 +5782,7 @@ func (x *ExposeServiceRequest) ProtoReflect() protoreflect.Message {
// Deprecated: Use ExposeServiceRequest.ProtoReflect.Descriptor instead.
func (*ExposeServiceRequest) Descriptor() ([]byte, []int) {
- return file_daemon_proto_rawDescGZIP(), []int{85}
+ return file_daemon_proto_rawDescGZIP(), []int{86}
}
func (x *ExposeServiceRequest) GetPort() uint32 {
@@ -5765,7 +5853,7 @@ type ExposeServiceEvent struct {
func (x *ExposeServiceEvent) Reset() {
*x = ExposeServiceEvent{}
- mi := &file_daemon_proto_msgTypes[86]
+ mi := &file_daemon_proto_msgTypes[87]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -5777,7 +5865,7 @@ func (x *ExposeServiceEvent) String() string {
func (*ExposeServiceEvent) ProtoMessage() {}
func (x *ExposeServiceEvent) ProtoReflect() protoreflect.Message {
- mi := &file_daemon_proto_msgTypes[86]
+ mi := &file_daemon_proto_msgTypes[87]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -5790,7 +5878,7 @@ func (x *ExposeServiceEvent) ProtoReflect() protoreflect.Message {
// Deprecated: Use ExposeServiceEvent.ProtoReflect.Descriptor instead.
func (*ExposeServiceEvent) Descriptor() ([]byte, []int) {
- return file_daemon_proto_rawDescGZIP(), []int{86}
+ return file_daemon_proto_rawDescGZIP(), []int{87}
}
func (x *ExposeServiceEvent) GetEvent() isExposeServiceEvent_Event {
@@ -5831,7 +5919,7 @@ type ExposeServiceReady struct {
func (x *ExposeServiceReady) Reset() {
*x = ExposeServiceReady{}
- mi := &file_daemon_proto_msgTypes[87]
+ mi := &file_daemon_proto_msgTypes[88]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -5843,7 +5931,7 @@ func (x *ExposeServiceReady) String() string {
func (*ExposeServiceReady) ProtoMessage() {}
func (x *ExposeServiceReady) ProtoReflect() protoreflect.Message {
- mi := &file_daemon_proto_msgTypes[87]
+ mi := &file_daemon_proto_msgTypes[88]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -5856,7 +5944,7 @@ func (x *ExposeServiceReady) ProtoReflect() protoreflect.Message {
// Deprecated: Use ExposeServiceReady.ProtoReflect.Descriptor instead.
func (*ExposeServiceReady) Descriptor() ([]byte, []int) {
- return file_daemon_proto_rawDescGZIP(), []int{87}
+ return file_daemon_proto_rawDescGZIP(), []int{88}
}
func (x *ExposeServiceReady) GetServiceName() string {
@@ -5901,7 +5989,7 @@ type StartCaptureRequest struct {
func (x *StartCaptureRequest) Reset() {
*x = StartCaptureRequest{}
- mi := &file_daemon_proto_msgTypes[88]
+ mi := &file_daemon_proto_msgTypes[89]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -5913,7 +6001,7 @@ func (x *StartCaptureRequest) String() string {
func (*StartCaptureRequest) ProtoMessage() {}
func (x *StartCaptureRequest) ProtoReflect() protoreflect.Message {
- mi := &file_daemon_proto_msgTypes[88]
+ mi := &file_daemon_proto_msgTypes[89]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -5926,7 +6014,7 @@ func (x *StartCaptureRequest) ProtoReflect() protoreflect.Message {
// Deprecated: Use StartCaptureRequest.ProtoReflect.Descriptor instead.
func (*StartCaptureRequest) Descriptor() ([]byte, []int) {
- return file_daemon_proto_rawDescGZIP(), []int{88}
+ return file_daemon_proto_rawDescGZIP(), []int{89}
}
func (x *StartCaptureRequest) GetTextOutput() bool {
@@ -5980,7 +6068,7 @@ type CapturePacket struct {
func (x *CapturePacket) Reset() {
*x = CapturePacket{}
- mi := &file_daemon_proto_msgTypes[89]
+ mi := &file_daemon_proto_msgTypes[90]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -5992,7 +6080,7 @@ func (x *CapturePacket) String() string {
func (*CapturePacket) ProtoMessage() {}
func (x *CapturePacket) ProtoReflect() protoreflect.Message {
- mi := &file_daemon_proto_msgTypes[89]
+ mi := &file_daemon_proto_msgTypes[90]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -6005,7 +6093,7 @@ func (x *CapturePacket) ProtoReflect() protoreflect.Message {
// Deprecated: Use CapturePacket.ProtoReflect.Descriptor instead.
func (*CapturePacket) Descriptor() ([]byte, []int) {
- return file_daemon_proto_rawDescGZIP(), []int{89}
+ return file_daemon_proto_rawDescGZIP(), []int{90}
}
func (x *CapturePacket) GetData() []byte {
@@ -6026,7 +6114,7 @@ type StartBundleCaptureRequest struct {
func (x *StartBundleCaptureRequest) Reset() {
*x = StartBundleCaptureRequest{}
- mi := &file_daemon_proto_msgTypes[90]
+ mi := &file_daemon_proto_msgTypes[91]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -6038,7 +6126,7 @@ func (x *StartBundleCaptureRequest) String() string {
func (*StartBundleCaptureRequest) ProtoMessage() {}
func (x *StartBundleCaptureRequest) ProtoReflect() protoreflect.Message {
- mi := &file_daemon_proto_msgTypes[90]
+ mi := &file_daemon_proto_msgTypes[91]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -6051,7 +6139,7 @@ func (x *StartBundleCaptureRequest) ProtoReflect() protoreflect.Message {
// Deprecated: Use StartBundleCaptureRequest.ProtoReflect.Descriptor instead.
func (*StartBundleCaptureRequest) Descriptor() ([]byte, []int) {
- return file_daemon_proto_rawDescGZIP(), []int{90}
+ return file_daemon_proto_rawDescGZIP(), []int{91}
}
func (x *StartBundleCaptureRequest) GetTimeout() *durationpb.Duration {
@@ -6069,7 +6157,7 @@ type StartBundleCaptureResponse struct {
func (x *StartBundleCaptureResponse) Reset() {
*x = StartBundleCaptureResponse{}
- mi := &file_daemon_proto_msgTypes[91]
+ mi := &file_daemon_proto_msgTypes[92]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -6081,7 +6169,7 @@ func (x *StartBundleCaptureResponse) String() string {
func (*StartBundleCaptureResponse) ProtoMessage() {}
func (x *StartBundleCaptureResponse) ProtoReflect() protoreflect.Message {
- mi := &file_daemon_proto_msgTypes[91]
+ mi := &file_daemon_proto_msgTypes[92]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -6094,7 +6182,7 @@ func (x *StartBundleCaptureResponse) ProtoReflect() protoreflect.Message {
// Deprecated: Use StartBundleCaptureResponse.ProtoReflect.Descriptor instead.
func (*StartBundleCaptureResponse) Descriptor() ([]byte, []int) {
- return file_daemon_proto_rawDescGZIP(), []int{91}
+ return file_daemon_proto_rawDescGZIP(), []int{92}
}
type StopBundleCaptureRequest struct {
@@ -6105,7 +6193,7 @@ type StopBundleCaptureRequest struct {
func (x *StopBundleCaptureRequest) Reset() {
*x = StopBundleCaptureRequest{}
- mi := &file_daemon_proto_msgTypes[92]
+ mi := &file_daemon_proto_msgTypes[93]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -6117,7 +6205,7 @@ func (x *StopBundleCaptureRequest) String() string {
func (*StopBundleCaptureRequest) ProtoMessage() {}
func (x *StopBundleCaptureRequest) ProtoReflect() protoreflect.Message {
- mi := &file_daemon_proto_msgTypes[92]
+ mi := &file_daemon_proto_msgTypes[93]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -6130,7 +6218,7 @@ func (x *StopBundleCaptureRequest) ProtoReflect() protoreflect.Message {
// Deprecated: Use StopBundleCaptureRequest.ProtoReflect.Descriptor instead.
func (*StopBundleCaptureRequest) Descriptor() ([]byte, []int) {
- return file_daemon_proto_rawDescGZIP(), []int{92}
+ return file_daemon_proto_rawDescGZIP(), []int{93}
}
type StopBundleCaptureResponse struct {
@@ -6141,7 +6229,7 @@ type StopBundleCaptureResponse struct {
func (x *StopBundleCaptureResponse) Reset() {
*x = StopBundleCaptureResponse{}
- mi := &file_daemon_proto_msgTypes[93]
+ mi := &file_daemon_proto_msgTypes[94]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -6153,7 +6241,7 @@ func (x *StopBundleCaptureResponse) String() string {
func (*StopBundleCaptureResponse) ProtoMessage() {}
func (x *StopBundleCaptureResponse) ProtoReflect() protoreflect.Message {
- mi := &file_daemon_proto_msgTypes[93]
+ mi := &file_daemon_proto_msgTypes[94]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -6166,7 +6254,7 @@ func (x *StopBundleCaptureResponse) ProtoReflect() protoreflect.Message {
// Deprecated: Use StopBundleCaptureResponse.ProtoReflect.Descriptor instead.
func (*StopBundleCaptureResponse) Descriptor() ([]byte, []int) {
- return file_daemon_proto_rawDescGZIP(), []int{93}
+ return file_daemon_proto_rawDescGZIP(), []int{94}
}
type PortInfo_Range struct {
@@ -6179,7 +6267,7 @@ type PortInfo_Range struct {
func (x *PortInfo_Range) Reset() {
*x = PortInfo_Range{}
- mi := &file_daemon_proto_msgTypes[95]
+ mi := &file_daemon_proto_msgTypes[96]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -6191,7 +6279,7 @@ func (x *PortInfo_Range) String() string {
func (*PortInfo_Range) ProtoMessage() {}
func (x *PortInfo_Range) ProtoReflect() protoreflect.Message {
- mi := &file_daemon_proto_msgTypes[95]
+ mi := &file_daemon_proto_msgTypes[96]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -6332,7 +6420,7 @@ const file_daemon_proto_rawDesc = "" +
"\fDownResponse\"P\n" +
"\x10GetConfigRequest\x12 \n" +
"\vprofileName\x18\x01 \x01(\tR\vprofileName\x12\x1a\n" +
- "\busername\x18\x02 \x01(\tR\busername\"\xfe\b\n" +
+ "\busername\x18\x02 \x01(\tR\busername\"\xaa\t\n" +
"\x11GetConfigResponse\x12$\n" +
"\rmanagementUrl\x18\x01 \x01(\tR\rmanagementUrl\x12\x1e\n" +
"\n" +
@@ -6364,7 +6452,8 @@ const file_daemon_proto_rawDesc = "" +
"\x1denableSSHRemotePortForwarding\x18\x17 \x01(\bR\x1denableSSHRemotePortForwarding\x12&\n" +
"\x0edisableSSHAuth\x18\x19 \x01(\bR\x0edisableSSHAuth\x12&\n" +
"\x0esshJWTCacheTTL\x18\x1a \x01(\x05R\x0esshJWTCacheTTL\x12!\n" +
- "\fdisable_ipv6\x18\x1b \x01(\bR\vdisableIpv6\"\x92\x06\n" +
+ "\fdisable_ipv6\x18\x1b \x01(\bR\vdisableIpv6\x12*\n" +
+ "\x10mDMManagedFields\x18\x1c \x03(\tR\x10mDMManagedFields\"\x92\x06\n" +
"\tPeerState\x12\x0e\n" +
"\x02IP\x18\x01 \x01(\tR\x02IP\x12\x16\n" +
"\x06pubKey\x18\x02 \x01(\tR\x06pubKey\x12\x1e\n" +
@@ -6389,7 +6478,7 @@ const file_daemon_proto_rawDesc = "" +
"\n" +
"sshHostKey\x18\x13 \x01(\fR\n" +
"sshHostKey\x12\x12\n" +
- "\x04ipv6\x18\x14 \x01(\tR\x04ipv6\"\x84\x02\n" +
+ "\x04ipv6\x18\x14 \x01(\tR\x04ipv6\"\x9c\x02\n" +
"\x0eLocalPeerState\x12\x0e\n" +
"\x02IP\x18\x01 \x01(\tR\x02IP\x12\x16\n" +
"\x06pubKey\x18\x02 \x01(\tR\x06pubKey\x12(\n" +
@@ -6398,7 +6487,8 @@ const file_daemon_proto_rawDesc = "" +
"\x10rosenpassEnabled\x18\x05 \x01(\bR\x10rosenpassEnabled\x120\n" +
"\x13rosenpassPermissive\x18\x06 \x01(\bR\x13rosenpassPermissive\x12\x1a\n" +
"\bnetworks\x18\a \x03(\tR\bnetworks\x12\x12\n" +
- "\x04ipv6\x18\b \x01(\tR\x04ipv6\"S\n" +
+ "\x04ipv6\x18\b \x01(\tR\x04ipv6\x12\x16\n" +
+ "\x06wgPort\x18\t \x01(\x05R\x06wgPort\"S\n" +
"\vSignalState\x12\x10\n" +
"\x03URL\x18\x01 \x01(\tR\x03URL\x12\x1c\n" +
"\tconnected\x18\x02 \x01(\bR\tconnected\x12\x14\n" +
@@ -6406,12 +6496,13 @@ const file_daemon_proto_rawDesc = "" +
"\x0fManagementState\x12\x10\n" +
"\x03URL\x18\x01 \x01(\tR\x03URL\x12\x1c\n" +
"\tconnected\x18\x02 \x01(\bR\tconnected\x12\x14\n" +
- "\x05error\x18\x03 \x01(\tR\x05error\"R\n" +
+ "\x05error\x18\x03 \x01(\tR\x05error\"p\n" +
"\n" +
"RelayState\x12\x10\n" +
"\x03URI\x18\x01 \x01(\tR\x03URI\x12\x1c\n" +
"\tavailable\x18\x02 \x01(\bR\tavailable\x12\x14\n" +
- "\x05error\x18\x03 \x01(\tR\x05error\"r\n" +
+ "\x05error\x18\x03 \x01(\tR\x05error\x12\x1c\n" +
+ "\ttransport\x18\x04 \x01(\tR\ttransport\"r\n" +
"\fNSGroupState\x12\x18\n" +
"\aservers\x18\x01 \x03(\tR\aservers\x12\x18\n" +
"\adomains\x18\x02 \x03(\tR\adomains\x12\x18\n" +
@@ -6475,14 +6566,17 @@ const file_daemon_proto_rawDesc = "" +
"\x12translatedHostname\x18\x04 \x01(\tR\x12translatedHostname\x128\n" +
"\x0etranslatedPort\x18\x05 \x01(\v2\x10.daemon.PortInfoR\x0etranslatedPort\"G\n" +
"\x17ForwardingRulesResponse\x12,\n" +
- "\x05rules\x18\x01 \x03(\v2\x16.daemon.ForwardingRuleR\x05rules\"\x94\x01\n" +
+ "\x05rules\x18\x01 \x03(\v2\x16.daemon.ForwardingRuleR\x05rules\"\xb4\x01\n" +
"\x12DebugBundleRequest\x12\x1c\n" +
"\tanonymize\x18\x01 \x01(\bR\tanonymize\x12\x1e\n" +
"\n" +
"systemInfo\x18\x03 \x01(\bR\n" +
"systemInfo\x12\x1c\n" +
"\tuploadURL\x18\x04 \x01(\tR\tuploadURL\x12\"\n" +
- "\flogFileCount\x18\x05 \x01(\rR\flogFileCount\"}\n" +
+ "\flogFileCount\x18\x05 \x01(\rR\flogFileCount\x12\x1e\n" +
+ "\n" +
+ "cliVersion\x18\x06 \x01(\tR\n" +
+ "cliVersion\"}\n" +
"\x13DebugBundleResponse\x12\x12\n" +
"\x04path\x18\x01 \x01(\tR\x04path\x12 \n" +
"\vuploadedKey\x18\x02 \x01(\tR\vuploadedKey\x120\n" +
@@ -6675,7 +6769,9 @@ const file_daemon_proto_rawDesc = "" +
"\x13GetFeaturesResponse\x12)\n" +
"\x10disable_profiles\x18\x01 \x01(\bR\x0fdisableProfiles\x126\n" +
"\x17disable_update_settings\x18\x02 \x01(\bR\x15disableUpdateSettings\x12)\n" +
- "\x10disable_networks\x18\x03 \x01(\bR\x0fdisableNetworks\"\x16\n" +
+ "\x10disable_networks\x18\x03 \x01(\bR\x0fdisableNetworks\"3\n" +
+ "\x19MDMManagedFieldsViolation\x12\x16\n" +
+ "\x06fields\x18\x01 \x03(\tR\x06fields\"\x16\n" +
"\x14TriggerUpdateRequest\"M\n" +
"\x15TriggerUpdateResponse\x12\x18\n" +
"\asuccess\x18\x01 \x01(\bR\asuccess\x12\x1a\n" +
@@ -6831,7 +6927,7 @@ func file_daemon_proto_rawDescGZIP() []byte {
}
var file_daemon_proto_enumTypes = make([]protoimpl.EnumInfo, 4)
-var file_daemon_proto_msgTypes = make([]protoimpl.MessageInfo, 97)
+var file_daemon_proto_msgTypes = make([]protoimpl.MessageInfo, 98)
var file_daemon_proto_goTypes = []any{
(LogLevel)(0), // 0: daemon.LogLevel
(ExposeProtocol)(0), // 1: daemon.ExposeProtocol
@@ -6908,41 +7004,42 @@ var file_daemon_proto_goTypes = []any{
(*LogoutResponse)(nil), // 72: daemon.LogoutResponse
(*GetFeaturesRequest)(nil), // 73: daemon.GetFeaturesRequest
(*GetFeaturesResponse)(nil), // 74: daemon.GetFeaturesResponse
- (*TriggerUpdateRequest)(nil), // 75: daemon.TriggerUpdateRequest
- (*TriggerUpdateResponse)(nil), // 76: daemon.TriggerUpdateResponse
- (*GetPeerSSHHostKeyRequest)(nil), // 77: daemon.GetPeerSSHHostKeyRequest
- (*GetPeerSSHHostKeyResponse)(nil), // 78: daemon.GetPeerSSHHostKeyResponse
- (*RequestJWTAuthRequest)(nil), // 79: daemon.RequestJWTAuthRequest
- (*RequestJWTAuthResponse)(nil), // 80: daemon.RequestJWTAuthResponse
- (*WaitJWTTokenRequest)(nil), // 81: daemon.WaitJWTTokenRequest
- (*WaitJWTTokenResponse)(nil), // 82: daemon.WaitJWTTokenResponse
- (*StartCPUProfileRequest)(nil), // 83: daemon.StartCPUProfileRequest
- (*StartCPUProfileResponse)(nil), // 84: daemon.StartCPUProfileResponse
- (*StopCPUProfileRequest)(nil), // 85: daemon.StopCPUProfileRequest
- (*StopCPUProfileResponse)(nil), // 86: daemon.StopCPUProfileResponse
- (*InstallerResultRequest)(nil), // 87: daemon.InstallerResultRequest
- (*InstallerResultResponse)(nil), // 88: daemon.InstallerResultResponse
- (*ExposeServiceRequest)(nil), // 89: daemon.ExposeServiceRequest
- (*ExposeServiceEvent)(nil), // 90: daemon.ExposeServiceEvent
- (*ExposeServiceReady)(nil), // 91: daemon.ExposeServiceReady
- (*StartCaptureRequest)(nil), // 92: daemon.StartCaptureRequest
- (*CapturePacket)(nil), // 93: daemon.CapturePacket
- (*StartBundleCaptureRequest)(nil), // 94: daemon.StartBundleCaptureRequest
- (*StartBundleCaptureResponse)(nil), // 95: daemon.StartBundleCaptureResponse
- (*StopBundleCaptureRequest)(nil), // 96: daemon.StopBundleCaptureRequest
- (*StopBundleCaptureResponse)(nil), // 97: daemon.StopBundleCaptureResponse
- nil, // 98: daemon.Network.ResolvedIPsEntry
- (*PortInfo_Range)(nil), // 99: daemon.PortInfo.Range
- nil, // 100: daemon.SystemEvent.MetadataEntry
- (*durationpb.Duration)(nil), // 101: google.protobuf.Duration
- (*timestamppb.Timestamp)(nil), // 102: google.protobuf.Timestamp
+ (*MDMManagedFieldsViolation)(nil), // 75: daemon.MDMManagedFieldsViolation
+ (*TriggerUpdateRequest)(nil), // 76: daemon.TriggerUpdateRequest
+ (*TriggerUpdateResponse)(nil), // 77: daemon.TriggerUpdateResponse
+ (*GetPeerSSHHostKeyRequest)(nil), // 78: daemon.GetPeerSSHHostKeyRequest
+ (*GetPeerSSHHostKeyResponse)(nil), // 79: daemon.GetPeerSSHHostKeyResponse
+ (*RequestJWTAuthRequest)(nil), // 80: daemon.RequestJWTAuthRequest
+ (*RequestJWTAuthResponse)(nil), // 81: daemon.RequestJWTAuthResponse
+ (*WaitJWTTokenRequest)(nil), // 82: daemon.WaitJWTTokenRequest
+ (*WaitJWTTokenResponse)(nil), // 83: daemon.WaitJWTTokenResponse
+ (*StartCPUProfileRequest)(nil), // 84: daemon.StartCPUProfileRequest
+ (*StartCPUProfileResponse)(nil), // 85: daemon.StartCPUProfileResponse
+ (*StopCPUProfileRequest)(nil), // 86: daemon.StopCPUProfileRequest
+ (*StopCPUProfileResponse)(nil), // 87: daemon.StopCPUProfileResponse
+ (*InstallerResultRequest)(nil), // 88: daemon.InstallerResultRequest
+ (*InstallerResultResponse)(nil), // 89: daemon.InstallerResultResponse
+ (*ExposeServiceRequest)(nil), // 90: daemon.ExposeServiceRequest
+ (*ExposeServiceEvent)(nil), // 91: daemon.ExposeServiceEvent
+ (*ExposeServiceReady)(nil), // 92: daemon.ExposeServiceReady
+ (*StartCaptureRequest)(nil), // 93: daemon.StartCaptureRequest
+ (*CapturePacket)(nil), // 94: daemon.CapturePacket
+ (*StartBundleCaptureRequest)(nil), // 95: daemon.StartBundleCaptureRequest
+ (*StartBundleCaptureResponse)(nil), // 96: daemon.StartBundleCaptureResponse
+ (*StopBundleCaptureRequest)(nil), // 97: daemon.StopBundleCaptureRequest
+ (*StopBundleCaptureResponse)(nil), // 98: daemon.StopBundleCaptureResponse
+ nil, // 99: daemon.Network.ResolvedIPsEntry
+ (*PortInfo_Range)(nil), // 100: daemon.PortInfo.Range
+ nil, // 101: daemon.SystemEvent.MetadataEntry
+ (*durationpb.Duration)(nil), // 102: google.protobuf.Duration
+ (*timestamppb.Timestamp)(nil), // 103: google.protobuf.Timestamp
}
var file_daemon_proto_depIdxs = []int32{
- 101, // 0: daemon.LoginRequest.dnsRouteInterval:type_name -> google.protobuf.Duration
+ 102, // 0: daemon.LoginRequest.dnsRouteInterval:type_name -> google.protobuf.Duration
25, // 1: daemon.StatusResponse.fullStatus:type_name -> daemon.FullStatus
- 102, // 2: daemon.PeerState.connStatusUpdate:type_name -> google.protobuf.Timestamp
- 102, // 3: daemon.PeerState.lastWireguardHandshake:type_name -> google.protobuf.Timestamp
- 101, // 4: daemon.PeerState.latency:type_name -> google.protobuf.Duration
+ 103, // 2: daemon.PeerState.connStatusUpdate:type_name -> google.protobuf.Timestamp
+ 103, // 3: daemon.PeerState.lastWireguardHandshake:type_name -> google.protobuf.Timestamp
+ 102, // 4: daemon.PeerState.latency:type_name -> google.protobuf.Duration
23, // 5: daemon.SSHServerState.sessions:type_name -> daemon.SSHSessionInfo
20, // 6: daemon.FullStatus.managementState:type_name -> daemon.ManagementState
19, // 7: daemon.FullStatus.signalState:type_name -> daemon.SignalState
@@ -6953,8 +7050,8 @@ var file_daemon_proto_depIdxs = []int32{
55, // 12: daemon.FullStatus.events:type_name -> daemon.SystemEvent
24, // 13: daemon.FullStatus.sshServerState:type_name -> daemon.SSHServerState
31, // 14: daemon.ListNetworksResponse.routes:type_name -> daemon.Network
- 98, // 15: daemon.Network.resolvedIPs:type_name -> daemon.Network.ResolvedIPsEntry
- 99, // 16: daemon.PortInfo.range:type_name -> daemon.PortInfo.Range
+ 99, // 15: daemon.Network.resolvedIPs:type_name -> daemon.Network.ResolvedIPsEntry
+ 100, // 16: daemon.PortInfo.range:type_name -> daemon.PortInfo.Range
32, // 17: daemon.ForwardingRule.destinationPort:type_name -> daemon.PortInfo
32, // 18: daemon.ForwardingRule.translatedPort:type_name -> daemon.PortInfo
33, // 19: daemon.ForwardingRulesResponse.rules:type_name -> daemon.ForwardingRule
@@ -6965,15 +7062,15 @@ var file_daemon_proto_depIdxs = []int32{
52, // 24: daemon.TracePacketResponse.stages:type_name -> daemon.TraceStage
2, // 25: daemon.SystemEvent.severity:type_name -> daemon.SystemEvent.Severity
3, // 26: daemon.SystemEvent.category:type_name -> daemon.SystemEvent.Category
- 102, // 27: daemon.SystemEvent.timestamp:type_name -> google.protobuf.Timestamp
- 100, // 28: daemon.SystemEvent.metadata:type_name -> daemon.SystemEvent.MetadataEntry
+ 103, // 27: daemon.SystemEvent.timestamp:type_name -> google.protobuf.Timestamp
+ 101, // 28: daemon.SystemEvent.metadata:type_name -> daemon.SystemEvent.MetadataEntry
55, // 29: daemon.GetEventsResponse.events:type_name -> daemon.SystemEvent
- 101, // 30: daemon.SetConfigRequest.dnsRouteInterval:type_name -> google.protobuf.Duration
+ 102, // 30: daemon.SetConfigRequest.dnsRouteInterval:type_name -> google.protobuf.Duration
68, // 31: daemon.ListProfilesResponse.profiles:type_name -> daemon.Profile
1, // 32: daemon.ExposeServiceRequest.protocol:type_name -> daemon.ExposeProtocol
- 91, // 33: daemon.ExposeServiceEvent.ready:type_name -> daemon.ExposeServiceReady
- 101, // 34: daemon.StartCaptureRequest.duration:type_name -> google.protobuf.Duration
- 101, // 35: daemon.StartBundleCaptureRequest.timeout:type_name -> google.protobuf.Duration
+ 92, // 33: daemon.ExposeServiceEvent.ready:type_name -> daemon.ExposeServiceReady
+ 102, // 34: daemon.StartCaptureRequest.duration:type_name -> google.protobuf.Duration
+ 102, // 35: daemon.StartBundleCaptureRequest.timeout:type_name -> google.protobuf.Duration
30, // 36: daemon.Network.ResolvedIPsEntry.value:type_name -> daemon.IPList
5, // 37: daemon.DaemonService.Login:input_type -> daemon.LoginRequest
7, // 38: daemon.DaemonService.WaitSSOLogin:input_type -> daemon.WaitSSOLoginRequest
@@ -6993,9 +7090,9 @@ var file_daemon_proto_depIdxs = []int32{
46, // 52: daemon.DaemonService.DeleteState:input_type -> daemon.DeleteStateRequest
48, // 53: daemon.DaemonService.SetSyncResponsePersistence:input_type -> daemon.SetSyncResponsePersistenceRequest
51, // 54: daemon.DaemonService.TracePacket:input_type -> daemon.TracePacketRequest
- 92, // 55: daemon.DaemonService.StartCapture:input_type -> daemon.StartCaptureRequest
- 94, // 56: daemon.DaemonService.StartBundleCapture:input_type -> daemon.StartBundleCaptureRequest
- 96, // 57: daemon.DaemonService.StopBundleCapture:input_type -> daemon.StopBundleCaptureRequest
+ 93, // 55: daemon.DaemonService.StartCapture:input_type -> daemon.StartCaptureRequest
+ 95, // 56: daemon.DaemonService.StartBundleCapture:input_type -> daemon.StartBundleCaptureRequest
+ 97, // 57: daemon.DaemonService.StopBundleCapture:input_type -> daemon.StopBundleCaptureRequest
54, // 58: daemon.DaemonService.SubscribeEvents:input_type -> daemon.SubscribeRequest
56, // 59: daemon.DaemonService.GetEvents:input_type -> daemon.GetEventsRequest
58, // 60: daemon.DaemonService.SwitchProfile:input_type -> daemon.SwitchProfileRequest
@@ -7006,14 +7103,14 @@ var file_daemon_proto_depIdxs = []int32{
69, // 65: daemon.DaemonService.GetActiveProfile:input_type -> daemon.GetActiveProfileRequest
71, // 66: daemon.DaemonService.Logout:input_type -> daemon.LogoutRequest
73, // 67: daemon.DaemonService.GetFeatures:input_type -> daemon.GetFeaturesRequest
- 75, // 68: daemon.DaemonService.TriggerUpdate:input_type -> daemon.TriggerUpdateRequest
- 77, // 69: daemon.DaemonService.GetPeerSSHHostKey:input_type -> daemon.GetPeerSSHHostKeyRequest
- 79, // 70: daemon.DaemonService.RequestJWTAuth:input_type -> daemon.RequestJWTAuthRequest
- 81, // 71: daemon.DaemonService.WaitJWTToken:input_type -> daemon.WaitJWTTokenRequest
- 83, // 72: daemon.DaemonService.StartCPUProfile:input_type -> daemon.StartCPUProfileRequest
- 85, // 73: daemon.DaemonService.StopCPUProfile:input_type -> daemon.StopCPUProfileRequest
- 87, // 74: daemon.DaemonService.GetInstallerResult:input_type -> daemon.InstallerResultRequest
- 89, // 75: daemon.DaemonService.ExposeService:input_type -> daemon.ExposeServiceRequest
+ 76, // 68: daemon.DaemonService.TriggerUpdate:input_type -> daemon.TriggerUpdateRequest
+ 78, // 69: daemon.DaemonService.GetPeerSSHHostKey:input_type -> daemon.GetPeerSSHHostKeyRequest
+ 80, // 70: daemon.DaemonService.RequestJWTAuth:input_type -> daemon.RequestJWTAuthRequest
+ 82, // 71: daemon.DaemonService.WaitJWTToken:input_type -> daemon.WaitJWTTokenRequest
+ 84, // 72: daemon.DaemonService.StartCPUProfile:input_type -> daemon.StartCPUProfileRequest
+ 86, // 73: daemon.DaemonService.StopCPUProfile:input_type -> daemon.StopCPUProfileRequest
+ 88, // 74: daemon.DaemonService.GetInstallerResult:input_type -> daemon.InstallerResultRequest
+ 90, // 75: daemon.DaemonService.ExposeService:input_type -> daemon.ExposeServiceRequest
6, // 76: daemon.DaemonService.Login:output_type -> daemon.LoginResponse
8, // 77: daemon.DaemonService.WaitSSOLogin:output_type -> daemon.WaitSSOLoginResponse
10, // 78: daemon.DaemonService.Up:output_type -> daemon.UpResponse
@@ -7032,9 +7129,9 @@ var file_daemon_proto_depIdxs = []int32{
47, // 91: daemon.DaemonService.DeleteState:output_type -> daemon.DeleteStateResponse
49, // 92: daemon.DaemonService.SetSyncResponsePersistence:output_type -> daemon.SetSyncResponsePersistenceResponse
53, // 93: daemon.DaemonService.TracePacket:output_type -> daemon.TracePacketResponse
- 93, // 94: daemon.DaemonService.StartCapture:output_type -> daemon.CapturePacket
- 95, // 95: daemon.DaemonService.StartBundleCapture:output_type -> daemon.StartBundleCaptureResponse
- 97, // 96: daemon.DaemonService.StopBundleCapture:output_type -> daemon.StopBundleCaptureResponse
+ 94, // 94: daemon.DaemonService.StartCapture:output_type -> daemon.CapturePacket
+ 96, // 95: daemon.DaemonService.StartBundleCapture:output_type -> daemon.StartBundleCaptureResponse
+ 98, // 96: daemon.DaemonService.StopBundleCapture:output_type -> daemon.StopBundleCaptureResponse
55, // 97: daemon.DaemonService.SubscribeEvents:output_type -> daemon.SystemEvent
57, // 98: daemon.DaemonService.GetEvents:output_type -> daemon.GetEventsResponse
59, // 99: daemon.DaemonService.SwitchProfile:output_type -> daemon.SwitchProfileResponse
@@ -7045,14 +7142,14 @@ var file_daemon_proto_depIdxs = []int32{
70, // 104: daemon.DaemonService.GetActiveProfile:output_type -> daemon.GetActiveProfileResponse
72, // 105: daemon.DaemonService.Logout:output_type -> daemon.LogoutResponse
74, // 106: daemon.DaemonService.GetFeatures:output_type -> daemon.GetFeaturesResponse
- 76, // 107: daemon.DaemonService.TriggerUpdate:output_type -> daemon.TriggerUpdateResponse
- 78, // 108: daemon.DaemonService.GetPeerSSHHostKey:output_type -> daemon.GetPeerSSHHostKeyResponse
- 80, // 109: daemon.DaemonService.RequestJWTAuth:output_type -> daemon.RequestJWTAuthResponse
- 82, // 110: daemon.DaemonService.WaitJWTToken:output_type -> daemon.WaitJWTTokenResponse
- 84, // 111: daemon.DaemonService.StartCPUProfile:output_type -> daemon.StartCPUProfileResponse
- 86, // 112: daemon.DaemonService.StopCPUProfile:output_type -> daemon.StopCPUProfileResponse
- 88, // 113: daemon.DaemonService.GetInstallerResult:output_type -> daemon.InstallerResultResponse
- 90, // 114: daemon.DaemonService.ExposeService:output_type -> daemon.ExposeServiceEvent
+ 77, // 107: daemon.DaemonService.TriggerUpdate:output_type -> daemon.TriggerUpdateResponse
+ 79, // 108: daemon.DaemonService.GetPeerSSHHostKey:output_type -> daemon.GetPeerSSHHostKeyResponse
+ 81, // 109: daemon.DaemonService.RequestJWTAuth:output_type -> daemon.RequestJWTAuthResponse
+ 83, // 110: daemon.DaemonService.WaitJWTToken:output_type -> daemon.WaitJWTTokenResponse
+ 85, // 111: daemon.DaemonService.StartCPUProfile:output_type -> daemon.StartCPUProfileResponse
+ 87, // 112: daemon.DaemonService.StopCPUProfile:output_type -> daemon.StopCPUProfileResponse
+ 89, // 113: daemon.DaemonService.GetInstallerResult:output_type -> daemon.InstallerResultResponse
+ 91, // 114: daemon.DaemonService.ExposeService:output_type -> daemon.ExposeServiceEvent
76, // [76:115] is the sub-list for method output_type
37, // [37:76] is the sub-list for method input_type
37, // [37:37] is the sub-list for extension type_name
@@ -7077,8 +7174,8 @@ func file_daemon_proto_init() {
file_daemon_proto_msgTypes[54].OneofWrappers = []any{}
file_daemon_proto_msgTypes[56].OneofWrappers = []any{}
file_daemon_proto_msgTypes[67].OneofWrappers = []any{}
- file_daemon_proto_msgTypes[75].OneofWrappers = []any{}
- file_daemon_proto_msgTypes[86].OneofWrappers = []any{
+ file_daemon_proto_msgTypes[76].OneofWrappers = []any{}
+ file_daemon_proto_msgTypes[87].OneofWrappers = []any{
(*ExposeServiceEvent_Ready)(nil),
}
type x struct{}
@@ -7087,7 +7184,7 @@ func file_daemon_proto_init() {
GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
RawDescriptor: unsafe.Slice(unsafe.StringData(file_daemon_proto_rawDesc), len(file_daemon_proto_rawDesc)),
NumEnums: 4,
- NumMessages: 97,
+ NumMessages: 98,
NumExtensions: 0,
NumServices: 1,
},
diff --git a/client/proto/daemon.proto b/client/proto/daemon.proto
index dedff43e2..ea668f629 100644
--- a/client/proto/daemon.proto
+++ b/client/proto/daemon.proto
@@ -314,6 +314,13 @@ message GetConfigResponse {
int32 sshJWTCacheTTL = 26;
bool disable_ipv6 = 27;
+
+ // mDMManagedFields lists the names of configuration keys whose value is
+ // currently enforced by an MDM policy. Names match mdm.Key* constants
+ // (e.g. "managementURL", "disableClientRoutes"). UI/CLI clients should
+ // render the corresponding inputs as read-only and display a "managed
+ // by MDM" indicator.
+ repeated string mDMManagedFields = 28;
}
// PeerState contains the latest state of a peer
@@ -349,6 +356,7 @@ message LocalPeerState {
bool rosenpassPermissive = 6;
repeated string networks = 7;
string ipv6 = 8;
+ int32 wgPort = 9;
}
// SignalState contains the latest state of a signal connection
@@ -370,6 +378,9 @@ message RelayState {
string URI = 1;
bool available = 2;
string error = 3;
+ // transport is the negotiated relay transport (e.g. "ws", "quic"),
+ // empty for stun/turn probes or when not connected.
+ string transport = 4;
}
message NSGroupState {
@@ -471,6 +482,7 @@ message DebugBundleRequest {
bool systemInfo = 3;
string uploadURL = 4;
uint32 logFileCount = 5;
+ string cliVersion = 6;
}
message DebugBundleResponse {
@@ -731,6 +743,15 @@ message GetFeaturesResponse{
bool disable_networks = 3;
}
+// MDMManagedFieldsViolation is attached as a gRPC error detail on a
+// FailedPrecondition status returned from SetConfig (and similar mutating
+// RPCs) when the caller tries to modify one or more MDM-enforced fields.
+// The fields list contains the offending key names; the entire request is
+// rejected (no partial apply).
+message MDMManagedFieldsViolation {
+ repeated string fields = 1;
+}
+
message TriggerUpdateRequest {}
message TriggerUpdateResponse {
diff --git a/client/proto/generate.sh b/client/proto/generate.sh
index e659cef90..1ae55e380 100755
--- a/client/proto/generate.sh
+++ b/client/proto/generate.sh
@@ -1,17 +1,16 @@
#!/bin/bash
set -e
-if ! which realpath > /dev/null 2>&1
-then
- echo realpath is not installed
- echo run: brew install coreutils
- exit 1
+if ! which realpath >/dev/null 2>&1; then
+ echo realpath is not installed
+ echo run: brew install coreutils
+ exit 1
fi
old_pwd=$(pwd)
-script_path=$(dirname $(realpath "$0"))
+script_path=$(dirname "$(realpath "$0")")
cd "$script_path"
go install google.golang.org/protobuf/cmd/protoc-gen-go@v1.36.6
-go install google.golang.org/grpc/cmd/protoc-gen-go-grpc@v1.1
+go install google.golang.org/grpc/cmd/protoc-gen-go-grpc@v1.6.1
protoc -I ./ ./daemon.proto --go_out=../ --go-grpc_out=../ --experimental_allow_proto3_optional
cd "$old_pwd"
diff --git a/client/server/debug.go b/client/server/debug.go
index 33247db5f..14dcaba33 100644
--- a/client/server/debug.go
+++ b/client/server/debug.go
@@ -14,6 +14,7 @@ import (
"github.com/netbirdio/netbird/client/internal/debug"
"github.com/netbirdio/netbird/client/proto"
mgmProto "github.com/netbirdio/netbird/shared/management/proto"
+ "github.com/netbirdio/netbird/version"
)
// DebugBundle creates a debug bundle and returns the location.
@@ -67,6 +68,8 @@ func (s *Server) DebugBundle(_ context.Context, req *proto.DebugBundleRequest) (
CapturePath: capturePath,
RefreshStatus: refreshStatus,
ClientMetrics: clientMetrics,
+ DaemonVersion: version.NetbirdVersion(),
+ CliVersion: req.CliVersion,
},
debug.BundleConfig{
Anonymize: req.GetAnonymize(),
diff --git a/client/server/mdm.go b/client/server/mdm.go
new file mode 100644
index 000000000..0da0ec5d1
--- /dev/null
+++ b/client/server/mdm.go
@@ -0,0 +1,419 @@
+package server
+
+import (
+ "context"
+ "fmt"
+ "time"
+
+ log "github.com/sirupsen/logrus"
+ "google.golang.org/grpc/codes"
+ gstatus "google.golang.org/grpc/status"
+
+ "github.com/netbirdio/netbird/client/mdm"
+ "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.
+//
+// Restart sequence:
+// 1. Cancel the active engine context (terminates connectWithRetryRuns).
+// 2. Wait briefly for that goroutine to exit (giveUpChan is closed on exit).
+// 3. Re-resolve Config from disk + MDM policy (Config.apply re-runs
+// applyMDMPolicy with the freshly loaded Policy).
+// 4. Spawn a fresh connectWithRetryRuns with the new context and config.
+// 5. Broadcast a SystemEvent so any GUI / CLI subscriber (SubscribeEvents
+// RPC) can refresh its cached config view without polling.
+//
+// The callback runs in the ticker's own goroutine. Ticker has already
+// logged the per-key diff before invoking this hook.
+func (s *Server) onMDMPolicyChange(_, _ *mdm.Policy) error {
+ log.Warn("MDM policy changed; restarting engine to apply new configuration")
+
+ // Hold s.mutex for the entire restart sequence (cancel + quiescence
+ // wait + re-spawn). Any concurrent Up/Down/Status arriving while
+ // MDM is restarting blocks on the Lock until we are done — they
+ // then observe the post-restart state coherently. This is safe
+ // because the connectWithRetryRuns goroutine no longer acquires
+ // s.mutex in its defer (intent vs. goroutine-alive concerns are
+ // fully separated; see the connectionGoroutineRunning helper).
+ s.mutex.Lock()
+ defer s.mutex.Unlock()
+
+ if !s.clientRunning {
+ // The client is not running, so there's no engine to restart.
+ return nil
+ }
+ if s.actCancel != nil {
+ s.actCancel()
+ }
+
+ // Wait for previous connectWithRetryRuns to exit so we don't end up
+ // with two goroutines fighting over the same status recorder + engine.
+ // The teardown engages a fan-out of engine goroutines (peer workers,
+ // signal handler, route manager, ...). close(clientGiveUpChan)
+ // happens in the function-scope defer of connectWithRetryRuns, on
+ // every exit path (ctx cancel, backoff exhausted, panic) — see the
+ // defer in server.go.
+ if s.clientGiveUpChan != nil {
+ select {
+ case <-s.clientGiveUpChan:
+ case <-time.After(10 * time.Second):
+ return fmt.Errorf("failed to restart the engine due to timeout")
+ }
+ }
+
+ if err := s.restartEngineForMDMLocked(); err != nil {
+ log.Errorf("MDM restart failed: %v", err)
+ return err
+ }
+
+ // publishConfigChangedEvent has already fired inside
+ // restartEngineForMDMLocked with source="mdm". Emit an MDM-specific
+ // user-visible toast so the operator knows their IT policy was
+ // applied (UserMessage != "" triggers the GUI notifier).
+ s.statusRecorder.PublishEvent(
+ proto.SystemEvent_INFO,
+ proto.SystemEvent_SYSTEM,
+ "MDM policy applied",
+ "NetBird configuration was updated by your IT policy.",
+ map[string]string{"source": "mdm", "type": "policy_applied"},
+ )
+ return nil
+}
+
+// publishConfigChangedEvent broadcasts a SystemEvent informing any active
+// SubscribeEvents subscriber (typically the GUI tray) that the daemon's
+// effective Config has been replaced and any cached client-side view
+// should be refreshed. Callers pass a stable `source` label so the GUI
+// can distinguish a startup spawn from a user-triggered Up or an
+// MDM-driven restart. Reusing the SYSTEM category keeps the proto enum
+// stable; metadata.type="config_changed" routes to the GUI's refresh
+// handler. UserMessage is left empty so the system tray does not toast
+// for every internal restart; the MDM path emits a separate
+// "policy_applied" event (with UserMessage) for that purpose.
+func (s *Server) publishConfigChangedEvent(source string) {
+ if s.statusRecorder == nil {
+ return
+ }
+ s.statusRecorder.PublishEvent(
+ proto.SystemEvent_INFO,
+ proto.SystemEvent_SYSTEM,
+ fmt.Sprintf("daemon config changed (source=%s)", source),
+ "",
+ map[string]string{
+ "source": source,
+ "type": "config_changed",
+ },
+ )
+}
+
+// restartEngineForMDMLocked re-resolves the active profile config
+// (re-running applyMDMPolicy via Config.apply) and re-spawns
+// connectWithRetryRuns. Mirrors the tail of Server.Start so a runtime
+// MDM change behaves identically to a fresh boot under the new policy.
+//
+// MUST be called with s.mutex held — onMDMPolicyChange holds the lock
+// for the entire restart sequence (cancel + quiescence wait + re-spawn)
+// so concurrent Up/Down/Status RPCs observe a coherent post-restart
+// state.
+func (s *Server) restartEngineForMDMLocked() error {
+ activeProf, err := s.profileManager.GetActiveProfileState()
+ if err != nil {
+ return fmt.Errorf("get active profile state: %w", err)
+ }
+ config, _, err := s.getConfig(activeProf)
+ if err != nil {
+ return fmt.Errorf("get active profile config: %w", err)
+ }
+
+ s.config = config
+ s.statusRecorder.UpdateManagementAddress(config.ManagementURL.String())
+ s.statusRecorder.UpdateRosenpass(config.RosenpassEnabled, config.RosenpassPermissive)
+ s.statusRecorder.UpdateLazyConnection(config.LazyConnectionEnabled)
+
+ ctx, cancel := context.WithCancel(s.rootCtx)
+ s.actCancel = cancel
+ s.clientRunning = true
+ s.clientRunningChan = make(chan struct{})
+ s.clientGiveUpChan = make(chan struct{})
+ log.Info("MDM restart: spawning connectWithRetryRuns with re-resolved config")
+ go s.connectWithRetryRuns(ctx, config, s.statusRecorder, s.clientRunningChan, s.clientGiveUpChan)
+ s.publishConfigChangedEvent("mdm")
+ 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
+ },
+ }
+}
+
+// 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
+ },
+ }
+}
+
+// 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
+// treated as a no-op echo (the GUI tray sends a full Config snapshot on
+// every toggle, so most fields in a typical request match the policy
+// exactly and must NOT be flagged as conflicts). The redacted PSK
+// sentinel ("**********") returned by GetConfig is recognised and
+// treated as no-op so the UI can safely round-trip it.
+func mdmManagedFieldConflicts(msg *proto.SetConfigRequest, policy *mdm.Policy) []string {
+ if msg == nil {
+ 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
+ }
+
+ return resolveConflicts(policy, []conflictCheck{
+ conflictString(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.KeyDisableClientRoutes, msg.DisableClientRoutes),
+ conflictBool(mdm.KeyDisableServerRoutes, msg.DisableServerRoutes),
+ conflictBool(mdm.KeyBlockInbound, msg.BlockInbound),
+ conflictInt64(mdm.KeyWireguardPort, msg.WireguardPort),
+ })
+}
+
+// setConfigRequestHasConfigOverrides reports whether the SetConfigRequest
+// carries ANY field that would actually mutate the persisted config.
+// The CLI builds a SetConfigRequest unconditionally on every
+// `netbird up` (see setupSetConfigReq in cmd/up.go) — a plain
+// `netbird up` produces a request with every field at its zero value;
+// the gate must skip such no-op invocations or it would always fire
+// even when the user did not pass any --flag. Returns false on a nil
+// msg; true when any management/admin URL, PSK, DNS/NAT list+clean
+// flag, interface/port/MTU, or any optional bool/duration field is set.
+func setConfigRequestHasConfigOverrides(msg *proto.SetConfigRequest) bool {
+ if msg == nil {
+ return false
+ }
+ return msg.ManagementUrl != "" ||
+ msg.AdminURL != "" ||
+ msg.OptionalPreSharedKey != nil ||
+ len(msg.CustomDNSAddress) > 0 ||
+ len(msg.NatExternalIPs) > 0 || msg.CleanNATExternalIPs ||
+ len(msg.ExtraIFaceBlacklist) > 0 ||
+ len(msg.DnsLabels) > 0 || msg.CleanDNSLabels ||
+ msg.DnsRouteInterval != nil ||
+ msg.RosenpassEnabled != nil ||
+ msg.RosenpassPermissive != nil ||
+ msg.InterfaceName != nil ||
+ msg.WireguardPort != nil ||
+ msg.Mtu != nil ||
+ msg.DisableAutoConnect != nil ||
+ msg.ServerSSHAllowed != nil ||
+ msg.NetworkMonitor != nil ||
+ msg.DisableClientRoutes != nil ||
+ msg.DisableServerRoutes != nil ||
+ msg.DisableDns != nil ||
+ msg.DisableFirewall != nil ||
+ msg.BlockLanAccess != nil ||
+ msg.DisableNotifications != nil ||
+ msg.LazyConnectionEnabled != nil ||
+ msg.BlockInbound != nil ||
+ msg.DisableIpv6 != nil ||
+ msg.EnableSSHRoot != nil ||
+ msg.EnableSSHSFTP != nil ||
+ msg.EnableSSHLocalPortForwarding != nil ||
+ msg.EnableSSHRemotePortForwarding != nil ||
+ msg.DisableSSHAuth != nil ||
+ msg.SshJWTCacheTTL != nil
+}
+
+// loginRequestHasConfigOverrides reports whether the LoginRequest
+// carries ANY field that would mutate persisted daemon configuration
+// (as opposed to pure-auth fields like setupKey, hostname, hint,
+// profileName, username). Used by the Login handler to decide whether
+// the `--disable-update-settings` / MDM gates must run: a re-auth that
+// changes nothing about the configuration is always allowed.
+func loginRequestHasConfigOverrides(msg *proto.LoginRequest) bool {
+ if msg == nil {
+ return false
+ }
+ return msg.ManagementUrl != "" ||
+ msg.AdminURL != "" ||
+ msg.PreSharedKey != "" || //nolint:staticcheck // SA1019: legacy proto field still accepted by Login
+ msg.OptionalPreSharedKey != nil ||
+ len(msg.CustomDNSAddress) > 0 ||
+ len(msg.NatExternalIPs) > 0 || msg.CleanNATExternalIPs ||
+ msg.RosenpassEnabled != nil ||
+ msg.InterfaceName != nil ||
+ msg.WireguardPort != nil ||
+ msg.DisableAutoConnect != nil ||
+ msg.ServerSSHAllowed != nil ||
+ msg.RosenpassPermissive != nil ||
+ len(msg.ExtraIFaceBlacklist) > 0 ||
+ msg.NetworkMonitor != nil ||
+ msg.DnsRouteInterval != nil ||
+ msg.DisableClientRoutes != nil ||
+ msg.DisableServerRoutes != nil ||
+ msg.DisableDns != nil ||
+ msg.DisableFirewall != nil ||
+ msg.BlockLanAccess != nil ||
+ msg.DisableNotifications != nil ||
+ len(msg.DnsLabels) > 0 || msg.CleanDNSLabels ||
+ msg.LazyConnectionEnabled != nil ||
+ msg.BlockInbound != nil
+}
+
+// loginRequestMDMConflicts mirrors mdmManagedFieldConflicts but for the
+// LoginRequest surface. Same value-aware semantics: a field set to the
+// MDM-enforced value is a no-op echo, not a conflict; only a divergent
+// value is flagged. PSK has two proto fields — PreSharedKey (deprecated)
+// and OptionalPreSharedKey (current); either route trips the gate if it
+// diverges from the MDM-enforced PSK. OptionalPreSharedKey wins when
+// both are set; the redaction sentinel ("**********") is accepted as
+// a no-op echo.
+func loginRequestMDMConflicts(msg *proto.LoginRequest, policy *mdm.Policy) []string {
+ if msg == nil {
+ 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
+ }
+ if pskGot == preSharedKeyRedactedSentinel {
+ pskGot = ""
+ }
+
+ return resolveConflicts(policy, []conflictCheck{
+ conflictString(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.KeyDisableClientRoutes, msg.DisableClientRoutes),
+ conflictBool(mdm.KeyDisableServerRoutes, msg.DisableServerRoutes),
+ conflictBool(mdm.KeyBlockInbound, msg.BlockInbound),
+ conflictInt64(mdm.KeyWireguardPort, msg.WireguardPort),
+ })
+}
+
+// rejectMDMManagedFieldConflicts returns a FailedPrecondition gRPC error
+// with an MDMManagedFieldsViolation detail when any of the requested
+// fields tries to change an MDM-enforced value to something else, and
+// nil otherwise. The whole request is rejected on any conflict; non-
+// conflicting fields in the same request are not applied either (no
+// partial apply).
+func rejectMDMManagedFieldConflicts(conflicts []string) error {
+ if len(conflicts) == 0 {
+ return nil
+ }
+ log.Warnf("MDM rejected request: tried to modify %d managed key(s): %v",
+ len(conflicts), conflicts)
+ st := gstatus.New(
+ codes.FailedPrecondition,
+ fmt.Sprintf("fields managed by MDM cannot be modified: %v", conflicts),
+ )
+ detailed, err := st.WithDetails(&proto.MDMManagedFieldsViolation{Fields: conflicts})
+ if err != nil {
+ // Detail attachment is best-effort; fall back to the plain status
+ // so the caller still gets a usable FailedPrecondition.
+ return st.Err()
+ }
+ return detailed.Err()
+}
diff --git a/client/server/network.go b/client/server/network.go
index 12cefbd9c..7a3c08f2e 100644
--- a/client/server/network.go
+++ b/client/server/network.go
@@ -30,7 +30,7 @@ func (s *Server) ListNetworks(context.Context, *proto.ListNetworksRequest) (*pro
s.mutex.Lock()
defer s.mutex.Unlock()
- if s.networksDisabled {
+ if s.checkNetworksDisabled() {
return nil, gstatus.Errorf(codes.Unavailable, errNetworksDisabled)
}
@@ -143,7 +143,7 @@ func (s *Server) SelectNetworks(_ context.Context, req *proto.SelectNetworksRequ
s.mutex.Lock()
defer s.mutex.Unlock()
- if s.networksDisabled {
+ if s.checkNetworksDisabled() {
return nil, gstatus.Errorf(codes.Unavailable, errNetworksDisabled)
}
@@ -195,7 +195,7 @@ func (s *Server) DeselectNetworks(_ context.Context, req *proto.SelectNetworksRe
s.mutex.Lock()
defer s.mutex.Unlock()
- if s.networksDisabled {
+ if s.checkNetworksDisabled() {
return nil, gstatus.Errorf(codes.Unavailable, errNetworksDisabled)
}
diff --git a/client/server/server.go b/client/server/server.go
index 397fb37e4..107d34fcc 100644
--- a/client/server/server.go
+++ b/client/server/server.go
@@ -24,6 +24,7 @@ import (
"github.com/netbirdio/netbird/client/internal/expose"
"github.com/netbirdio/netbird/client/internal/profilemanager"
sleephandler "github.com/netbirdio/netbird/client/internal/sleep/handler"
+ "github.com/netbirdio/netbird/client/mdm"
"github.com/netbirdio/netbird/client/system"
mgm "github.com/netbirdio/netbird/shared/management/client"
"github.com/netbirdio/netbird/shared/management/domain"
@@ -71,7 +72,13 @@ type Server struct {
mutex sync.Mutex
config *profilemanager.Config
proto.UnimplementedDaemonServiceServer
- clientRunning bool // protected by mutex
+ // clientRunning tracks "the daemon wants to be connected" — set true by
+ // Start / Up, cleared by Down / Logout. Persists across retry
+ // loops, signal disconnects, and ErrResetConnection cycles. NOT
+ // changed by connectWithRetryRuns goroutine exit — for that
+ // (goroutine-still-alive) check, see connectionGoroutineRunning() which
+ // derives from clientGiveUpChan close state. Protected by s.mutex.
+ clientRunning bool
clientRunningChan chan struct{}
clientGiveUpChan chan struct{} // closed when connectWithRetryRuns goroutine exits
@@ -98,6 +105,11 @@ type Server struct {
sleepHandler *sleephandler.SleepHandler
+ // mdmTicker periodically re-reads the OS-native MDM policy and triggers
+ // an engine restart when the policy changes. Launched once by Start;
+ // stopped by the rootCtx cancellation.
+ mdmTicker *mdm.Ticker
+
updateManager *updater.Manager
jwtCache *jwtCache
@@ -155,6 +167,17 @@ func (s *Server) Start() error {
s.updateManager.CheckUpdateSuccess(s.rootCtx)
}
+ // MDM policy reload ticker: every minute the desktop daemon re-reads
+ // the OS-native managed-config store and, on diff vs the previous
+ // observation, cancels the active engine context so connectWithRetry-
+ // 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.mdmTicker == nil {
+ s.mdmTicker = mdm.NewTicker(mdm.DefaultReloadInterval)
+ go s.mdmTicker.Run(s.rootCtx, s.onMDMPolicyChange)
+ }
+
// if current state contains any error, return it
// in all other cases we can continue execution only if status is idle and up command was
// not in the progress or already successfully established connection.
@@ -213,17 +236,27 @@ func (s *Server) Start() error {
s.clientRunningChan = make(chan struct{})
s.clientGiveUpChan = make(chan struct{})
go s.connectWithRetryRuns(ctx, config, s.statusRecorder, s.clientRunningChan, s.clientGiveUpChan)
+ s.publishConfigChangedEvent("startup")
return nil
}
// connectWithRetryRuns runs the client connection with a backoff strategy where we retry the operation as additional
// mechanism to keep the client connected even when the connection is lost.
// we cancel retry if the client receive a stop or down command, or if disable auto connect is configured.
+//
+// The goroutine's exit is signalled to the daemon via close(giveUpChan)
+// — placed in the function-scope defer so every return path (panic,
+// DisableAutoConnect early-exit, backoff exhausted, ctx cancel) closes
+// it. Callers that need to observe "is the goroutine still alive?" use
+// Server.connectionGoroutineRunning() which non-blockingly checks the close state
+// of clientGiveUpChan. The defer does NOT touch s.mutex; the daemon's
+// "intent" (clientRunning) is maintained by the RPC handlers, not by this
+// goroutine.
func (s *Server) connectWithRetryRuns(ctx context.Context, profileConfig *profilemanager.Config, statusRecorder *peer.Status, runningChan chan struct{}, giveUpChan chan struct{}) {
defer func() {
- s.mutex.Lock()
- s.clientRunning = false
- s.mutex.Unlock()
+ if giveUpChan != nil {
+ close(giveUpChan)
+ }
}()
if s.config.DisableAutoConnect {
@@ -269,9 +302,26 @@ func (s *Server) connectWithRetryRuns(ctx context.Context, profileConfig *profil
if err := backoff.Retry(runOperation, backOff); err != nil {
log.Errorf("operation failed: %v", err)
}
+ // giveUpChan is closed by the function-scope defer.
+}
- if giveUpChan != nil {
- close(giveUpChan)
+// connectionGoroutineRunning reports whether the connectWithRetryRuns goroutine is
+// still running. Returns false when no goroutine has ever been started
+// AND when the most recent one has already closed clientGiveUpChan on
+// exit (whether due to ctx cancel, DisableAutoConnect single-shot
+// completion, or backoff retry exhaustion).
+//
+// MUST be called with s.mutex held — accesses s.clientGiveUpChan which
+// is written by Start/Up under the same lock.
+func (s *Server) connectionGoroutineRunning() bool {
+ if s.clientGiveUpChan == nil {
+ return false
+ }
+ select {
+ case <-s.clientGiveUpChan:
+ return false
+ default:
+ return true
}
}
@@ -304,54 +354,85 @@ func (s *Server) SetConfig(callerCtx context.Context, msg *proto.SetConfigReques
s.mutex.Lock()
defer s.mutex.Unlock()
- if s.checkUpdateSettingsDisabled() {
- return nil, gstatus.Errorf(codes.Unavailable, errUpdateSettingsDisabled)
+ // Skip the update-settings gate when the request carries no actual
+ // overrides: the CLI builds a SetConfigRequest unconditionally on
+ // every `netbird up` (setupSetConfigReq in cmd/up.go), so a plain
+ // `netbird up` would otherwise always trip the gate and surface a
+ // misleading "setConfig method is not available" warning, even when
+ // the user did not pass any config flag.
+ if setConfigRequestHasConfigOverrides(msg) {
+ if s.checkUpdateSettingsDisabled() {
+ return nil, gstatus.Errorf(codes.Unavailable, errUpdateSettingsDisabled)
+ }
}
+ // MDM gate: refuse the whole request if any of its fields is enforced
+ // 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()
+ if err := rejectMDMManagedFieldConflicts(mdmManagedFieldConflicts(msg, policy)); err != nil {
+ return nil, err
+ }
+
+ config, err := setConfigInputFromRequest(msg)
+ if err != nil {
+ return nil, err
+ }
+
+ if _, err := profilemanager.UpdateConfig(config); err != nil {
+ log.Errorf("failed to update profile config: %v", err)
+ return nil, fmt.Errorf("failed to update profile config: %w", err)
+ }
+
+ return &proto.SetConfigResponse{}, nil
+}
+
+// setConfigInputFromRequest translates a SetConfigRequest into the
+// profilemanager.ConfigInput that profilemanager.UpdateConfig consumes.
+// Pure mapping with no business logic beyond presence-aware copying of
+// optional fields and the "empty / clean" semantics for the two slice
+// fields (DNS labels, NAT external IPs). Extracted from SetConfig to
+// keep the handler's cognitive complexity below the SonarCube
+// threshold; the body is intentionally linear because each proto
+// field is its own optional case. Returns the resolved ConfigInput
+// and a non-nil error only when the active profile file path cannot
+// be determined.
+func setConfigInputFromRequest(msg *proto.SetConfigRequest) (profilemanager.ConfigInput, error) {
+ var config profilemanager.ConfigInput
+
profState := profilemanager.ActiveProfileState{
Name: msg.ProfileName,
Username: msg.Username,
}
-
profPath, err := profState.FilePath()
if err != nil {
log.Errorf("failed to get active profile file path: %v", err)
- return nil, fmt.Errorf("failed to get active profile file path: %w", err)
+ return config, fmt.Errorf("failed to get active profile file path: %w", err)
}
-
- var config profilemanager.ConfigInput
-
config.ConfigPath = profPath
if msg.ManagementUrl != "" {
config.ManagementURL = msg.ManagementUrl
}
-
if msg.AdminURL != "" {
config.AdminURL = msg.AdminURL
}
-
if msg.InterfaceName != nil {
config.InterfaceName = msg.InterfaceName
}
-
if msg.WireguardPort != nil {
wgPort := int(*msg.WireguardPort)
config.WireguardPort = &wgPort
}
-
- if msg.OptionalPreSharedKey != nil {
- if *msg.OptionalPreSharedKey != "" {
- config.PreSharedKey = msg.OptionalPreSharedKey
- }
+ if msg.OptionalPreSharedKey != nil && *msg.OptionalPreSharedKey != "" {
+ config.PreSharedKey = msg.OptionalPreSharedKey
}
if msg.CleanDNSLabels {
config.DNSLabels = domain.List{}
-
} else if msg.DnsLabels != nil {
- dnsLabels := domain.FromPunycodeList(msg.DnsLabels)
- config.DNSLabels = dnsLabels
+ config.DNSLabels = domain.FromPunycodeList(msg.DnsLabels)
}
if msg.CleanNATExternalIPs {
@@ -364,7 +445,6 @@ func (s *Server) SetConfig(callerCtx context.Context, msg *proto.SetConfigReques
if string(msg.CustomDNSAddress) == "empty" {
config.CustomDNSAddress = []byte{}
}
-
config.ExtraIFaceBlackList = msg.ExtraIFaceBlacklist
if msg.DnsRouteInterval != nil {
@@ -397,22 +477,31 @@ func (s *Server) SetConfig(callerCtx context.Context, msg *proto.SetConfigReques
ttl := int(*msg.SshJWTCacheTTL)
config.SSHJWTCacheTTL = &ttl
}
-
if msg.Mtu != nil {
mtu := uint16(*msg.Mtu)
config.MTU = &mtu
}
-
- if _, err := profilemanager.UpdateConfig(config); err != nil {
- log.Errorf("failed to update profile config: %v", err)
- return nil, fmt.Errorf("failed to update profile config: %w", err)
- }
-
- return &proto.SetConfigResponse{}, nil
+ return config, nil
}
// Login uses setup key to prepare configuration for the daemon.
func (s *Server) Login(callerCtx context.Context, msg *proto.LoginRequest) (*proto.LoginResponse, error) {
+ // Config-override gates. LoginRequest carries the same surface as
+ // SetConfigRequest (managementUrl, PSK, ssh/rosenpass/port toggles,
+ // ...), so the same protections must apply. Without these the CLI
+ // command `netbird up --management-url=X` (which falls through to
+ // Login when SetConfig is rejected — see cmd/up.go) would silently
+ // bypass `--disable-update-settings` and any MDM policy.
+ if loginRequestHasConfigOverrides(msg) {
+ if s.checkUpdateSettingsDisabled() {
+ return nil, gstatus.Errorf(codes.Unavailable, errUpdateSettingsDisabled)
+ }
+ policy := loadMDMPolicy()
+ if err := rejectMDMManagedFieldConflicts(loginRequestMDMConflicts(msg, policy)); err != nil {
+ return nil, err
+ }
+ }
+
s.mutex.Lock()
if s.actCancel != nil {
s.actCancel()
@@ -652,7 +741,13 @@ func (s *Server) WaitSSOLogin(callerCtx context.Context, msg *proto.WaitSSOLogin
// Up starts engine work in the daemon.
func (s *Server) Up(callerCtx context.Context, msg *proto.UpRequest) (*proto.UpResponse, error) {
s.mutex.Lock()
- if s.clientRunning {
+ // clientRunning is the daemon-intent flag (set by previous Up/Start, cleared
+ // by Down). connectionGoroutineRunning() reports whether the previous retry-loop
+ // goroutine is still trying. When intent is up AND goroutine is alive,
+ // the existing engine is on the job — just wait for it. When intent
+ // is up but the goroutine has given up (backoff exhausted) OR when
+ // intent is down, fall through to spawn a fresh retry loop.
+ if s.clientRunning && s.connectionGoroutineRunning() {
state := internal.CtxGetState(s.rootCtx)
status, err := state.Status()
if err != nil {
@@ -743,6 +838,7 @@ func (s *Server) Up(callerCtx context.Context, msg *proto.UpRequest) (*proto.UpR
s.clientGiveUpChan = make(chan struct{})
go s.connectWithRetryRuns(ctx, s.config, s.statusRecorder, s.clientRunningChan, s.clientGiveUpChan)
+ s.publishConfigChangedEvent("up_rpc")
s.mutex.Unlock()
return s.waitForUp(callerCtx)
@@ -871,6 +967,12 @@ func (s *Server) cleanupConnection() error {
return ErrServiceNotUp
}
+ // Daemon intent flips to "down" — all callers (Down RPC,
+ // Logout RPC handlers) tear down the connection because the user
+ // explicitly asked for it. MDM restart does NOT go through this
+ // path, so its clientRunning stays true.
+ s.clientRunning = false
+
// Capture the engine reference before cancelling the context.
// After actCancel(), the connectWithRetryRuns goroutine wakes up
// and sets connectClient.engine = nil, causing connectClient.Stop()
@@ -1074,10 +1176,14 @@ func (s *Server) Status(
msg *proto.StatusRequest,
) (*proto.StatusResponse, error) {
s.mutex.Lock()
- clientRunning := s.clientRunning
+ // Only wait if the retry-loop goroutine is alive and making
+ // progress. clientRunning=true with connectionGoroutineRunning=false means the
+ // backoff has given up — there is nothing to wait for; let the
+ // caller observe the failed status directly.
+ alive := s.connectionGoroutineRunning()
s.mutex.Unlock()
- if msg.WaitForReady != nil && *msg.WaitForReady && clientRunning {
+ if msg.WaitForReady != nil && *msg.WaitForReady && alive {
state := internal.CtxGetState(s.rootCtx)
status, err := state.Status()
if err != nil {
@@ -1548,6 +1654,7 @@ func (s *Server) GetConfig(ctx context.Context, req *proto.GetConfigRequest) (*p
EnableSSHRemotePortForwarding: enableSSHRemotePortForwarding,
DisableSSHAuth: disableSSHAuth,
SshJWTCacheTTL: sshJWTCacheTTL,
+ MDMManagedFields: cfg.Policy().ManagedKeys(),
}, nil
}
@@ -1646,7 +1753,7 @@ func (s *Server) GetFeatures(ctx context.Context, msg *proto.GetFeaturesRequest)
features := &proto.GetFeaturesResponse{
DisableProfiles: s.checkProfilesDisabled(),
DisableUpdateSettings: s.checkUpdateSettingsDisabled(),
- DisableNetworks: s.networksDisabled,
+ DisableNetworks: s.checkNetworksDisabled(),
}
return features, nil
@@ -1668,22 +1775,46 @@ func (s *Server) connect(ctx context.Context, config *profilemanager.Config, sta
return nil
}
+// MDM authority: when the platform-native MDM source sets a kill switch
+// key (regardless of true/false value), that value wins. The CLI flag
+// supplied at service install time is the fallback used only when the
+// MDM source is silent on the key. This honors the "MDM decides
+// everything" semantic agreed for NET-1214 — an admin pushing
+// disableX=false via MDM explicitly re-enables the feature even on a
+// box installed with --disable-X.
func (s *Server) checkProfilesDisabled() bool {
- // Check if the environment variable is set to disable profiles
- if s.profilesDisabled {
- return true
+ if s.config != nil {
+ if v, ok := s.config.Policy().GetBool(mdm.KeyDisableProfiles); ok {
+ return v
+ }
}
+ return s.profilesDisabled
+}
- return false
+// checkNetworksDisabled reports whether the networks/exit-node feature
+// is disabled on this daemon instance. Resolved MDM-first: when the
+// active policy declares mdm.KeyDisableNetworks the policy value wins
+// (regardless of true/false), so an admin can re-enable the feature
+// via MDM even on a host that was installed with --disable-networks.
+// Falls back to the s.networksDisabled CLI flag when the policy is
+// silent on the key. Mirrors checkProfilesDisabled and
+// checkUpdateSettingsDisabled.
+func (s *Server) checkNetworksDisabled() bool {
+ if s.config != nil {
+ if v, ok := s.config.Policy().GetBool(mdm.KeyDisableNetworks); ok {
+ return v
+ }
+ }
+ return s.networksDisabled
}
func (s *Server) checkUpdateSettingsDisabled() bool {
- // Check if the environment variable is set to disable profiles
- if s.updateSettingsDisabled {
- return true
+ if s.config != nil {
+ if v, ok := s.config.Policy().GetBool(mdm.KeyDisableUpdateSettings); ok {
+ return v
+ }
}
-
- return false
+ return s.updateSettingsDisabled
}
func (s *Server) startUpdateManagerForGUI() {
diff --git a/client/server/server_connect_test.go b/client/server/server_connect_test.go
index faea7da39..0c6e03a4a 100644
--- a/client/server/server_connect_test.go
+++ b/client/server/server_connect_test.go
@@ -101,6 +101,7 @@ func TestCleanupConnection_ClearsConnectClient(t *testing.T) {
require.NoError(t, err)
assert.Nil(t, s.connectClient, "connectClient should be nil after cleanup")
+ assert.False(t, s.clientRunning, "clientRunning should be cleared after cleanup (intent = down)")
}
// TestCleanState_NilConnectClient validates that CleanState doesn't panic
@@ -144,17 +145,20 @@ func TestDownThenUp_StaleRunningChan(t *testing.T) {
_, cancel := context.WithCancel(context.Background())
s.actCancel = cancel
- // Simulate Down(): cleanupConnection sets connectClient = nil
+ // Simulate Down(): cleanupConnection sets connectClient = nil and
+ // flips clientRunning to false (intent = down). The connectionGoroutineRunning state
+ // remains independent of intent — derived from clientGiveUpChan.
s.mutex.Lock()
err := s.cleanupConnection()
s.mutex.Unlock()
require.NoError(t, err)
- // After cleanup: connectClient is nil, clientRunning still true
- // (goroutine hasn't exited yet)
+ // After cleanup: connectClient is nil, clientRunning is false (intent
+ // cleared by cleanupConnection), connectionGoroutineRunning may still be true
+ // (goroutine teardown is independent of the intent flag).
s.mutex.Lock()
assert.Nil(t, s.connectClient, "connectClient should be nil after cleanup")
- assert.True(t, s.clientRunning, "clientRunning still true until goroutine exits")
+ assert.False(t, s.clientRunning, "clientRunning should be cleared by cleanupConnection (intent = down)")
s.mutex.Unlock()
// waitForUp() returns immediately due to stale closed clientRunningChan
diff --git a/client/server/setconfig_mdm_test.go b/client/server/setconfig_mdm_test.go
new file mode 100644
index 000000000..a2c07cb02
--- /dev/null
+++ b/client/server/setconfig_mdm_test.go
@@ -0,0 +1,198 @@
+package server
+
+import (
+ "context"
+ "os/user"
+ "path/filepath"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+ "google.golang.org/grpc/codes"
+ gstatus "google.golang.org/grpc/status"
+
+ "github.com/netbirdio/netbird/client/internal/profilemanager"
+ "github.com/netbirdio/netbird/client/mdm"
+ "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) {
+ t.Helper()
+ prev := loadMDMPolicy
+ loadMDMPolicy = func() *mdm.Policy { return policy }
+ t.Cleanup(func() { loadMDMPolicy = prev })
+}
+
+// setupServerWithProfile mirrors the boilerplate of TestSetConfig_AllFieldsSaved:
+// overrides profilemanager paths to a temp dir, seeds a profile, sets it
+// active, and constructs a Server instance. Returns the constructed server
+// plus context + profile name + username + cfgPath for the seeded profile.
+func setupServerWithProfile(t *testing.T) (s *Server, ctx context.Context, profName, username, cfgPath string) {
+ t.Helper()
+ tempDir := t.TempDir()
+
+ origDefaultProfileDir := profilemanager.DefaultConfigPathDir
+ origDefaultConfigPath := profilemanager.DefaultConfigPath
+ origActiveProfileStatePath := profilemanager.ActiveProfileStatePath
+ profilemanager.ConfigDirOverride = tempDir
+ profilemanager.DefaultConfigPathDir = tempDir
+ profilemanager.ActiveProfileStatePath = tempDir + "/active_profile.json"
+ profilemanager.DefaultConfigPath = filepath.Join(tempDir, "default.json")
+ t.Cleanup(func() {
+ profilemanager.DefaultConfigPathDir = origDefaultProfileDir
+ profilemanager.ActiveProfileStatePath = origActiveProfileStatePath
+ profilemanager.DefaultConfigPath = origDefaultConfigPath
+ profilemanager.ConfigDirOverride = ""
+ })
+
+ currUser, err := user.Current()
+ require.NoError(t, err)
+
+ profName = "test-profile-mdm"
+ cfgPath = filepath.Join(tempDir, profName+".json")
+
+ _, err = profilemanager.UpdateOrCreateConfig(profilemanager.ConfigInput{
+ ConfigPath: cfgPath,
+ ManagementURL: "https://api.netbird.io:443",
+ })
+ require.NoError(t, err)
+
+ pm := profilemanager.ServiceManager{}
+ require.NoError(t, pm.SetActiveProfileState(&profilemanager.ActiveProfileState{
+ Name: profName,
+ Username: currUser.Username,
+ }))
+
+ ctx = context.Background()
+ s = New(ctx, "console", "", false, false, false, false)
+ return s, ctx, profName, currUser.Username, cfgPath
+}
+
+// extractViolation pulls the MDMManagedFieldsViolation detail from a
+// FailedPrecondition error. Fails the test if absent or malformed.
+func extractViolation(t *testing.T, err error) *proto.MDMManagedFieldsViolation {
+ t.Helper()
+ require.Error(t, err)
+ st, ok := gstatus.FromError(err)
+ require.True(t, ok, "error must be a gRPC status: %v", err)
+ require.Equal(t, codes.FailedPrecondition, st.Code(), "expected FailedPrecondition, got %s", st.Code())
+ for _, d := range st.Details() {
+ if v, ok := d.(*proto.MDMManagedFieldsViolation); ok {
+ return v
+ }
+ }
+ t.Fatalf("MDMManagedFieldsViolation detail not found on status; details: %v", st.Details())
+ return nil
+}
+
+func TestSetConfig_MDMReject_SingleField(t *testing.T) {
+ withMDMPolicy(t, 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,
+ ManagementUrl: "https://user.tried.this.com:443",
+ })
+
+ v := extractViolation(t, err)
+ assert.Equal(t, []string{mdm.KeyManagementURL}, v.GetFields())
+}
+
+func TestSetConfig_MDMReject_MultipleFields(t *testing.T) {
+ withMDMPolicy(t, 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{
+ ProfileName: profName,
+ Username: username,
+ ManagementUrl: "https://user.tried.this.com:443",
+ BlockInbound: &blockInbound,
+ RosenpassEnabled: &rosenpassEnabled,
+ })
+
+ v := extractViolation(t, err)
+ assert.ElementsMatch(t, []string{
+ mdm.KeyManagementURL,
+ mdm.KeyBlockInbound,
+ mdm.KeyRosenpassEnabled,
+ }, v.GetFields())
+}
+
+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{
+ mdm.KeyManagementURL: "https://mdm.example.com:443",
+ }))
+
+ s, ctx, profName, username, cfgPath := setupServerWithProfile(t)
+
+ rosenpassEnabled := true
+ _, err := s.SetConfig(ctx, &proto.SetConfigRequest{
+ ProfileName: profName,
+ Username: username,
+ ManagementUrl: "https://user.tried.this.com:443",
+ RosenpassEnabled: &rosenpassEnabled,
+ })
+
+ v := extractViolation(t, err)
+ assert.Equal(t, []string{mdm.KeyManagementURL}, v.GetFields())
+
+ // Confirm RosenpassEnabled was NOT applied even though it was not
+ // in the conflict list: the request was rejected as a whole.
+ reloaded, err := profilemanager.GetConfig(cfgPath)
+ require.NoError(t, err)
+ assert.False(t, reloaded.RosenpassEnabled, "non-conflicting field must not be applied when request is rejected")
+}
+
+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{
+ mdm.KeyManagementURL: "https://mdm.example.com:443",
+ }))
+
+ s, ctx, profName, username, _ := setupServerWithProfile(t)
+
+ rosenpassEnabled := true
+ resp, err := s.SetConfig(ctx, &proto.SetConfigRequest{
+ ProfileName: profName,
+ Username: username,
+ RosenpassEnabled: &rosenpassEnabled,
+ })
+
+ require.NoError(t, err)
+ require.NotNil(t, resp)
+}
+
+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)
+
+ resp, err := s.SetConfig(ctx, &proto.SetConfigRequest{
+ ProfileName: profName,
+ Username: username,
+ ManagementUrl: "https://user.changed.url.com:443",
+ })
+
+ require.NoError(t, err)
+ require.NotNil(t, resp)
+}
diff --git a/client/status/status.go b/client/status/status.go
index 11ed06c2d..5b815aaa3 100644
--- a/client/status/status.go
+++ b/client/status/status.go
@@ -98,6 +98,7 @@ type RelayStateOutputDetail struct {
URI string `json:"uri" yaml:"uri"`
Available bool `json:"available" yaml:"available"`
Error string `json:"error" yaml:"error"`
+ Transport string `json:"transport,omitempty" yaml:"transport,omitempty"`
}
type RelayStateOutput struct {
@@ -143,6 +144,7 @@ type OutputOverview struct {
IPv6 string `json:"netbirdIpv6,omitempty" yaml:"netbirdIpv6,omitempty"`
PubKey string `json:"publicKey" yaml:"publicKey"`
KernelInterface bool `json:"usesKernelInterface" yaml:"usesKernelInterface"`
+ WgPort int `json:"wireguardPort" yaml:"wireguardPort"`
FQDN string `json:"fqdn" yaml:"fqdn"`
RosenpassEnabled bool `json:"quantumResistance" yaml:"quantumResistance"`
RosenpassPermissive bool `json:"quantumResistancePermissive" yaml:"quantumResistancePermissive"`
@@ -187,6 +189,7 @@ func ConvertToStatusOutputOverview(pbFullStatus *proto.FullStatus, opts ConvertO
IPv6: pbFullStatus.GetLocalPeerState().GetIpv6(),
PubKey: pbFullStatus.GetLocalPeerState().GetPubKey(),
KernelInterface: pbFullStatus.GetLocalPeerState().GetKernelInterface(),
+ WgPort: int(pbFullStatus.GetLocalPeerState().GetWgPort()),
FQDN: pbFullStatus.GetLocalPeerState().GetFqdn(),
RosenpassEnabled: pbFullStatus.GetLocalPeerState().GetRosenpassEnabled(),
RosenpassPermissive: pbFullStatus.GetLocalPeerState().GetRosenpassPermissive(),
@@ -217,7 +220,8 @@ func mapRelays(relays []*proto.RelayState) RelayStateOutput {
RelayStateOutputDetail{
URI: relay.URI,
Available: available,
- Error: relay.GetError(),
+ Error: relayErrorString(relay.GetError()),
+ Transport: relay.GetTransport(),
},
)
@@ -233,6 +237,12 @@ func mapRelays(relays []*proto.RelayState) RelayStateOutput {
}
}
+// relayErrorString flattens a newline-joined aggregated relay error onto a
+// single line for status output.
+func relayErrorString(s string) string {
+ return strings.ReplaceAll(s, "\n", "; ")
+}
+
func mapNSGroups(servers []*proto.NSGroupState) []NsServerGroupStateOutput {
mappedNSGroups := make([]NsServerGroupStateOutput, 0, len(servers))
for _, pbNsGroupServer := range servers {
@@ -439,6 +449,8 @@ func (o *OutputOverview) GeneralSummary(showURL bool, showRelays bool, showNameS
available = "Unavailable"
reason = fmt.Sprintf(", reason: %s", relay.Error)
}
+ } else if relay.Transport != "" {
+ available = fmt.Sprintf("%s via %s", available, relay.Transport)
}
relaysString += fmt.Sprintf("\n [%s] is %s%s", relay.URI, available, reason)
@@ -547,6 +559,21 @@ func (o *OutputOverview) GeneralSummary(showURL bool, showRelays bool, showNameS
goarm = fmt.Sprintf(" (ARMv%s)", os.Getenv("GOARM"))
}
+ daemonVersion := "N/A"
+ if o.DaemonVersion != "" {
+ daemonVersion = o.DaemonVersion
+ }
+
+ cliVersion := version.NetbirdVersion()
+ if o.CliVersion != "" {
+ cliVersion = o.CliVersion
+ }
+
+ wgPortString := "N/A"
+ if o.WgPort > 0 {
+ wgPortString = fmt.Sprintf("%d", o.WgPort)
+ }
+
summary := fmt.Sprintf(
"OS: %s\n"+
"Daemon version: %s\n"+
@@ -560,6 +587,7 @@ func (o *OutputOverview) GeneralSummary(showURL bool, showRelays bool, showNameS
"NetBird IP: %s\n"+
"%s"+
"Interface type: %s\n"+
+ "Wireguard port: %s\n"+
"Quantum resistance: %s\n"+
"Lazy connection: %s\n"+
"SSH Server: %s\n"+
@@ -567,8 +595,8 @@ func (o *OutputOverview) GeneralSummary(showURL bool, showRelays bool, showNameS
"%s"+
"Peers count: %s\n",
fmt.Sprintf("%s/%s%s", goos, goarch, goarm),
- o.DaemonVersion,
- version.NetbirdVersion(),
+ daemonVersion,
+ cliVersion,
o.ProfileName,
managementConnString,
signalConnString,
@@ -578,6 +606,7 @@ func (o *OutputOverview) GeneralSummary(showURL bool, showRelays bool, showNameS
interfaceIP,
ipv6Line,
interfaceTypeString,
+ wgPortString,
rosenpassEnabledStatus,
lazyConnectionEnabledStatus,
sshServerStatus,
diff --git a/client/status/status_test.go b/client/status/status_test.go
index 0986bf0cd..44fc30baf 100644
--- a/client/status/status_test.go
+++ b/client/status/status_test.go
@@ -94,6 +94,7 @@ var resp = &proto.StatusResponse{
Ipv6: "fd00::100",
PubKey: "Some-Pub-Key",
KernelInterface: true,
+ WgPort: 51820,
Fqdn: "some-localhost.awesome-domain.com",
Networks: []string{
"10.10.0.0/24",
@@ -210,6 +211,7 @@ var overview = OutputOverview{
IPv6: "fd00::100",
PubKey: "Some-Pub-Key",
KernelInterface: true,
+ WgPort: 51820,
FQDN: "some-localhost.awesome-domain.com",
NSServerGroups: []NsServerGroupStateOutput{
{
@@ -369,6 +371,7 @@ func TestParsingToJSON(t *testing.T) {
"netbirdIpv6": "fd00::100",
"publicKey": "Some-Pub-Key",
"usesKernelInterface": true,
+ "wireguardPort": 51820,
"fqdn": "some-localhost.awesome-domain.com",
"quantumResistance": false,
"quantumResistancePermissive": false,
@@ -487,6 +490,7 @@ netbirdIp: 192.168.178.100/16
netbirdIpv6: fd00::100
publicKey: Some-Pub-Key
usesKernelInterface: true
+wireguardPort: 51820
fqdn: some-localhost.awesome-domain.com
quantumResistance: false
quantumResistancePermissive: false
@@ -579,12 +583,13 @@ FQDN: some-localhost.awesome-domain.com
NetBird IP: 192.168.178.100/16
NetBird IPv6: fd00::100
Interface type: Kernel
+Wireguard port: %d
Quantum resistance: false
Lazy connection: false
SSH Server: Disabled
Networks: 10.10.0.0/24
Peers count: 2/2 Connected
-`, lastConnectionUpdate1, lastHandshake1, lastConnectionUpdate2, lastHandshake2, runtime.GOOS, runtime.GOARCH, overview.CliVersion)
+`, lastConnectionUpdate1, lastHandshake1, lastConnectionUpdate2, lastHandshake2, runtime.GOOS, runtime.GOARCH, overview.CliVersion, overview.WgPort)
assert.Equal(t, expectedDetail, detail)
}
@@ -604,6 +609,7 @@ FQDN: some-localhost.awesome-domain.com
NetBird IP: 192.168.178.100/16
NetBird IPv6: fd00::100
Interface type: Kernel
+Wireguard port: 51820
Quantum resistance: false
Lazy connection: false
SSH Server: Disabled
@@ -641,3 +647,13 @@ func TestTimeAgo(t *testing.T) {
})
}
}
+
+func TestMapRelaysTransport(t *testing.T) {
+ out := mapRelays([]*proto.RelayState{
+ {URI: "rels://relay.example:443", Available: true, Transport: "quic"},
+ {URI: "rels://relay2.example:443", Available: true, Transport: "ws"},
+ })
+ require.Len(t, out.Details, 2)
+ assert.Equal(t, "quic", out.Details[0].Transport)
+ assert.Equal(t, "ws", out.Details[1].Transport)
+}
diff --git a/client/ui/client_ui.go b/client/ui/client_ui.go
index c2129c7a2..0d3ac416b 100644
--- a/client/ui/client_ui.go
+++ b/client/ui/client_ui.go
@@ -38,6 +38,7 @@ import (
"github.com/netbirdio/netbird/client/iface"
"github.com/netbirdio/netbird/client/internal"
"github.com/netbirdio/netbird/client/internal/profilemanager"
+ "github.com/netbirdio/netbird/client/mdm"
"github.com/netbirdio/netbird/client/proto"
"github.com/netbirdio/netbird/client/ui/desktop"
"github.com/netbirdio/netbird/client/ui/event"
@@ -56,8 +57,22 @@ const (
const (
censoredPreSharedKey = "**********"
maxSSHJWTCacheTTL = 86_400 // 24 hours in seconds
+ // mdmFieldSuffix is appended to plain-text Entry widgets in the
+ // advanced Settings window when the underlying field is enforced
+ // by MDM, so the user sees the lock indicator inline next to the
+ // value. Stripped before any read site that feeds the value back
+ // into a SetConfig request (saveSettings / parseNumericSettings).
+ mdmFieldSuffix = " (MDM)"
)
+// main is the entry point for the UI tray/client binary. Parses CLI
+// flags, initialises logging, builds the Fyne application and tray
+// icons, and constructs the service client (which may open a
+// requested UI window). When a window-mode flag is set the Fyne event
+// loop runs and main returns; otherwise main enforces single-instance
+// behaviour (signalling an existing instance to show its window when
+// present), sets up signal handling + default fonts, and runs the
+// system tray loop.
func main() {
flags := parseFlags()
@@ -315,13 +330,17 @@ type serviceClient struct {
isUpdateIconActive bool
isEnforcedUpdate bool
lastNotifiedVersion string
- settingsEnabled bool
profilesEnabled bool
networksEnabled bool
- showNetworks bool
- wNetworks fyne.Window
- wProfiles fyne.Window
- wQuickActions fyne.Window
+ // networksMenuEnabled caches the last applied enabled-state of the
+ // mNetworks + mExitNode submenu items. Combines features.DisableNetworks
+ // AND s.connected — both must be true for the menus to be active.
+ // Zero value (false) matches the Disable() call at AddMenuItem time.
+ networksMenuEnabled bool
+ showNetworks bool
+ wNetworks fyne.Window
+ wProfiles fyne.Window
+ wQuickActions fyne.Window
eventManager *event.Manager
@@ -336,6 +355,13 @@ type serviceClient struct {
updateContextCancel context.CancelFunc
connectCancel context.CancelFunc
+
+ // mdmManagedFields caches the names of MDM-enforced policy keys
+ // surfaced by the daemon in GetConfigResponse. Each refresh of
+ // daemon config (loadSettings, getSrvConfig, config_changed event)
+ // updates this set and re-applies the lock/badge to the affected
+ // menu items and settings-form widgets.
+ mdmManagedFields map[string]bool
}
type menuHandler struct {
@@ -441,15 +467,12 @@ func (s *serviceClient) updateIcon() {
}
func (s *serviceClient) showSettingsUI() {
- // Check if update settings are disabled by daemon
- features, err := s.getFeatures()
- if err != nil {
- log.Errorf("failed to get features from daemon: %v", err)
- // Continue with default behavior if features can't be retrieved
- } else if features != nil && features.DisableUpdateSettings {
- log.Warn("Update settings are disabled by daemon")
- return
- }
+ // DisableUpdateSettings no longer gates the window from opening:
+ // the daemon blocks every actual mutation at SetConfig / Login,
+ // so the window is safe to show as a read-only view. The previous
+ // early-return also blocked Advanced Settings whenever update
+ // editing was off, which conflated two distinct kill switches
+ // (see comment in checkAndUpdateFeatures).
// add settings window UI elements.
s.wSettings = s.app.NewWindow("NetBird Settings")
@@ -502,7 +525,7 @@ func (s *serviceClient) getConnectionForm() *widget.Form {
{Text: "Pre-shared Key", Widget: s.iPreSharedKey},
{Text: "Quantum-Resistance", Widget: s.sRosenpassPermissive},
{Text: "Interface Name", Widget: s.iInterfaceName},
- {Text: "Interface Port", Widget: s.iInterfacePort},
+ {Text: "Interface Port", Widget: s.iInterfacePort, HintText: "If set to 0, a random free port will be used"},
{Text: "MTU", Widget: s.iMTU},
{Text: "Log File", Widget: s.iLogFile},
},
@@ -532,7 +555,7 @@ func (s *serviceClient) saveSettings() {
return
}
- iMngURL := strings.TrimSpace(s.iMngURL.Text)
+ iMngURL := strings.TrimSpace(strings.TrimSuffix(s.iMngURL.Text, mdmFieldSuffix))
if s.hasSettingsChanged(iMngURL, port, mtu) {
if err := s.applySettingsChanges(iMngURL, port, mtu); err != nil {
@@ -554,12 +577,12 @@ func (s *serviceClient) validateSettings() error {
}
func (s *serviceClient) parseNumericSettings() (int64, int64, error) {
- port, err := strconv.ParseInt(s.iInterfacePort.Text, 10, 64)
+ port, err := strconv.ParseInt(strings.TrimSpace(strings.TrimSuffix(s.iInterfacePort.Text, mdmFieldSuffix)), 10, 64)
if err != nil {
return 0, 0, errors.New("invalid interface port")
}
- if port < 1 || port > 65535 {
- return 0, 0, errors.New("invalid interface port: out of range 1-65535")
+ if port < 0 || port > 65535 {
+ return 0, 0, errors.New("invalid interface port: out of range 0-65535")
}
var mtu int64
@@ -663,7 +686,15 @@ func (s *serviceClient) buildSetConfigRequest(iMngURL string, port, mtu int64) (
req.SshJWTCacheTTL = &sshJWTCacheTTL32
}
- if s.iPreSharedKey.Text != censoredPreSharedKey {
+ // Only attach the PSK when the user actually typed something:
+ // - "" means the field was left untouched (we deliberately render
+ // an empty Text + placeholder hint to avoid leaking the daemon's
+ // "**********" redaction through the password reveal toggle);
+ // sending an empty pointer would tell the daemon to clear / overwrite
+ // the on-disk or MDM-enforced PSK, which then trips the MDM
+ // conflict gate when PSK is policy-managed.
+ // - "**********" is the redacted echo (legacy non-MDM path); also a no-op.
+ if s.iPreSharedKey.Text != "" && s.iPreSharedKey.Text != censoredPreSharedKey {
req.OptionalPreSharedKey = &s.iPreSharedKey.Text
}
@@ -1036,6 +1067,13 @@ func (s *serviceClient) onTrayReady() {
}
s.mProfile = newProfileMenu(*newProfileMenuArgs)
+ // Seed the transition cache to match the actual default menu
+ // state (visible / enabled). Without this, the first
+ // checkAndUpdateFeatures tick that observes DisableProfiles=true
+ // is a no-op (cache zero-value == desired-false) and the menu
+ // never gets hidden — symptom: MDM enforces the kill switch but
+ // the profile menu stays clickable.
+ s.profilesEnabled = true
systray.AddSeparator()
s.mUp = systray.AddMenuItem("Connect", "Connect")
@@ -1055,18 +1093,18 @@ func (s *serviceClient) onTrayReady() {
s.mCreateDebugBundle = s.mSettings.AddSubMenuItem("Create Debug Bundle", debugBundleMenuDescr)
s.loadSettings()
- // Disable settings menu if update settings are disabled by daemon
+ // Disable profile menu if profiles are disabled by daemon.
+ // DisableUpdateSettings is enforced at the daemon's SetConfig /
+ // Login gates, not by hiding the UI — so the Settings menu (and
+ // its Advanced Settings submenu, which has its own kill switch)
+ // stays visible and the user can still inspect current values.
features, err := s.getFeatures()
if err != nil {
log.Errorf("failed to get features from daemon: %v", err)
// Continue with default behavior if features can't be retrieved
- } else {
- if features != nil && features.DisableUpdateSettings {
- s.setSettingsEnabled(false)
- }
- if features != nil && features.DisableProfiles {
- s.mProfile.setEnabled(false)
- }
+ } else if features != nil && features.DisableProfiles {
+ s.mProfile.setEnabled(false)
+ s.profilesEnabled = false
}
s.exitNodeMu.Lock()
@@ -1100,13 +1138,20 @@ func (s *serviceClient) onTrayReady() {
// update exit node menu in case service is already connected
go s.updateExitNodes()
+ // Features (DisableProfiles, DisableUpdateSettings, DisableNetworks,
+ // ...) only change in two ways: at service install time (CLI flag,
+ // static) and at MDM ticker diff time. The daemon already publishes
+ // a SystemEvent{type=config_changed} on every MDM-driven engine
+ // restart, so the UI no longer needs to poll GetFeatures every 2 s.
+ // A single fetch at startup covers the static CLI-flag case; the
+ // event handler below covers MDM transitions. updateStatus stays in
+ // the 2 s loop because connection / peer state genuinely change
+ // continuously and have no event yet.
+ s.checkAndUpdateFeatures()
go func() {
s.getSrvConfig()
time.Sleep(100 * time.Millisecond) // To prevent race condition caused by systray not being fully initialized and ignoring setIcon
for {
- // Check features before status so menus respect disable flags before being enabled
- s.checkAndUpdateFeatures()
-
err := s.updateStatus()
if err != nil {
log.Errorf("error while updating status: %v", err)
@@ -1150,6 +1195,23 @@ func (s *serviceClient) onTrayReady() {
s.onUpdateAvailable(newVersion, enforced)
}
})
+ s.eventManager.AddHandler(func(event *proto.SystemEvent) {
+ // Daemon emits a config_changed event after every engine spawn
+ // (Server.Start, Server.Up, MDM ticker restart). Re-sync the
+ // tray submenu checkboxes from the fresh daemon-side config so
+ // the user does not have to restart the tray to see CLI- or
+ // MDM-driven changes.
+ if event.Category == proto.SystemEvent_SYSTEM && event.Metadata["type"] == "config_changed" {
+ log.Infof("config_changed event received (source=%s); refreshing settings + features", event.Metadata["source"])
+ s.loadSettings()
+ // MDM-driven feature kill switches (DisableProfiles /
+ // DisableUpdateSettings / DisableNetworks) ride the same
+ // config_changed signal because the daemon re-applies its
+ // MDM policy on every engine spawn. Pull them in here so
+ // the UI is up to date without a periodic GetFeatures poll.
+ s.checkAndUpdateFeatures()
+ }
+ })
go s.eventManager.Start(s.ctx)
go s.eventHandler.listen(s.ctx)
@@ -1213,18 +1275,6 @@ func (s *serviceClient) getSrvClient(timeout time.Duration) (proto.DaemonService
return s.conn, nil
}
-// setSettingsEnabled enables or disables the settings menu based on the provided state
-func (s *serviceClient) setSettingsEnabled(enabled bool) {
- if s.mSettings != nil {
- if enabled {
- s.mSettings.Enable()
- } else {
- s.mSettings.Hide()
- s.mSettings.SetTooltip("Settings are disabled by daemon")
- }
- }
-}
-
// checkAndUpdateFeatures checks the current features and updates the UI accordingly
func (s *serviceClient) checkAndUpdateFeatures() {
features, err := s.getFeatures()
@@ -1236,12 +1286,11 @@ func (s *serviceClient) checkAndUpdateFeatures() {
s.updateIndicationLock.Lock()
defer s.updateIndicationLock.Unlock()
- // Update settings menu based on current features
- settingsEnabled := features == nil || !features.DisableUpdateSettings
- if s.settingsEnabled != settingsEnabled {
- s.settingsEnabled = settingsEnabled
- s.setSettingsEnabled(settingsEnabled)
- }
+ // DisableUpdateSettings is enforced server-side by the daemon gates
+ // on SetConfig + Login: any attempt to mutate config from UI or
+ // CLI is rejected at that layer. The UI deliberately keeps the
+ // Settings menu visible so the user can still inspect current
+ // values — read-only by virtue of the daemon refusing edits.
// Update profile menu based on current features
if s.mProfile != nil {
@@ -1252,14 +1301,23 @@ func (s *serviceClient) checkAndUpdateFeatures() {
}
}
- // Update networks and exit node menus based on current features
+ // Update networks and exit node menus based on current features.
+ // `networksEnabled` is the bare feature flag (read elsewhere, e.g. at
+ // connection-status transitions). `networksMenuEnabled` is the
+ // transition-cached state actually applied to the menu items —
+ // it folds in the connection state so a Connected client with the
+ // kill switch off shows the menus active, and only flips on diff.
s.networksEnabled = features == nil || !features.DisableNetworks
- if s.networksEnabled && s.connected {
- s.mNetworks.Enable()
- s.mExitNode.Enable()
- } else {
- s.mNetworks.Disable()
- s.mExitNode.Disable()
+ desiredNetworksMenu := s.networksEnabled && s.connected
+ if desiredNetworksMenu != s.networksMenuEnabled {
+ s.networksMenuEnabled = desiredNetworksMenu
+ if desiredNetworksMenu {
+ s.mNetworks.Enable()
+ s.mExitNode.Enable()
+ } else {
+ s.mNetworks.Disable()
+ s.mExitNode.Disable()
+ }
}
}
@@ -1356,7 +1414,14 @@ func (s *serviceClient) getSrvConfig() {
if s.showAdvancedSettings {
s.iMngURL.SetText(s.managementURL)
- s.iPreSharedKey.SetText(cfg.PreSharedKey)
+ // PSK is rendered with an empty Text and a hint via the
+ // placeholder so the eye toggle never reveals literal asterisks
+ // (the daemon returns the "**********" sentinel — writing that
+ // into a PasswordEntry would surface the literal sentinel when
+ // the user unmasks the field). The placeholder communicates the
+ // configured / MDM-managed state without exposing any value.
+ s.iPreSharedKey.SetText("")
+ s.iPreSharedKey.SetPlaceHolder(preSharedKeyPlaceholder(srvCfg))
s.iInterfaceName.SetText(cfg.WgIface)
s.iInterfacePort.SetText(strconv.Itoa(cfg.WgPort))
if cfg.MTU != 0 {
@@ -1366,7 +1431,15 @@ func (s *serviceClient) getSrvConfig() {
s.iMTU.SetPlaceHolder(strconv.Itoa(int(iface.DefaultMTU)))
}
s.sRosenpassPermissive.SetChecked(cfg.RosenpassPermissive)
- if !cfg.RosenpassEnabled {
+ // Re-baseline the enabled state on every refresh: when Rosenpass
+ // is on the checkbox is editable, when it's off the field is
+ // inert. Without an explicit Enable() here the control stays
+ // stuck disabled after a previous refresh (or an MDM unlock) had
+ // turned it off — applyMDMLocksToSettingsForm below adds the
+ // MDM lock on top of this baseline.
+ if cfg.RosenpassEnabled {
+ s.sRosenpassPermissive.Enable()
+ } else {
s.sRosenpassPermissive.Disable()
}
s.sNetworkMonitor.SetChecked(*cfg.NetworkMonitor)
@@ -1395,6 +1468,13 @@ func (s *serviceClient) getSrvConfig() {
}
}
+ // MDM locks must run before the mNotifications-nil early return:
+ // the Settings window is rendered by a separate UI process launched
+ // with --settings (see handleAdvancedSettingsClick), and that child
+ // process does NOT run onReady — so its mNotifications is nil and
+ // the early return below skipped the lock pass entirely.
+ s.applyMDMLocks(srvCfg.MDMManagedFields)
+
if s.mNotifications == nil {
return
}
@@ -1438,7 +1518,7 @@ func protoConfigToConfig(cfg *proto.GetConfigResponse) *profilemanager.Config {
}
config.WgIface = cfg.InterfaceName
- if cfg.WireguardPort != 0 {
+ if cfg.WireguardPort >= 0 && cfg.WireguardPort <= 65535 {
config.WgPort = int(cfg.WireguardPort)
} else {
config.WgPort = iface.DefaultWgPort
@@ -1579,6 +1659,129 @@ func (s *serviceClient) loadSettings() {
if s.eventManager != nil {
s.eventManager.SetNotificationsEnabled(s.mNotifications.Checked())
}
+ s.applyMDMLocks(cfg.MDMManagedFields)
+}
+
+// applyMDMLocks disables and badges any tray submenu item or settings-
+// form widget whose underlying field is enforced by the active MDM
+// policy. Called from loadSettings (submenu refresh) and from
+// getSrvConfig (settings-window refresh). Locked items keep their value
+// already set by the surrounding refresh code — this routine only
+// flips the enabled state and the title suffix, never the value.
+func (s *serviceClient) applyMDMLocks(managed []string) {
+ set := make(map[string]bool, len(managed))
+ for _, k := range managed {
+ set[k] = true
+ }
+ s.mdmManagedFields = set
+ if len(managed) > 0 {
+ log.Infof("MDM-managed UI fields: %v", managed)
+ }
+
+ type submenuTarget struct {
+ item *systray.MenuItem
+ title string
+ key string
+ }
+ for _, t := range []submenuTarget{
+ {s.mAllowSSH, "Allow SSH", mdm.KeyAllowServerSSH},
+ {s.mAutoConnect, "Connect on Startup", mdm.KeyDisableAutoConnect},
+ {s.mEnableRosenpass, "Enable Quantum-Resistance", mdm.KeyRosenpassEnabled},
+ {s.mBlockInbound, "Block Inbound Connections", mdm.KeyBlockInbound},
+ } {
+ if t.item == nil {
+ continue
+ }
+ if set[t.key] {
+ t.item.SetTitle(t.title + " (MDM)")
+ t.item.Disable()
+ } else {
+ t.item.SetTitle(t.title)
+ t.item.Enable()
+ }
+ }
+
+ s.applyMDMLocksToSettingsForm(set)
+}
+
+// preSharedKeyPlaceholder returns the hint string shown in the PSK
+// Entry's placeholder slot. The placeholder is the only signal the
+// user gets that a PSK is configured, because the entry's Text is
+// forced to empty to keep the password reveal toggle from leaking
+// the daemon-returned "**********" redaction sentinel. Returns "" if
+// no PSK is present, "MDM-managed" if the key is enforced by MDM,
+// and "configured" otherwise.
+func preSharedKeyPlaceholder(cfg *proto.GetConfigResponse) string {
+ if cfg == nil || cfg.PreSharedKey == "" {
+ return ""
+ }
+ for _, k := range cfg.MDMManagedFields {
+ if k == mdm.KeyPreSharedKey {
+ return "MDM-managed"
+ }
+ }
+ return "configured"
+}
+
+// applyMDMLocksToSettingsForm disables the per-field input widgets in
+// the advanced Settings window when the corresponding MDM key is set.
+// For plain-text entries (Management URL, Interface Port) the visible
+// value is suffixed with " (MDM)" so the user sees the lock indicator
+// inline; for the password entry the suffix is skipped (a password
+// widget renders every char as a dot and the indicator would not be
+// readable). The widgets are created lazily by showSettingsUI, so
+// guard each ref against nil.
+func (s *serviceClient) applyMDMLocksToSettingsForm(set map[string]bool) {
+ type entryTarget struct {
+ entry *widget.Entry
+ key string
+ inlineTag bool
+ }
+ for _, t := range []entryTarget{
+ {s.iMngURL, mdm.KeyManagementURL, true},
+ {s.iPreSharedKey, mdm.KeyPreSharedKey, false},
+ {s.iInterfacePort, mdm.KeyWireguardPort, true},
+ } {
+ if t.entry == nil {
+ continue
+ }
+ if set[t.key] {
+ if t.inlineTag && t.entry.Text != "" && !strings.HasSuffix(t.entry.Text, mdmFieldSuffix) {
+ t.entry.SetText(t.entry.Text + mdmFieldSuffix)
+ }
+ t.entry.Disable()
+ } else {
+ if t.inlineTag {
+ t.entry.SetText(strings.TrimSuffix(t.entry.Text, mdmFieldSuffix))
+ }
+ t.entry.Enable()
+ }
+ }
+ type checkTarget struct {
+ check *widget.Check
+ key string
+ }
+ for _, t := range []checkTarget{
+ {s.sDisableClientRoutes, mdm.KeyDisableClientRoutes},
+ {s.sDisableServerRoutes, mdm.KeyDisableServerRoutes},
+ } {
+ if t.check == nil {
+ continue
+ }
+ if set[t.key] {
+ t.check.Disable()
+ } else {
+ t.check.Enable()
+ }
+ }
+ if s.sRosenpassPermissive != nil && set[mdm.KeyRosenpassPermissive] {
+ // MDM lock layered on top of the Rosenpass-on/off baseline
+ // applied by getSrvConfig. No Enable() branch here: when the
+ // MDM key is removed, the next getSrvConfig refresh re-baselines
+ // the control on cfg.RosenpassEnabled and brings it back if
+ // Rosenpass is on.
+ s.sRosenpassPermissive.Disable()
+ }
}
// updateConfig updates the configuration parameters
diff --git a/client/ui/debug.go b/client/ui/debug.go
index cf5ac1a75..d3d4fa4f8 100644
--- a/client/ui/debug.go
+++ b/client/ui/debug.go
@@ -21,6 +21,7 @@ import (
"github.com/netbirdio/netbird/client/internal"
"github.com/netbirdio/netbird/client/proto"
uptypes "github.com/netbirdio/netbird/upload-server/types"
+ "github.com/netbirdio/netbird/version"
)
// Initial state for the debug collection
@@ -462,6 +463,7 @@ func (s *serviceClient) createDebugBundleFromCollection(
request := &proto.DebugBundleRequest{
Anonymize: params.anonymize,
SystemInfo: params.systemInfo,
+ CliVersion: version.NetbirdVersion(),
}
if params.upload {
@@ -593,6 +595,7 @@ func (s *serviceClient) createDebugBundle(anonymize bool, systemInfo bool, uploa
request := &proto.DebugBundleRequest{
Anonymize: anonymize,
SystemInfo: systemInfo,
+ CliVersion: version.NetbirdVersion(),
}
if uploadURL != "" {
diff --git a/client/ui/profile.go b/client/ui/profile.go
index 7ee89e631..d3db17855 100644
--- a/client/ui/profile.go
+++ b/client/ui/profile.go
@@ -666,16 +666,48 @@ func (p *profileMenu) clear(profiles []Profile) {
}
}
-// setEnabled enables or disables the profile menu based on the provided state
+// setEnabled greys out (Disable) the profile menu and every existing
+// sub-item when the daemon reports the kill switch active, so the user
+// sees the menu but cannot enter "Manage Profiles" or switch profile.
+// Previously this used Hide() on the parent, but Fyne's systray on
+// Windows does not propagate Hide() to a parent that already has
+// children — the submenu kept popping up and accepting clicks. Disable
+// is the reliable visual lock.
func (p *profileMenu) setEnabled(enabled bool) {
- if p.profileMenuItem != nil {
- if enabled {
- p.profileMenuItem.Enable()
- p.profileMenuItem.SetTooltip("")
- } else {
- p.profileMenuItem.Hide()
- p.profileMenuItem.SetTooltip("Profiles are disabled by daemon")
+ if p.profileMenuItem == nil {
+ return
+ }
+ p.mu.Lock()
+ defer p.mu.Unlock()
+
+ if enabled {
+ p.profileMenuItem.Enable()
+ p.profileMenuItem.SetTooltip("")
+ } else {
+ p.profileMenuItem.Disable()
+ p.profileMenuItem.SetTooltip("Profiles are disabled by daemon")
+ }
+
+ apply := func(item *systray.MenuItem) {
+ if item == nil {
+ return
}
+ if enabled {
+ item.Enable()
+ } else {
+ item.Disable()
+ }
+ }
+ for _, sub := range p.profileSubItems {
+ if sub != nil {
+ apply(sub.MenuItem)
+ }
+ }
+ if p.manageProfilesSubItem != nil {
+ apply(p.manageProfilesSubItem.MenuItem)
+ }
+ if p.logoutSubItem != nil {
+ apply(p.logoutSubItem.MenuItem)
}
}
diff --git a/client/wasm/cmd/main.go b/client/wasm/cmd/main.go
index 066fe043b..4683f4033 100644
--- a/client/wasm/cmd/main.go
+++ b/client/wasm/cmd/main.go
@@ -21,6 +21,7 @@ import (
"github.com/netbirdio/netbird/client/wasm/internal/http"
"github.com/netbirdio/netbird/client/wasm/internal/rdp"
"github.com/netbirdio/netbird/client/wasm/internal/ssh"
+ nbwebsocket "github.com/netbirdio/netbird/client/wasm/internal/websocket"
"github.com/netbirdio/netbird/util"
)
@@ -30,6 +31,7 @@ const (
pingTimeout = 10 * time.Second
defaultLogLevel = "warn"
defaultSSHDetectionTimeout = 20 * time.Second
+ dialWebSocketTimeout = 30 * time.Second
icmpEchoRequest = 8
icmpCodeEcho = 0
@@ -677,6 +679,7 @@ func createClientObject(client *netbird.Client) js.Value {
obj["createSSHConnection"] = createSSHMethod(client)
obj["proxyRequest"] = createProxyRequestMethod(client)
obj["createRDPProxy"] = createRDPProxyMethod(client)
+ obj["dialWebSocket"] = createDialWebSocketMethod(client)
obj["status"] = createStatusMethod(client)
obj["statusSummary"] = createStatusSummaryMethod(client)
obj["statusDetail"] = createStatusDetailMethod(client)
@@ -691,6 +694,74 @@ func createClientObject(client *netbird.Client) js.Value {
return js.ValueOf(obj)
}
+func createDialWebSocketMethod(client *netbird.Client) js.Func {
+ return js.FuncOf(func(_ js.Value, args []js.Value) any {
+ url, protocols, timeout, errVal := parseDialWebSocketArgs(args)
+ if !errVal.IsUndefined() {
+ return errVal
+ }
+
+ return createPromise(func(resolve, reject js.Value) {
+ ctx, cancel := context.WithTimeout(context.Background(), timeout)
+ defer cancel()
+
+ conn, err := nbwebsocket.Dial(ctx, client, url, protocols)
+ if err != nil {
+ reject.Invoke(js.ValueOf(fmt.Sprintf("dial websocket: %v", err)))
+ return
+ }
+
+ resolve.Invoke(nbwebsocket.NewJSInterface(conn))
+ })
+ })
+}
+
+func parseDialWebSocketArgs(args []js.Value) (url string, protocols []string, timeout time.Duration, errVal js.Value) {
+ if len(args) < 1 || args[0].Type() != js.TypeString {
+ return "", nil, 0, js.ValueOf("error: dialWebSocket requires a URL string argument")
+ }
+ url = args[0].String()
+
+ if len(args) >= 2 && !args[1].IsNull() && !args[1].IsUndefined() {
+ arr, err := jsStringArray(args[1])
+ if err != nil {
+ return "", nil, 0, js.ValueOf(fmt.Sprintf("error: protocols: %v", err))
+ }
+ protocols = arr
+ }
+
+ timeout = dialWebSocketTimeout
+ if len(args) >= 3 && !args[2].IsNull() && !args[2].IsUndefined() {
+ if args[2].Type() != js.TypeNumber {
+ return "", nil, 0, js.ValueOf("error: timeoutMs must be a number")
+ }
+ timeoutMs := args[2].Int()
+ if timeoutMs <= 0 {
+ return "", nil, 0, js.ValueOf("error: timeout must be positive")
+ }
+ timeout = time.Duration(timeoutMs) * time.Millisecond
+ }
+
+ return url, protocols, timeout, js.Undefined()
+}
+
+// jsStringArray converts a JS array of strings to a Go []string.
+func jsStringArray(v js.Value) ([]string, error) {
+ if !v.InstanceOf(js.Global().Get("Array")) {
+ return nil, fmt.Errorf("expected array")
+ }
+ n := v.Length()
+ out := make([]string, n)
+ for i := 0; i < n; i++ {
+ el := v.Index(i)
+ if el.Type() != js.TypeString {
+ return nil, fmt.Errorf("element %d is not a string", i)
+ }
+ out[i] = el.String()
+ }
+ return out, nil
+}
+
// netBirdClientConstructor acts as a JavaScript constructor function
func netBirdClientConstructor(_ js.Value, args []js.Value) any {
return js.Global().Get("Promise").New(js.FuncOf(func(_ js.Value, promiseArgs []js.Value) any {
diff --git a/client/wasm/internal/websocket/websocket.go b/client/wasm/internal/websocket/websocket.go
new file mode 100644
index 000000000..19ddaa38c
--- /dev/null
+++ b/client/wasm/internal/websocket/websocket.go
@@ -0,0 +1,304 @@
+//go:build js
+
+package websocket
+
+import (
+ "context"
+ "encoding/binary"
+ "errors"
+ "fmt"
+ "io"
+ "net"
+ "sync"
+ "syscall/js"
+
+ "github.com/gobwas/ws"
+ "github.com/gobwas/ws/wsutil"
+ netbird "github.com/netbirdio/netbird/client/embed"
+ log "github.com/sirupsen/logrus"
+)
+
+type closeError struct {
+ code uint16
+ reason string
+}
+
+func (e *closeError) Error() string {
+ return fmt.Sprintf("websocket closed: %d %s", e.code, e.reason)
+}
+
+// bufferedConn fronts a net.Conn with a reader that serves any bytes buffered
+// during the WebSocket handshake before falling through to the raw conn.
+type bufferedConn struct {
+ net.Conn
+ r io.Reader
+}
+
+func (c *bufferedConn) Read(p []byte) (int, error) { return c.r.Read(p) }
+
+// Conn wraps a WebSocket connection over a NetBird TCP connection.
+type Conn struct {
+ conn net.Conn
+ mu sync.Mutex
+ closed chan struct{}
+ closeOnce sync.Once
+ closeErr error
+}
+
+// Dial establishes a WebSocket connection to the given URL through the NetBird network.
+// Optional protocols are sent via the Sec-WebSocket-Protocol header.
+func Dial(ctx context.Context, client *netbird.Client, rawURL string, protocols []string) (*Conn, error) {
+ d := ws.Dialer{
+ NetDial: client.Dial,
+ Protocols: protocols,
+ }
+
+ conn, br, _, err := d.Dial(ctx, rawURL)
+ if err != nil {
+ return nil, fmt.Errorf("websocket dial: %w", err)
+ }
+
+ // br is non-nil when the server pushed frames alongside the handshake
+ // response; those bytes live in the bufio.Reader and must be drained
+ // before reading from conn, otherwise we'd skip the first frames.
+ if br != nil {
+ if br.Buffered() > 0 {
+ conn = &bufferedConn{Conn: conn, r: io.MultiReader(br, conn)}
+ } else {
+ ws.PutReader(br)
+ }
+ }
+
+ return &Conn{
+ conn: conn,
+ closed: make(chan struct{}),
+ }, nil
+}
+
+// ReadMessage reads the next WebSocket message, handling control frames automatically.
+func (c *Conn) ReadMessage() (ws.OpCode, []byte, error) {
+ for {
+ msgs, err := wsutil.ReadServerMessage(c.conn, nil)
+ if err != nil {
+ return 0, nil, err
+ }
+
+ for _, msg := range msgs {
+ if msg.OpCode.IsControl() {
+ if err := c.handleControl(msg); err != nil {
+ return 0, nil, err
+ }
+ continue
+ }
+ return msg.OpCode, msg.Payload, nil
+ }
+ }
+}
+
+func (c *Conn) handleControl(msg wsutil.Message) error {
+ switch msg.OpCode {
+ case ws.OpPing:
+ c.mu.Lock()
+ defer c.mu.Unlock()
+ return wsutil.WriteClientMessage(c.conn, ws.OpPong, msg.Payload)
+ case ws.OpClose:
+ code, reason := parseClosePayload(msg.Payload)
+ return &closeError{code: code, reason: reason}
+ default:
+ return nil
+ }
+}
+
+// WriteText sends a text WebSocket message.
+func (c *Conn) WriteText(data []byte) error {
+ c.mu.Lock()
+ defer c.mu.Unlock()
+ return wsutil.WriteClientMessage(c.conn, ws.OpText, data)
+}
+
+// WriteBinary sends a binary WebSocket message.
+func (c *Conn) WriteBinary(data []byte) error {
+ c.mu.Lock()
+ defer c.mu.Unlock()
+ return wsutil.WriteClientMessage(c.conn, ws.OpBinary, data)
+}
+
+// Close sends a close frame with StatusNormalClosure and closes the underlying connection.
+func (c *Conn) Close() error {
+ return c.closeWith(ws.StatusNormalClosure, "")
+}
+
+// closeWith sends a close frame with the given code/reason and closes the underlying connection.
+// Used to echo the server's code when responding to a server-initiated close per RFC 6455 §5.5.1.
+func (c *Conn) closeWith(code ws.StatusCode, reason string) error {
+ var first bool
+ c.closeOnce.Do(func() {
+ first = true
+ close(c.closed)
+
+ c.mu.Lock()
+ _ = wsutil.WriteClientMessage(c.conn, ws.OpClose, ws.NewCloseFrameBody(code, reason))
+ c.mu.Unlock()
+
+ c.closeErr = c.conn.Close()
+ })
+
+ if !first {
+ return net.ErrClosed
+ }
+ return c.closeErr
+}
+
+// NewJSInterface creates a JavaScript object wrapping the WebSocket connection.
+// It exposes: send(string|Uint8Array), close(), and callback properties
+// onmessage, onclose, onerror.
+//
+// Callback properties may be set from the JS thread while the read loop
+// goroutine reads them. In WASM this is safe because Go and JS share a
+// single thread, but the design would need synchronization on
+// multi-threaded runtimes.
+func NewJSInterface(conn *Conn) js.Value {
+ obj := js.Global().Get("Object").Call("create", js.Null())
+
+ sendFunc := js.FuncOf(func(_ js.Value, args []js.Value) any {
+ if len(args) < 1 {
+ log.Errorf("websocket send requires a data argument")
+ return js.ValueOf(false)
+ }
+
+ data := args[0]
+ switch data.Type() {
+ case js.TypeString:
+ if err := conn.WriteText([]byte(data.String())); err != nil {
+ log.Errorf("failed to send websocket text: %v", err)
+ return js.ValueOf(false)
+ }
+ default:
+ buf, err := jsToBytes(data)
+ if err != nil {
+ log.Errorf("failed to convert js value to bytes: %v", err)
+ return js.ValueOf(false)
+ }
+ if err := conn.WriteBinary(buf); err != nil {
+ log.Errorf("failed to send websocket binary: %v", err)
+ return js.ValueOf(false)
+ }
+ }
+ return js.ValueOf(true)
+ })
+ obj.Set("send", sendFunc)
+
+ closeFunc := js.FuncOf(func(_ js.Value, _ []js.Value) any {
+ if err := conn.Close(); err != nil {
+ log.Debugf("failed to close websocket: %v", err)
+ }
+ return js.Undefined()
+ })
+ obj.Set("close", closeFunc)
+
+ go func() {
+ defer func() {
+ if err := conn.Close(); err != nil && !errors.Is(err, net.ErrClosed) {
+ log.Debugf("close websocket on readLoop exit: %v", err)
+ }
+ }()
+ readLoop(conn, obj)
+ // Undefining before Release turns post-close JS calls into TypeError
+ // instead of a silent "call to released function".
+ obj.Set("send", js.Undefined())
+ obj.Set("close", js.Undefined())
+ sendFunc.Release()
+ closeFunc.Release()
+ }()
+
+ return obj
+}
+
+func jsToBytes(data js.Value) ([]byte, error) {
+ var uint8Array js.Value
+ switch {
+ case data.InstanceOf(js.Global().Get("Uint8Array")):
+ uint8Array = data
+ case data.InstanceOf(js.Global().Get("ArrayBuffer")):
+ uint8Array = js.Global().Get("Uint8Array").New(data)
+ default:
+ return nil, fmt.Errorf("send: unsupported data type, use string, Uint8Array, or ArrayBuffer")
+ }
+
+ buf := make([]byte, uint8Array.Get("length").Int())
+ js.CopyBytesToGo(buf, uint8Array)
+ return buf, nil
+}
+
+func readLoop(conn *Conn, obj js.Value) {
+ var ce *closeError
+ defer func() { invokeOnClose(obj, ce) }()
+
+ for {
+ select {
+ case <-conn.closed:
+ return
+ default:
+ }
+
+ op, payload, err := conn.ReadMessage()
+ if err != nil {
+ ce = handleReadError(conn, obj, err)
+ return
+ }
+
+ dispatchMessage(obj, op, payload)
+ }
+}
+
+func handleReadError(conn *Conn, obj js.Value, err error) *closeError {
+ var ce *closeError
+ if errors.As(err, &ce) {
+ if cerr := conn.closeWith(ws.StatusCode(ce.code), ce.reason); cerr != nil {
+ log.Debugf("failed to close websocket after server close frame: %v", cerr)
+ }
+ return ce
+ }
+ if errors.Is(err, io.EOF) || errors.Is(err, net.ErrClosed) {
+ return nil
+ }
+ if onerror := obj.Get("onerror"); onerror.Truthy() {
+ onerror.Invoke(js.ValueOf(err.Error()))
+ }
+ return nil
+}
+
+func invokeOnClose(obj js.Value, ce *closeError) {
+ onclose := obj.Get("onclose")
+ if !onclose.Truthy() {
+ return
+ }
+ if ce != nil {
+ onclose.Invoke(js.ValueOf(int(ce.code)), js.ValueOf(ce.reason))
+ return
+ }
+ onclose.Invoke()
+}
+
+func dispatchMessage(obj js.Value, op ws.OpCode, payload []byte) {
+ onmessage := obj.Get("onmessage")
+ if !onmessage.Truthy() {
+ return
+ }
+ switch op {
+ case ws.OpText:
+ onmessage.Invoke(js.ValueOf(string(payload)))
+ case ws.OpBinary:
+ uint8Array := js.Global().Get("Uint8Array").New(len(payload))
+ js.CopyBytesToJS(uint8Array, payload)
+ onmessage.Invoke(uint8Array)
+ }
+}
+
+func parseClosePayload(payload []byte) (uint16, string) {
+ if len(payload) < 2 {
+ return 1005, "" // RFC 6455: No Status Rcvd
+ }
+ code := binary.BigEndian.Uint16(payload[:2])
+ return code, string(payload[2:])
+}
diff --git a/combined/cmd/root.go b/combined/cmd/root.go
index 78290388b..31e0580fb 100644
--- a/combined/cmd/root.go
+++ b/combined/cmd/root.go
@@ -67,6 +67,10 @@ func init() {
rootCmd.AddCommand(newTokenCommands())
}
+func RootCmd() *cobra.Command {
+ return rootCmd
+}
+
func Execute() error {
return rootCmd.Execute()
}
@@ -168,7 +172,7 @@ func initializeConfig() error {
// serverInstances holds all server instances created during startup.
type serverInstances struct {
relaySrv *relayServer.Server
- mgmtSrv *mgmtServer.BaseServer
+ mgmtSrv mgmtServer.Server
signalSrv *signalServer.Server
healthcheck *healthcheck.Server
stunServer *stun.Server
@@ -324,19 +328,24 @@ func setupServerHooks(servers *serverInstances, cfg *CombinedConfig) {
return
}
- servers.mgmtSrv.AfterInit(func(s *mgmtServer.BaseServer) {
- grpcSrv := s.GRPCServer()
+ if s, ok := servers.mgmtSrv.GetContainer(mgmtServer.ContainerKeyBaseServer); ok {
+ if baseServer, ok := s.(*mgmtServer.BaseServer); ok {
+ baseServer.AfterInit(func(s *mgmtServer.BaseServer) {
+ grpcSrv := s.GRPCServer()
- if servers.signalSrv != nil {
- proto.RegisterSignalExchangeServer(grpcSrv, servers.signalSrv)
- log.Infof("Signal server registered on port %s", cfg.Server.ListenAddress)
- }
+ if servers.signalSrv != nil {
+ proto.RegisterSignalExchangeServer(grpcSrv, servers.signalSrv)
+ log.Infof("Signal server registered on port %s", cfg.Server.ListenAddress)
+ }
- s.SetHandlerFunc(createCombinedHandler(grpcSrv, s.APIHandler(), s.IDPHandler(), servers.relaySrv, servers.metricsServer.Meter, cfg))
- if servers.relaySrv != nil {
- log.Infof("Relay WebSocket handler added (path: /relay)")
+ s.SetHandlerFunc(createCombinedHandler(grpcSrv, s.APIHandler(), s.IDPHandler(), servers.relaySrv, servers.metricsServer.Meter, cfg))
+ if servers.relaySrv != nil {
+ log.Infof("Relay WebSocket handler added (path: /relay)")
+ }
+ })
}
- })
+ }
+
}
func startServers(wg *sync.WaitGroup, srv *relayServer.Server, httpHealthcheck *healthcheck.Server, stunServer *stun.Server, metricsServer *sharedMetrics.Metrics) {
@@ -346,38 +355,32 @@ func startServers(wg *sync.WaitGroup, srv *relayServer.Server, httpHealthcheck *
log.Infof("Relay WebSocket multiplexed on management port (no separate relay listener)")
}
- wg.Add(1)
- go func() {
- defer wg.Done()
+ wg.Go(func() {
log.Infof("running metrics server: %s%s", metricsServer.Addr, metricsServer.Endpoint)
if err := metricsServer.ListenAndServe(); !errors.Is(err, http.ErrServerClosed) {
log.Fatalf("failed to start metrics server: %v", err)
}
- }()
+ })
- wg.Add(1)
- go func() {
- defer wg.Done()
+ wg.Go(func() {
if err := httpHealthcheck.ListenAndServe(); !errors.Is(err, http.ErrServerClosed) {
log.Fatalf("failed to start healthcheck server: %v", err)
}
- }()
+ })
if stunServer != nil {
- wg.Add(1)
- go func() {
- defer wg.Done()
+ wg.Go(func() {
if err := stunServer.Listen(); err != nil {
if errors.Is(err, stun.ErrServerClosed) {
return
}
log.Errorf("STUN server error: %v", err)
}
- }()
+ })
}
}
-func shutdownServers(ctx context.Context, srv *relayServer.Server, httpHealthcheck *healthcheck.Server, stunServer *stun.Server, mgmtSrv *mgmtServer.BaseServer, metricsServer *sharedMetrics.Metrics) error {
+func shutdownServers(ctx context.Context, srv *relayServer.Server, httpHealthcheck *healthcheck.Server, stunServer *stun.Server, mgmtSrv mgmtServer.Server, metricsServer *sharedMetrics.Metrics) error {
var errs error
if err := httpHealthcheck.Shutdown(ctx); err != nil {
@@ -491,7 +494,7 @@ func handleTLSConfig(cfg *CombinedConfig) (*tls.Config, bool, error) {
return nil, false, nil
}
-func createManagementServer(cfg *CombinedConfig, mgmtConfig *nbconfig.Config) (*mgmtServer.BaseServer, error) {
+func createManagementServer(cfg *CombinedConfig, mgmtConfig *nbconfig.Config) (mgmtServer.Server, error) {
mgmt := cfg.Management
// Extract port from listen address
@@ -502,7 +505,7 @@ func createManagementServer(cfg *CombinedConfig, mgmtConfig *nbconfig.Config) (*
}
mgmtPort, _ := strconv.Atoi(portStr)
- mgmtSrv := mgmtServer.NewServer(
+ mgmtSrv := newServer(
&mgmtServer.Config{
NbConfig: mgmtConfig,
DNSDomain: "",
diff --git a/combined/cmd/server.go b/combined/cmd/server.go
new file mode 100644
index 000000000..f9384dfb1
--- /dev/null
+++ b/combined/cmd/server.go
@@ -0,0 +1,13 @@
+package cmd
+
+import (
+ mgmtServer "github.com/netbirdio/netbird/management/internals/server"
+)
+
+var newServer = func(cfg *mgmtServer.Config) mgmtServer.Server {
+ return mgmtServer.NewServer(cfg)
+}
+
+func SetNewServer(fn func(*mgmtServer.Config) mgmtServer.Server) {
+ newServer = fn
+}
diff --git a/docs/io.netbird.client.plist b/docs/io.netbird.client.plist
new file mode 100644
index 000000000..f42b6b3d2
--- /dev/null
+++ b/docs/io.netbird.client.plist
@@ -0,0 +1,126 @@
+
+
+
+
+
+
+
+ managementURL
+ https://api.netbird.io:443
+
+
+
+
+
+
+ allowServerSSH
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/docs/netbird-macos.mobileconfig b/docs/netbird-macos.mobileconfig
new file mode 100644
index 000000000..53453db5c
--- /dev/null
+++ b/docs/netbird-macos.mobileconfig
@@ -0,0 +1,159 @@
+
+
+
+
+
+
+ PayloadType
+ Configuration
+ PayloadVersion
+ 1
+ PayloadIdentifier
+ io.netbird.client.mdm
+ PayloadUUID
+ 11111111-1111-1111-1111-111111111111
+ PayloadDisplayName
+ NetBird MDM Policy
+ PayloadDescription
+ Enforces NetBird client configuration. Values written here override any local user / CLI / on-disk setting and are re-applied at every daemon boot and on every 1-minute MDM reload tick.
+ PayloadOrganization
+ NetBird
+ PayloadScope
+ System
+ PayloadRemovalDisallowed
+
+
+ PayloadContent
+
+
+
+ PayloadType
+ com.apple.ManagedClient.preferences
+ PayloadVersion
+ 1
+ PayloadIdentifier
+ io.netbird.client.mdm.preferences
+ PayloadUUID
+ 22222222-2222-2222-2222-222222222222
+ PayloadDisplayName
+ NetBird Managed Preferences
+ PayloadEnabled
+
+
+ PayloadContent
+
+ io.netbird.client
+
+ Forced
+
+
+ mcx_preference_settings
+
+
+
+ managementURL
+ https://api.netbird.io:443
+
+
+
+
+
+
+ allowServerSSH
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/docs/netbird-macos.sh b/docs/netbird-macos.sh
new file mode 100644
index 000000000..a2f5ff5e8
--- /dev/null
+++ b/docs/netbird-macos.sh
@@ -0,0 +1,189 @@
+#!/bin/bash
+#
+# SYNOPSIS
+# Push the NetBird MDM policy to a macOS device via JumpCloud Commands.
+#
+# DESCRIPTION
+# This is the macOS counterpart of docs/netbird-policy.reg.ps1.
+# It writes the values declared in the "POLICY VALUES" block below to
+# the managed-preferences plist that the NetBird daemon's
+# client/mdm/policy_darwin.go loader reads on every 1-minute MDM
+# reload tick:
+#
+# /Library/Managed Preferences/io.netbird.client.plist
+#
+# Once the plist lands, the daemon picks up the new values without
+# restart (the ticker calls Config.apply() → applyMDMPolicy() and
+# restarts the engine on diff).
+#
+# DEPLOYMENT (JumpCloud)
+# 1. Admin Console -> Device Management -> Commands -> +.
+# 2. Type: Mac, Shell, Run as: root.
+# 3. Paste this file verbatim into the command body.
+# 4. Bind to the target system group, save, run.
+#
+# IMPORTANT: PERSISTENCE
+# macOS wipes /Library/Managed Preferences/ at every boot on devices
+# that are NOT MDM-enrolled. For a persistent fleet rollout, push the
+# companion docs/netbird-macos.mobileconfig as a Custom Configuration
+# Profile (Admin Console -> MDM -> Mac Custom Configuration Profiles)
+# instead of this script. Use this script when:
+# - the device is MDM-enrolled (file survives reboots), or
+# - you need a one-shot test push before reboot, or
+# - you orchestrate via JumpCloud Commands and want the same
+# variable-driven workflow as the Windows .ps1 sibling.
+#
+# IDEMPOTENCY: re-running with the same values is a no-op from the
+# daemon's point of view (the 1-minute reload ticker diff returns empty).
+#
+# SECURITY: PreSharedKey is redacted in this script's log output.
+
+set -euo pipefail
+
+### POLICY VALUES — EDIT THIS BLOCK ###########################################
+#
+# Set each variable below to the desired value. Set to empty string ""
+# or to NULL to omit a key entirely (the daemon treats an absent key
+# as "no enforcement" for that field). Booleans use "true"/"false"
+# (lowercase). Integers as decimal.
+#
+# Reference for key names + accepted values:
+# client/mdm/policy.go (Key* constants)
+# docs/netbird-macos.mobileconfig (sample profile)
+# docs/netbird.admx + .adml (Windows ADMX schema)
+#
+NULL='__UNSET__'
+managementURL='https://api.netbird.io:443'
+preSharedKey="$NULL" # secret; redacted in log
+allowServerSSH='true'
+blockInbound="$NULL"
+disableAutoConnect="$NULL"
+disableClientRoutes="$NULL"
+disableServerRoutes="$NULL"
+disableMetricsCollection="$NULL"
+disableUpdateSettings="$NULL"
+disableProfiles="$NULL"
+disableNetworks="$NULL"
+rosenpassEnabled="$NULL"
+rosenpassPermissive="$NULL"
+wireguardPort='51820'
+splitTunnelMode="$NULL" # "allow" or "disallow", Android-only at the daemon level
+splitTunnelApps="$NULL" # comma-separated app IDs, Android-only
+##############################################################################
+
+readonly PLIST_DIR='/Library/Managed Preferences'
+readonly PLIST_PATH="$PLIST_DIR/io.netbird.client.plist"
+readonly LOG_TAG='netbird-mdm'
+
+# log sends a message to the system logger using the configured tag and echoes the message to stdout prefixed by an ISO 8601 UTC timestamp and the tag.
+log() {
+ /usr/bin/logger -t "$LOG_TAG" "$*"
+ printf '%s [%s] %s\n' "$(date -u '+%Y-%m-%dT%H:%M:%SZ')" "$LOG_TAG" "$*"
+}
+
+# is_set returns success if the provided value is non-empty and is not equal to the special NULL marker.
+is_set() {
+ local value="$1"
+ [[ -n "$value" && "$value" != "$NULL" ]]
+}
+
+# start_plist creates the temporary plist file at "$PLIST_PATH.tmp" containing the XML plist header and opening `` for the policy plist.
+start_plist() {
+ cat > "$PLIST_PATH.tmp" <<'EOF'
+
+
+
+
+EOF
+}
+
+# end_plist appends the closing `` and `` tags to the temporary plist file.
+end_plist() {
+ cat >> "$PLIST_PATH.tmp" <<'EOF'
+
+
+EOF
+}
+
+# emit_string appends a plist ``/`` entry for the given key and value to "$PLIST_PATH.tmp", XML-escaping `&`, `<`, and `>`, and logs the assignment (masking the logged value as `********** (secret)` when the key is `preSharedKey`).
+emit_string() {
+ local key="$1" value="$2" log_value="$2"
+ # Escape XML entities in the value
+ local escaped
+ escaped="$(printf '%s' "$value" | sed -e 's/&/\&/g' -e 's/\</g' -e 's/>/\>/g')"
+ printf ' %s\n %s\n' "$key" "$escaped" >> "$PLIST_PATH.tmp"
+ if [[ "$key" == "preSharedKey" ]]; then
+ log_value='********** (secret)'
+ fi
+ log "set $key = $log_value"
+}
+
+# emit_bool writes a boolean plist entry for a given key into the temporary plist file.
+# emit_bool writes a boolean plist entry for a key when the provided value matches an accepted boolean token; logs an error and skips the key on invalid input.
+emit_bool() {
+ local key="$1" value="$2"
+ local xml_bool
+ case "$value" in
+ true|True|TRUE|1|yes) xml_bool='' ; value='true' ;;
+ false|False|FALSE|0|no) xml_bool='' ; value='false' ;;
+ *) log "invalid boolean for $key: $value (must be true/false); skipping"; return ;;
+ esac
+ printf ' %s\n %s\n' "$key" "$xml_bool" >> "$PLIST_PATH.tmp"
+ log "set $key = $value"
+}
+
+# emit_int validates that VALUE contains only decimal digits and, if valid, appends an `` plist entry for KEY to the temporary plist (`$PLIST_PATH.tmp`) and logs the assignment; on invalid input it logs a skip and does not emit the key.
+emit_int() {
+ local key="$1" value="$2"
+ if ! [[ "$value" =~ ^[0-9]+$ ]]; then
+ log "invalid integer for $key: $value (must be decimal); skipping"
+ return
+ fi
+ printf ' %s\n %s\n' "$key" "$value" >> "$PLIST_PATH.tmp"
+ log "set $key = $value"
+}
+
+# main builds the NetBird MDM plist from configured policy variables, validates and installs it to /Library/Managed Preferences/io.netbird.client.plist (root:wheel, 644) and optionally triggers the NetBird daemon to reload.
+main() {
+ log "applying NetBird MDM policy to $PLIST_PATH"
+ /bin/mkdir -p "$PLIST_DIR"
+ start_plist
+
+ is_set "$managementURL" && emit_string managementURL "$managementURL"
+ is_set "$preSharedKey" && emit_string preSharedKey "$preSharedKey"
+ is_set "$allowServerSSH" && emit_bool allowServerSSH "$allowServerSSH"
+ is_set "$blockInbound" && emit_bool blockInbound "$blockInbound"
+ is_set "$disableAutoConnect" && emit_bool disableAutoConnect "$disableAutoConnect"
+ is_set "$disableClientRoutes" && emit_bool disableClientRoutes "$disableClientRoutes"
+ is_set "$disableServerRoutes" && emit_bool disableServerRoutes "$disableServerRoutes"
+ is_set "$disableMetricsCollection" && emit_bool disableMetricsCollection "$disableMetricsCollection"
+ is_set "$disableUpdateSettings" && emit_bool disableUpdateSettings "$disableUpdateSettings"
+ is_set "$disableProfiles" && emit_bool disableProfiles "$disableProfiles"
+ is_set "$disableNetworks" && emit_bool disableNetworks "$disableNetworks"
+ is_set "$rosenpassEnabled" && emit_bool rosenpassEnabled "$rosenpassEnabled"
+ is_set "$rosenpassPermissive" && emit_bool rosenpassPermissive "$rosenpassPermissive"
+ is_set "$wireguardPort" && emit_int wireguardPort "$wireguardPort"
+ is_set "$splitTunnelMode" && emit_string splitTunnelMode "$splitTunnelMode"
+ is_set "$splitTunnelApps" && emit_string splitTunnelApps "$splitTunnelApps"
+
+ end_plist
+
+ if ! /usr/bin/plutil -lint "$PLIST_PATH.tmp" >/dev/null 2>&1; then
+ log "ERROR: generated plist failed plutil lint; not installing"
+ /usr/bin/plutil -lint "$PLIST_PATH.tmp" >&2 || true
+ /bin/rm -f "$PLIST_PATH.tmp"
+ exit 1
+ fi
+
+ /bin/mv -f "$PLIST_PATH.tmp" "$PLIST_PATH"
+ /usr/sbin/chown root:wheel "$PLIST_PATH"
+ /bin/chmod 644 "$PLIST_PATH"
+
+ log "policy installed; NetBird daemon will pick it up within the next 1-minute reload tick"
+
+ # Optional: kick the daemon for an immediate apply. Safe — does
+ # nothing on a host where NetBird is not yet installed.
+ /bin/launchctl kickstart -k system/io.netbird.client 2>/dev/null || true
+}
+
+main "$@"
diff --git a/docs/netbird-policy.reg b/docs/netbird-policy.reg
new file mode 100644
index 000000000..ba4402e50
Binary files /dev/null and b/docs/netbird-policy.reg differ
diff --git a/docs/netbird-policy.reg.ps1 b/docs/netbird-policy.reg.ps1
new file mode 100644
index 000000000..011d706dc
--- /dev/null
+++ b/docs/netbird-policy.reg.ps1
@@ -0,0 +1,94 @@
+#requires -Version 5.1
+<#
+.SYNOPSIS
+ Push the NetBird MDM policy to a Windows device via JumpCloud Commands
+ by importing a sidecar netbird-policy.reg file.
+
+.DESCRIPTION
+ Windows counterpart of docs/netbird-macos.sh. Outcome:
+ HKLM\Software\Policies\NetBird populated from the attached
+ netbird-policy.reg file, daemon picks up the change via the
+ 1-minute MDM reload ticker.
+
+ Deployment:
+ 1. Admin Console -> Device Management -> Commands -> +.
+ 2. Type: Windows PowerShell. Run as: SYSTEM.
+ 3. Paste this file verbatim into the command body.
+ 4. In the same command, attach `netbird-policy.reg` as a file.
+ JumpCloud copies attached files into the command's working
+ directory before invoking the script, so `$PSScriptRoot` or
+ Get-Location resolves to where the .reg lives.
+ 5. Bind to the target system group, save, run.
+
+ Producing the .reg file:
+ On a reference machine, after configuring the policy values either
+ via gpedit (GPO) or manual `reg add`, export with:
+
+ reg export "HKLM\Software\Policies\NetBird" netbird-policy.reg /y
+
+ Then attach the resulting file to the JumpCloud command.
+
+ Semantics:
+ - The script nukes the existing HKLM\Software\Policies\NetBird key
+ before importing the .reg, so the .reg is the SINGLE SOURCE OF
+ TRUTH. Any value present in the registry but absent from the .reg
+ is removed. This is what an MDM admin almost always wants.
+ - Setting the .reg to an empty (header-only) file effectively unsets
+ the policy.
+
+ Idempotency: re-running the script with the same .reg is a no-op from
+ the daemon's perspective (values identical → 1-min ticker sees no
+ diff → engine not restarted).
+
+ Exit codes: 0 = success; 1 = .reg missing or reg.exe error.
+#>
+
+$ErrorActionPreference = "Stop"
+
+$RegFileName = "netbird-policy.reg"
+$RegKey = "HKLM\Software\Policies\NetBird"
+
+# Resolve the attached .reg file: JumpCloud copies command attachments
+# into C:\Windows\Temp\ before invoking the script. Cwd / $PSScriptRoot
+# fallbacks cover the local-dev case where you might dot-source this
+# from elsewhere.
+$candidates = @(
+ (Join-Path "$env:WINDIR\Temp" $RegFileName)
+ (Join-Path (Get-Location) $RegFileName)
+ (Join-Path $PSScriptRoot $RegFileName)
+) | Where-Object { Test-Path $_ }
+
+if ($candidates.Count -eq 0) {
+ Write-Error "[netbird-mdm] $RegFileName not found in working directory or `$PSScriptRoot. Attach the file to the JumpCloud command."
+ exit 1
+}
+$regFile = $candidates[0]
+Write-Host "[netbird-mdm] using $regFile"
+
+# Wipe the existing policy key so the .reg is authoritative.
+$existed = Test-Path "Registry::HKEY_LOCAL_MACHINE\Software\Policies\NetBird"
+if ($existed) {
+ & reg.exe delete $RegKey /f | Out-Null
+ if ($LASTEXITCODE -ne 0) {
+ Write-Error "[netbird-mdm] failed to clear $RegKey before import (exit $LASTEXITCODE)"
+ exit 1
+ }
+ Write-Host "[netbird-mdm] cleared previous values under $RegKey"
+}
+
+# Import. reg.exe writes both data and (re-)creates the key if needed.
+& reg.exe import $regFile
+if ($LASTEXITCODE -ne 0) {
+ Write-Error "[netbird-mdm] reg import failed (exit $LASTEXITCODE)"
+ exit 1
+}
+
+# Audit dump so the JumpCloud per-execution log captures the applied state.
+Write-Host "[netbird-mdm] final policy state under $RegKey :"
+& reg.exe query $RegKey /s
+
+# Daemon's 1-min reload ticker picks up the change automatically.
+# Uncomment to force immediate convergence (skips the ticker wait):
+# Restart-Service netbird -Force -ErrorAction SilentlyContinue
+
+exit 0
diff --git a/docs/netbird.adml b/docs/netbird.adml
new file mode 100644
index 000000000..d49b05022
--- /dev/null
+++ b/docs/netbird.adml
@@ -0,0 +1,95 @@
+
+
+ NetBird Client Policies
+ Group Policy template for NetBird client MDM-managed settings. Values are written under HKLM\Software\Policies\NetBird and consumed by the netbird daemon at startup and every 1-minute reload tick.
+
+
+
+
+ NetBird
+ NetBird Client 0.40+
+
+
+ Management URL
+ URL of the NetBird management server. Format: https://host[:port]. When set, users cannot override this value via UI or CLI.
+
+ Pre-shared key
+ WireGuard pre-shared key used as an additional symmetric secret on every peer-to-peer tunnel. Secret value.
+
+
+ Disable auto-connect
+ When enabled, the NetBird tunnel does not auto-connect at daemon startup. Equivalent to --disable-auto-connect.
+
+ Disable client routes
+ When enabled, this client will not consume routes advertised by routing peers. Equivalent to --disable-client-routes.
+
+ Disable server routes
+ When enabled, this client will not act as a routing peer for other clients. Equivalent to --disable-server-routes.
+
+ Block inbound
+ When enabled, the client firewall blocks all inbound peer traffic on the WireGuard interface. Equivalent to --block-inbound.
+
+
+
+
+ Enable Rosenpass
+ Enables Rosenpass post-quantum key exchange on WireGuard tunnels. Both peers must support it.
+
+ Rosenpass permissive
+ When enabled, the client falls back to plain WireGuard if a peer does not support Rosenpass; otherwise it refuses the connection.
+
+ WireGuard port
+ UDP port used by the local WireGuard interface. Allowed range: 1-65535.
+
+ Split tunnel
+ Restrict the NetBird tunnel to or from a chosen list of application package names. Choose either the allow mode (only the listed apps route through NetBird) or the disallow mode (the listed apps bypass NetBird; everything else routes through). The mode is mutually exclusive — only one can be active at a time. Android-only at the daemon level; Windows/macOS/iOS clients ignore this policy.
+ Allow only listed apps (everything else bypasses)
+ Disallow listed apps (everything else routes)
+
+
+ Disable update settings
+ When enabled, blocks every configuration change from the client UI and from the CLI (netbird up / login / setconfig). The Settings view stays viewable but read-only. Equivalent to --disable-update-settings.
+
+ Disable profiles
+ When enabled, the client UI/CLI cannot list, create, switch or remove NetBird connection profiles. Equivalent to --disable-profiles.
+
+ Disable networks
+ When enabled, the client UI/CLI cannot list, select or deselect NetBird networks (the corresponding daemon RPCs return Unavailable). Equivalent to --disable-networks.
+
+ Disable metrics collection
+ When enabled, the client does not collect or report local usage metrics.
+
+
+
+
+
+
+
+ https://api.netbird.io:443
+
+
+
+
+
+
+
+
+
+
+ WireGuard UDP port:
+
+
+
+ Mode:
+
+
+
+
+
+
+
+
diff --git a/docs/netbird.admx b/docs/netbird.admx
new file mode 100644
index 000000000..2f7645d63
--- /dev/null
+++ b/docs/netbird.admx
@@ -0,0 +1,223 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ - allow
+ - disallow
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/formatter/hook/hook.go b/formatter/hook/hook.go
index f0ee509f8..69758566d 100644
--- a/formatter/hook/hook.go
+++ b/formatter/hook/hook.go
@@ -99,6 +99,9 @@ func addFields(entry *logrus.Entry) {
if ctxAccountID, ok := entry.Context.Value(context.AccountIDKey).(string); ok {
entry.Data[context.AccountIDKey] = ctxAccountID
}
+ if ctxUserAgent, ok := entry.Context.Value(context.UserAgentKey).(string); ok {
+ entry.Data[context.UserAgentKey] = ctxUserAgent
+ }
if ctxInitiatorID, ok := entry.Context.Value(context.UserIDKey).(string); ok {
entry.Data[context.UserIDKey] = ctxInitiatorID
}
diff --git a/go.mod b/go.mod
index caf9cb689..2858d2044 100644
--- a/go.mod
+++ b/go.mod
@@ -2,6 +2,8 @@ module github.com/netbirdio/netbird
go 1.25.5
+toolchain go1.25.11
+
require (
cunicu.li/go-rosenpass v0.5.42
github.com/cenkalti/backoff/v4 v4.3.0
@@ -24,13 +26,13 @@ require (
golang.zx2c4.com/wireguard/windows v0.5.3
google.golang.org/grpc v1.80.0
google.golang.org/protobuf v1.36.11
- gopkg.in/natefinch/lumberjack.v2 v2.2.1
)
require (
fyne.io/fyne/v2 v2.7.0
fyne.io/systray v1.12.1-0.20260116214250-81f8e1a496f9
git.sr.ht/~jackmordaunt/go-toast/v2 v2.0.3
+ github.com/DeRuina/timberjack v1.4.2
github.com/awnumar/memguard v0.23.0
github.com/aws/aws-sdk-go-v2 v1.38.3
github.com/aws/aws-sdk-go-v2/config v1.31.6
@@ -54,6 +56,8 @@ require (
github.com/fsnotify/fsnotify v1.9.0
github.com/gliderlabs/ssh v0.3.8
github.com/go-jose/go-jose/v4 v4.1.4
+ github.com/gobwas/ws v1.4.0
+ github.com/goccy/go-yaml v1.18.0
github.com/godbus/dbus/v5 v5.1.0
github.com/golang-jwt/jwt/v5 v5.3.1
github.com/golang/mock v1.6.0
@@ -131,6 +135,7 @@ require (
gorm.io/driver/sqlite v1.5.7
gorm.io/gorm v1.25.12
gvisor.dev/gvisor v0.0.0-20260219192049-0f2374377e89
+ howett.net/plist v1.0.1
)
require (
@@ -211,10 +216,11 @@ require (
github.com/go-viper/mapstructure/v2 v2.5.0 // indirect
github.com/go-webauthn/webauthn v0.16.4 // indirect
github.com/go-webauthn/x v0.2.3 // indirect
- github.com/goccy/go-yaml v1.18.0 // indirect
+ github.com/gobwas/httphead v0.1.0 // indirect
+ github.com/gobwas/pool v0.2.1 // indirect
github.com/gogo/protobuf v1.3.2 // indirect
github.com/golang-jwt/jwt/v4 v4.5.2 // indirect
- github.com/google/btree v1.1.2 // indirect
+ github.com/google/btree v1.1.3 // indirect
github.com/google/go-querystring v1.1.0 // indirect
github.com/google/go-tpm v0.9.8 // indirect
github.com/google/s2a-go v0.1.9 // indirect
diff --git a/go.sum b/go.sum
index 7f0081425..1768ee069 100644
--- a/go.sum
+++ b/go.sum
@@ -29,6 +29,8 @@ github.com/Azure/go-ntlmssp v0.1.0 h1:DjFo6YtWzNqNvQdrwEyr/e4nhU3vRiwenz5QX7sFz+
github.com/Azure/go-ntlmssp v0.1.0/go.mod h1:NYqdhxd/8aAct/s4qSYZEerdPuH1liG2/X9DiVTbhpk=
github.com/BurntSushi/toml v1.5.0 h1:W5quZX/G/csjUnuI8SUYlsHs9M38FC7znL0lIO+DvMg=
github.com/BurntSushi/toml v1.5.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
+github.com/DeRuina/timberjack v1.4.2 h1:4bKlzhKdsR+2oNkgef9mqb4n11ICow8VK88RfzJPzN8=
+github.com/DeRuina/timberjack v1.4.2/go.mod h1:RLoeQrwrCGIEF8gO5nV5b/gMD0QIy7bzQhBUgpp1EqE=
github.com/Masterminds/goutils v1.1.1 h1:5nUrii3FMTL5diU80unEVvNevw1nH4+ZV4DSLVJLSYI=
github.com/Masterminds/goutils v1.1.1/go.mod h1:8cTjp+g8YejhMuvIA5y2vz3BpJxksy863GQaJW2MFNU=
github.com/Masterminds/semver/v3 v3.3.0 h1:B8LGeaivUe71a5qox1ICM/JLl0NqZSW5CHyL+hmvYS0=
@@ -247,6 +249,12 @@ github.com/go-webauthn/webauthn v0.16.4 h1:R9jqR/cYZa7hRquFF7Za/8qoH/K/TIs1/Q/4C
github.com/go-webauthn/webauthn v0.16.4/go.mod h1:SU2ljAgToTV/YLPI0C05QS4qn+e04WpB5g1RMfcZfS4=
github.com/go-webauthn/x v0.2.3 h1:8oArS+Rc1SWFLXhE17KZNx258Z4kUSyaDgsSncCO5RA=
github.com/go-webauthn/x v0.2.3/go.mod h1:tM04GF3V6VYq79AZMl7vbj4q6pz9r7L2criWRzbWhPk=
+github.com/gobwas/httphead v0.1.0 h1:exrUm0f4YX0L7EBwZHuCF4GDp8aJfVeBrlLQrs6NqWU=
+github.com/gobwas/httphead v0.1.0/go.mod h1:O/RXo79gxV8G+RqlR/otEwx4Q36zl9rqC5u12GKvMCM=
+github.com/gobwas/pool v0.2.1 h1:xfeeEhW7pwmX8nuLVlqbzVc7udMDrwetjEv+TZIz1og=
+github.com/gobwas/pool v0.2.1/go.mod h1:q8bcK0KcYlCgd9e7WYLm9LpyS+YeLd8JVDW6WezmKEw=
+github.com/gobwas/ws v1.4.0 h1:CTaoG1tojrh4ucGPcoJFiAQUAsEWekEWvLy7GsVNqGs=
+github.com/gobwas/ws v1.4.0/go.mod h1:G3gNqMNtPppf5XUz7O4shetPpcZ1VJ7zt18dlUeakrc=
github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw=
github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk=
@@ -273,8 +281,8 @@ github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiu
github.com/golang/protobuf v1.5.3/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
-github.com/google/btree v1.1.2 h1:xf4v41cLI2Z6FxbKm+8Bu+m8ifhj15JuZ9sa0jZCMUU=
-github.com/google/btree v1.1.2/go.mod h1:qOPhT0dTNdNzV6Z/lhRX0YXUafgPLFUh+gZMl761Gm4=
+github.com/google/btree v1.1.3 h1:CVpQJjYgC4VbzxeGVHfvZrv1ctoYCAI8vbl07Fcxlyg=
+github.com/google/btree v1.1.3/go.mod h1:qOPhT0dTNdNzV6Z/lhRX0YXUafgPLFUh+gZMl761Gm4=
github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
@@ -378,6 +386,7 @@ github.com/jcmturner/rpc/v2 v2.0.3 h1:7FXXj8Ti1IaVFpSAziCZWNzbNuZmnvw/i6CqLNdWfZ
github.com/jcmturner/rpc/v2 v2.0.3/go.mod h1:VUJYCIDm3PVOEHw8sgt091/20OJjskO/YJki3ELg/Hc=
github.com/jeandeaual/go-locale v0.0.0-20250612000132-0ef82f21eade h1:FmusiCI1wHw+XQbvL9M+1r/C3SPqKrmBaIOYwVfQoDE=
github.com/jeandeaual/go-locale v0.0.0-20250612000132-0ef82f21eade/go.mod h1:ZDXo8KHryOWSIqnsb/CiDq7hQUYryCgdVnxbj8tDG7o=
+github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI=
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
@@ -842,6 +851,7 @@ golang.org/x/sys v0.0.0-20220908164124-27713097b956/go.mod h1:oPkhp1MJrh7nUepCBc
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.7.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.10.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
@@ -940,12 +950,11 @@ gopkg.in/go-playground/assert.v1 v1.2.1/go.mod h1:9RXL0bg/zibRAgZUYszZSwO/z8Y/a8
gopkg.in/go-playground/validator.v9 v9.29.1/go.mod h1:+c9/zcJMFNgbLvly1L1V+PpxWdVbfP1avr/N00E2vyQ=
gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA=
gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
-gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc=
-gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYskCTPBJVb9jqSc=
gopkg.in/square/go-jose.v2 v2.6.0 h1:NGk74WTnPKBNUhNzQX7PYcTLUjoq7mzKk2OKbvwk2iI=
gopkg.in/square/go-jose.v2 v2.6.0/go.mod h1:M9dMgbHiYLoDGQrXy7OpJDJWiKiU//h+vD76mk0e1AI=
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ=
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw=
+gopkg.in/yaml.v1 v1.0.0-20140924161607-9f9df34309c0/go.mod h1:WDnlLJ4WF5VGsH/HVa3CI79GS0ol3YnhVnKP89i0kNg=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
@@ -968,5 +977,7 @@ gotest.tools/v3 v3.5.1 h1:EENdUnS3pdur5nybKYIh2Vfgc8IUNBjxDPSjtiJcOzU=
gotest.tools/v3 v3.5.1/go.mod h1:isy3WKz7GK6uNw/sbHzfKBLvlvXwUyV06n6brMxxopU=
gvisor.dev/gvisor v0.0.0-20260219192049-0f2374377e89 h1:mGJaeA61P8dEHTqdvAgc70ZIV3QoUoJcXCRyyjO26OA=
gvisor.dev/gvisor v0.0.0-20260219192049-0f2374377e89/go.mod h1:QkHjoMIBaYtpVufgwv3keYAbln78mBoCuShZrPrer1Q=
+howett.net/plist v1.0.1 h1:37GdZ8tP09Q35o9ych3ehygcsL+HqKSwzctveSlarvM=
+howett.net/plist v1.0.1/go.mod h1:lqaXoTrLY4hg8tnEzNru53gicrbv7rrk+2xJA/7hw9g=
rsc.io/qr v0.2.0 h1:6vBLea5/NRMVTz8V66gipeLycZMl/+UlFmk8DvqQ6WY=
rsc.io/qr v0.2.0/go.mod h1:IF+uZjkb9fqyeF/4tlBoynqmQxUoPfWEKh921coOuXs=
diff --git a/idp/dex/provider.go b/idp/dex/provider.go
index 854382a2f..4a00098a4 100644
--- a/idp/dex/provider.go
+++ b/idp/dex/provider.go
@@ -40,6 +40,8 @@ type Config struct {
GRPCAddr string
}
+const localConnectorID = "local"
+
// Provider wraps a Dex server
type Provider struct {
config *Config
@@ -543,7 +545,7 @@ func (p *Provider) CreateUser(ctx context.Context, email, username, password str
// Encode the user ID in Dex's format: base64(protobuf{user_id, connector_id})
// This matches the format Dex uses in JWT tokens
- encodedID := EncodeDexUserID(userID, "local")
+ encodedID := EncodeDexUserID(userID, localConnectorID)
return encodedID, nil
}
@@ -618,6 +620,13 @@ func DecodeDexUserID(encodedID string) (userID, connectorID string, err error) {
return userID, connectorID, nil
}
+// IsLocalUserID reports whether encodedID is a Dex subject for the built-in
+// local password connector.
+func IsLocalUserID(encodedID string) bool {
+ _, connectorID, err := DecodeDexUserID(encodedID)
+ return err == nil && connectorID == localConnectorID
+}
+
// GetUser returns a user by email
func (p *Provider) GetUser(ctx context.Context, email string) (storage.Password, error) {
return p.storage.GetPassword(ctx, email)
diff --git a/idp/dex/provider_test.go b/idp/dex/provider_test.go
index 88828fbbb..3eb29db97 100644
--- a/idp/dex/provider_test.go
+++ b/idp/dex/provider_test.go
@@ -115,6 +115,26 @@ func TestDecodeDexUserID(t *testing.T) {
}
}
+func TestIsLocalUserID(t *testing.T) {
+ tests := []struct {
+ name string
+ encodedID string
+ want bool
+ }{
+ {name: "local connector", encodedID: EncodeDexUserID("7aad8c05-3287-473f-b42a-365504bf25e7", "local"), want: true},
+ {name: "federated connector", encodedID: EncodeDexUserID("entra-user", "entra"), want: false},
+ {name: "non-dex external IdP id", encodedID: "google-oauth2|1234567890", want: false},
+ {name: "invalid base64", encodedID: "not-valid-base64!!!", want: false},
+ {name: "empty", encodedID: "", want: false},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ assert.Equal(t, tt.want, IsLocalUserID(tt.encodedID))
+ })
+ }
+}
+
func TestEncodeDexUserID(t *testing.T) {
userID := "7aad8c05-3287-473f-b42a-365504bf25e7"
connectorID := "local"
diff --git a/infrastructure_files/getting-started.sh b/infrastructure_files/getting-started.sh
index 9d1b57258..770cecc44 100755
--- a/infrastructure_files/getting-started.sh
+++ b/infrastructure_files/getting-started.sh
@@ -19,6 +19,46 @@ readonly MSG_SEPARATOR="=========================================="
# Utility Functions
############################################
+check_docker_sock_perms() {
+ local sock="${DOCKER_HOST:-unix:///var/run/docker.sock}"
+ sock="${sock#unix://}"
+
+ if [[ ! -S "$sock" ]]; then
+ return 0
+ fi
+
+ if [[ ! -r "$sock" ]] || [[ ! -w "$sock" ]]; then
+ local group
+ if [[ "${OSTYPE}" == "darwin"* ]]; then
+ group="$(stat -f '%Sg' "$sock")"
+ else
+ group="$(stat -c '%G' "$sock")"
+ fi
+
+ echo "Cannot access Docker socket: $sock" > /dev/stderr
+ echo "" > /dev/stderr
+ echo "Socket permissions:" > /dev/stderr
+ ls -l "$sock" > /dev/stderr
+ echo "" > /dev/stderr
+
+ if [[ "$group" == "docker" ]]; then
+ echo "Your user may need to be added to the '$group' group:" > /dev/stderr
+ echo " sudo usermod -aG $group \"$USER\"" > /dev/stderr
+ echo "Then log out and back in, or run this for the current shell:" > /dev/stderr
+ echo " newgrp $group" > /dev/stderr
+ echo "Note: newgrp is temporary; usermod is the permanent group change." > /dev/stderr
+ else
+ echo "The Docker socket is owned by the '$group' group, which is not the standard 'docker' group." > /dev/stderr
+ echo "For safety, this script will not suggest adding your user to '$group'." > /dev/stderr
+ echo "Instead, either run this script with appropriate privileges (for example, via sudo) or follow Docker's post-install steps to configure access via the 'docker' group:" > /dev/stderr
+ echo " https://docs.docker.com/engine/install/linux-postinstall/" > /dev/stderr
+ fi
+
+ exit 1
+ fi
+ return 0
+}
+
check_docker_compose() {
if command -v docker-compose &> /dev/null
then
@@ -311,11 +351,12 @@ initialize_default_values() {
NETBIRD_STUN_PORT=3478
# Docker images
- DASHBOARD_IMAGE="netbirdio/dashboard:latest"
+ DASHBOARD_IMAGE=${DASHBOARD_IMAGE:-"netbirdio/dashboard:latest"}
# Combined server replaces separate signal, relay, and management containers
- NETBIRD_SERVER_IMAGE="netbirdio/netbird-server:latest"
- NETBIRD_PROXY_IMAGE="netbirdio/reverse-proxy:latest"
-
+ NETBIRD_SERVER_IMAGE=${NETBIRD_SERVER_IMAGE:-"netbirdio/netbird-server:latest"}
+ NETBIRD_PROXY_IMAGE=${NETBIRD_PROXY_IMAGE:-"netbirdio/reverse-proxy:latest"}
+ TRAEFIK_IMAGE=${TRAEFIK_IMAGE:-"traefik:v3.6"}
+ CROWDSEC_IMAGE=${CROWDSEC_IMAGE:-"crowdsecurity/crowdsec:v1.7.7"}
# Reverse proxy configuration
REVERSE_PROXY_TYPE="0"
TRAEFIK_EXTERNAL_NETWORK=""
@@ -580,12 +621,15 @@ start_services_and_show_instructions() {
}
init_environment() {
+ # Check if docker compose is installed using check_docker_compose function
+ DOCKER_COMPOSE_COMMAND=$(check_docker_compose)
+ check_docker_sock_perms
+
initialize_default_values
configure_domain
configure_reverse_proxy
check_jq
- DOCKER_COMPOSE_COMMAND=$(check_docker_compose)
check_existing_installation
generate_configuration_files
@@ -656,7 +700,7 @@ render_docker_compose_traefik_builtin() {
if [[ "$ENABLE_CROWDSEC" == "true" ]]; then
crowdsec_service="
crowdsec:
- image: crowdsecurity/crowdsec:v1.7.7
+ image: $CROWDSEC_IMAGE
container_name: netbird-crowdsec
restart: unless-stopped
networks: [netbird]
@@ -687,7 +731,7 @@ render_docker_compose_traefik_builtin() {
services:
# Traefik reverse proxy (automatic TLS via Let's Encrypt)
traefik:
- image: traefik:v3.6
+ image: $TRAEFIK_IMAGE
container_name: netbird-traefik
restart: unless-stopped
networks:
@@ -771,7 +815,7 @@ $traefik_dynamic_volume
labels:
- traefik.enable=true
# gRPC router (needs h2c backend for HTTP/2 cleartext)
- - traefik.http.routers.netbird-grpc.rule=Host(\`$NETBIRD_DOMAIN\`) && (PathPrefix(\`/signalexchange.SignalExchange/\`) || PathPrefix(\`/management.ManagementService/\`))
+ - traefik.http.routers.netbird-grpc.rule=Host(\`$NETBIRD_DOMAIN\`) && (PathPrefix(\`/signalexchange.SignalExchange/\`) || PathPrefix(\`/management.ManagementService/\`) || PathPrefix(\`/management.ProxyService/\`))
- traefik.http.routers.netbird-grpc.entrypoints=websecure
- traefik.http.routers.netbird-grpc.tls=true
- traefik.http.routers.netbird-grpc.tls.certresolver=letsencrypt
diff --git a/management/internals/controllers/network_map/controller/controller.go b/management/internals/controllers/network_map/controller/controller.go
index 9b31b76b2..81dc62f49 100644
--- a/management/internals/controllers/network_map/controller/controller.go
+++ b/management/internals/controllers/network_map/controller/controller.go
@@ -32,6 +32,7 @@ import (
"github.com/netbirdio/netbird/shared/management/proto"
"github.com/netbirdio/netbird/shared/management/status"
"github.com/netbirdio/netbird/util"
+ "github.com/netbirdio/netbird/version"
)
type Controller struct {
@@ -44,7 +45,7 @@ type Controller struct {
EphemeralPeersManager ephemeral.Manager
accountUpdateLocks sync.Map
- sendAccountUpdateLocks sync.Map
+ affectedPeerUpdateLocks sync.Map
updateAccountPeersBufferInterval atomic.Int64
// dnsDomain is used for peer resolution. This is appended to the peer's name
dnsDomain string
@@ -69,6 +70,13 @@ type bufferUpdate struct {
update atomic.Bool
}
+type bufferAffectedUpdate struct {
+ sendMu sync.Mutex
+ dataMu sync.Mutex
+ next *time.Timer
+ peerIDs map[string]struct{}
+}
+
var _ network_map.Controller = (*Controller)(nil)
func NewController(ctx context.Context, store store.Store, metrics telemetry.AppMetrics, peersUpdateManager network_map.PeersUpdateManager, requestBuffer account.RequestBuffer, integratedPeerValidator integrated_validator.IntegratedValidator, settingsManager settings.Manager, dnsDomain string, proxyController port_forwarding.Controller, ephemeralPeersManager ephemeral.Manager, config *config.Config) *Controller {
@@ -254,44 +262,6 @@ func (c *Controller) sendUpdateAccountPeers(ctx context.Context, accountID strin
return nil
}
-func (c *Controller) bufferSendUpdateAccountPeers(ctx context.Context, accountID string, reason types.UpdateReason) error {
- log.WithContext(ctx).Tracef("buffer sending update peers for account %s from %s", accountID, util.GetCallerName())
-
- if c.accountManagerMetrics != nil {
- c.accountManagerMetrics.CountUpdateAccountPeersTriggered(string(reason.Resource), string(reason.Operation))
- }
-
- bufUpd, _ := c.sendAccountUpdateLocks.LoadOrStore(accountID, &bufferUpdate{})
- b := bufUpd.(*bufferUpdate)
-
- if !b.mu.TryLock() {
- b.update.Store(true)
- return nil
- }
-
- if b.next != nil {
- b.next.Stop()
- }
-
- go func() {
- defer b.mu.Unlock()
- _ = c.sendUpdateAccountPeers(ctx, accountID, reason)
- if !b.update.Load() {
- return
- }
- b.update.Store(false)
- if b.next == nil {
- b.next = time.AfterFunc(time.Duration(c.updateAccountPeersBufferInterval.Load()), func() {
- _ = c.sendUpdateAccountPeers(ctx, accountID, reason)
- })
- return
- }
- b.next.Reset(time.Duration(c.updateAccountPeersBufferInterval.Load()))
- }()
-
- return nil
-}
-
// UpdatePeers updates all peers that belong to an account.
// Should be called when changes have to be synced to peers.
func (c *Controller) UpdateAccountPeers(ctx context.Context, accountID string, reason types.UpdateReason) error {
@@ -301,6 +271,143 @@ func (c *Controller) UpdateAccountPeers(ctx context.Context, accountID string, r
return c.sendUpdateAccountPeers(ctx, accountID, reason)
}
+// UpdateAffectedPeers updates only the specified peers that belong to an account.
+func (c *Controller) UpdateAffectedPeers(ctx context.Context, accountID string, peerIDs []string) error {
+ if len(peerIDs) == 0 {
+ return nil
+ }
+ return c.sendUpdateForAffectedPeers(ctx, accountID, peerIDs)
+}
+
+func (c *Controller) sendUpdateForAffectedPeers(ctx context.Context, accountID string, peerIDs []string) error {
+ log.WithContext(ctx).Tracef("sendUpdateForAffectedPeers: account %s, %d affected peers: %v (caller: %s)", accountID, len(peerIDs), peerIDs, util.GetCallerName())
+
+ if !c.hasConnectedPeers(peerIDs) {
+ log.WithContext(ctx).Tracef("sendUpdateForAffectedPeers: no connected peers among %v, skipping", peerIDs)
+ return nil
+ }
+
+ account, err := c.requestBuffer.GetAccountWithBackpressure(ctx, accountID)
+ if err != nil {
+ return fmt.Errorf("failed to get account: %v", err)
+ }
+
+ globalStart := time.Now()
+
+ peersToUpdate := c.filterConnectedAffectedPeers(account, peerIDs)
+ if len(peersToUpdate) == 0 {
+ log.WithContext(ctx).Tracef("sendUpdateForAffectedPeers: no peers to update (affected peers not found in account or no channels)")
+ return nil
+ }
+
+ log.WithContext(ctx).Tracef("sendUpdateForAffectedPeers: sending network map to %d connected peers", len(peersToUpdate))
+
+ approvedPeersMap, err := c.integratedPeerValidator.GetValidatedPeers(ctx, account.Id, maps.Values(account.Groups), maps.Values(account.Peers), account.Settings.Extra)
+ if err != nil {
+ return fmt.Errorf("failed to get validate peers: %v", err)
+ }
+
+ var wg sync.WaitGroup
+ semaphore := make(chan struct{}, 10)
+
+ account.InjectProxyPolicies(ctx)
+ dnsCache := &cache.DNSConfigCache{}
+ dnsDomain := c.GetDNSDomain(account.Settings)
+ peersCustomZone := account.GetPeersCustomZone(ctx, dnsDomain)
+ resourcePolicies := account.GetResourcePoliciesMap()
+ routers := account.GetResourceRoutersMap()
+ groupIDToUserIDs := account.GetActiveGroupUsers()
+
+ proxyNetworkMaps, err := c.proxyController.GetProxyNetworkMapsAll(ctx, accountID, account.Peers)
+ if err != nil {
+ log.WithContext(ctx).Errorf("failed to get proxy network maps: %v", err)
+ return fmt.Errorf("failed to get proxy network maps: %v", err)
+ }
+
+ extraSetting, err := c.settingsManager.GetExtraSettings(ctx, accountID)
+ if err != nil {
+ return fmt.Errorf("failed to get flow enabled status: %v", err)
+ }
+
+ dnsFwdPort := computeForwarderPort(maps.Values(account.Peers), network_map.DnsForwarderPortMinVersion)
+
+ accountZones, err := c.repo.GetAccountZones(ctx, account.Id)
+ if err != nil {
+ log.WithContext(ctx).Errorf("failed to get account zones: %v", err)
+ return fmt.Errorf("failed to get account zones: %v", err)
+ }
+
+ for _, peer := range peersToUpdate {
+ wg.Add(1)
+ semaphore <- struct{}{}
+ go func(p *nbpeer.Peer) {
+ defer wg.Done()
+ defer func() { <-semaphore }()
+
+ start := time.Now()
+
+ postureChecks, err := c.getPeerPostureChecks(account, p.ID)
+ if err != nil {
+ log.WithContext(ctx).Debugf("failed to get posture checks for peer %s: %v", p.ID, err)
+ return
+ }
+
+ c.metrics.CountCalcPostureChecksDuration(time.Since(start))
+ start = time.Now()
+
+ remotePeerNetworkMap := account.GetPeerNetworkMapFromComponents(ctx, p.ID, peersCustomZone, accountZones, approvedPeersMap, resourcePolicies, routers, c.accountManagerMetrics, groupIDToUserIDs)
+
+ c.metrics.CountCalcPeerNetworkMapDuration(time.Since(start))
+
+ proxyNetworkMap, ok := proxyNetworkMaps[p.ID]
+ if ok {
+ remotePeerNetworkMap.Merge(proxyNetworkMap)
+ }
+
+ peerGroups := account.GetPeerGroups(p.ID)
+ start = time.Now()
+ update := grpc.ToSyncResponse(ctx, nil, c.config.HttpConfig, c.config.DeviceAuthorizationFlow, p, nil, nil, remotePeerNetworkMap, dnsDomain, postureChecks, dnsCache, account.Settings, extraSetting, maps.Keys(peerGroups), dnsFwdPort)
+ c.metrics.CountToSyncResponseDuration(time.Since(start))
+
+ c.peersUpdateManager.SendUpdate(ctx, p.ID, &network_map.UpdateMessage{
+ Update: update,
+ MessageType: network_map.MessageTypeNetworkMap,
+ })
+ }(peer)
+ }
+
+ wg.Wait()
+ if c.accountManagerMetrics != nil {
+ c.accountManagerMetrics.CountUpdateAccountPeersDuration(time.Since(globalStart))
+ }
+
+ return nil
+}
+
+func (c *Controller) hasConnectedPeers(peerIDs []string) bool {
+ for _, id := range peerIDs {
+ if c.peersUpdateManager.HasChannel(id) {
+ return true
+ }
+ }
+ return false
+}
+
+func (c *Controller) filterConnectedAffectedPeers(account *types.Account, peerIDs []string) []*nbpeer.Peer {
+ affected := make(map[string]struct{}, len(peerIDs))
+ for _, id := range peerIDs {
+ affected[id] = struct{}{}
+ }
+
+ var result []*nbpeer.Peer
+ for _, peer := range account.Peers {
+ if _, ok := affected[peer.ID]; ok && c.peersUpdateManager.HasChannel(peer.ID) {
+ result = append(result, peer)
+ }
+ }
+ return result
+}
+
func (c *Controller) UpdateAccountPeer(ctx context.Context, accountId string, peerId string) error {
if !c.peersUpdateManager.HasChannel(peerId) {
return fmt.Errorf("peer %s doesn't have a channel, skipping network map update", peerId)
@@ -474,66 +581,164 @@ func (c *Controller) GetValidatedPeerWithComponents(ctx context.Context, isRequi
return peer, components, proxyNetworkMaps[peer.ID], postureChecks, dnsFwdPort, nil
}
-func (c *Controller) GetValidatedPeerWithMap(ctx context.Context, isRequiresApproval bool, accountID string, peer *nbpeer.Peer) (*nbpeer.Peer, *types.NetworkMap, []*posture.Checks, int64, error) {
+// BufferUpdateAffectedPeers accumulates peer IDs and flushes them after the buffer interval.
+func (c *Controller) BufferUpdateAffectedPeers(ctx context.Context, accountID string, peerIDs []string, reason types.UpdateReason) error {
+ if len(peerIDs) == 0 {
+ return nil
+ }
+
+ if c.accountManagerMetrics != nil {
+ c.accountManagerMetrics.CountUpdateAccountPeersTriggered(string(reason.Resource), string(reason.Operation))
+ }
+
+ log.WithContext(ctx).Tracef("buffer updating %d affected peers for account %s from %s", len(peerIDs), accountID, util.GetCallerName())
+
+ bufUpd, _ := c.affectedPeerUpdateLocks.LoadOrStore(accountID, &bufferAffectedUpdate{
+ peerIDs: make(map[string]struct{}),
+ })
+ b := bufUpd.(*bufferAffectedUpdate)
+
+ b.addPeerIDs(peerIDs)
+
+ if !b.sendMu.TryLock() {
+ // Another goroutine is already sending; it will pick up our IDs on its next drain.
+ return nil
+ }
+
+ b.stopTimer()
+
+ // The send and the debounced timer outlive the calling request, so detach from
+ // its context to avoid sending with a cancelled context once the handler returns.
+ bgCtx := context.WithoutCancel(ctx)
+
+ collected := b.drainPeerIDs()
+ go func() {
+ defer b.sendMu.Unlock()
+ _ = c.sendUpdateForAffectedPeers(bgCtx, accountID, collected)
+
+ // Check if more peer IDs accumulated while we were sending.
+ if !b.hasPending() {
+ return
+ }
+
+ // Schedule a debounced flush for the newly accumulated IDs.
+ b.setTimer(time.Duration(c.updateAccountPeersBufferInterval.Load()), func() {
+ ids := b.drainPeerIDs()
+ if len(ids) > 0 {
+ _ = c.sendUpdateForAffectedPeers(bgCtx, accountID, ids)
+ }
+ })
+ }()
+
+ return nil
+}
+
+func (b *bufferAffectedUpdate) addPeerIDs(ids []string) {
+ b.dataMu.Lock()
+ for _, id := range ids {
+ b.peerIDs[id] = struct{}{}
+ }
+ b.dataMu.Unlock()
+}
+
+func (b *bufferAffectedUpdate) drainPeerIDs() []string {
+ b.dataMu.Lock()
+ defer b.dataMu.Unlock()
+ if len(b.peerIDs) == 0 {
+ return nil
+ }
+ ids := make([]string, 0, len(b.peerIDs))
+ for id := range b.peerIDs {
+ ids = append(ids, id)
+ }
+ b.peerIDs = make(map[string]struct{})
+ return ids
+}
+
+func (b *bufferAffectedUpdate) hasPending() bool {
+ b.dataMu.Lock()
+ defer b.dataMu.Unlock()
+ return len(b.peerIDs) > 0
+}
+
+func (b *bufferAffectedUpdate) stopTimer() {
+ b.dataMu.Lock()
+ defer b.dataMu.Unlock()
+ if b.next != nil {
+ b.next.Stop()
+ }
+}
+
+func (b *bufferAffectedUpdate) setTimer(d time.Duration, f func()) {
+ b.dataMu.Lock()
+ defer b.dataMu.Unlock()
+ if b.next == nil {
+ b.next = time.AfterFunc(d, f)
+ return
+ }
+ b.next.Reset(d)
+}
+
+func (c *Controller) GetValidatedPeerWithMap(ctx context.Context, isRequiresApproval bool, accountID string, peerID string) (*types.NetworkMap, []*posture.Checks, int64, error) {
if isRequiresApproval {
network, err := c.repo.GetAccountNetwork(ctx, accountID)
if err != nil {
- return nil, nil, nil, 0, err
+ return nil, nil, 0, err
}
emptyMap := &types.NetworkMap{
Network: network.Copy(),
}
- return peer, emptyMap, nil, 0, nil
+ return emptyMap, nil, 0, nil
}
account, err := c.requestBuffer.GetAccountWithBackpressure(ctx, accountID)
if err != nil {
- return nil, nil, nil, 0, err
+ return nil, nil, 0, err
}
account.InjectProxyPolicies(ctx)
approvedPeersMap, err := c.integratedPeerValidator.GetValidatedPeers(ctx, account.Id, maps.Values(account.Groups), maps.Values(account.Peers), account.Settings.Extra)
if err != nil {
- return nil, nil, nil, 0, err
+ return nil, nil, 0, err
}
startPosture := time.Now()
- postureChecks, err := c.getPeerPostureChecks(account, peer.ID)
+ postureChecks, err := c.getPeerPostureChecks(account, peerID)
if err != nil {
- return nil, nil, nil, 0, err
+ return nil, nil, 0, err
}
log.WithContext(ctx).Debugf("getPeerPostureChecks took %s", time.Since(startPosture))
accountZones, err := c.repo.GetAccountZones(ctx, account.Id)
if err != nil {
log.WithContext(ctx).Errorf("failed to get account zones: %v", err)
- return nil, nil, nil, 0, err
+ return nil, nil, 0, err
}
dnsDomain := c.GetDNSDomain(account.Settings)
peersCustomZone := account.GetPeersCustomZone(ctx, dnsDomain)
- proxyNetworkMaps, err := c.proxyController.GetProxyNetworkMaps(ctx, account.Id, peer.ID, account.Peers)
+ proxyNetworkMaps, err := c.proxyController.GetProxyNetworkMaps(ctx, account.Id, peerID, account.Peers)
if err != nil {
log.WithContext(ctx).Errorf("failed to get proxy network maps: %v", err)
- return nil, nil, nil, 0, err
+ return nil, nil, 0, err
}
resourcePolicies := account.GetResourcePoliciesMap()
routers := account.GetResourceRoutersMap()
groupIDToUserIDs := account.GetActiveGroupUsers()
- networkMap := account.GetPeerNetworkMapFromComponents(ctx, peer.ID, peersCustomZone, accountZones, approvedPeersMap, resourcePolicies, routers, c.accountManagerMetrics, groupIDToUserIDs)
+ networkMap := account.GetPeerNetworkMapFromComponents(ctx, peerID, peersCustomZone, accountZones, approvedPeersMap, resourcePolicies, routers, c.accountManagerMetrics, groupIDToUserIDs)
- proxyNetworkMap, ok := proxyNetworkMaps[peer.ID]
+ proxyNetworkMap, ok := proxyNetworkMaps[peerID]
if ok {
networkMap.Merge(proxyNetworkMap)
}
dnsFwdPort := computeForwarderPort(maps.Values(account.Peers), network_map.DnsForwarderPortMinVersion)
- return peer, networkMap, postureChecks, dnsFwdPort, nil
+ return networkMap, postureChecks, dnsFwdPort, nil
}
// GetDNSDomain returns the configured dnsDomain
@@ -608,7 +813,7 @@ func computeForwarderPort(peers []*nbpeer.Peer, requiredVersion string) int64 {
for _, peer := range peers {
// Development version is always supported
- if peer.Meta.WtVersion == "development" {
+ if version.IsDevelopmentVersion(peer.Meta.WtVersion) {
continue
}
peerVersion := semver.Canonical("v" + peer.Meta.WtVersion)
@@ -671,21 +876,24 @@ func isPeerInPolicySourceGroups(account *types.Account, peerID string, policy *t
return false, nil
}
-func (c *Controller) OnPeersUpdated(ctx context.Context, accountID string, peerIDs []string) error {
- err := c.bufferSendUpdateAccountPeers(ctx, accountID, types.UpdateReason{Resource: types.UpdateResourcePeer, Operation: types.UpdateOperationUpdate})
- if err != nil {
- log.WithContext(ctx).Errorf("failed to buffer update account peers for peer update in account %s: %v", accountID, err)
+func (c *Controller) OnPeersUpdated(ctx context.Context, accountID string, peerIDs []string, affectedPeerIDs []string) error {
+ if len(affectedPeerIDs) == 0 {
+ log.WithContext(ctx).Tracef("no affected peers for peer update in account %s, skipping", accountID)
+ return nil
}
-
- return nil
+ return c.BufferUpdateAffectedPeers(ctx, accountID, affectedPeerIDs, types.UpdateReason{Resource: types.UpdateResourcePeer, Operation: types.UpdateOperationUpdate})
}
-func (c *Controller) OnPeersAdded(ctx context.Context, accountID string, peerIDs []string) error {
+func (c *Controller) OnPeersAdded(ctx context.Context, accountID string, peerIDs []string, affectedPeerIDs []string) error {
log.WithContext(ctx).Debugf("OnPeersAdded call to add peers: %v", peerIDs)
- return c.bufferSendUpdateAccountPeers(ctx, accountID, types.UpdateReason{Resource: types.UpdateResourcePeer, Operation: types.UpdateOperationCreate})
+ if len(affectedPeerIDs) == 0 {
+ log.WithContext(ctx).Tracef("no affected peers for peer add in account %s, skipping", accountID)
+ return nil
+ }
+ return c.BufferUpdateAffectedPeers(ctx, accountID, affectedPeerIDs, types.UpdateReason{Resource: types.UpdateResourcePeer, Operation: types.UpdateOperationCreate})
}
-func (c *Controller) OnPeersDeleted(ctx context.Context, accountID string, peerIDs []string) error {
+func (c *Controller) OnPeersDeleted(ctx context.Context, accountID string, peerIDs []string, affectedPeerIDs []string) error {
network, err := c.repo.GetAccountNetwork(ctx, accountID)
if err != nil {
return err
@@ -718,7 +926,11 @@ func (c *Controller) OnPeersDeleted(ctx context.Context, accountID string, peerI
c.peersUpdateManager.CloseChannel(ctx, peerID)
}
- return c.bufferSendUpdateAccountPeers(ctx, accountID, types.UpdateReason{Resource: types.UpdateResourcePeer, Operation: types.UpdateOperationDelete})
+ if len(affectedPeerIDs) == 0 {
+ log.WithContext(ctx).Tracef("no affected peers for peer delete in account %s, skipping", accountID)
+ return nil
+ }
+ return c.BufferUpdateAffectedPeers(ctx, accountID, affectedPeerIDs, types.UpdateReason{Resource: types.UpdateResourcePeer, Operation: types.UpdateOperationDelete})
}
// GetNetworkMap returns Network map for a given peer (omits original peer from the Peers result)
diff --git a/management/internals/controllers/network_map/interface.go b/management/internals/controllers/network_map/interface.go
index 738d3a430..f1323911c 100644
--- a/management/internals/controllers/network_map/interface.go
+++ b/management/internals/controllers/network_map/interface.go
@@ -19,9 +19,11 @@ const (
type Controller interface {
UpdateAccountPeers(ctx context.Context, accountID string, reason types.UpdateReason) error
+ UpdateAffectedPeers(ctx context.Context, accountID string, peerIDs []string) error
+ BufferUpdateAffectedPeers(ctx context.Context, accountID string, peerIDs []string, reason types.UpdateReason) error
UpdateAccountPeer(ctx context.Context, accountId string, peerId string) error
BufferUpdateAccountPeers(ctx context.Context, accountID string, reason types.UpdateReason) error
- GetValidatedPeerWithMap(ctx context.Context, isRequiresApproval bool, accountID string, p *nbpeer.Peer) (*nbpeer.Peer, *types.NetworkMap, []*posture.Checks, int64, error)
+ GetValidatedPeerWithMap(ctx context.Context, isRequiresApproval bool, accountID string, peerID string) (*types.NetworkMap, []*posture.Checks, int64, error)
GetValidatedPeerWithComponents(ctx context.Context, isRequiresApproval bool, accountID string, p *nbpeer.Peer) (*nbpeer.Peer, *types.NetworkMapComponents, *types.NetworkMap, []*posture.Checks, int64, error)
// PeerNeedsComponents combines the peer's advertised capability with the
// kill-switch flag — the only public predicate gRPC layers should ask.
@@ -31,9 +33,9 @@ type Controller interface {
GetNetworkMap(ctx context.Context, peerID string) (*types.NetworkMap, error)
CountStreams() int
- OnPeersUpdated(ctx context.Context, accountId string, peerIDs []string) error
- OnPeersAdded(ctx context.Context, accountID string, peerIDs []string) error
- OnPeersDeleted(ctx context.Context, accountID string, peerIDs []string) error
+ OnPeersUpdated(ctx context.Context, accountId string, peerIDs []string, affectedPeerIDs []string) error
+ OnPeersAdded(ctx context.Context, accountID string, peerIDs []string, affectedPeerIDs []string) error
+ OnPeersDeleted(ctx context.Context, accountID string, peerIDs []string, affectedPeerIDs []string) error
DisconnectPeers(ctx context.Context, accountId string, peerIDs []string)
OnPeerConnected(ctx context.Context, accountID string, peerID string) (chan *UpdateMessage, error)
OnPeerDisconnected(ctx context.Context, accountID string, peerID string)
diff --git a/management/internals/controllers/network_map/interface_mock.go b/management/internals/controllers/network_map/interface_mock.go
index b24554d39..d673375ee 100644
--- a/management/internals/controllers/network_map/interface_mock.go
+++ b/management/internals/controllers/network_map/interface_mock.go
@@ -57,6 +57,20 @@ func (mr *MockControllerMockRecorder) BufferUpdateAccountPeers(ctx, accountID, r
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "BufferUpdateAccountPeers", reflect.TypeOf((*MockController)(nil).BufferUpdateAccountPeers), ctx, accountID, reason)
}
+// BufferUpdateAffectedPeers mocks base method.
+func (m *MockController) BufferUpdateAffectedPeers(ctx context.Context, accountID string, peerIDs []string, reason types.UpdateReason) error {
+ m.ctrl.T.Helper()
+ ret := m.ctrl.Call(m, "BufferUpdateAffectedPeers", ctx, accountID, peerIDs, reason)
+ ret0, _ := ret[0].(error)
+ return ret0
+}
+
+// BufferUpdateAffectedPeers indicates an expected call of BufferUpdateAffectedPeers.
+func (mr *MockControllerMockRecorder) BufferUpdateAffectedPeers(ctx, accountID, peerIDs, reason any) *gomock.Call {
+ mr.mock.ctrl.T.Helper()
+ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "BufferUpdateAffectedPeers", reflect.TypeOf((*MockController)(nil).BufferUpdateAffectedPeers), ctx, accountID, peerIDs, reason)
+}
+
// CountStreams mocks base method.
func (m *MockController) CountStreams() int {
m.ctrl.T.Helper()
@@ -113,21 +127,20 @@ func (mr *MockControllerMockRecorder) GetNetworkMap(ctx, peerID any) *gomock.Cal
}
// GetValidatedPeerWithMap mocks base method.
-func (m *MockController) GetValidatedPeerWithMap(ctx context.Context, isRequiresApproval bool, accountID string, p *peer.Peer) (*peer.Peer, *types.NetworkMap, []*posture.Checks, int64, error) {
+func (m *MockController) GetValidatedPeerWithMap(ctx context.Context, isRequiresApproval bool, accountID string, peerID string) (*types.NetworkMap, []*posture.Checks, int64, error) {
m.ctrl.T.Helper()
- ret := m.ctrl.Call(m, "GetValidatedPeerWithMap", ctx, isRequiresApproval, accountID, p)
- ret0, _ := ret[0].(*peer.Peer)
- ret1, _ := ret[1].(*types.NetworkMap)
- ret2, _ := ret[2].([]*posture.Checks)
- ret3, _ := ret[3].(int64)
- ret4, _ := ret[4].(error)
- return ret0, ret1, ret2, ret3, ret4
+ ret := m.ctrl.Call(m, "GetValidatedPeerWithMap", ctx, isRequiresApproval, accountID, peerID)
+ ret0, _ := ret[0].(*types.NetworkMap)
+ ret1, _ := ret[1].([]*posture.Checks)
+ ret2, _ := ret[2].(int64)
+ ret3, _ := ret[3].(error)
+ return ret0, ret1, ret2, ret3
}
// GetValidatedPeerWithMap indicates an expected call of GetValidatedPeerWithMap.
-func (mr *MockControllerMockRecorder) GetValidatedPeerWithMap(ctx, isRequiresApproval, accountID, p any) *gomock.Call {
+func (mr *MockControllerMockRecorder) GetValidatedPeerWithMap(ctx, isRequiresApproval, accountID, peerID any) *gomock.Call {
mr.mock.ctrl.T.Helper()
- return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetValidatedPeerWithMap", reflect.TypeOf((*MockController)(nil).GetValidatedPeerWithMap), ctx, isRequiresApproval, accountID, p)
+ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetValidatedPeerWithMap", reflect.TypeOf((*MockController)(nil).GetValidatedPeerWithMap), ctx, isRequiresApproval, accountID, peerID)
}
// GetValidatedPeerWithComponents mocks base method.
@@ -191,45 +204,45 @@ func (mr *MockControllerMockRecorder) OnPeerDisconnected(ctx, accountID, peerID
}
// OnPeersAdded mocks base method.
-func (m *MockController) OnPeersAdded(ctx context.Context, accountID string, peerIDs []string) error {
+func (m *MockController) OnPeersAdded(ctx context.Context, accountID string, peerIDs []string, affectedPeerIDs []string) error {
m.ctrl.T.Helper()
- ret := m.ctrl.Call(m, "OnPeersAdded", ctx, accountID, peerIDs)
+ ret := m.ctrl.Call(m, "OnPeersAdded", ctx, accountID, peerIDs, affectedPeerIDs)
ret0, _ := ret[0].(error)
return ret0
}
// OnPeersAdded indicates an expected call of OnPeersAdded.
-func (mr *MockControllerMockRecorder) OnPeersAdded(ctx, accountID, peerIDs any) *gomock.Call {
+func (mr *MockControllerMockRecorder) OnPeersAdded(ctx, accountID, peerIDs, affectedPeerIDs any) *gomock.Call {
mr.mock.ctrl.T.Helper()
- return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "OnPeersAdded", reflect.TypeOf((*MockController)(nil).OnPeersAdded), ctx, accountID, peerIDs)
+ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "OnPeersAdded", reflect.TypeOf((*MockController)(nil).OnPeersAdded), ctx, accountID, peerIDs, affectedPeerIDs)
}
// OnPeersDeleted mocks base method.
-func (m *MockController) OnPeersDeleted(ctx context.Context, accountID string, peerIDs []string) error {
+func (m *MockController) OnPeersDeleted(ctx context.Context, accountID string, peerIDs []string, affectedPeerIDs []string) error {
m.ctrl.T.Helper()
- ret := m.ctrl.Call(m, "OnPeersDeleted", ctx, accountID, peerIDs)
+ ret := m.ctrl.Call(m, "OnPeersDeleted", ctx, accountID, peerIDs, affectedPeerIDs)
ret0, _ := ret[0].(error)
return ret0
}
// OnPeersDeleted indicates an expected call of OnPeersDeleted.
-func (mr *MockControllerMockRecorder) OnPeersDeleted(ctx, accountID, peerIDs any) *gomock.Call {
+func (mr *MockControllerMockRecorder) OnPeersDeleted(ctx, accountID, peerIDs, affectedPeerIDs any) *gomock.Call {
mr.mock.ctrl.T.Helper()
- return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "OnPeersDeleted", reflect.TypeOf((*MockController)(nil).OnPeersDeleted), ctx, accountID, peerIDs)
+ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "OnPeersDeleted", reflect.TypeOf((*MockController)(nil).OnPeersDeleted), ctx, accountID, peerIDs, affectedPeerIDs)
}
// OnPeersUpdated mocks base method.
-func (m *MockController) OnPeersUpdated(ctx context.Context, accountId string, peerIDs []string) error {
+func (m *MockController) OnPeersUpdated(ctx context.Context, accountId string, peerIDs []string, affectedPeerIDs []string) error {
m.ctrl.T.Helper()
- ret := m.ctrl.Call(m, "OnPeersUpdated", ctx, accountId, peerIDs)
+ ret := m.ctrl.Call(m, "OnPeersUpdated", ctx, accountId, peerIDs, affectedPeerIDs)
ret0, _ := ret[0].(error)
return ret0
}
// OnPeersUpdated indicates an expected call of OnPeersUpdated.
-func (mr *MockControllerMockRecorder) OnPeersUpdated(ctx, accountId, peerIDs any) *gomock.Call {
+func (mr *MockControllerMockRecorder) OnPeersUpdated(ctx, accountId, peerIDs, affectedPeerIDs any) *gomock.Call {
mr.mock.ctrl.T.Helper()
- return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "OnPeersUpdated", reflect.TypeOf((*MockController)(nil).OnPeersUpdated), ctx, accountId, peerIDs)
+ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "OnPeersUpdated", reflect.TypeOf((*MockController)(nil).OnPeersUpdated), ctx, accountId, peerIDs, affectedPeerIDs)
}
// StartWarmup mocks base method.
@@ -283,3 +296,17 @@ func (mr *MockControllerMockRecorder) UpdateAccountPeers(ctx, accountID, reason
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateAccountPeers", reflect.TypeOf((*MockController)(nil).UpdateAccountPeers), ctx, accountID, reason)
}
+
+// UpdateAffectedPeers mocks base method.
+func (m *MockController) UpdateAffectedPeers(ctx context.Context, accountID string, peerIDs []string) error {
+ m.ctrl.T.Helper()
+ ret := m.ctrl.Call(m, "UpdateAffectedPeers", ctx, accountID, peerIDs)
+ ret0, _ := ret[0].(error)
+ return ret0
+}
+
+// UpdateAffectedPeers indicates an expected call of UpdateAffectedPeers.
+func (mr *MockControllerMockRecorder) UpdateAffectedPeers(ctx, accountID, peerIDs any) *gomock.Call {
+ mr.mock.ctrl.T.Helper()
+ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateAffectedPeers", reflect.TypeOf((*MockController)(nil).UpdateAffectedPeers), ctx, accountID, peerIDs)
+}
diff --git a/management/internals/modules/peers/manager.go b/management/internals/modules/peers/manager.go
index 75ae8de91..e22d1e6e0 100644
--- a/management/internals/modules/peers/manager.go
+++ b/management/internals/modules/peers/manager.go
@@ -75,7 +75,7 @@ func (m *managerImpl) SetAccountManager(accountManager account.Manager) {
}
func (m *managerImpl) GetPeer(ctx context.Context, accountID, userID, peerID string) (*peer.Peer, error) {
- allowed, err := m.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Peers, operations.Read)
+ allowed, ctx, err := m.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Peers, operations.Read)
if err != nil {
return nil, fmt.Errorf("failed to validate user permissions: %w", err)
}
@@ -88,7 +88,7 @@ func (m *managerImpl) GetPeer(ctx context.Context, accountID, userID, peerID str
}
func (m *managerImpl) GetAllPeers(ctx context.Context, accountID, userID string) ([]*peer.Peer, error) {
- allowed, err := m.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Peers, operations.Read)
+ allowed, ctx, err := m.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Peers, operations.Read)
if err != nil {
return nil, fmt.Errorf("failed to validate user permissions: %w", err)
}
@@ -242,7 +242,7 @@ func (m *managerImpl) CreateProxyPeer(ctx context.Context, accountID string, pee
},
}
- _, _, _, err = m.accountManager.AddPeer(ctx, accountID, "", "", peer, true)
+ _, _, _, _, err = m.accountManager.AddPeer(ctx, accountID, "", "", peer, true)
if err != nil {
return fmt.Errorf("failed to create proxy peer: %w", err)
}
diff --git a/management/internals/modules/reverseproxy/accesslogs/manager/manager.go b/management/internals/modules/reverseproxy/accesslogs/manager/manager.go
index 59d7704eb..ced2ec4d1 100644
--- a/management/internals/modules/reverseproxy/accesslogs/manager/manager.go
+++ b/management/internals/modules/reverseproxy/accesslogs/manager/manager.go
@@ -63,7 +63,7 @@ func (m *managerImpl) SaveAccessLog(ctx context.Context, logEntry *accesslogs.Ac
// GetAllAccessLogs retrieves access logs for an account with pagination and filtering
func (m *managerImpl) GetAllAccessLogs(ctx context.Context, accountID, userID string, filter *accesslogs.AccessLogFilter) ([]*accesslogs.AccessLogEntry, int64, error) {
- ok, err := m.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Services, operations.Read)
+ ok, ctx, err := m.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Services, operations.Read)
if err != nil {
return nil, 0, status.NewPermissionValidationError(err)
}
diff --git a/management/internals/modules/reverseproxy/domain/manager/manager.go b/management/internals/modules/reverseproxy/domain/manager/manager.go
index 2a026c7fa..3c0f0d73b 100644
--- a/management/internals/modules/reverseproxy/domain/manager/manager.go
+++ b/management/internals/modules/reverseproxy/domain/manager/manager.go
@@ -57,7 +57,7 @@ func NewManager(store store, proxyMgr proxyManager, permissionsManager permissio
}
func (m Manager) GetDomains(ctx context.Context, accountID, userID string) ([]*domain.Domain, error) {
- ok, err := m.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Services, operations.Read)
+ ok, ctx, err := m.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Services, operations.Read)
if err != nil {
return nil, status.NewPermissionValidationError(err)
}
@@ -122,7 +122,7 @@ func (m Manager) GetDomains(ctx context.Context, accountID, userID string) ([]*d
}
func (m Manager) CreateDomain(ctx context.Context, accountID, userID, domainName, targetCluster string) (*domain.Domain, error) {
- ok, err := m.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Services, operations.Create)
+ ok, ctx, err := m.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Services, operations.Create)
if err != nil {
return nil, status.NewPermissionValidationError(err)
}
@@ -163,7 +163,7 @@ func (m Manager) CreateDomain(ctx context.Context, accountID, userID, domainName
}
func (m Manager) DeleteDomain(ctx context.Context, accountID, userID, domainID string) error {
- ok, err := m.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Services, operations.Delete)
+ ok, ctx, err := m.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Services, operations.Delete)
if err != nil {
return status.NewPermissionValidationError(err)
}
@@ -187,7 +187,7 @@ func (m Manager) DeleteDomain(ctx context.Context, accountID, userID, domainID s
}
func (m Manager) ValidateDomain(ctx context.Context, accountID, userID, domainID string) {
- ok, err := m.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Services, operations.Create)
+ ok, _, err := m.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Services, operations.Create)
if err != nil {
log.WithFields(log.Fields{
"accountID": accountID,
diff --git a/management/internals/modules/reverseproxy/proxytoken/handler.go b/management/internals/modules/reverseproxy/proxytoken/handler.go
index 728cdf723..ed098a6dd 100644
--- a/management/internals/modules/reverseproxy/proxytoken/handler.go
+++ b/management/internals/modules/reverseproxy/proxytoken/handler.go
@@ -37,7 +37,7 @@ func (h *handler) createToken(w http.ResponseWriter, r *http.Request) {
return
}
- ok, err := h.permissionsManager.ValidateUserPermissions(r.Context(), userAuth.AccountId, userAuth.UserId, modules.Services, operations.Create)
+ ok, ctx, err := h.permissionsManager.ValidateUserPermissions(r.Context(), userAuth.AccountId, userAuth.UserId, modules.Services, operations.Create)
if err != nil {
util.WriteErrorResponse("failed to validate permissions", http.StatusInternalServerError, w)
return
@@ -76,13 +76,13 @@ func (h *handler) createToken(w http.ResponseWriter, r *http.Request) {
return
}
- if err := h.store.SaveProxyAccessToken(r.Context(), &generated.ProxyAccessToken); err != nil {
+ if err := h.store.SaveProxyAccessToken(ctx, &generated.ProxyAccessToken); err != nil {
util.WriteErrorResponse("failed to save token", http.StatusInternalServerError, w)
return
}
resp := toProxyTokenCreatedResponse(generated)
- util.WriteJSONObject(r.Context(), w, resp)
+ util.WriteJSONObject(ctx, w, resp)
}
func (h *handler) listTokens(w http.ResponseWriter, r *http.Request) {
@@ -92,7 +92,7 @@ func (h *handler) listTokens(w http.ResponseWriter, r *http.Request) {
return
}
- ok, err := h.permissionsManager.ValidateUserPermissions(r.Context(), userAuth.AccountId, userAuth.UserId, modules.Services, operations.Read)
+ ok, ctx, err := h.permissionsManager.ValidateUserPermissions(r.Context(), userAuth.AccountId, userAuth.UserId, modules.Services, operations.Read)
if err != nil {
util.WriteErrorResponse("failed to validate permissions", http.StatusInternalServerError, w)
return
@@ -102,7 +102,7 @@ func (h *handler) listTokens(w http.ResponseWriter, r *http.Request) {
return
}
- tokens, err := h.store.GetProxyAccessTokensByAccountID(r.Context(), store.LockingStrengthNone, userAuth.AccountId)
+ tokens, err := h.store.GetProxyAccessTokensByAccountID(ctx, store.LockingStrengthNone, userAuth.AccountId)
if err != nil {
util.WriteErrorResponse("failed to list tokens", http.StatusInternalServerError, w)
return
@@ -113,7 +113,7 @@ func (h *handler) listTokens(w http.ResponseWriter, r *http.Request) {
resp = append(resp, toProxyTokenResponse(token))
}
- util.WriteJSONObject(r.Context(), w, resp)
+ util.WriteJSONObject(ctx, w, resp)
}
func (h *handler) revokeToken(w http.ResponseWriter, r *http.Request) {
@@ -123,7 +123,7 @@ func (h *handler) revokeToken(w http.ResponseWriter, r *http.Request) {
return
}
- ok, err := h.permissionsManager.ValidateUserPermissions(r.Context(), userAuth.AccountId, userAuth.UserId, modules.Services, operations.Delete)
+ ok, ctx, err := h.permissionsManager.ValidateUserPermissions(r.Context(), userAuth.AccountId, userAuth.UserId, modules.Services, operations.Delete)
if err != nil {
util.WriteErrorResponse("failed to validate permissions", http.StatusInternalServerError, w)
return
@@ -139,7 +139,7 @@ func (h *handler) revokeToken(w http.ResponseWriter, r *http.Request) {
return
}
- token, err := h.store.GetProxyAccessTokenByID(r.Context(), store.LockingStrengthNone, tokenID)
+ token, err := h.store.GetProxyAccessTokenByID(ctx, store.LockingStrengthNone, tokenID)
if err != nil {
if s, ok := status.FromError(err); ok && s.ErrorType == status.NotFound {
util.WriteErrorResponse("token not found", http.StatusNotFound, w)
@@ -154,12 +154,12 @@ func (h *handler) revokeToken(w http.ResponseWriter, r *http.Request) {
return
}
- if err := h.store.RevokeProxyAccessToken(r.Context(), tokenID); err != nil {
+ if err := h.store.RevokeProxyAccessToken(ctx, tokenID); err != nil {
util.WriteErrorResponse("failed to revoke token", http.StatusInternalServerError, w)
return
}
- util.WriteJSONObject(r.Context(), w, util.EmptyObject{})
+ util.WriteJSONObject(ctx, w, util.EmptyObject{})
}
func toProxyTokenResponse(token *types.ProxyAccessToken) api.ProxyToken {
diff --git a/management/internals/modules/reverseproxy/proxytoken/handler_test.go b/management/internals/modules/reverseproxy/proxytoken/handler_test.go
index a28752909..a5b5713c6 100644
--- a/management/internals/modules/reverseproxy/proxytoken/handler_test.go
+++ b/management/internals/modules/reverseproxy/proxytoken/handler_test.go
@@ -47,7 +47,7 @@ func TestCreateToken_AccountScoped(t *testing.T) {
)
permsMgr := permissions.NewMockManager(ctrl)
- permsMgr.EXPECT().ValidateUserPermissions(gomock.Any(), accountID, "user-1", modules.Services, operations.Create).Return(true, nil)
+ permsMgr.EXPECT().ValidateUserPermissions(gomock.Any(), accountID, "user-1", modules.Services, operations.Create).Return(true, context.Background(), nil)
h := &handler{
store: mockStore,
@@ -90,7 +90,7 @@ func TestCreateToken_WithExpiration(t *testing.T) {
)
permsMgr := permissions.NewMockManager(ctrl)
- permsMgr.EXPECT().ValidateUserPermissions(gomock.Any(), "acc-123", "user-1", modules.Services, operations.Create).Return(true, nil)
+ permsMgr.EXPECT().ValidateUserPermissions(gomock.Any(), "acc-123", "user-1", modules.Services, operations.Create).Return(true, context.Background(), nil)
h := &handler{
store: mockStore,
@@ -115,7 +115,7 @@ func TestCreateToken_EmptyName(t *testing.T) {
defer ctrl.Finish()
permsMgr := permissions.NewMockManager(ctrl)
- permsMgr.EXPECT().ValidateUserPermissions(gomock.Any(), "acc-123", "user-1", modules.Services, operations.Create).Return(true, nil)
+ permsMgr.EXPECT().ValidateUserPermissions(gomock.Any(), "acc-123", "user-1", modules.Services, operations.Create).Return(true, context.Background(), nil)
h := &handler{
permissionsManager: permsMgr,
@@ -135,7 +135,7 @@ func TestCreateToken_PermissionDenied(t *testing.T) {
defer ctrl.Finish()
permsMgr := permissions.NewMockManager(ctrl)
- permsMgr.EXPECT().ValidateUserPermissions(gomock.Any(), "acc-123", "user-1", modules.Services, operations.Create).Return(false, nil)
+ permsMgr.EXPECT().ValidateUserPermissions(gomock.Any(), "acc-123", "user-1", modules.Services, operations.Create).Return(false, context.Background(), nil)
h := &handler{
permissionsManager: permsMgr,
@@ -164,7 +164,7 @@ func TestListTokens(t *testing.T) {
}, nil)
permsMgr := permissions.NewMockManager(ctrl)
- permsMgr.EXPECT().ValidateUserPermissions(gomock.Any(), accountID, "user-1", modules.Services, operations.Read).Return(true, nil)
+ permsMgr.EXPECT().ValidateUserPermissions(gomock.Any(), accountID, "user-1", modules.Services, operations.Read).Return(true, context.Background(), nil)
h := &handler{
store: mockStore,
@@ -202,7 +202,7 @@ func TestRevokeToken_Success(t *testing.T) {
mockStore.EXPECT().RevokeProxyAccessToken(gomock.Any(), "tok-1").Return(nil)
permsMgr := permissions.NewMockManager(ctrl)
- permsMgr.EXPECT().ValidateUserPermissions(gomock.Any(), accountID, "user-1", modules.Services, operations.Delete).Return(true, nil)
+ permsMgr.EXPECT().ValidateUserPermissions(gomock.Any(), accountID, "user-1", modules.Services, operations.Delete).Return(true, context.Background(), nil)
h := &handler{
store: mockStore,
@@ -231,7 +231,7 @@ func TestRevokeToken_WrongAccount(t *testing.T) {
}, nil)
permsMgr := permissions.NewMockManager(ctrl)
- permsMgr.EXPECT().ValidateUserPermissions(gomock.Any(), "acc-123", "user-1", modules.Services, operations.Delete).Return(true, nil)
+ permsMgr.EXPECT().ValidateUserPermissions(gomock.Any(), "acc-123", "user-1", modules.Services, operations.Delete).Return(true, context.Background(), nil)
h := &handler{
store: mockStore,
@@ -258,7 +258,7 @@ func TestRevokeToken_ManagementWideToken(t *testing.T) {
}, nil)
permsMgr := permissions.NewMockManager(ctrl)
- permsMgr.EXPECT().ValidateUserPermissions(gomock.Any(), "acc-123", "user-1", modules.Services, operations.Delete).Return(true, nil)
+ permsMgr.EXPECT().ValidateUserPermissions(gomock.Any(), "acc-123", "user-1", modules.Services, operations.Delete).Return(true, context.Background(), nil)
h := &handler{
store: mockStore,
diff --git a/management/internals/modules/reverseproxy/service/manager/l4_port_test.go b/management/internals/modules/reverseproxy/service/manager/l4_port_test.go
index 3485d51fe..c218291ef 100644
--- a/management/internals/modules/reverseproxy/service/manager/l4_port_test.go
+++ b/management/internals/modules/reverseproxy/service/manager/l4_port_test.go
@@ -488,6 +488,195 @@ func TestUpdate_AllowsPortChange(t *testing.T) {
assert.Equal(t, uint16(54321), updated.ListenPort, "explicit port change should be applied")
}
+func TestUpdate_PreservesPortWhenCustomPortsNotSupported(t *testing.T) {
+ mgr, testStore, _ := setupL4Test(t, boolPtr(false))
+ ctx := context.Background()
+
+ existing := seedService(t, testStore, "tcp-svc", "tcp", testCluster, testCluster, 12345)
+
+ updated := &rpservice.Service{
+ ID: existing.ID,
+ AccountID: testAccountID,
+ Name: "tcp-svc-renamed",
+ Mode: "tcp",
+ Domain: testCluster,
+ ProxyCluster: testCluster,
+ ListenPort: 0,
+ Enabled: true,
+ Targets: []*rpservice.Target{
+ {AccountID: testAccountID, TargetId: testPeerID, TargetType: rpservice.TargetTypePeer, Protocol: "tcp", Port: 9090, Enabled: true},
+ },
+ }
+
+ _, err := mgr.persistServiceUpdate(ctx, testAccountID, updated)
+ require.NoError(t, err, "update must not be rejected by the custom-port capability check")
+ assert.Equal(t, uint16(12345), updated.ListenPort, "existing listen port should be preserved on unsupported cluster")
+}
+
+func TestUpdate_PreservesPortWhenCustomPortsUnknown(t *testing.T) {
+ mgr, testStore, _ := setupL4Test(t, nil)
+ ctx := context.Background()
+
+ existing := seedService(t, testStore, "tcp-svc", "tcp", testCluster, testCluster, 12345)
+
+ updated := &rpservice.Service{
+ ID: existing.ID,
+ AccountID: testAccountID,
+ Name: "tcp-svc-renamed",
+ Mode: "tcp",
+ Domain: testCluster,
+ ProxyCluster: testCluster,
+ ListenPort: 0,
+ Enabled: true,
+ Targets: []*rpservice.Target{
+ {AccountID: testAccountID, TargetId: testPeerID, TargetType: rpservice.TargetTypePeer, Protocol: "tcp", Port: 9090, Enabled: true},
+ },
+ }
+
+ _, err := mgr.persistServiceUpdate(ctx, testAccountID, updated)
+ require.NoError(t, err, "update must not be rejected when cluster capability is unknown")
+ assert.Equal(t, uint16(12345), updated.ListenPort, "existing listen port should be preserved when capability is unknown")
+}
+
+func TestUpdate_RejectsPortChangeWhenCustomPortsNotSupported(t *testing.T) {
+ mgr, testStore, _ := setupL4Test(t, boolPtr(false))
+ ctx := context.Background()
+
+ existing := seedService(t, testStore, "tcp-svc", "tcp", testCluster, testCluster, 12345)
+
+ updated := &rpservice.Service{
+ ID: existing.ID,
+ AccountID: testAccountID,
+ Name: "tcp-svc",
+ Mode: "tcp",
+ Domain: testCluster,
+ ProxyCluster: testCluster,
+ ListenPort: 54321,
+ Enabled: true,
+ Targets: []*rpservice.Target{
+ {AccountID: testAccountID, TargetId: testPeerID, TargetType: rpservice.TargetTypePeer, Protocol: "tcp", Port: 9090, Enabled: true},
+ },
+ }
+
+ _, err := mgr.persistServiceUpdate(ctx, testAccountID, updated)
+ require.Error(t, err, "explicit port change on update must be rejected on unsupported clusters")
+ assert.Contains(t, err.Error(), "custom ports not supported on target cluster")
+}
+
+func TestUpdate_TLSPortChangeAllowedWhenNotSupported(t *testing.T) {
+ mgr, testStore, _ := setupL4Test(t, boolPtr(false))
+ ctx := context.Background()
+
+ existing := seedService(t, testStore, "tls-svc", "tls", "app.example.com", testCluster, 443)
+
+ updated := &rpservice.Service{
+ ID: existing.ID,
+ AccountID: testAccountID,
+ Name: "tls-svc",
+ Mode: "tls",
+ Domain: "app.example.com",
+ ProxyCluster: testCluster,
+ ListenPort: 9999,
+ Enabled: true,
+ Targets: []*rpservice.Target{
+ {AccountID: testAccountID, TargetId: testPeerID, TargetType: rpservice.TargetTypePeer, Protocol: "tcp", Port: 8443, Enabled: true},
+ },
+ }
+
+ _, err := mgr.persistServiceUpdate(ctx, testAccountID, updated)
+ require.NoError(t, err, "TLS port change uses SNI routing and is exempt from the custom-port check")
+ assert.Equal(t, uint16(9999), updated.ListenPort, "TLS port change should be applied")
+}
+
+func TestValidateL4PortDiffOnClusterDiff(t *testing.T) {
+ tests := []struct {
+ name string
+ mode string
+ customPorts *bool
+ newPort uint16
+ oldPort uint16
+ wantErr bool
+ }{
+ {"tcp port change unsupported", "tcp", boolPtr(false), 54321, 12345, true},
+ {"tcp port change unknown capability", "tcp", nil, 54321, 12345, true},
+ {"udp port change unsupported", "udp", boolPtr(false), 54321, 12345, true},
+ {"tcp first port assignment unsupported", "tcp", boolPtr(false), 54321, 0, true},
+ {"tcp port change supported", "tcp", boolPtr(true), 54321, 12345, false},
+ {"tcp port unchanged unsupported", "tcp", boolPtr(false), 12345, 12345, false},
+ {"tcp zero port unsupported", "tcp", boolPtr(false), 0, 12345, false},
+ {"tls port change unsupported", "tls", boolPtr(false), 9999, 443, false},
+ {"http mode ignored", "http", boolPtr(false), 54321, 12345, false},
+ {"empty mode ignored", "", boolPtr(false), 54321, 12345, false},
+ }
+
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ newSvc := &rpservice.Service{Mode: tc.mode, ListenPort: tc.newPort, ProxyCluster: testCluster}
+ oldSvc := &rpservice.Service{Mode: tc.mode, ListenPort: tc.oldPort, ProxyCluster: testCluster}
+
+ err := validateL4PortDiffOnClusterDiff(tc.customPorts, newSvc, oldSvc)
+ if tc.wantErr {
+ assert.Error(t, err, "port diff should be rejected for %s", tc.name)
+ } else {
+ assert.NoError(t, err, "port diff should be allowed for %s", tc.name)
+ }
+ })
+ }
+}
+
+func TestUpdate_PortConflictRejected(t *testing.T) {
+ mgr, testStore, _ := setupL4Test(t, boolPtr(true))
+ ctx := context.Background()
+
+ seedService(t, testStore, "tcp-a", "tcp", "tcp-a."+testCluster, testCluster, 5432)
+ svcB := seedService(t, testStore, "tcp-b", "tcp", "tcp-b."+testCluster, testCluster, 6543)
+
+ updated := &rpservice.Service{
+ ID: svcB.ID,
+ AccountID: testAccountID,
+ Name: "tcp-b",
+ Mode: "tcp",
+ Domain: "tcp-b." + testCluster,
+ ProxyCluster: testCluster,
+ ListenPort: 5432,
+ Enabled: true,
+ Targets: []*rpservice.Target{
+ {AccountID: testAccountID, TargetId: testPeerID, TargetType: rpservice.TargetTypePeer, Protocol: "tcp", Port: 9090, Enabled: true},
+ },
+ }
+
+ _, err := mgr.persistServiceUpdate(ctx, testAccountID, updated)
+ require.Error(t, err, "updating to a port held by another service should be rejected")
+ assert.Contains(t, err.Error(), "already in use")
+}
+
+func TestUpdate_AutoAssignsWhenNoPort(t *testing.T) {
+ mgr, testStore, _ := setupL4Test(t, boolPtr(false))
+ ctx := context.Background()
+
+ existing := seedService(t, testStore, "tcp-svc", "tcp", testCluster, testCluster, 0)
+
+ updated := &rpservice.Service{
+ ID: existing.ID,
+ AccountID: testAccountID,
+ Name: "tcp-svc",
+ Mode: "tcp",
+ Domain: testCluster,
+ ProxyCluster: testCluster,
+ ListenPort: 0,
+ Enabled: true,
+ Targets: []*rpservice.Target{
+ {AccountID: testAccountID, TargetId: testPeerID, TargetType: rpservice.TargetTypePeer, Protocol: "tcp", Port: 9090, Enabled: true},
+ },
+ }
+
+ _, err := mgr.persistServiceUpdate(ctx, testAccountID, updated)
+ require.NoError(t, err)
+ assert.True(t, updated.ListenPort >= autoAssignPortMin && updated.ListenPort <= autoAssignPortMax,
+ "auto-assigned port %d should be in range [%d, %d]", updated.ListenPort, autoAssignPortMin, autoAssignPortMax)
+ assert.True(t, updated.PortAutoAssigned, "PortAutoAssigned should be set when update triggers auto-assignment")
+}
+
func TestCreateServiceFromPeer_TCP(t *testing.T) {
mgr, _, _ := setupL4Test(t, boolPtr(false))
ctx := context.Background()
diff --git a/management/internals/modules/reverseproxy/service/manager/manager.go b/management/internals/modules/reverseproxy/service/manager/manager.go
index f0ac68ed0..365fbab40 100644
--- a/management/internals/modules/reverseproxy/service/manager/manager.go
+++ b/management/internals/modules/reverseproxy/service/manager/manager.go
@@ -120,7 +120,7 @@ func (m *Manager) StartExposeReaper(ctx context.Context) {
// capability flags reported by its active proxies so the dashboard can
// render feature support without a second round-trip.
func (m *Manager) GetClusters(ctx context.Context, accountID, userID string) ([]proxy.Cluster, error) {
- ok, err := m.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Services, operations.Read)
+ ok, ctx, err := m.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Services, operations.Read)
if err != nil {
return nil, status.NewPermissionValidationError(err)
}
@@ -146,7 +146,7 @@ func (m *Manager) GetClusters(ctx context.Context, accountID, userID string) ([]
// DeleteAccountCluster removes all proxy registrations for the given cluster address
// owned by the account.
func (m *Manager) DeleteAccountCluster(ctx context.Context, accountID, userID, clusterAddress string) error {
- ok, err := m.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Services, operations.Delete)
+ ok, ctx, err := m.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Services, operations.Delete)
if err != nil {
return status.NewPermissionValidationError(err)
}
@@ -158,7 +158,7 @@ func (m *Manager) DeleteAccountCluster(ctx context.Context, accountID, userID, c
}
func (m *Manager) GetAllServices(ctx context.Context, accountID, userID string) ([]*service.Service, error) {
- ok, err := m.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Services, operations.Read)
+ ok, ctx, err := m.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Services, operations.Read)
if err != nil {
return nil, status.NewPermissionValidationError(err)
}
@@ -222,7 +222,7 @@ func (m *Manager) replaceHostByLookup(ctx context.Context, accountID string, s *
}
func (m *Manager) GetService(ctx context.Context, accountID, userID, serviceID string) (*service.Service, error) {
- ok, err := m.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Services, operations.Read)
+ ok, ctx, err := m.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Services, operations.Read)
if err != nil {
return nil, status.NewPermissionValidationError(err)
}
@@ -243,7 +243,7 @@ func (m *Manager) GetService(ctx context.Context, accountID, userID, serviceID s
}
func (m *Manager) CreateService(ctx context.Context, accountID, userID string, s *service.Service) (*service.Service, error) {
- ok, err := m.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Services, operations.Create)
+ ok, ctx, err := m.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Services, operations.Create)
if err != nil {
return nil, status.NewPermissionValidationError(err)
}
@@ -338,7 +338,7 @@ func (m *Manager) persistNewService(ctx context.Context, accountID string, svc *
}
}
- if err := m.ensureL4Port(ctx, transaction, svc, customPorts); err != nil {
+ if err := m.ensureL4Port(ctx, transaction, svc, customPorts, false); err != nil {
return err
}
@@ -367,11 +367,11 @@ func (m *Manager) clusterCustomPorts(ctx context.Context, svc *service.Service)
// ensureL4Port auto-assigns a listen port when needed and validates cluster support.
// customPorts must be pre-computed via clusterCustomPorts before entering a transaction.
-func (m *Manager) ensureL4Port(ctx context.Context, tx store.Store, svc *service.Service, customPorts *bool) error {
+func (m *Manager) ensureL4Port(ctx context.Context, tx store.Store, svc *service.Service, customPorts *bool, serviceUpdate bool) error {
if !service.IsL4Protocol(svc.Mode) {
return nil
}
- if service.IsPortBasedProtocol(svc.Mode) && svc.ListenPort > 0 && (customPorts == nil || !*customPorts) {
+ if service.IsPortBasedProtocol(svc.Mode) && svc.ListenPort > 0 && !serviceUpdate && (customPorts == nil || !*customPorts) {
if svc.Source != service.SourceEphemeral {
return status.Errorf(status.InvalidArgument, "custom ports not supported on cluster %s", svc.ProxyCluster)
}
@@ -465,7 +465,7 @@ func (m *Manager) persistNewEphemeralService(ctx context.Context, accountID, pee
return err
}
- if err := m.ensureL4Port(ctx, transaction, svc, customPorts); err != nil {
+ if err := m.ensureL4Port(ctx, transaction, svc, customPorts, false); err != nil {
return err
}
@@ -528,7 +528,7 @@ func (m *Manager) checkDomainAvailable(ctx context.Context, transaction store.St
}
func (m *Manager) UpdateService(ctx context.Context, accountID, userID string, service *service.Service) (*service.Service, error) {
- ok, err := m.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Services, operations.Update)
+ ok, ctx, err := m.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Services, operations.Update)
if err != nil {
return nil, status.NewPermissionValidationError(err)
}
@@ -651,12 +651,22 @@ func (m *Manager) executeServiceUpdate(ctx context.Context, transaction store.St
m.preserveListenPort(service, existingService)
updateInfo.serviceEnabledChanged = existingService.Enabled != service.Enabled
- if err := m.ensureL4Port(ctx, transaction, service, customPorts); err != nil {
+ // if the service is being updated, and we decide in the future to allow mode update,
+ // we should reconsider the currently assigned port if not 0 for clusters that don't support custom ports
+ if err := validateL4PortDiffOnClusterDiff(customPorts, service, existingService); err != nil {
return err
}
+
+ if err := m.ensureL4Port(ctx, transaction, service, customPorts, true); err != nil {
+ return err
+ }
+
+ // we can try carrying the previous service port into a new cluster, if this becomes a problem for multiple users,
+ // we should reconsider adding another check
if err := m.checkPortConflict(ctx, transaction, service); err != nil {
return err
}
+
if err := transaction.UpdateService(ctx, service); err != nil {
return fmt.Errorf("update service: %w", err)
}
@@ -664,6 +674,21 @@ func (m *Manager) executeServiceUpdate(ctx context.Context, transaction store.St
return nil
}
+// validateL4PortDiffOnClusterDiff checks if custom L4 ports are configured and validates port changes across clusters.
+// It ensures no port changes if custom ports are unsupported for a given cluster and protocol mode.
+// Returns an error if validation fails, otherwise returns nil.
+func validateL4PortDiffOnClusterDiff(customPorts *bool, newSVC, oldSVC *service.Service) error {
+ if !service.IsPortBasedProtocol(newSVC.Mode) || (customPorts != nil && *customPorts) {
+ return nil
+ }
+
+ if newSVC.ListenPort != 0 && newSVC.ListenPort != oldSVC.ListenPort {
+ return status.Errorf(status.InvalidArgument, "custom ports not supported on target cluster %s", newSVC.ProxyCluster)
+ }
+
+ return nil
+}
+
// handleDomainChange validates the new domain is free inside the transaction
// and applies the pre-resolved cluster (computed outside the tx by
// resolveEffectiveCluster). It must NOT call clusterDeriver here: that talks
@@ -836,7 +861,7 @@ func validateResourceTargetType(target *service.Target, resource *resourcetypes.
}
func (m *Manager) DeleteService(ctx context.Context, accountID, userID, serviceID string) error {
- ok, err := m.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Services, operations.Delete)
+ ok, ctx, err := m.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Services, operations.Delete)
if err != nil {
return status.NewPermissionValidationError(err)
}
@@ -876,7 +901,7 @@ func (m *Manager) DeleteService(ctx context.Context, accountID, userID, serviceI
}
func (m *Manager) DeleteAllServices(ctx context.Context, accountID, userID string) error {
- ok, err := m.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Services, operations.Delete)
+ ok, ctx, err := m.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Services, operations.Delete)
if err != nil {
return status.NewPermissionValidationError(err)
}
@@ -893,6 +918,10 @@ func (m *Manager) DeleteAllServices(ctx context.Context, accountID, userID strin
}
for _, svc := range services {
+ if err = transaction.DeleteServiceTargets(ctx, accountID, svc.ID); err != nil {
+ return fmt.Errorf("failed to delete service targets: %w", err)
+ }
+
if err = transaction.DeleteService(ctx, accountID, svc.ID); err != nil {
return fmt.Errorf("failed to delete service: %w", err)
}
@@ -1245,6 +1274,10 @@ func (m *Manager) deletePeerService(ctx context.Context, accountID, peerID, serv
return status.Errorf(status.PermissionDenied, "cannot delete service exposed by another peer")
}
+ if err = transaction.DeleteServiceTargets(ctx, accountID, serviceID); err != nil {
+ return fmt.Errorf("delete service targets: %w", err)
+ }
+
if err = transaction.DeleteService(ctx, accountID, serviceID); err != nil {
return fmt.Errorf("delete service: %w", err)
}
@@ -1294,6 +1327,10 @@ func (m *Manager) deleteExpiredPeerService(ctx context.Context, accountID, peerI
return nil
}
+ if err = transaction.DeleteServiceTargets(ctx, accountID, serviceID); err != nil {
+ return fmt.Errorf("delete service targets: %w", err)
+ }
+
if err = transaction.DeleteService(ctx, accountID, serviceID); err != nil {
return fmt.Errorf("delete service: %w", err)
}
diff --git a/management/internals/modules/reverseproxy/service/manager/manager_test.go b/management/internals/modules/reverseproxy/service/manager/manager_test.go
index f3ab89a25..ace105b31 100644
--- a/management/internals/modules/reverseproxy/service/manager/manager_test.go
+++ b/management/internals/modules/reverseproxy/service/manager/manager_test.go
@@ -458,6 +458,9 @@ func TestDeletePeerService_SourcePeerValidation(t *testing.T) {
txMock.EXPECT().
GetServiceByID(ctx, store.LockingStrengthUpdate, accountID, serviceID).
Return(newEphemeralService(), nil)
+ txMock.EXPECT().
+ DeleteServiceTargets(ctx, accountID, serviceID).
+ Return(nil)
txMock.EXPECT().
DeleteService(ctx, accountID, serviceID).
Return(nil)
@@ -560,6 +563,9 @@ func TestDeletePeerService_SourcePeerValidation(t *testing.T) {
txMock.EXPECT().
GetServiceByID(ctx, store.LockingStrengthUpdate, accountID, serviceID).
Return(newEphemeralService(), nil)
+ txMock.EXPECT().
+ DeleteServiceTargets(ctx, accountID, serviceID).
+ Return(nil)
txMock.EXPECT().
DeleteService(ctx, accountID, serviceID).
Return(nil)
@@ -604,6 +610,9 @@ func TestDeletePeerService_SourcePeerValidation(t *testing.T) {
txMock.EXPECT().
GetServiceByID(ctx, store.LockingStrengthUpdate, accountID, serviceID).
Return(newEphemeralService(), nil)
+ txMock.EXPECT().
+ DeleteServiceTargets(ctx, accountID, serviceID).
+ Return(nil)
txMock.EXPECT().
DeleteService(ctx, accountID, serviceID).
Return(nil)
@@ -1172,7 +1181,7 @@ func TestDeleteService_DeletesTargets(t *testing.T) {
mockPerms.EXPECT().
ValidateUserPermissions(ctx, accountID, userID, modules.Services, operations.Delete).
- Return(true, nil)
+ Return(true, ctx, nil)
mockAcct.EXPECT().
StoreEvent(ctx, userID, service.ID, accountID, activity.ServiceDeleted, gomock.Any())
mockAcct.EXPECT().
@@ -1192,6 +1201,67 @@ func TestDeleteService_DeletesTargets(t *testing.T) {
assert.Len(t, targets, 0, "All targets should be deleted when service is deleted")
}
+func TestDeleteExpiredPeerService_DeletesTargets(t *testing.T) {
+ ctx := context.Background()
+ mgr, testStore := setupIntegrationTest(t)
+
+ resp, err := mgr.CreateServiceFromPeer(ctx, testAccountID, testPeerID, &rpservice.ExposeServiceRequest{
+ Port: 8080,
+ Mode: "http",
+ })
+ require.NoError(t, err)
+
+ svcID := resolveServiceIDByDomain(t, testStore, resp.Domain)
+
+ targets, err := testStore.GetTargetsByServiceID(ctx, store.LockingStrengthNone, testAccountID, svcID)
+ require.NoError(t, err)
+ require.Len(t, targets, 1, "ephemeral peer-exposed service should have exactly one persisted target before reaping")
+
+ expireEphemeralService(t, testStore, testAccountID, resp.Domain)
+ err = mgr.deleteExpiredPeerService(ctx, testAccountID, testPeerID, svcID)
+ require.NoError(t, err)
+
+ _, err = testStore.GetServiceByDomain(ctx, resp.Domain)
+ require.Error(t, err, "expired peer-exposed service should be deleted")
+ s, ok := status.FromError(err)
+ require.True(t, ok)
+ assert.Equal(t, status.NotFound, s.Type())
+
+ targets, err = testStore.GetTargetsByServiceID(ctx, store.LockingStrengthNone, testAccountID, svcID)
+ require.NoError(t, err)
+ assert.Len(t, targets, 0, "orphaned target rows must be deleted when an expired peer-exposed service is reaped")
+}
+
+func TestDeleteServiceFromPeer_DeletesTargets(t *testing.T) {
+ ctx := context.Background()
+ mgr, testStore := setupIntegrationTest(t)
+
+ resp, err := mgr.CreateServiceFromPeer(ctx, testAccountID, testPeerID, &rpservice.ExposeServiceRequest{
+ Port: 8080,
+ Mode: "http",
+ })
+ require.NoError(t, err)
+
+ svcID := resolveServiceIDByDomain(t, testStore, resp.Domain)
+
+ targets, err := testStore.GetTargetsByServiceID(ctx, store.LockingStrengthNone, testAccountID, svcID)
+ require.NoError(t, err)
+ require.Len(t, targets, 1, "ephemeral peer-exposed service should have exactly one persisted target before stopping")
+
+ err = mgr.StopServiceFromPeer(ctx, testAccountID, testPeerID, svcID)
+ require.NoError(t, err)
+
+ _, err = testStore.GetServiceByDomain(ctx, resp.Domain)
+ require.Error(t, err, "stopped peer-exposed service should be deleted")
+ s, ok := status.FromError(err)
+ require.True(t, ok)
+ assert.Equal(t, status.NotFound, s.Type())
+
+ targets, err = testStore.GetTargetsByServiceID(ctx, store.LockingStrengthNone, testAccountID, svcID)
+ require.NoError(t, err)
+ assert.Len(t, targets, 0, "orphaned target rows must be deleted when a peer stops its exposed service")
+}
+
func TestValidateProtocolChange(t *testing.T) {
tests := []struct {
name string
diff --git a/management/internals/modules/reverseproxy/service/service.go b/management/internals/modules/reverseproxy/service/service.go
index 27f6d914d..ee1e3c8b2 100644
--- a/management/internals/modules/reverseproxy/service/service.go
+++ b/management/internals/modules/reverseproxy/service/service.go
@@ -932,7 +932,11 @@ func (s *Service) validateL4Target(target *Target) error {
if target.TargetId == "" {
return errors.New("target_id is required for L4 services")
}
- if target.TargetType != TargetTypeCluster && target.Port == 0 {
+ // Cluster targets resolve their upstream host:port from the target's
+ // own Host/Port fields just like the other L4 types — buildPathMappings
+ // emits net.JoinHostPort(target.Host, target.Port) for every L4
+ // target, so allowing port=0 here would let ":0" reach the proxy.
+ if target.Port == 0 {
return errors.New("target port is required for L4 services")
}
switch target.TargetType {
diff --git a/management/internals/modules/reverseproxy/service/service_test.go b/management/internals/modules/reverseproxy/service/service_test.go
index ba63d76ed..a149ac609 100644
--- a/management/internals/modules/reverseproxy/service/service_test.go
+++ b/management/internals/modules/reverseproxy/service/service_test.go
@@ -1176,7 +1176,12 @@ func TestValidate_HTTPClusterTarget_RequiresDirectUpstream(t *testing.T) {
assert.ErrorContains(t, rp.Validate(), "direct upstream disabled", "cluster target must reject direct_upstream=false")
}
-func TestValidate_L4ClusterTarget(t *testing.T) {
+// TestValidate_L4ClusterTarget_RequiresPort confirms that an L4 cluster
+// target without an explicit port is rejected. buildPathMappings emits
+// net.JoinHostPort(target.Host, target.Port) for every L4 target — so
+// allowing port=0 would let the proxy ship ":0" upstreams. The port
+// requirement is the same as every other L4 target type.
+func TestValidate_L4ClusterTarget_RequiresPort(t *testing.T) {
rp := validProxy()
rp.Mode = ModeTCP
rp.ListenPort = 9000
@@ -1186,7 +1191,12 @@ func TestValidate_L4ClusterTarget(t *testing.T) {
Protocol: "tcp",
Enabled: true,
}}
- require.NoError(t, rp.Validate(), "L4 cluster target must validate without an explicit port")
+ assert.ErrorContains(t, rp.Validate(), "port is required",
+ "L4 cluster target must require an explicit port like other L4 target types")
+
+ rp.Targets[0].Port = 5432
+ rp.Targets[0].Host = "db.lan"
+ require.NoError(t, rp.Validate(), "L4 cluster target with host:port must validate")
}
func TestService_Copy_RoundtripsPrivate(t *testing.T) {
diff --git a/management/internals/modules/zones/manager/manager.go b/management/internals/modules/zones/manager/manager.go
index 439671e65..d5348d3d0 100644
--- a/management/internals/modules/zones/manager/manager.go
+++ b/management/internals/modules/zones/manager/manager.go
@@ -32,7 +32,7 @@ func NewManager(store store.Store, accountManager account.Manager, permissionsMa
}
func (m *managerImpl) GetAllZones(ctx context.Context, accountID, userID string) ([]*zones.Zone, error) {
- ok, err := m.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Dns, operations.Read)
+ ok, ctx, err := m.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Dns, operations.Read)
if err != nil {
return nil, status.NewPermissionValidationError(err)
}
@@ -44,7 +44,7 @@ func (m *managerImpl) GetAllZones(ctx context.Context, accountID, userID string)
}
func (m *managerImpl) GetZone(ctx context.Context, accountID, userID, zoneID string) (*zones.Zone, error) {
- ok, err := m.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Dns, operations.Read)
+ ok, ctx, err := m.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Dns, operations.Read)
if err != nil {
return nil, status.NewPermissionValidationError(err)
}
@@ -56,7 +56,7 @@ func (m *managerImpl) GetZone(ctx context.Context, accountID, userID, zoneID str
}
func (m *managerImpl) CreateZone(ctx context.Context, accountID, userID string, zone *zones.Zone) (*zones.Zone, error) {
- ok, err := m.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Dns, operations.Create)
+ ok, ctx, err := m.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Dns, operations.Create)
if err != nil {
return nil, status.NewPermissionValidationError(err)
}
@@ -103,7 +103,7 @@ func (m *managerImpl) CreateZone(ctx context.Context, accountID, userID string,
}
func (m *managerImpl) UpdateZone(ctx context.Context, accountID, userID string, updatedZone *zones.Zone) (*zones.Zone, error) {
- ok, err := m.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Dns, operations.Update)
+ ok, ctx, err := m.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Dns, operations.Update)
if err != nil {
return nil, status.NewPermissionValidationError(err)
}
@@ -151,7 +151,7 @@ func (m *managerImpl) UpdateZone(ctx context.Context, accountID, userID string,
}
func (m *managerImpl) DeleteZone(ctx context.Context, accountID, userID, zoneID string) error {
- ok, err := m.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Dns, operations.Delete)
+ ok, ctx, err := m.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Dns, operations.Delete)
if err != nil {
return status.NewPermissionValidationError(err)
}
diff --git a/management/internals/modules/zones/manager/manager_test.go b/management/internals/modules/zones/manager/manager_test.go
index b45ec7874..29e7e8677 100644
--- a/management/internals/modules/zones/manager/manager_test.go
+++ b/management/internals/modules/zones/manager/manager_test.go
@@ -79,7 +79,7 @@ func TestManagerImpl_GetAllZones(t *testing.T) {
mockPermissionsManager.EXPECT().
ValidateUserPermissions(ctx, testAccountID, testUserID, modules.Dns, operations.Read).
- Return(true, nil)
+ Return(true, ctx, nil)
result, err := manager.GetAllZones(ctx, testAccountID, testUserID)
require.NoError(t, err)
@@ -95,7 +95,7 @@ func TestManagerImpl_GetAllZones(t *testing.T) {
mockPermissionsManager.EXPECT().
ValidateUserPermissions(ctx, testAccountID, testUserID, modules.Dns, operations.Read).
- Return(false, nil)
+ Return(false, ctx, nil)
result, err := manager.GetAllZones(ctx, testAccountID, testUserID)
require.Error(t, err)
@@ -112,7 +112,7 @@ func TestManagerImpl_GetAllZones(t *testing.T) {
mockPermissionsManager.EXPECT().
ValidateUserPermissions(ctx, testAccountID, testUserID, modules.Dns, operations.Read).
- Return(false, status.Errorf(status.Internal, "permission check failed"))
+ Return(false, ctx, status.Errorf(status.Internal, "permission check failed"))
result, err := manager.GetAllZones(ctx, testAccountID, testUserID)
require.Error(t, err)
@@ -134,7 +134,7 @@ func TestManagerImpl_GetZone(t *testing.T) {
mockPermissionsManager.EXPECT().
ValidateUserPermissions(ctx, testAccountID, testUserID, modules.Dns, operations.Read).
- Return(true, nil)
+ Return(true, ctx, nil)
result, err := manager.GetZone(ctx, testAccountID, testUserID, zone.ID)
require.NoError(t, err)
@@ -150,7 +150,7 @@ func TestManagerImpl_GetZone(t *testing.T) {
mockPermissionsManager.EXPECT().
ValidateUserPermissions(ctx, testAccountID, testUserID, modules.Dns, operations.Read).
- Return(false, nil)
+ Return(false, ctx, nil)
result, err := manager.GetZone(ctx, testAccountID, testUserID, testZoneID)
require.Error(t, err)
@@ -179,7 +179,7 @@ func TestManagerImpl_CreateZone(t *testing.T) {
mockPermissionsManager.EXPECT().
ValidateUserPermissions(ctx, testAccountID, testUserID, modules.Dns, operations.Create).
- Return(true, nil)
+ Return(true, ctx, nil)
mockAccountManager.StoreEventFunc = func(ctx context.Context, initiatorID, targetID, accountID string, activityID activity.ActivityDescriber, meta map[string]any) {
assert.Equal(t, testUserID, initiatorID)
@@ -212,7 +212,7 @@ func TestManagerImpl_CreateZone(t *testing.T) {
mockPermissionsManager.EXPECT().
ValidateUserPermissions(ctx, testAccountID, testUserID, modules.Dns, operations.Create).
- Return(false, nil)
+ Return(false, ctx, nil)
result, err := manager.CreateZone(ctx, testAccountID, testUserID, inputZone)
require.Error(t, err)
@@ -235,7 +235,7 @@ func TestManagerImpl_CreateZone(t *testing.T) {
mockPermissionsManager.EXPECT().
ValidateUserPermissions(ctx, testAccountID, testUserID, modules.Dns, operations.Create).
- Return(true, nil)
+ Return(true, ctx, nil)
result, err := manager.CreateZone(ctx, testAccountID, testUserID, inputZone)
require.Error(t, err)
@@ -261,7 +261,7 @@ func TestManagerImpl_CreateZone(t *testing.T) {
mockPermissionsManager.EXPECT().
ValidateUserPermissions(ctx, testAccountID, testUserID, modules.Dns, operations.Create).
- Return(true, nil)
+ Return(true, ctx, nil)
result, err := manager.CreateZone(ctx, testAccountID, testUserID, inputZone)
require.Error(t, err)
@@ -293,7 +293,7 @@ func TestManagerImpl_CreateZone(t *testing.T) {
mockPermissionsManager.EXPECT().
ValidateUserPermissions(ctx, testAccountID, testUserID, modules.Dns, operations.Create).
- Return(true, nil)
+ Return(true, ctx, nil)
result, err := manager.CreateZone(ctx, testAccountID, testUserID, inputZone)
require.Error(t, err)
@@ -319,7 +319,7 @@ func TestManagerImpl_CreateZone(t *testing.T) {
mockPermissionsManager.EXPECT().
ValidateUserPermissions(ctx, testAccountID, testUserID, modules.Dns, operations.Create).
- Return(true, nil)
+ Return(true, ctx, nil)
result, err := manager.CreateZone(ctx, testAccountID, testUserID, inputZone)
require.Error(t, err)
@@ -354,7 +354,7 @@ func TestManagerImpl_UpdateZone(t *testing.T) {
mockPermissionsManager.EXPECT().
ValidateUserPermissions(ctx, testAccountID, testUserID, modules.Dns, operations.Update).
- Return(true, nil)
+ Return(true, ctx, nil)
storeEventCalled := false
mockAccountManager.StoreEventFunc = func(ctx context.Context, initiatorID, targetID, accountID string, activityID activity.ActivityDescriber, meta map[string]any) {
@@ -394,7 +394,7 @@ func TestManagerImpl_UpdateZone(t *testing.T) {
mockPermissionsManager.EXPECT().
ValidateUserPermissions(ctx, testAccountID, testUserID, modules.Dns, operations.Update).
- Return(true, nil)
+ Return(true, ctx, nil)
result, err := manager.UpdateZone(ctx, testAccountID, testUserID, updatedZone)
require.Error(t, err)
@@ -418,7 +418,7 @@ func TestManagerImpl_UpdateZone(t *testing.T) {
mockPermissionsManager.EXPECT().
ValidateUserPermissions(ctx, testAccountID, testUserID, modules.Dns, operations.Update).
- Return(false, nil)
+ Return(false, ctx, nil)
result, err := manager.UpdateZone(ctx, testAccountID, testUserID, updatedZone)
require.Error(t, err)
@@ -441,7 +441,7 @@ func TestManagerImpl_UpdateZone(t *testing.T) {
mockPermissionsManager.EXPECT().
ValidateUserPermissions(ctx, testAccountID, testUserID, modules.Dns, operations.Update).
- Return(true, nil)
+ Return(true, ctx, nil)
result, err := manager.UpdateZone(ctx, testAccountID, testUserID, updatedZone)
require.Error(t, err)
@@ -471,7 +471,7 @@ func TestManagerImpl_DeleteZone(t *testing.T) {
mockPermissionsManager.EXPECT().
ValidateUserPermissions(ctx, testAccountID, testUserID, modules.Dns, operations.Delete).
- Return(true, nil)
+ Return(true, ctx, nil)
storeEventCallCount := 0
mockAccountManager.StoreEventFunc = func(ctx context.Context, initiatorID, targetID, accountID string, activityID activity.ActivityDescriber, meta map[string]any) {
@@ -503,7 +503,7 @@ func TestManagerImpl_DeleteZone(t *testing.T) {
mockPermissionsManager.EXPECT().
ValidateUserPermissions(ctx, testAccountID, testUserID, modules.Dns, operations.Delete).
- Return(true, nil)
+ Return(true, ctx, nil)
storeEventCalled := false
mockAccountManager.StoreEventFunc = func(ctx context.Context, initiatorID, targetID, accountID string, activityID activity.ActivityDescriber, meta map[string]any) {
@@ -529,7 +529,7 @@ func TestManagerImpl_DeleteZone(t *testing.T) {
mockPermissionsManager.EXPECT().
ValidateUserPermissions(ctx, testAccountID, testUserID, modules.Dns, operations.Delete).
- Return(false, nil)
+ Return(false, ctx, nil)
err := manager.DeleteZone(ctx, testAccountID, testUserID, testZoneID)
require.Error(t, err)
@@ -545,7 +545,7 @@ func TestManagerImpl_DeleteZone(t *testing.T) {
mockPermissionsManager.EXPECT().
ValidateUserPermissions(ctx, testAccountID, testUserID, modules.Dns, operations.Delete).
- Return(true, nil)
+ Return(true, ctx, nil)
err := manager.DeleteZone(ctx, testAccountID, testUserID, "non-existent-zone")
require.Error(t, err)
diff --git a/management/internals/modules/zones/records/manager/manager.go b/management/internals/modules/zones/records/manager/manager.go
index 7458b41db..b041aca30 100644
--- a/management/internals/modules/zones/records/manager/manager.go
+++ b/management/internals/modules/zones/records/manager/manager.go
@@ -32,7 +32,7 @@ func NewManager(store store.Store, accountManager account.Manager, permissionsMa
}
func (m *managerImpl) GetAllRecords(ctx context.Context, accountID, userID, zoneID string) ([]*records.Record, error) {
- ok, err := m.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Dns, operations.Read)
+ ok, ctx, err := m.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Dns, operations.Read)
if err != nil {
return nil, status.NewPermissionValidationError(err)
}
@@ -44,7 +44,7 @@ func (m *managerImpl) GetAllRecords(ctx context.Context, accountID, userID, zone
}
func (m *managerImpl) GetRecord(ctx context.Context, accountID, userID, zoneID, recordID string) (*records.Record, error) {
- ok, err := m.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Dns, operations.Read)
+ ok, ctx, err := m.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Dns, operations.Read)
if err != nil {
return nil, status.NewPermissionValidationError(err)
}
@@ -56,7 +56,7 @@ func (m *managerImpl) GetRecord(ctx context.Context, accountID, userID, zoneID,
}
func (m *managerImpl) CreateRecord(ctx context.Context, accountID, userID, zoneID string, record *records.Record) (*records.Record, error) {
- ok, err := m.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Dns, operations.Create)
+ ok, ctx, err := m.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Dns, operations.Create)
if err != nil {
return nil, status.NewPermissionValidationError(err)
}
@@ -102,7 +102,7 @@ func (m *managerImpl) CreateRecord(ctx context.Context, accountID, userID, zoneI
}
func (m *managerImpl) UpdateRecord(ctx context.Context, accountID, userID, zoneID string, updatedRecord *records.Record) (*records.Record, error) {
- ok, err := m.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Dns, operations.Update)
+ ok, ctx, err := m.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Dns, operations.Update)
if err != nil {
return nil, status.NewPermissionValidationError(err)
}
@@ -161,7 +161,7 @@ func (m *managerImpl) UpdateRecord(ctx context.Context, accountID, userID, zoneI
}
func (m *managerImpl) DeleteRecord(ctx context.Context, accountID, userID, zoneID, recordID string) error {
- ok, err := m.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Dns, operations.Delete)
+ ok, ctx, err := m.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Dns, operations.Delete)
if err != nil {
return status.NewPermissionValidationError(err)
}
diff --git a/management/internals/modules/zones/records/manager/manager_test.go b/management/internals/modules/zones/records/manager/manager_test.go
index 0a962e0f4..a5f48c4a9 100644
--- a/management/internals/modules/zones/records/manager/manager_test.go
+++ b/management/internals/modules/zones/records/manager/manager_test.go
@@ -80,7 +80,7 @@ func TestManagerImpl_GetAllRecords(t *testing.T) {
mockPermissionsManager.EXPECT().
ValidateUserPermissions(ctx, testAccountID, testUserID, modules.Dns, operations.Read).
- Return(true, nil)
+ Return(true, ctx, nil)
result, err := manager.GetAllRecords(ctx, testAccountID, testUserID, zone.ID)
require.NoError(t, err)
@@ -96,7 +96,7 @@ func TestManagerImpl_GetAllRecords(t *testing.T) {
mockPermissionsManager.EXPECT().
ValidateUserPermissions(ctx, testAccountID, testUserID, modules.Dns, operations.Read).
- Return(false, nil)
+ Return(false, ctx, nil)
result, err := manager.GetAllRecords(ctx, testAccountID, testUserID, zone.ID)
require.Error(t, err)
@@ -113,7 +113,7 @@ func TestManagerImpl_GetAllRecords(t *testing.T) {
mockPermissionsManager.EXPECT().
ValidateUserPermissions(ctx, testAccountID, testUserID, modules.Dns, operations.Read).
- Return(false, status.Errorf(status.Internal, "permission check failed"))
+ Return(false, ctx, status.Errorf(status.Internal, "permission check failed"))
result, err := manager.GetAllRecords(ctx, testAccountID, testUserID, zone.ID)
require.Error(t, err)
@@ -135,7 +135,7 @@ func TestManagerImpl_GetRecord(t *testing.T) {
mockPermissionsManager.EXPECT().
ValidateUserPermissions(ctx, testAccountID, testUserID, modules.Dns, operations.Read).
- Return(true, nil)
+ Return(true, ctx, nil)
result, err := manager.GetRecord(ctx, testAccountID, testUserID, zone.ID, record.ID)
require.NoError(t, err)
@@ -153,7 +153,7 @@ func TestManagerImpl_GetRecord(t *testing.T) {
mockPermissionsManager.EXPECT().
ValidateUserPermissions(ctx, testAccountID, testUserID, modules.Dns, operations.Read).
- Return(false, nil)
+ Return(false, ctx, nil)
result, err := manager.GetRecord(ctx, testAccountID, testUserID, zone.ID, testRecordID)
require.Error(t, err)
@@ -181,7 +181,7 @@ func TestManagerImpl_CreateRecord(t *testing.T) {
mockPermissionsManager.EXPECT().
ValidateUserPermissions(ctx, testAccountID, testUserID, modules.Dns, operations.Create).
- Return(true, nil)
+ Return(true, ctx, nil)
mockAccountManager.StoreEventFunc = func(ctx context.Context, initiatorID, targetID, accountID string, activityID activity.ActivityDescriber, meta map[string]any) {
assert.Equal(t, testUserID, initiatorID)
@@ -215,7 +215,7 @@ func TestManagerImpl_CreateRecord(t *testing.T) {
mockPermissionsManager.EXPECT().
ValidateUserPermissions(ctx, testAccountID, testUserID, modules.Dns, operations.Create).
- Return(true, nil)
+ Return(true, ctx, nil)
mockAccountManager.StoreEventFunc = func(ctx context.Context, initiatorID, targetID, accountID string, activityID activity.ActivityDescriber, meta map[string]any) {
assert.Equal(t, testUserID, initiatorID)
@@ -244,7 +244,7 @@ func TestManagerImpl_CreateRecord(t *testing.T) {
mockPermissionsManager.EXPECT().
ValidateUserPermissions(ctx, testAccountID, testUserID, modules.Dns, operations.Create).
- Return(true, nil)
+ Return(true, ctx, nil)
mockAccountManager.StoreEventFunc = func(ctx context.Context, initiatorID, targetID, accountID string, activityID activity.ActivityDescriber, meta map[string]any) {
assert.Equal(t, testUserID, initiatorID)
@@ -273,7 +273,7 @@ func TestManagerImpl_CreateRecord(t *testing.T) {
mockPermissionsManager.EXPECT().
ValidateUserPermissions(ctx, testAccountID, testUserID, modules.Dns, operations.Create).
- Return(false, nil)
+ Return(false, ctx, nil)
result, err := manager.CreateRecord(ctx, testAccountID, testUserID, zone.ID, inputRecord)
require.Error(t, err)
@@ -297,7 +297,7 @@ func TestManagerImpl_CreateRecord(t *testing.T) {
mockPermissionsManager.EXPECT().
ValidateUserPermissions(ctx, testAccountID, testUserID, modules.Dns, operations.Create).
- Return(true, nil)
+ Return(true, ctx, nil)
result, err := manager.CreateRecord(ctx, testAccountID, testUserID, zone.ID, inputRecord)
require.Error(t, err)
@@ -323,7 +323,7 @@ func TestManagerImpl_CreateRecord(t *testing.T) {
mockPermissionsManager.EXPECT().
ValidateUserPermissions(ctx, testAccountID, testUserID, modules.Dns, operations.Create).
- Return(true, nil)
+ Return(true, ctx, nil)
result, err := manager.CreateRecord(ctx, testAccountID, testUserID, zone.ID, inputRecord)
require.Error(t, err)
@@ -349,7 +349,7 @@ func TestManagerImpl_CreateRecord(t *testing.T) {
mockPermissionsManager.EXPECT().
ValidateUserPermissions(ctx, testAccountID, testUserID, modules.Dns, operations.Create).
- Return(true, nil)
+ Return(true, ctx, nil)
result, err := manager.CreateRecord(ctx, testAccountID, testUserID, zone.ID, inputRecord)
require.Error(t, err)
@@ -380,7 +380,7 @@ func TestManagerImpl_UpdateRecord(t *testing.T) {
mockPermissionsManager.EXPECT().
ValidateUserPermissions(ctx, testAccountID, testUserID, modules.Dns, operations.Update).
- Return(true, nil)
+ Return(true, ctx, nil)
storeEventCalled := false
mockAccountManager.StoreEventFunc = func(ctx context.Context, initiatorID, targetID, accountID string, activityID activity.ActivityDescriber, meta map[string]any) {
@@ -418,7 +418,7 @@ func TestManagerImpl_UpdateRecord(t *testing.T) {
mockPermissionsManager.EXPECT().
ValidateUserPermissions(ctx, testAccountID, testUserID, modules.Dns, operations.Update).
- Return(true, nil)
+ Return(true, ctx, nil)
mockAccountManager.StoreEventFunc = func(ctx context.Context, initiatorID, targetID, accountID string, activityID activity.ActivityDescriber, meta map[string]any) {
// Event should be stored
@@ -445,7 +445,7 @@ func TestManagerImpl_UpdateRecord(t *testing.T) {
mockPermissionsManager.EXPECT().
ValidateUserPermissions(ctx, testAccountID, testUserID, modules.Dns, operations.Update).
- Return(false, nil)
+ Return(false, ctx, nil)
result, err := manager.UpdateRecord(ctx, testAccountID, testUserID, zone.ID, updatedRecord)
require.Error(t, err)
@@ -470,7 +470,7 @@ func TestManagerImpl_UpdateRecord(t *testing.T) {
mockPermissionsManager.EXPECT().
ValidateUserPermissions(ctx, testAccountID, testUserID, modules.Dns, operations.Update).
- Return(true, nil)
+ Return(true, ctx, nil)
result, err := manager.UpdateRecord(ctx, testAccountID, testUserID, zone.ID, updatedRecord)
require.Error(t, err)
@@ -500,7 +500,7 @@ func TestManagerImpl_UpdateRecord(t *testing.T) {
mockPermissionsManager.EXPECT().
ValidateUserPermissions(ctx, testAccountID, testUserID, modules.Dns, operations.Update).
- Return(true, nil)
+ Return(true, ctx, nil)
result, err := manager.UpdateRecord(ctx, testAccountID, testUserID, zone.ID, updatedRecord)
require.Error(t, err)
@@ -523,7 +523,7 @@ func TestManagerImpl_DeleteRecord(t *testing.T) {
mockPermissionsManager.EXPECT().
ValidateUserPermissions(ctx, testAccountID, testUserID, modules.Dns, operations.Delete).
- Return(true, nil)
+ Return(true, ctx, nil)
storeEventCalled := false
mockAccountManager.StoreEventFunc = func(ctx context.Context, initiatorID, targetID, accountID string, activityID activity.ActivityDescriber, meta map[string]any) {
@@ -549,7 +549,7 @@ func TestManagerImpl_DeleteRecord(t *testing.T) {
mockPermissionsManager.EXPECT().
ValidateUserPermissions(ctx, testAccountID, testUserID, modules.Dns, operations.Delete).
- Return(false, nil)
+ Return(false, ctx, nil)
err := manager.DeleteRecord(ctx, testAccountID, testUserID, zone.ID, testRecordID)
require.Error(t, err)
@@ -565,7 +565,7 @@ func TestManagerImpl_DeleteRecord(t *testing.T) {
mockPermissionsManager.EXPECT().
ValidateUserPermissions(ctx, testAccountID, testUserID, modules.Dns, operations.Delete).
- Return(true, nil)
+ Return(true, ctx, nil)
err := manager.DeleteRecord(ctx, testAccountID, testUserID, zone.ID, "non-existent-record")
require.Error(t, err)
diff --git a/management/internals/server/server.go b/management/internals/server/server.go
index 63d13baab..9411073ac 100644
--- a/management/internals/server/server.go
+++ b/management/internals/server/server.go
@@ -34,6 +34,8 @@ const (
ManagementLegacyPort = 33073
// DefaultSelfHostedDomain is the default domain used for self-hosted fresh installs.
DefaultSelfHostedDomain = "netbird.selfhosted"
+
+ ContainerKeyBaseServer = "baseServer"
)
type Server interface {
@@ -91,7 +93,7 @@ type Config struct {
// NewServer initializes and configures a new Server instance
func NewServer(cfg *Config) *BaseServer {
- return &BaseServer{
+ s := &BaseServer{
Config: cfg.NbConfig,
container: make(map[string]any),
dnsDomain: cfg.DNSDomain,
@@ -104,6 +106,9 @@ func NewServer(cfg *Config) *BaseServer {
mgmtMetricsPort: cfg.MgmtMetricsPort,
autoResolveDomains: cfg.AutoResolveDomains,
}
+ s.container[ContainerKeyBaseServer] = s
+
+ return s
}
func (s *BaseServer) AfterInit(fn func(s *BaseServer)) {
@@ -117,7 +122,7 @@ func (s *BaseServer) Start(ctx context.Context) error {
s.errCh = make(chan error, 4)
if s.autoResolveDomains {
- s.resolveDomains(srvCtx)
+ s.ResolveDomains(srvCtx)
}
s.PeersManager()
@@ -393,10 +398,10 @@ func (s *BaseServer) serveGRPCWithHTTP(ctx context.Context, listener net.Listene
}()
}
-// resolveDomains determines dnsDomain and mgmtSingleAccModeDomain based on store state.
+// ResolveDomains determines dnsDomain and mgmtSingleAccModeDomain based on store state.
// Fresh installs use the default self-hosted domain, while existing installs reuse the
// persisted account domain to keep addressing stable across config changes.
-func (s *BaseServer) resolveDomains(ctx context.Context) {
+func (s *BaseServer) ResolveDomains(ctx context.Context) {
st := s.Store()
setDefault := func(logMsg string, args ...any) {
diff --git a/management/internals/server/server_resolve_domains_test.go b/management/internals/server/server_resolve_domains_test.go
index db1d7e8ca..ba9eb3f74 100644
--- a/management/internals/server/server_resolve_domains_test.go
+++ b/management/internals/server/server_resolve_domains_test.go
@@ -22,7 +22,7 @@ func TestResolveDomains_FreshInstallUsesDefault(t *testing.T) {
srv := NewServer(&Config{NbConfig: &nbconfig.Config{}})
Inject[store.Store](srv, mockStore)
- srv.resolveDomains(context.Background())
+ srv.ResolveDomains(context.Background())
require.Equal(t, DefaultSelfHostedDomain, srv.dnsDomain)
require.Equal(t, DefaultSelfHostedDomain, srv.mgmtSingleAccModeDomain)
@@ -40,7 +40,7 @@ func TestResolveDomains_ExistingInstallUsesPersistedDomain(t *testing.T) {
srv := NewServer(&Config{NbConfig: &nbconfig.Config{}})
Inject[store.Store](srv, mockStore)
- srv.resolveDomains(context.Background())
+ srv.ResolveDomains(context.Background())
require.Equal(t, "vpn.mycompany.com", srv.dnsDomain)
require.Equal(t, "vpn.mycompany.com", srv.mgmtSingleAccModeDomain)
@@ -56,7 +56,7 @@ func TestResolveDomains_StoreErrorFallsBackToDefault(t *testing.T) {
srv := NewServer(&Config{NbConfig: &nbconfig.Config{}})
Inject[store.Store](srv, mockStore)
- srv.resolveDomains(context.Background())
+ srv.ResolveDomains(context.Background())
require.Equal(t, DefaultSelfHostedDomain, srv.dnsDomain)
require.Equal(t, DefaultSelfHostedDomain, srv.mgmtSingleAccModeDomain)
diff --git a/management/internals/shared/grpc/conversion.go b/management/internals/shared/grpc/conversion.go
index 328da1aee..95e0d71d4 100644
--- a/management/internals/shared/grpc/conversion.go
+++ b/management/internals/shared/grpc/conversion.go
@@ -8,6 +8,8 @@ import (
"strings"
"time"
+ "github.com/hashicorp/go-version"
+ nbversion "github.com/netbirdio/netbird/version"
"google.golang.org/protobuf/types/known/timestamppb"
integrationsConfig "github.com/netbirdio/management-integrations/integrations/config"
@@ -24,6 +26,23 @@ import (
"github.com/netbirdio/netbird/shared/netiputil"
)
+const (
+ // deprecatedRemotePeersVersion is the version of Netbird that introduced the NetworkMap.RemotePeers field, deprecated in favor of RemotePeers.
+ deprecatedRemotePeersVersion = "0.29.3"
+)
+
+// precomputedDeprecatedRemotePeersConstraint is the parsed ">= 0.29.3" constraint,
+// built once at init since the bound is a compile-time constant.
+var precomputedDeprecatedRemotePeersConstraint version.Constraints
+
+func init() {
+ constraint, err := version.NewConstraint(">= " + deprecatedRemotePeersVersion)
+ if err != nil {
+ panic("parse deprecated remote peers version constraint: " + err.Error())
+ }
+ precomputedDeprecatedRemotePeersConstraint = constraint
+}
+
func toNetbirdConfig(config *nbconfig.Config, turnCredentials *Token, relayToken *Token, extraSettings *types.ExtraSettings) *proto.NetbirdConfig {
if config == nil {
return nil
@@ -151,7 +170,11 @@ func ToSyncResponse(ctx context.Context, config *nbconfig.Config, httpConfig *nb
remotePeers := make([]*proto.RemotePeerConfig, 0, len(networkMap.Peers)+len(networkMap.OfflinePeers))
remotePeers = networkmap.AppendRemotePeerConfig(remotePeers, networkMap.Peers, dnsName, includeIPv6)
- response.RemotePeers = remotePeers
+
+ if !shouldSkipSendingDeprecatedRemotePeers(peer.Meta.WtVersion) {
+ response.RemotePeers = remotePeers
+ }
+
response.NetworkMap.RemotePeers = remotePeers
response.RemotePeersIsEmpty = len(remotePeers) == 0
response.NetworkMap.RemotePeersIsEmpty = response.RemotePeersIsEmpty
@@ -215,6 +238,19 @@ func encodeSessionExpiresAt(deadline time.Time) *timestamppb.Timestamp {
return timestamppb.New(deadline)
}
+func shouldSkipSendingDeprecatedRemotePeers(peerVersion string) bool {
+ if nbversion.IsDevelopmentVersion(peerVersion) {
+ return true
+ }
+
+ peerNBVersion, err := version.NewVersion(peerVersion)
+ if err != nil {
+ return false
+ }
+
+ return precomputedDeprecatedRemotePeersConstraint.Check(peerNBVersion)
+}
+
func ToResponseProto(configProto nbconfig.Protocol) proto.HostConfig_Protocol {
switch configProto {
case nbconfig.UDP:
diff --git a/management/internals/shared/grpc/conversion_test.go b/management/internals/shared/grpc/conversion_test.go
index cb8a86bdc..8b11ff2c0 100644
--- a/management/internals/shared/grpc/conversion_test.go
+++ b/management/internals/shared/grpc/conversion_test.go
@@ -203,6 +203,42 @@ func TestBuildJWTConfig_Audiences(t *testing.T) {
}
}
+// TestShouldSkipSendingDeprecatedRemotePeers covers the version gate that
+// stops populating the deprecated top-level SyncResponse.RemotePeers field for
+// peers new enough to read RemotePeers off the NetworkMap. Development builds
+// are treated as latest and skip the field. The gate otherwise fails safe: a
+// release version older than the boundary, or one that can't be parsed (empty,
+// garbage, prereleases of the boundary) still receives the deprecated field so
+// older/unknown clients keep working.
+func TestShouldSkipSendingDeprecatedRemotePeers(t *testing.T) {
+ tests := []struct {
+ name string
+ peerVersion string
+ wantSkip bool
+ }{
+ {"exact boundary skips", "0.29.3", true},
+ {"newer patch skips", "0.29.4", true},
+ {"newer minor skips", "0.30.0", true},
+ {"newer major skips", "1.0.0", true},
+ {"v-prefixed newer skips", "v0.30.0", true},
+ {"development build skips", "development", true},
+ {"development build with commit skips", "development-abc123def456-dirty", true},
+ {"older patch keeps field", "0.29.2", false},
+ {"older minor keeps field", "0.28.0", false},
+ {"prerelease of boundary keeps field", "0.29.3-SNAPSHOT", false},
+ {"tagged dev prerelease keeps field", "v0.31.1-dev", false},
+ {"empty version keeps field", "", false},
+ {"garbage version keeps field", "not-a-version", false},
+ }
+
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ got := shouldSkipSendingDeprecatedRemotePeers(tc.peerVersion)
+ assert.Equal(t, tc.wantSkip, got, "skip decision for peer version %q", tc.peerVersion)
+ })
+ }
+}
+
// TestEncodeSessionExpiresAt pins the wire encoding the client's
// applySessionDeadline depends on:
//
diff --git a/management/internals/shared/grpc/proxy.go b/management/internals/shared/grpc/proxy.go
index e7155ae09..0feb807f6 100644
--- a/management/internals/shared/grpc/proxy.go
+++ b/management/internals/shared/grpc/proxy.go
@@ -666,8 +666,10 @@ func (s *ProxyServiceServer) sender(conn *proxyConnection, errChan chan<- error)
case resp := <-conn.sendChan:
if err := conn.sendResponse(resp); err != nil {
errChan <- err
+ log.WithContext(conn.ctx).Tracef("Failed to send response to proxy %s: %v", conn.proxyID, err)
return
}
+ log.WithContext(conn.ctx).Tracef("Send response to proxy %s", conn.proxyID)
case <-conn.ctx.Done():
return
}
@@ -978,6 +980,7 @@ func shallowCloneMapping(m *proto.ProxyMapping) *proto.ProxyMapping {
Mode: m.Mode,
ListenPort: m.ListenPort,
AccessRestrictions: m.AccessRestrictions,
+ Private: m.Private,
}
}
diff --git a/management/internals/shared/grpc/proxy_clone_test.go b/management/internals/shared/grpc/proxy_clone_test.go
new file mode 100644
index 000000000..f00d40fae
--- /dev/null
+++ b/management/internals/shared/grpc/proxy_clone_test.go
@@ -0,0 +1,88 @@
+package grpc
+
+import (
+ "reflect"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+
+ "github.com/netbirdio/netbird/shared/management/proto"
+)
+
+// authTokenField is the only per-proxy field that shallowCloneMapping must NOT
+// copy from the source, since callers assign it individually after cloning.
+const authTokenField = "AuthToken"
+
+// TestShallowCloneMapping_ClonesAllFields populates every exported field of
+// ProxyMapping with a non-zero value and verifies the clone carries each one
+// (except AuthToken). It uses reflection so adding a new field to ProxyMapping
+// without updating shallowCloneMapping fails this test.
+func TestShallowCloneMapping_ClonesAllFields(t *testing.T) {
+ src := &proto.ProxyMapping{}
+ populated := populateExportedFields(t, reflect.ValueOf(src).Elem())
+ require.NotEmpty(t, populated, "ProxyMapping should expose fields to populate")
+
+ clone := shallowCloneMapping(src)
+ require.NotNil(t, clone, "clone must not be nil")
+
+ srcVal := reflect.ValueOf(src).Elem()
+ cloneVal := reflect.ValueOf(clone).Elem()
+
+ for _, name := range populated {
+ srcField := srcVal.FieldByName(name).Interface()
+ cloneField := cloneVal.FieldByName(name).Interface()
+
+ if name == authTokenField {
+ assert.Zero(t, cloneField, "AuthToken must not be cloned; it is set per proxy after cloning")
+ continue
+ }
+
+ assert.Equal(t, srcField, cloneField, "field %s must be carried over by shallowCloneMapping", name)
+ }
+}
+
+// populateExportedFields sets a non-zero value on every settable exported field
+// of the struct and returns their names.
+func populateExportedFields(t *testing.T, v reflect.Value) []string {
+ t.Helper()
+
+ var names []string
+ typ := v.Type()
+ for i := 0; i < v.NumField(); i++ {
+ field := v.Field(i)
+ structField := typ.Field(i)
+
+ if structField.PkgPath != "" || !field.CanSet() {
+ continue
+ }
+
+ setNonZero(t, field, structField.Name)
+ names = append(names, structField.Name)
+ }
+ return names
+}
+
+// setNonZero assigns a deterministic non-zero value based on the field kind.
+func setNonZero(t *testing.T, field reflect.Value, name string) {
+ t.Helper()
+
+ switch field.Kind() {
+ case reflect.String:
+ field.SetString("non-zero-" + name)
+ case reflect.Bool:
+ field.SetBool(true)
+ case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
+ field.SetInt(7)
+ case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64:
+ field.SetUint(7)
+ case reflect.Ptr:
+ field.Set(reflect.New(field.Type().Elem()))
+ case reflect.Slice:
+ field.Set(reflect.MakeSlice(field.Type(), 1, 1))
+ case reflect.Map:
+ field.Set(reflect.MakeMapWithSize(field.Type(), 0))
+ default:
+ t.Fatalf("unhandled field kind %s for field %s; extend setNonZero", field.Kind(), name)
+ }
+}
diff --git a/management/internals/shared/grpc/server.go b/management/internals/shared/grpc/server.go
index ed44f65e5..5c92de552 100644
--- a/management/internals/shared/grpc/server.go
+++ b/management/internals/shared/grpc/server.go
@@ -778,7 +778,7 @@ func (s *Server) Login(ctx context.Context, req *proto.EncryptedMessage) (*proto
sshKey = loginReq.GetPeerKeys().GetSshPubKey()
}
- peer, netMap, postureChecks, err := s.accountManager.LoginPeer(ctx, types.PeerLogin{
+ peer, network, postureChecks, enableSSH, err := s.accountManager.LoginPeer(ctx, types.PeerLogin{
WireGuardPubKey: peerKey.String(),
SSHKey: string(sshKey),
Meta: peerMeta,
@@ -792,7 +792,7 @@ func (s *Server) Login(ctx context.Context, req *proto.EncryptedMessage) (*proto
return nil, mapError(ctx, err)
}
- loginResp, err := s.prepareLoginResponse(ctx, peer, netMap, postureChecks)
+ loginResp, err := s.prepareLoginResponse(ctx, peer, network, postureChecks, enableSSH)
if err != nil {
log.WithContext(ctx).Warnf("failed preparing login response for peer %s: %s", peerKey, err)
return nil, status.Errorf(codes.Internal, "failed logging in peer")
@@ -895,7 +895,7 @@ func (s *Server) ExtendAuthSession(ctx context.Context, req *proto.EncryptedMess
}, nil
}
-func (s *Server) prepareLoginResponse(ctx context.Context, peer *nbpeer.Peer, netMap *types.NetworkMap, postureChecks []*posture.Checks) (*proto.LoginResponse, error) {
+func (s *Server) prepareLoginResponse(ctx context.Context, peer *nbpeer.Peer, network *types.Network, postureChecks []*posture.Checks, enableSSH bool) (*proto.LoginResponse, error) {
var relayToken *Token
var err error
if s.config.Relay != nil && len(s.config.Relay.Addresses) > 0 {
@@ -914,7 +914,7 @@ func (s *Server) prepareLoginResponse(ctx context.Context, peer *nbpeer.Peer, ne
// if peer has reached this point then it has logged in
loginResp := &proto.LoginResponse{
NetbirdConfig: toNetbirdConfig(s.config, nil, relayToken, nil),
- PeerConfig: toPeerConfig(peer, netMap.Network, s.networkMapController.GetDNSDomain(settings), settings, s.config.HttpConfig, s.config.DeviceAuthorizationFlow, netMap.EnableSSH),
+ PeerConfig: toPeerConfig(peer, network, s.networkMapController.GetDNSDomain(settings), settings, s.config.HttpConfig, s.config.DeviceAuthorizationFlow, enableSSH),
Checks: toProtocolChecks(ctx, postureChecks),
}
diff --git a/management/internals/shared/grpc/validate_session_test.go b/management/internals/shared/grpc/validate_session_test.go
index 1dc2dac28..27d9a65e7 100644
--- a/management/internals/shared/grpc/validate_session_test.go
+++ b/management/internals/shared/grpc/validate_session_test.go
@@ -102,7 +102,7 @@ func generateSessionKeyPair(t *testing.T) (string, string) {
func createSessionToken(t *testing.T, privKeyB64, userID, domain string) string {
t.Helper()
- token, err := sessionkey.SignToken(privKeyB64, userID, domain, auth.MethodOIDC, nil, time.Hour)
+ token, err := sessionkey.SignToken(privKeyB64, userID, "", domain, auth.MethodOIDC, nil, nil, time.Hour)
require.NoError(t, err)
return token
}
@@ -394,6 +394,10 @@ func (m *testValidateSessionProxyManager) ClusterSupportsCrowdSec(_ context.Cont
return nil
}
+func (m *testValidateSessionProxyManager) ClusterSupportsPrivate(_ context.Context, _ string) *bool {
+ return nil
+}
+
type testValidateSessionUsersManager struct {
store store.Store
}
@@ -401,3 +405,24 @@ type testValidateSessionUsersManager struct {
func (m *testValidateSessionUsersManager) GetUser(ctx context.Context, userID string) (*types.User, error) {
return m.store.GetUserByUserID(ctx, store.LockingStrengthNone, userID)
}
+
+func (m *testValidateSessionUsersManager) GetUserWithGroups(ctx context.Context, userID string) (*types.User, []*types.Group, error) {
+ user, err := m.store.GetUserByUserID(ctx, store.LockingStrengthNone, userID)
+ if err != nil {
+ return nil, nil, err
+ }
+ if len(user.AutoGroups) == 0 {
+ return user, nil, nil
+ }
+ groupsMap, err := m.store.GetGroupsByIDs(ctx, store.LockingStrengthNone, user.AccountID, user.AutoGroups)
+ if err != nil {
+ return nil, nil, err
+ }
+ groups := make([]*types.Group, 0, len(user.AutoGroups))
+ for _, id := range user.AutoGroups {
+ if g, ok := groupsMap[id]; ok && g != nil {
+ groups = append(groups, g)
+ }
+ }
+ return user, groups, nil
+}
diff --git a/management/server/account.go b/management/server/account.go
index 455d10bb4..3bcb211a7 100644
--- a/management/server/account.go
+++ b/management/server/account.go
@@ -28,6 +28,7 @@ import (
nbdns "github.com/netbirdio/netbird/dns"
"github.com/netbirdio/netbird/formatter/hook"
+ "github.com/netbirdio/netbird/idp/dex"
"github.com/netbirdio/netbird/management/internals/controllers/network_map"
nbconfig "github.com/netbirdio/netbird/management/internals/server/config"
"github.com/netbirdio/netbird/management/server/account"
@@ -282,7 +283,7 @@ func (am *DefaultAccountManager) GetIdpManager() idp.Manager {
// User that performs the update has to belong to the account.
// Returns an updated Settings
func (am *DefaultAccountManager) UpdateAccountSettings(ctx context.Context, accountID, userID string, newSettings *types.Settings) (*types.Settings, error) {
- allowed, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Settings, operations.Update)
+ allowed, ctx, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Settings, operations.Update)
if err != nil {
return nil, fmt.Errorf("failed to validate user permissions: %w", err)
}
@@ -855,7 +856,7 @@ func (am *DefaultAccountManager) DeleteAccount(ctx context.Context, accountID, u
return err
}
- allowed, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Accounts, operations.Delete)
+ allowed, ctx, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Accounts, operations.Delete)
if err != nil {
return fmt.Errorf("failed to validate user permissions: %w", err)
}
@@ -1422,7 +1423,7 @@ func (am *DefaultAccountManager) GetAccount(ctx context.Context, accountID strin
// GetAccountByID returns an account associated with this account ID.
func (am *DefaultAccountManager) GetAccountByID(ctx context.Context, accountID string, userID string) (*types.Account, error) {
- allowed, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Accounts, operations.Read)
+ allowed, ctx, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Accounts, operations.Read)
if err != nil {
return nil, status.NewPermissionValidationError(err)
}
@@ -1435,7 +1436,7 @@ func (am *DefaultAccountManager) GetAccountByID(ctx context.Context, accountID s
// GetAccountMeta returns the account metadata associated with this account ID.
func (am *DefaultAccountManager) GetAccountMeta(ctx context.Context, accountID string, userID string) (*types.AccountMeta, error) {
- allowed, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Accounts, operations.Read)
+ allowed, ctx, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Accounts, operations.Read)
if err != nil {
return nil, status.NewPermissionValidationError(err)
}
@@ -1448,7 +1449,7 @@ func (am *DefaultAccountManager) GetAccountMeta(ctx context.Context, accountID s
// GetAccountOnboarding retrieves the onboarding information for a specific account.
func (am *DefaultAccountManager) GetAccountOnboarding(ctx context.Context, accountID string, userID string) (*types.AccountOnboarding, error) {
- allowed, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Accounts, operations.Read)
+ allowed, ctx, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Accounts, operations.Read)
if err != nil {
return nil, status.NewPermissionValidationError(err)
}
@@ -1473,7 +1474,7 @@ func (am *DefaultAccountManager) GetAccountOnboarding(ctx context.Context, accou
}
func (am *DefaultAccountManager) UpdateAccountOnboarding(ctx context.Context, accountID, userID string, newOnboarding *types.AccountOnboarding) (*types.AccountOnboarding, error) {
- allowed, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Settings, operations.Update)
+ allowed, ctx, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Settings, operations.Update)
if err != nil {
return nil, fmt.Errorf("failed to validate user permissions: %w", err)
}
@@ -1540,7 +1541,8 @@ func (am *DefaultAccountManager) GetAccountIDFromUserAuth(ctx context.Context, u
return accountID, user.Id, nil
}
- if err := am.permissionsManager.ValidateAccountAccess(ctx, accountID, user, false); err != nil {
+ ctx, err = am.permissionsManager.ValidateAccountAccess(ctx, accountID, user, false)
+ if err != nil {
return "", "", err
}
@@ -1587,7 +1589,10 @@ func (am *DefaultAccountManager) updateUserAuthWithSingleMode(ctx context.Contex
// and propagates changes to peers if group propagation is enabled.
// requires userAuth to have been ValidateAndParseToken and EnsureUserAccessByJWTGroups by the AuthManager
func (am *DefaultAccountManager) SyncUserJWTGroups(ctx context.Context, userAuth auth.UserAuth) error {
- if userAuth.IsChild || userAuth.IsPAT {
+ // Child accounts and PAT-authenticated requests do not sync JWT groups.
+ // Embedded-Dex local users also skip sync because local password authentication
+ // does not provide external IdP group claims.
+ if userAuth.IsChild || userAuth.IsPAT || dex.IsLocalUserID(userAuth.UserId) {
return nil
}
@@ -1897,7 +1902,7 @@ func (am *DefaultAccountManager) SyncAndMarkPeer(ctx context.Context, accountID
return nil, nil, nil, 0, fmt.Errorf("error syncing peer: %w", err)
}
- if err := am.MarkPeerConnected(ctx, peerPubKey, realIP, accountID, syncTime.UnixNano()); err != nil {
+ if err := am.MarkPeerConnected(ctx, peerPubKey, realIP, accountID, syncTime.UnixNano(), netMap); err != nil {
log.WithContext(ctx).Warnf("failed marking peer as connected %s %v", peerPubKey, err)
}
@@ -1994,7 +1999,7 @@ func (am *DefaultAccountManager) handleUserPeer(ctx context.Context, transaction
}
func (am *DefaultAccountManager) GetAccountSettings(ctx context.Context, accountID string, userID string) (*types.Settings, error) {
- allowed, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Settings, operations.Read)
+ allowed, ctx, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Settings, operations.Read)
if err != nil {
return nil, status.NewPermissionValidationError(err)
}
@@ -2562,7 +2567,7 @@ func (am *DefaultAccountManager) validateIPForUpdate(account *types.Account, pee
}
func (am *DefaultAccountManager) UpdatePeerIP(ctx context.Context, accountID, userID, peerID string, newIP netip.Addr) error {
- allowed, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Peers, operations.Update)
+ allowed, ctx, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Peers, operations.Update)
if err != nil {
return fmt.Errorf("validate user permissions: %w", err)
}
@@ -2580,7 +2585,9 @@ func (am *DefaultAccountManager) UpdatePeerIP(ctx context.Context, accountID, us
if err != nil {
return err
}
- err = am.networkMapController.OnPeersUpdated(ctx, peer.AccountID, []string{peerID})
+ changedPeerIDs := []string{peerID}
+ affectedPeerIDs := am.resolveAffectedPeersForPeerChanges(ctx, am.Store, accountID, changedPeerIDs)
+ err = am.networkMapController.OnPeersUpdated(ctx, peer.AccountID, changedPeerIDs, affectedPeerIDs)
if err != nil {
return fmt.Errorf("notify network map controller of peer update: %w", err)
}
@@ -2652,7 +2659,7 @@ func (am *DefaultAccountManager) savePeerIPUpdate(ctx context.Context, transacti
// UpdatePeerIPv6 updates the IPv6 overlay address of a peer, validating it's
// within the account's v6 network range and not already taken.
func (am *DefaultAccountManager) UpdatePeerIPv6(ctx context.Context, accountID, userID, peerID string, newIPv6 netip.Addr) error {
- allowed, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Peers, operations.Update)
+ allowed, ctx, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Peers, operations.Update)
if err != nil {
return fmt.Errorf("validate user permissions: %w", err)
}
@@ -2671,7 +2678,9 @@ func (am *DefaultAccountManager) UpdatePeerIPv6(ctx context.Context, accountID,
}
if updateNetworkMap {
- if err := am.networkMapController.OnPeersUpdated(ctx, accountID, []string{peerID}); err != nil {
+ changedPeerIDs := []string{peerID}
+ affectedPeerIDs := am.resolveAffectedPeersForPeerChanges(ctx, am.Store, accountID, changedPeerIDs)
+ if err := am.networkMapController.OnPeersUpdated(ctx, accountID, changedPeerIDs, affectedPeerIDs); err != nil {
return fmt.Errorf("notify network map controller: %w", err)
}
}
diff --git a/management/server/account/manager.go b/management/server/account/manager.go
index b7b159915..784e432f6 100644
--- a/management/server/account/manager.go
+++ b/management/server/account/manager.go
@@ -13,6 +13,7 @@ import (
nbdns "github.com/netbirdio/netbird/dns"
"github.com/netbirdio/netbird/management/server/activity"
+ "github.com/netbirdio/netbird/management/server/affectedpeers"
nbcache "github.com/netbirdio/netbird/management/server/cache"
"github.com/netbirdio/netbird/management/server/idp"
nbpeer "github.com/netbirdio/netbird/management/server/peer"
@@ -61,7 +62,7 @@ type Manager interface {
GetUserFromUserAuth(ctx context.Context, userAuth auth.UserAuth) (*types.User, error)
ListUsers(ctx context.Context, accountID string) ([]*types.User, error)
GetPeers(ctx context.Context, accountID, userID, nameFilter, ipFilter string) ([]*nbpeer.Peer, error)
- MarkPeerConnected(ctx context.Context, peerKey string, realIP net.IP, accountID string, sessionStartedAt int64) error
+ MarkPeerConnected(ctx context.Context, peerKey string, realIP net.IP, accountID string, sessionStartedAt int64, nmap *types.NetworkMap) error
MarkPeerDisconnected(ctx context.Context, peerKey string, accountID string, sessionStartedAt int64) error
DeletePeer(ctx context.Context, accountID, peerID, userID string) error
UpdatePeer(ctx context.Context, accountID, userID string, p *nbpeer.Peer) (*nbpeer.Peer, error)
@@ -69,7 +70,7 @@ type Manager interface {
UpdatePeerIPv6(ctx context.Context, accountID, userID, peerID string, newIPv6 netip.Addr) error
GetNetworkMap(ctx context.Context, peerID string) (*types.NetworkMap, error)
GetPeerNetwork(ctx context.Context, peerID string) (*types.Network, error)
- AddPeer(ctx context.Context, accountID, setupKey, userID string, p *nbpeer.Peer, temporary bool) (*nbpeer.Peer, *types.NetworkMap, []*posture.Checks, error)
+ AddPeer(ctx context.Context, accountID, setupKey, userID string, p *nbpeer.Peer, temporary bool) (*nbpeer.Peer, *types.Network, []*posture.Checks, bool, error)
CreatePAT(ctx context.Context, accountID string, initiatorUserID string, targetUserID string, tokenName string, expiresIn int) (*types.PersonalAccessTokenGenerated, error)
DeletePAT(ctx context.Context, accountID string, initiatorUserID string, targetUserID string, tokenID string) error
GetPAT(ctx context.Context, accountID string, initiatorUserID string, targetUserID string, tokenID string) (*types.PersonalAccessToken, error)
@@ -108,8 +109,8 @@ type Manager interface {
GetPeer(ctx context.Context, accountID, peerID, userID string) (*nbpeer.Peer, error)
UpdateAccountSettings(ctx context.Context, accountID, userID string, newSettings *types.Settings) (*types.Settings, error)
UpdateAccountOnboarding(ctx context.Context, accountID, userID string, newOnboarding *types.AccountOnboarding) (*types.AccountOnboarding, error)
- LoginPeer(ctx context.Context, login types.PeerLogin) (*nbpeer.Peer, *types.NetworkMap, []*posture.Checks, error) // used by peer gRPC API
- ExtendPeerSession(ctx context.Context, peerPubKey, userID string) (time.Time, error) // used by peer gRPC API for ExtendAuthSession
+ LoginPeer(ctx context.Context, login types.PeerLogin) (*nbpeer.Peer, *types.Network, []*posture.Checks, bool, error) // used by peer gRPC API
+ ExtendPeerSession(ctx context.Context, peerPubKey, userID string) (time.Time, error) // used by peer gRPC API for ExtendAuthSession
SyncPeer(ctx context.Context, sync types.PeerSync, accountID string) (*nbpeer.Peer, *types.NetworkMap, []*posture.Checks, int64, error) // used by peer gRPC API
GetExternalCacheManager() ExternalCacheManager
GetPostureChecks(ctx context.Context, accountID, postureChecksID, userID string) (*posture.Checks, error)
@@ -128,6 +129,7 @@ type Manager interface {
GetAccountSettings(ctx context.Context, accountID string, userID string) (*types.Settings, error)
DeleteSetupKey(ctx context.Context, accountID, userID, keyID string) error
UpdateAccountPeers(ctx context.Context, accountID string, reason types.UpdateReason)
+ ExpandAndUpdateAffected(ctx context.Context, accountID string, snap *affectedpeers.Snapshot, change affectedpeers.Change)
BufferUpdateAccountPeers(ctx context.Context, accountID string, reason types.UpdateReason)
BuildUserInfosForAccount(ctx context.Context, accountID, initiatorUserID string, accountUsers []*types.User) (map[string]*types.UserInfo, error)
SyncUserJWTGroups(ctx context.Context, userAuth auth.UserAuth) error
diff --git a/management/server/account/manager_mock.go b/management/server/account/manager_mock.go
index 81127a6b4..145e6e00f 100644
--- a/management/server/account/manager_mock.go
+++ b/management/server/account/manager_mock.go
@@ -15,6 +15,7 @@ import (
dns "github.com/netbirdio/netbird/dns"
service "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/service"
activity "github.com/netbirdio/netbird/management/server/activity"
+ affectedpeers "github.com/netbirdio/netbird/management/server/affectedpeers"
idp "github.com/netbirdio/netbird/management/server/idp"
peer "github.com/netbirdio/netbird/management/server/peer"
posture "github.com/netbirdio/netbird/management/server/posture"
@@ -79,14 +80,15 @@ func (mr *MockManagerMockRecorder) AccountExists(ctx, accountID interface{}) *go
}
// AddPeer mocks base method.
-func (m *MockManager) AddPeer(ctx context.Context, accountID, setupKey, userID string, p *peer.Peer, temporary bool) (*peer.Peer, *types.NetworkMap, []*posture.Checks, error) {
+func (m *MockManager) AddPeer(ctx context.Context, accountID, setupKey, userID string, p *peer.Peer, temporary bool) (*peer.Peer, *types.Network, []*posture.Checks, bool, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "AddPeer", ctx, accountID, setupKey, userID, p, temporary)
ret0, _ := ret[0].(*peer.Peer)
- ret1, _ := ret[1].(*types.NetworkMap)
+ ret1, _ := ret[1].(*types.Network)
ret2, _ := ret[2].([]*posture.Checks)
- ret3, _ := ret[3].(error)
- return ret0, ret1, ret2, ret3
+ ret3, _ := ret[3].(bool)
+ ret4, _ := ret[4].(error)
+ return ret0, ret1, ret2, ret3, ret4
}
// AddPeer indicates an expected call of AddPeer.
@@ -1288,14 +1290,15 @@ func (mr *MockManagerMockRecorder) ListUsers(ctx, accountID interface{}) *gomock
}
// LoginPeer mocks base method.
-func (m *MockManager) LoginPeer(ctx context.Context, login types.PeerLogin) (*peer.Peer, *types.NetworkMap, []*posture.Checks, error) {
+func (m *MockManager) LoginPeer(ctx context.Context, login types.PeerLogin) (*peer.Peer, *types.Network, []*posture.Checks, bool, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "LoginPeer", ctx, login)
ret0, _ := ret[0].(*peer.Peer)
- ret1, _ := ret[1].(*types.NetworkMap)
+ ret1, _ := ret[1].(*types.Network)
ret2, _ := ret[2].([]*posture.Checks)
- ret3, _ := ret[3].(error)
- return ret0, ret1, ret2, ret3
+ ret3, _ := ret[3].(bool)
+ ret4, _ := ret[4].(error)
+ return ret0, ret1, ret2, ret3, ret4
}
// LoginPeer indicates an expected call of LoginPeer.
@@ -1320,17 +1323,17 @@ func (mr *MockManagerMockRecorder) ExtendPeerSession(ctx, peerPubKey, userID int
}
// MarkPeerConnected mocks base method.
-func (m *MockManager) MarkPeerConnected(ctx context.Context, peerKey string, realIP net.IP, accountID string, sessionStartedAt int64) error {
+func (m *MockManager) MarkPeerConnected(ctx context.Context, peerKey string, realIP net.IP, accountID string, sessionStartedAt int64, nmap *types.NetworkMap) error {
m.ctrl.T.Helper()
- ret := m.ctrl.Call(m, "MarkPeerConnected", ctx, peerKey, realIP, accountID, sessionStartedAt)
+ ret := m.ctrl.Call(m, "MarkPeerConnected", ctx, peerKey, realIP, accountID, sessionStartedAt, nmap)
ret0, _ := ret[0].(error)
return ret0
}
// MarkPeerConnected indicates an expected call of MarkPeerConnected.
-func (mr *MockManagerMockRecorder) MarkPeerConnected(ctx, peerKey, realIP, accountID, sessionStartedAt interface{}) *gomock.Call {
+func (mr *MockManagerMockRecorder) MarkPeerConnected(ctx, peerKey, realIP, accountID, sessionStartedAt, nmap interface{}) *gomock.Call {
mr.mock.ctrl.T.Helper()
- return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "MarkPeerConnected", reflect.TypeOf((*MockManager)(nil).MarkPeerConnected), ctx, peerKey, realIP, accountID, sessionStartedAt)
+ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "MarkPeerConnected", reflect.TypeOf((*MockManager)(nil).MarkPeerConnected), ctx, peerKey, realIP, accountID, sessionStartedAt, nmap)
}
// MarkPeerDisconnected mocks base method.
@@ -1637,6 +1640,18 @@ func (mr *MockManagerMockRecorder) UpdateAccountPeers(ctx, accountID, reason int
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateAccountPeers", reflect.TypeOf((*MockManager)(nil).UpdateAccountPeers), ctx, accountID, reason)
}
+// ExpandAndUpdateAffected mocks base method.
+func (m *MockManager) ExpandAndUpdateAffected(ctx context.Context, accountID string, snap *affectedpeers.Snapshot, change affectedpeers.Change) {
+ m.ctrl.T.Helper()
+ m.ctrl.Call(m, "ExpandAndUpdateAffected", ctx, accountID, snap, change)
+}
+
+// ExpandAndUpdateAffected indicates an expected call of ExpandAndUpdateAffected.
+func (mr *MockManagerMockRecorder) ExpandAndUpdateAffected(ctx, accountID, snap, change interface{}) *gomock.Call {
+ mr.mock.ctrl.T.Helper()
+ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ExpandAndUpdateAffected", reflect.TypeOf((*MockManager)(nil).ExpandAndUpdateAffected), ctx, accountID, snap, change)
+}
+
// UpdateAccountSettings mocks base method.
func (m *MockManager) UpdateAccountSettings(ctx context.Context, accountID, userID string, newSettings *types.Settings) (*types.Settings, error) {
m.ctrl.T.Helper()
diff --git a/management/server/account_test.go b/management/server/account_test.go
index 31da3f533..5bfe7c44f 100644
--- a/management/server/account_test.go
+++ b/management/server/account_test.go
@@ -26,6 +26,7 @@ import (
"github.com/netbirdio/netbird/shared/management/status"
nbdns "github.com/netbirdio/netbird/dns"
+ "github.com/netbirdio/netbird/idp/dex"
"github.com/netbirdio/netbird/management/internals/controllers/network_map"
"github.com/netbirdio/netbird/management/internals/controllers/network_map/controller"
"github.com/netbirdio/netbird/management/internals/controllers/network_map/update_channel"
@@ -83,7 +84,7 @@ func verifyCanAddPeerToAccount(t *testing.T, manager nbAccount.Manager, account
setupKey = key.Key
}
- _, _, _, err := manager.AddPeer(context.Background(), "", setupKey, userID, peer, false)
+ _, _, _, _, err := manager.AddPeer(context.Background(), "", setupKey, userID, peer, false)
if err != nil {
t.Error("expected to add new peer successfully after creating new account, but failed", err)
}
@@ -723,6 +724,28 @@ func TestDefaultAccountManager_SyncUserJWTGroups(t *testing.T) {
require.Equal(t, g2.Name, "group2", "group2 name should match")
require.Equal(t, g2.Issued, types.GroupIssuedJWT, "group2 issued should match")
})
+ t.Run("local embedded-Dex user is skipped", func(t *testing.T) {
+ initAccount.Settings.JWTGroupsEnabled = true
+ initAccount.Settings.JWTGroupsClaimName = "idp-groups"
+ err := manager.Store.SaveAccount(context.Background(), initAccount)
+ require.NoError(t, err, "save account failed")
+
+ localClaims := auth.UserAuth{
+ AccountId: accountID,
+ Domain: domain,
+ UserId: dex.EncodeDexUserID("local-owner", "local"),
+ Groups: []string{"group3", "group4"},
+ }
+ err = manager.SyncUserJWTGroups(context.Background(), localClaims)
+ require.NoError(t, err, "sync should be a no-op for local users")
+
+ account, err := manager.Store.GetAccount(context.Background(), accountID)
+ require.NoError(t, err, "get account failed")
+ for _, g := range account.Groups {
+ require.NotEqual(t, "group3", g.Name, "local user JWT groups must not be synced")
+ require.NotEqual(t, "group4", g.Name, "local user JWT groups must not be synced")
+ }
+ })
}
func TestAccountManager_PrivateAccount(t *testing.T) {
@@ -1069,7 +1092,7 @@ func TestAccountManager_AddPeer(t *testing.T) {
}
expectedPeerKey := key.PublicKey().String()
- peer, _, _, err := manager.AddPeer(context.Background(), "", setupKey.Key, "", &nbpeer.Peer{
+ peer, _, _, _, err := manager.AddPeer(context.Background(), "", setupKey.Key, "", &nbpeer.Peer{
Key: expectedPeerKey,
Meta: nbpeer.PeerSystemMeta{Hostname: expectedPeerKey},
}, false)
@@ -1133,7 +1156,7 @@ func TestAccountManager_AddPeerWithUserID(t *testing.T) {
expectedPeerKey := key.PublicKey().String()
expectedUserID := userID
- peer, _, _, err := manager.AddPeer(context.Background(), "", "", userID, &nbpeer.Peer{
+ peer, _, _, _, err := manager.AddPeer(context.Background(), "", "", userID, &nbpeer.Peer{
Key: expectedPeerKey,
Meta: nbpeer.PeerSystemMeta{Hostname: expectedPeerKey},
}, false)
@@ -1481,7 +1504,7 @@ func TestAccountManager_DeletePeer(t *testing.T) {
peerKey := key.PublicKey().String()
- peer, _, _, err := manager.AddPeer(context.Background(), "", setupKey.Key, "", &nbpeer.Peer{
+ peer, _, _, _, err := manager.AddPeer(context.Background(), "", setupKey.Key, "", &nbpeer.Peer{
Key: peerKey,
Meta: nbpeer.PeerSystemMeta{Hostname: peerKey},
}, false)
@@ -1803,7 +1826,7 @@ func TestDefaultAccountManager_UpdatePeer_PeerLoginExpiration(t *testing.T) {
key, err := wgtypes.GenerateKey()
require.NoError(t, err, "unable to generate WireGuard key")
- peer, _, _, err := manager.AddPeer(context.Background(), "", "", userID, &nbpeer.Peer{
+ peer, _, _, _, err := manager.AddPeer(context.Background(), "", "", userID, &nbpeer.Peer{
Key: key.PublicKey().String(),
Meta: nbpeer.PeerSystemMeta{Hostname: "test-peer"},
LoginExpirationEnabled: true,
@@ -1813,7 +1836,7 @@ func TestDefaultAccountManager_UpdatePeer_PeerLoginExpiration(t *testing.T) {
accountID, err := manager.GetAccountIDByUserID(context.Background(), auth.UserAuth{UserId: userID})
require.NoError(t, err, "unable to get the account")
- err = manager.MarkPeerConnected(context.Background(), key.PublicKey().String(), nil, accountID, time.Now().UTC().UnixNano())
+ err = manager.MarkPeerConnected(context.Background(), key.PublicKey().String(), nil, accountID, time.Now().UTC().UnixNano(), nil)
require.NoError(t, err, "unable to mark peer connected")
_, err = manager.UpdateAccountSettings(context.Background(), accountID, userID, &types.Settings{
@@ -1859,7 +1882,7 @@ func TestDefaultAccountManager_MarkPeerConnected_PeerLoginExpiration(t *testing.
key, err := wgtypes.GenerateKey()
require.NoError(t, err, "unable to generate WireGuard key")
- _, _, _, err = manager.AddPeer(context.Background(), "", "", userID, &nbpeer.Peer{
+ _, _, _, _, err = manager.AddPeer(context.Background(), "", "", userID, &nbpeer.Peer{
Key: key.PublicKey().String(),
Meta: nbpeer.PeerSystemMeta{Hostname: "test-peer"},
LoginExpirationEnabled: true,
@@ -1884,7 +1907,7 @@ func TestDefaultAccountManager_MarkPeerConnected_PeerLoginExpiration(t *testing.
require.NoError(t, err, "unable to get the account")
// when we mark peer as connected, the peer login expiration routine should trigger
- err = manager.MarkPeerConnected(context.Background(), key.PublicKey().String(), nil, accountID, time.Now().UTC().UnixNano())
+ err = manager.MarkPeerConnected(context.Background(), key.PublicKey().String(), nil, accountID, time.Now().UTC().UnixNano(), nil)
require.NoError(t, err, "unable to mark peer connected")
failed := waitTimeout(wg, time.Second)
@@ -1904,7 +1927,7 @@ func TestDefaultAccountManager_OnPeerDisconnected_LastSeenCheck(t *testing.T) {
require.NoError(t, err, "unable to generate WireGuard key")
peerPubKey := key.PublicKey().String()
- _, _, _, err = manager.AddPeer(context.Background(), "", "", userID, &nbpeer.Peer{
+ _, _, _, _, err = manager.AddPeer(context.Background(), "", "", userID, &nbpeer.Peer{
Key: peerPubKey,
Meta: nbpeer.PeerSystemMeta{Hostname: "test-peer"},
}, false)
@@ -1912,7 +1935,7 @@ func TestDefaultAccountManager_OnPeerDisconnected_LastSeenCheck(t *testing.T) {
t.Run("disconnect peer when session token matches", func(t *testing.T) {
streamStartTime := time.Now().UTC()
- err = manager.MarkPeerConnected(context.Background(), peerPubKey, nil, accountID, streamStartTime.UnixNano())
+ err = manager.MarkPeerConnected(context.Background(), peerPubKey, nil, accountID, streamStartTime.UnixNano(), nil)
require.NoError(t, err, "unable to mark peer connected")
peer, err := manager.Store.GetPeerByPeerPubKey(context.Background(), store.LockingStrengthNone, peerPubKey)
@@ -1933,7 +1956,7 @@ func TestDefaultAccountManager_OnPeerDisconnected_LastSeenCheck(t *testing.T) {
t.Run("skip disconnect when stored session is newer (zombie stream protection)", func(t *testing.T) {
// Newer stream wins on connect (sets SessionStartedAt = now ns).
streamStartTime := time.Now().UTC()
- err = manager.MarkPeerConnected(context.Background(), peerPubKey, nil, accountID, streamStartTime.UnixNano())
+ err = manager.MarkPeerConnected(context.Background(), peerPubKey, nil, accountID, streamStartTime.UnixNano(), nil)
require.NoError(t, err, "unable to mark peer connected")
peer, err := manager.Store.GetPeerByPeerPubKey(context.Background(), store.LockingStrengthNone, peerPubKey)
@@ -1957,7 +1980,7 @@ func TestDefaultAccountManager_OnPeerDisconnected_LastSeenCheck(t *testing.T) {
t.Run("skip stale connect when stored session is newer (blocked goroutine protection)", func(t *testing.T) {
node2SyncTime := time.Now().UTC()
- err = manager.MarkPeerConnected(context.Background(), peerPubKey, nil, accountID, node2SyncTime.UnixNano())
+ err = manager.MarkPeerConnected(context.Background(), peerPubKey, nil, accountID, node2SyncTime.UnixNano(), nil)
require.NoError(t, err, "node 2 should connect peer")
peer, err := manager.Store.GetPeerByPeerPubKey(context.Background(), store.LockingStrengthNone, peerPubKey)
@@ -1967,7 +1990,7 @@ func TestDefaultAccountManager_OnPeerDisconnected_LastSeenCheck(t *testing.T) {
"SessionStartedAt should equal node2SyncTime token")
node1StaleSyncTime := node2SyncTime.Add(-1 * time.Minute)
- err = manager.MarkPeerConnected(context.Background(), peerPubKey, nil, accountID, node1StaleSyncTime.UnixNano())
+ err = manager.MarkPeerConnected(context.Background(), peerPubKey, nil, accountID, node1StaleSyncTime.UnixNano(), nil)
require.NoError(t, err, "stale connect should not return error")
peer, err = manager.Store.GetPeerByPeerPubKey(context.Background(), store.LockingStrengthNone, peerPubKey)
@@ -1994,7 +2017,7 @@ func TestDefaultAccountManager_MarkPeerConnected_ConcurrentRace(t *testing.T) {
require.NoError(t, err, "unable to generate WireGuard key")
peerPubKey := key.PublicKey().String()
- _, _, _, err = manager.AddPeer(context.Background(), "", "", userID, &nbpeer.Peer{
+ _, _, _, _, err = manager.AddPeer(context.Background(), "", "", userID, &nbpeer.Peer{
Key: peerPubKey,
Meta: nbpeer.PeerSystemMeta{Hostname: "race-peer"},
}, false)
@@ -2029,7 +2052,7 @@ func TestDefaultAccountManager_MarkPeerConnected_ConcurrentRace(t *testing.T) {
defer done.Done()
ready.Done()
start.Wait()
- errs <- manager.MarkPeerConnected(context.Background(), peerPubKey, nil, accountID, token)
+ errs <- manager.MarkPeerConnected(context.Background(), peerPubKey, nil, accountID, token, nil)
}()
}
@@ -2057,7 +2080,7 @@ func TestDefaultAccountManager_UpdateAccountSettings_PeerLoginExpiration(t *test
key, err := wgtypes.GenerateKey()
require.NoError(t, err, "unable to generate WireGuard key")
- _, _, _, err = manager.AddPeer(context.Background(), "", "", userID, &nbpeer.Peer{
+ _, _, _, _, err = manager.AddPeer(context.Background(), "", "", userID, &nbpeer.Peer{
Key: key.PublicKey().String(),
Meta: nbpeer.PeerSystemMeta{Hostname: "test-peer"},
LoginExpirationEnabled: true,
@@ -2070,7 +2093,7 @@ func TestDefaultAccountManager_UpdateAccountSettings_PeerLoginExpiration(t *test
account, err := manager.Store.GetAccount(context.Background(), accountID)
require.NoError(t, err, "unable to get the account")
- err = manager.MarkPeerConnected(context.Background(), key.PublicKey().String(), nil, accountID, time.Now().UTC().UnixNano())
+ err = manager.MarkPeerConnected(context.Background(), key.PublicKey().String(), nil, accountID, time.Now().UTC().UnixNano(), nil)
require.NoError(t, err, "unable to mark peer connected")
wg := &sync.WaitGroup{}
@@ -3263,7 +3286,7 @@ func setupNetworkMapTest(t *testing.T) (*DefaultAccountManager, *update_channel.
}
expectedPeerKey := key.PublicKey().String()
- peer, _, _, err := manager.AddPeer(context.Background(), "", setupKey.Key, "", &nbpeer.Peer{
+ peer, _, _, _, err := manager.AddPeer(context.Background(), "", setupKey.Key, "", &nbpeer.Peer{
Key: expectedPeerKey,
Meta: nbpeer.PeerSystemMeta{Hostname: expectedPeerKey},
Status: &nbpeer.PeerStatus{
@@ -3292,6 +3315,19 @@ func setupNetworkMapTest(t *testing.T) (*DefaultAccountManager, *update_channel.
// when the channel delivers.
const peerUpdateTimeout = 5 * time.Second
+func drainPeerUpdates(ch <-chan *network_map.UpdateMessage) {
+ for {
+ select {
+ case _, ok := <-ch:
+ if !ok {
+ return
+ }
+ case <-time.After(200 * time.Millisecond):
+ return
+ }
+ }
+}
+
func peerShouldNotReceiveUpdate(t *testing.T, updateMessage <-chan *network_map.UpdateMessage) {
t.Helper()
select {
@@ -3418,7 +3454,7 @@ func BenchmarkLoginPeer_ExistingPeer(b *testing.B) {
b.ResetTimer()
start := time.Now()
for i := 0; i < b.N; i++ {
- _, _, _, err := manager.LoginPeer(context.Background(), types.PeerLogin{
+ _, _, _, _, err := manager.LoginPeer(context.Background(), types.PeerLogin{
WireGuardPubKey: account.Peers["peer-1"].Key,
SSHKey: "someKey",
Meta: nbpeer.PeerSystemMeta{Hostname: strconv.Itoa(i)},
@@ -3487,7 +3523,7 @@ func BenchmarkLoginPeer_NewPeer(b *testing.B) {
b.ResetTimer()
start := time.Now()
for i := 0; i < b.N; i++ {
- _, _, _, err := manager.LoginPeer(context.Background(), types.PeerLogin{
+ _, _, _, _, err := manager.LoginPeer(context.Background(), types.PeerLogin{
WireGuardPubKey: "some-new-key" + strconv.Itoa(i),
SSHKey: "someKey",
Meta: nbpeer.PeerSystemMeta{Hostname: strconv.Itoa(i)},
@@ -3882,13 +3918,13 @@ func TestDefaultAccountManager_UpdatePeerIP(t *testing.T) {
key2, err := wgtypes.GenerateKey()
require.NoError(t, err, "unable to generate WireGuard key")
- peer1, _, _, err := manager.AddPeer(context.Background(), "", "", userID, &nbpeer.Peer{
+ peer1, _, _, _, err := manager.AddPeer(context.Background(), "", "", userID, &nbpeer.Peer{
Key: key1.PublicKey().String(),
Meta: nbpeer.PeerSystemMeta{Hostname: "test-peer-1"},
}, false)
require.NoError(t, err, "unable to add peer1")
- peer2, _, _, err := manager.AddPeer(context.Background(), "", "", userID, &nbpeer.Peer{
+ peer2, _, _, _, err := manager.AddPeer(context.Background(), "", "", userID, &nbpeer.Peer{
Key: key2.PublicKey().String(),
Meta: nbpeer.PeerSystemMeta{Hostname: "test-peer-2"},
}, false)
diff --git a/management/server/affected_peers_coverage_test.go b/management/server/affected_peers_coverage_test.go
new file mode 100644
index 000000000..56917905f
--- /dev/null
+++ b/management/server/affected_peers_coverage_test.go
@@ -0,0 +1,117 @@
+package server
+
+import (
+ "context"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+
+ "github.com/netbirdio/netbird/management/server/affectedpeers"
+ resourceTypes "github.com/netbirdio/netbird/management/server/networks/resources/types"
+ networkTypes "github.com/netbirdio/netbird/management/server/networks/types"
+ "github.com/netbirdio/netbird/management/server/posture"
+ "github.com/netbirdio/netbird/management/server/types"
+)
+
+// TestAffectedPeers_DependencyCoverageMatrix enumerates each network-map
+// dependency crossed with the change-type that can alter it, asserting the
+// resolver folds in exactly the peers whose map changes. A new dependency that
+// the resolver fails to walk should fail one of these rows; a new change-type
+// without a row is a coverage gap to add here.
+func TestAffectedPeers_DependencyCoverageMatrix(t *testing.T) {
+ type row struct {
+ name string
+ build func(t *testing.T, s *routerScenario, ctx context.Context) (affectedpeers.Change, []string, []string)
+ }
+
+ rows := []row{
+ {
+ name: "policy-groups/source-group-change refreshes source+routing, excludes unrelated",
+ build: func(t *testing.T, s *routerScenario, ctx context.Context) (affectedpeers.Change, []string, []string) {
+ _, err := s.manager.SavePolicy(ctx, s.accountID, userID, peerToResourcePolicyByGroup(s.sourceGroupID, s.resourceGroupID), true)
+ require.NoError(t, err)
+ return affectedpeers.Change{ChangedGroupIDs: []string{s.sourceGroupID}},
+ []string{s.sourcePeerID, s.routerPeerID}, []string{s.unrelatedPeerID}
+ },
+ },
+ {
+ name: "resource-routing-bridge/router-peer-change refreshes policy sources",
+ build: func(t *testing.T, s *routerScenario, ctx context.Context) (affectedpeers.Change, []string, []string) {
+ _, err := s.manager.SavePolicy(ctx, s.accountID, userID, peerToResourcePolicyByGroup(s.sourceGroupID, s.resourceGroupID), true)
+ require.NoError(t, err)
+ return affectedpeers.Change{ChangedPeerIDs: []string{s.routerPeerID}},
+ []string{s.sourcePeerID}, []string{s.unrelatedPeerID}
+ },
+ },
+ {
+ name: "policy-change/explicit-policy refreshes source+routing",
+ build: func(t *testing.T, s *routerScenario, ctx context.Context) (affectedpeers.Change, []string, []string) {
+ policy := peerToResourcePolicyByGroup(s.sourceGroupID, s.resourceGroupID)
+ return affectedpeers.Change{Policies: []*types.Policy{policy}},
+ []string{s.sourcePeerID, s.routerPeerID}, []string{s.unrelatedPeerID}
+ },
+ },
+ {
+ name: "policy-destinationresource/explicit-policy bridges to routing peer",
+ build: func(t *testing.T, s *routerScenario, ctx context.Context) (affectedpeers.Change, []string, []string) {
+ policy := peerToResourcePolicyByResource(s.sourceGroupID, s.resourceID)
+ return affectedpeers.Change{Policies: []*types.Policy{policy}},
+ []string{s.sourcePeerID, s.routerPeerID}, []string{s.unrelatedPeerID}
+ },
+ },
+ {
+ name: "resource-change refreshes source+routing on its network",
+ build: func(t *testing.T, s *routerScenario, ctx context.Context) (affectedpeers.Change, []string, []string) {
+ _, err := s.manager.SavePolicy(ctx, s.accountID, userID, peerToResourcePolicyByGroup(s.sourceGroupID, s.resourceGroupID), true)
+ require.NoError(t, err)
+ return affectedpeers.Change{Resources: []*resourceTypes.NetworkResource{
+ {ID: s.resourceID, NetworkID: s.networkID, GroupIDs: []string{s.resourceGroupID}},
+ }},
+ []string{s.sourcePeerID, s.routerPeerID}, []string{s.unrelatedPeerID}
+ },
+ },
+ {
+ name: "network-change refreshes source+routing on that network",
+ build: func(t *testing.T, s *routerScenario, ctx context.Context) (affectedpeers.Change, []string, []string) {
+ _, err := s.manager.SavePolicy(ctx, s.accountID, userID, peerToResourcePolicyByGroup(s.sourceGroupID, s.resourceGroupID), true)
+ require.NoError(t, err)
+ return affectedpeers.Change{Networks: []*networkTypes.Network{{ID: s.networkID}}},
+ []string{s.sourcePeerID, s.routerPeerID}, []string{s.unrelatedPeerID}
+ },
+ },
+ {
+ name: "posture-check-change refreshes source+routing of gated policy",
+ build: func(t *testing.T, s *routerScenario, ctx context.Context) (affectedpeers.Change, []string, []string) {
+ check, err := s.manager.SavePostureChecks(ctx, s.accountID, userID, &posture.Checks{
+ Name: "cov-min-version",
+ Checks: posture.ChecksDefinition{NBVersionCheck: &posture.NBVersionCheck{MinVersion: "0.30.0"}},
+ }, true)
+ require.NoError(t, err)
+ policy := peerToResourcePolicyByGroup(s.sourceGroupID, s.resourceGroupID)
+ policy.SourcePostureChecks = []string{check.ID}
+ _, err = s.manager.SavePolicy(ctx, s.accountID, userID, policy, true)
+ require.NoError(t, err)
+ return affectedpeers.Change{PostureCheckIDs: []string{check.ID}},
+ []string{s.sourcePeerID, s.routerPeerID}, []string{s.unrelatedPeerID}
+ },
+ },
+ }
+
+ for _, r := range rows {
+ t.Run(r.name, func(t *testing.T) {
+ s := setupRouterScenario(t, true)
+ ctx := context.Background()
+
+ change, mustContain, mustExclude := r.build(t, s, ctx)
+ affected := resolveAffected(t, s.manager.Store, s.accountID, change)
+
+ for _, id := range mustContain {
+ assert.Contains(t, affected, id, "expected peer to be affected")
+ }
+ for _, id := range mustExclude {
+ assert.NotContains(t, affected, id, "peer must not be affected")
+ }
+ })
+ }
+}
diff --git a/management/server/affected_peers_oldstate_test.go b/management/server/affected_peers_oldstate_test.go
new file mode 100644
index 000000000..bcb78a660
--- /dev/null
+++ b/management/server/affected_peers_oldstate_test.go
@@ -0,0 +1,143 @@
+package server
+
+import (
+ "context"
+ "testing"
+ "time"
+
+ "github.com/stretchr/testify/require"
+
+ resourceTypes "github.com/netbirdio/netbird/management/server/networks/resources/types"
+ routerTypes "github.com/netbirdio/netbird/management/server/networks/routers/types"
+ "github.com/netbirdio/netbird/management/server/store"
+ "github.com/netbirdio/netbird/management/server/types"
+)
+
+// An update spans an old and a new state. The affected set must be the UNION of
+// peers reachable before and after the change; resolving only against the final
+// state drops peers that were reachable but no longer are. These tests pin the
+// two paths where the old state is reachable only by the changed object's
+// previous references: detaching a resource group, and re-pointing a router peer.
+
+// TestAffectedPeers_E2E_UpdateResource_DetachGroup_RefreshesOldGroupSources:
+// a resource is reachable by a source group via two destination resource groups;
+// detaching one of them must still refresh that group's policy source peers, even
+// though the post-update resource no longer maps to it.
+func TestAffectedPeers_E2E_UpdateResource_DetachGroup_RefreshesOldGroupSources(t *testing.T) {
+ s := setupRouterScenario(t, true)
+ ctx := context.Background()
+
+ // A second resource group + a second source group/peer that reaches the
+ // resource only through that second group.
+ const detachGroupID = "rs-detach-grp"
+ require.NoError(t, s.manager.CreateGroup(ctx, s.accountID, userID, &types.Group{ID: detachGroupID, Name: "rs-detach"}))
+
+ const secondSourceGroupID = "rs-source-grp-2"
+ setupKey, err := s.manager.CreateSetupKey(ctx, s.accountID, "rs-detach-key", types.SetupKeyReusable, time.Hour, nil, 999, userID, false, false)
+ require.NoError(t, err)
+ secondSourcePeer := addPeerToAccount(t, s.manager, s.accountID, setupKey.Key)
+ require.NoError(t, s.manager.CreateGroup(ctx, s.accountID, userID, &types.Group{
+ ID: secondSourceGroupID, Name: "rs-source-2", Peers: []string{secondSourcePeer.ID},
+ }))
+
+ resourcesManager, _, _ := s.managers()
+
+ // Attach the resource to the detach group as well: now in [resourceGroup, detachGroup].
+ _, err = resourcesManager.UpdateResource(ctx, userID, &resourceTypes.NetworkResource{
+ ID: s.resourceID,
+ AccountID: s.accountID,
+ NetworkID: s.networkID,
+ Name: "rs-resource-host",
+ Address: "10.20.30.0/24",
+ GroupIDs: []string{s.resourceGroupID, detachGroupID},
+ Enabled: true,
+ })
+ require.NoError(t, err)
+
+ // Policy granting the second source group access via the detach group.
+ _, err = s.manager.SavePolicy(ctx, s.accountID, userID, peerToResourcePolicyByGroup(secondSourceGroupID, detachGroupID), true)
+ require.NoError(t, err)
+
+ secondSrcCh := s.updateManager.CreateChannel(ctx, secondSourcePeer.ID)
+ t.Cleanup(func() { s.updateManager.CloseChannel(ctx, secondSourcePeer.ID) })
+ settleAffectedUpdates(secondSrcCh)
+
+ done := make(chan struct{})
+ go func() {
+ // Detaching the resource from detachGroup removes the second source's
+ // access; that source peer must be refreshed even though the post-update
+ // resource no longer maps to detachGroup.
+ peerShouldReceiveUpdate(t, secondSrcCh)
+ close(done)
+ }()
+
+ _, err = resourcesManager.UpdateResource(ctx, userID, &resourceTypes.NetworkResource{
+ ID: s.resourceID,
+ AccountID: s.accountID,
+ NetworkID: s.networkID,
+ Name: "rs-resource-host",
+ Address: "10.20.30.0/24",
+ GroupIDs: []string{s.resourceGroupID}, // detached detachGroup
+ Enabled: true,
+ })
+ require.NoError(t, err)
+
+ select {
+ case <-done:
+ case <-time.After(peerUpdateTimeout):
+ t.Error("timeout: detaching a resource group did not refresh the old group's policy source peer")
+ }
+}
+
+// TestAffectedPeers_E2E_UpdateRouter_RepointPeer_RefreshesOldRoutingPeer:
+// changing router.Peer within the same network must still refresh the OLD routing
+// peer, which loses its routing role.
+func TestAffectedPeers_E2E_UpdateRouter_RepointPeer_RefreshesOldRoutingPeer(t *testing.T) {
+ s := setupRouterScenario(t, true)
+ ctx := context.Background()
+
+ _, err := s.manager.SavePolicy(ctx, s.accountID, userID, peerToResourcePolicyByGroup(s.sourceGroupID, s.resourceGroupID), true)
+ require.NoError(t, err)
+
+ _, routersManager, _ := s.managers()
+
+ routers, err := s.manager.Store.GetNetworkRoutersByNetID(ctx, store.LockingStrengthNone, s.accountID, s.networkID)
+ require.NoError(t, err)
+ require.Len(t, routers, 1)
+ router := routers[0]
+ oldRoutingPeer := router.Peer
+ require.NotEmpty(t, oldRoutingPeer)
+
+ // A new peer to become the routing peer in place of the old one.
+ setupKey, err := s.manager.CreateSetupKey(ctx, s.accountID, "rs-newrouter-key", types.SetupKeyReusable, time.Hour, nil, 999, userID, false, false)
+ require.NoError(t, err)
+ newRoutingPeer := addPeerToAccount(t, s.manager, s.accountID, setupKey.Key)
+
+ oldCh := s.updateManager.CreateChannel(ctx, oldRoutingPeer)
+ t.Cleanup(func() { s.updateManager.CloseChannel(ctx, oldRoutingPeer) })
+ settleAffectedUpdates(oldCh)
+
+ done := make(chan struct{})
+ go func() {
+ // The old routing peer stops serving the resource and must be refreshed.
+ peerShouldReceiveUpdate(t, oldCh)
+ close(done)
+ }()
+
+ _, err = routersManager.UpdateRouter(ctx, userID, &routerTypes.NetworkRouter{
+ ID: router.ID,
+ NetworkID: s.networkID,
+ AccountID: s.accountID,
+ Peer: newRoutingPeer.ID, // repoint within the same network
+ Masquerade: true,
+ Metric: 9999,
+ Enabled: true,
+ })
+ require.NoError(t, err)
+
+ select {
+ case <-done:
+ case <-time.After(peerUpdateTimeout):
+ t.Error("timeout: re-pointing the router peer did not refresh the old routing peer")
+ }
+}
diff --git a/management/server/affected_peers_property_test.go b/management/server/affected_peers_property_test.go
new file mode 100644
index 000000000..f393465bc
--- /dev/null
+++ b/management/server/affected_peers_property_test.go
@@ -0,0 +1,255 @@
+package server
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "math/rand"
+ "sort"
+ "testing"
+
+ "github.com/stretchr/testify/require"
+ "golang.org/x/exp/maps"
+
+ nbdns "github.com/netbirdio/netbird/dns"
+ "github.com/netbirdio/netbird/management/server/affectedpeers"
+ "github.com/netbirdio/netbird/management/server/store"
+ "github.com/netbirdio/netbird/management/server/types"
+)
+
+// allPeerMaps computes the serialized per-peer network map for every peer in the
+// account, mirroring the controller's compute path so the property test compares
+// against real output.
+func allPeerMaps(t *testing.T, manager *DefaultAccountManager, accountID string) map[string]string {
+ t.Helper()
+ ctx := context.Background()
+
+ account, err := manager.Store.GetAccount(ctx, accountID)
+ require.NoError(t, err)
+
+ account.InjectProxyPolicies(ctx)
+
+ validated := make(map[string]struct{}, len(account.Peers))
+ for id := range account.Peers {
+ validated[id] = struct{}{}
+ }
+ resourcePolicies := account.GetResourcePoliciesMap()
+ routers := account.GetResourceRoutersMap()
+ groupIDToUserIDs := account.GetActiveGroupUsers()
+
+ out := make(map[string]string, len(account.Peers))
+ for peerID := range account.Peers {
+ nm := account.GetPeerNetworkMapFromComponents(ctx, peerID, nbdns.CustomZone{}, nil, validated, resourcePolicies, routers, nil, groupIDToUserIDs)
+ // Network.Serial is an account-global counter bumped on every change; it
+ // is not a per-peer dependency, so normalize it out of the comparison.
+ if nm.Network != nil {
+ nm.Network.Serial = 0
+ }
+ out[peerID] = canonicalJSON(t, nm)
+ }
+ return out
+}
+
+// canonicalJSON marshals v and returns an order-insensitive string form: every
+// JSON array is sorted by the canonical form of its elements. The network map's
+// Peers/Routes/FirewallRules/SourceRanges slices have nondeterministic order, so
+// a raw JSON compare would report spurious changes.
+func canonicalJSON(t *testing.T, v interface{}) string {
+ t.Helper()
+ b, err := json.Marshal(v)
+ require.NoError(t, err)
+ var parsed interface{}
+ require.NoError(t, json.Unmarshal(b, &parsed))
+ canonicalized, err := json.Marshal(sortAny(parsed))
+ require.NoError(t, err)
+ return string(canonicalized)
+}
+
+func sortAny(v interface{}) interface{} {
+ switch val := v.(type) {
+ case []interface{}:
+ for i := range val {
+ val[i] = sortAny(val[i])
+ }
+ sort.Slice(val, func(i, j int) bool {
+ bi, _ := json.Marshal(val[i])
+ bj, _ := json.Marshal(val[j])
+ return string(bi) < string(bj)
+ })
+ return val
+ case map[string]interface{}:
+ for k := range val {
+ val[k] = sortAny(val[k])
+ }
+ return val
+ default:
+ return v
+ }
+}
+
+// changedPeers returns the peer IDs whose serialized map differs between before
+// and after.
+func changedPeers(before, after map[string]string) []string {
+ var changed []string
+ for id, b := range before {
+ a, ok := after[id]
+ if !ok || a != b {
+ changed = append(changed, id)
+ }
+ }
+ for id := range after {
+ if _, ok := before[id]; !ok {
+ changed = append(changed, id)
+ }
+ }
+ return changed
+}
+
+// TestAffectedPeers_Property_ResolverSupersetsRealChanges builds a topology,
+// applies random changes, and asserts that the resolver's affected set is a
+// superset of the peers whose real network map actually changed. If the resolver
+// ever misses a dependency, a change will alter a peer's map without that peer
+// appearing in the affected set, failing here.
+func TestAffectedPeers_Property_ResolverSupersetsRealChanges(t *testing.T) {
+ s := setupRouterScenario(t, true)
+ ctx := context.Background()
+
+ // A pre-existing peer->resource policy so the resource/router bridge is live.
+ _, err := s.manager.SavePolicy(ctx, s.accountID, userID, peerToResourcePolicyByGroup(s.sourceGroupID, s.resourceGroupID), true)
+ require.NoError(t, err)
+
+ // Extra peers and groups to give mutations room to move membership around.
+ setupKey, err := s.manager.CreateSetupKey(ctx, s.accountID, "prop-key", types.SetupKeyReusable, 0, nil, 999, userID, false, false)
+ require.NoError(t, err)
+ extraPeers := make([]string, 0, 4)
+ for i := 0; i < 4; i++ {
+ p := addPeerToAccount(t, s.manager, s.accountID, setupKey.Key)
+ extraPeers = append(extraPeers, p.ID)
+ }
+ extraGroups := []string{"prop-grp-0", "prop-grp-1"}
+ for _, g := range extraGroups {
+ require.NoError(t, s.manager.CreateGroup(ctx, s.accountID, userID, &types.Group{ID: g, Name: g}))
+ }
+
+ rng := rand.New(rand.NewSource(1))
+ allGroups := append([]string{s.sourceGroupID, s.resourceGroupID, s.routerPeerGroupID}, extraGroups...)
+ allPeers := append([]string{s.sourcePeerID, s.routerPeerID, s.routerGroupPeerID, s.unrelatedPeerID}, extraPeers...)
+
+ for iter := 0; iter < 60; iter++ {
+ change, apply := s.randomMutation(t, rng, allGroups, allPeers)
+ if apply == nil {
+ continue
+ }
+
+ before := allPeerMaps(t, s.manager, s.accountID)
+
+ resolvedSet := make(map[string]struct{})
+ resolve := func() {
+ require.NoError(t, s.manager.Store.ExecuteInTransaction(ctx, func(tx store.Store) error {
+ snap, err := affectedpeers.Load(ctx, tx, s.accountID, change)
+ if err != nil {
+ return err
+ }
+ for _, id := range snap.Expand(ctx, s.accountID, change) {
+ resolvedSet[id] = struct{}{}
+ }
+ return nil
+ }))
+ }
+
+ // Resolve on both sides of the mutation and union: removals are visible
+ // only pre-apply (the leaving peer is still a member), additions only
+ // post-apply (the joining peer is now a member). Production captures both
+ // via per-path handling (e.g. UpdateGroup passes peersToRemove); the union
+ // models that without coupling the test to each path's ordering.
+ resolve()
+ changedIDs := change.ChangedPeerIDs
+ apply()
+ resolve()
+
+ after := allPeerMaps(t, s.manager, s.accountID)
+
+ // The explicitly-changed peer's own map refresh is the caller's
+ // responsibility (the resolver returns the peers to propagate to), so it
+ // is allowed to be absent from the resolved set.
+ changedExplicitly := make(map[string]struct{}, len(changedIDs))
+ for _, id := range changedIDs {
+ changedExplicitly[id] = struct{}{}
+ }
+
+ for _, id := range changedPeers(before, after) {
+ if _, stillExists := after[id]; !stillExists {
+ continue
+ }
+ if _, isExplicit := changedExplicitly[id]; isExplicit {
+ continue
+ }
+ _, ok := resolvedSet[id]
+ require.Truef(t, ok,
+ "iter %d: peer %s network map changed but was not in the resolver's affected set %v (change=%+v)",
+ iter, id, maps.Keys(resolvedSet), change)
+ }
+ }
+}
+
+// randomMutation picks a random change, returns the Change to resolve and a
+// function that applies the underlying store mutation. apply is nil when the
+// drawn mutation is a no-op for the current state.
+func (s *routerScenario) randomMutation(t *testing.T, rng *rand.Rand, allGroups, allPeers []string) (affectedpeers.Change, func()) {
+ t.Helper()
+ ctx := context.Background()
+
+ switch rng.Intn(3) {
+ case 0:
+ groupID := allGroups[rng.Intn(len(allGroups))]
+ peerID := allPeers[rng.Intn(len(allPeers))]
+ grp, err := s.manager.Store.GetGroupByID(ctx, store.LockingStrengthNone, s.accountID, groupID)
+ require.NoError(t, err)
+ if slicesContains(grp.Peers, peerID) {
+ return affectedpeers.Change{}, nil
+ }
+ return affectedpeers.Change{ChangedGroupIDs: []string{groupID}, ChangedPeerIDs: []string{peerID}},
+ func() {
+ require.NoError(t, s.manager.GroupAddPeer(ctx, s.accountID, groupID, peerID))
+ }
+ case 1:
+ groupID := allGroups[rng.Intn(len(allGroups))]
+ grp, err := s.manager.Store.GetGroupByID(ctx, store.LockingStrengthNone, s.accountID, groupID)
+ require.NoError(t, err)
+ if len(grp.Peers) == 0 {
+ return affectedpeers.Change{}, nil
+ }
+ peerID := grp.Peers[rng.Intn(len(grp.Peers))]
+ return affectedpeers.Change{ChangedGroupIDs: []string{groupID}, ChangedPeerIDs: []string{peerID}},
+ func() {
+ require.NoError(t, s.manager.GroupDeletePeer(ctx, s.accountID, groupID, peerID))
+ }
+ default:
+ src := allGroups[rng.Intn(len(allGroups))]
+ dst := allGroups[rng.Intn(len(allGroups))]
+ policy := &types.Policy{
+ Enabled: true,
+ Name: fmt.Sprintf("prop-policy-%d", rng.Int()),
+ Rules: []*types.PolicyRule{{
+ Enabled: true,
+ Sources: []string{src},
+ Destinations: []string{dst},
+ Action: types.PolicyTrafficActionAccept,
+ }},
+ }
+ return affectedpeers.Change{Policies: []*types.Policy{policy}},
+ func() {
+ _, err := s.manager.SavePolicy(ctx, s.accountID, userID, policy, true)
+ require.NoError(t, err)
+ }
+ }
+}
+
+func slicesContains(s []string, v string) bool {
+ for _, x := range s {
+ if x == v {
+ return true
+ }
+ }
+ return false
+}
diff --git a/management/server/affected_peers_querycount_test.go b/management/server/affected_peers_querycount_test.go
new file mode 100644
index 000000000..d451a0a29
--- /dev/null
+++ b/management/server/affected_peers_querycount_test.go
@@ -0,0 +1,164 @@
+package server
+
+import (
+ "context"
+ "sync"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+
+ nbdns "github.com/netbirdio/netbird/dns"
+ rpservice "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/service"
+ "github.com/netbirdio/netbird/management/server/affectedpeers"
+ resourceTypes "github.com/netbirdio/netbird/management/server/networks/resources/types"
+ routerTypes "github.com/netbirdio/netbird/management/server/networks/routers/types"
+ networkTypes "github.com/netbirdio/netbird/management/server/networks/types"
+ "github.com/netbirdio/netbird/management/server/store"
+ "github.com/netbirdio/netbird/management/server/types"
+ "github.com/netbirdio/netbird/route"
+)
+
+// countingStore wraps a real store and counts the per-account collection loads
+// the resolver performs, so a test can assert each is read at most once and that
+// irrelevant collections are skipped entirely.
+type countingStore struct {
+ store.Store
+ mu sync.Mutex
+ counts map[string]int
+}
+
+func newCountingStore(s store.Store) *countingStore {
+ return &countingStore{Store: s, counts: map[string]int{}}
+}
+
+func (c *countingStore) bump(name string) {
+ c.mu.Lock()
+ c.counts[name]++
+ c.mu.Unlock()
+}
+
+func (c *countingStore) count(name string) int {
+ c.mu.Lock()
+ defer c.mu.Unlock()
+ return c.counts[name]
+}
+
+func (c *countingStore) total() int {
+ c.mu.Lock()
+ defer c.mu.Unlock()
+ n := 0
+ for _, v := range c.counts {
+ n += v
+ }
+ return n
+}
+
+func (c *countingStore) GetAccountPolicies(ctx context.Context, ls store.LockingStrength, accountID string) ([]*types.Policy, error) {
+ c.bump("policies")
+ return c.Store.GetAccountPolicies(ctx, ls, accountID)
+}
+
+func (c *countingStore) GetAccountRoutes(ctx context.Context, ls store.LockingStrength, accountID string) ([]*route.Route, error) {
+ c.bump("routes")
+ return c.Store.GetAccountRoutes(ctx, ls, accountID)
+}
+
+func (c *countingStore) GetAccountNameServerGroups(ctx context.Context, ls store.LockingStrength, accountID string) ([]*nbdns.NameServerGroup, error) {
+ c.bump("nameservers")
+ return c.Store.GetAccountNameServerGroups(ctx, ls, accountID)
+}
+
+func (c *countingStore) GetAccountDNSSettings(ctx context.Context, ls store.LockingStrength, accountID string) (*types.DNSSettings, error) {
+ c.bump("dnssettings")
+ return c.Store.GetAccountDNSSettings(ctx, ls, accountID)
+}
+
+func (c *countingStore) GetNetworkRoutersByAccountID(ctx context.Context, ls store.LockingStrength, accountID string) ([]*routerTypes.NetworkRouter, error) {
+ c.bump("routers")
+ return c.Store.GetNetworkRoutersByAccountID(ctx, ls, accountID)
+}
+
+func (c *countingStore) GetNetworkResourcesByAccountID(ctx context.Context, ls store.LockingStrength, accountID string) ([]*resourceTypes.NetworkResource, error) {
+ c.bump("resources")
+ return c.Store.GetNetworkResourcesByAccountID(ctx, ls, accountID)
+}
+
+func (c *countingStore) GetAccountServices(ctx context.Context, ls store.LockingStrength, accountID string) ([]*rpservice.Service, error) {
+ c.bump("services")
+ return c.Store.GetAccountServices(ctx, ls, accountID)
+}
+
+// TestAffectedPeers_QueryCount_NoRedundantFullTableLoads asserts the resolver
+// loads each per-account collection at most once per Resolve (memoization) even
+// on a change that drives every bridge, and skips the services table when the
+// account has no embedded proxy peers.
+func TestAffectedPeers_QueryCount_NoRedundantFullTableLoads(t *testing.T) {
+ s := setupRouterScenario(t, true)
+ ctx := context.Background()
+
+ _, err := s.manager.SavePolicy(ctx, s.accountID, userID, peerToResourcePolicyByGroup(s.sourceGroupID, s.resourceGroupID), true)
+ require.NoError(t, err)
+
+ cs := newCountingStore(s.manager.Store)
+
+ // A group change that exercises policies, routers, resources and the bridge.
+ change := affectedpeers.Change{ChangedGroupIDs: []string{s.sourceGroupID}}
+ snap, err := affectedpeers.Load(ctx, cs, s.accountID, change)
+ require.NoError(t, err)
+ affected := snap.Expand(ctx, s.accountID, change)
+ assert.Contains(t, affected, s.routerPeerID, "bridge must still resolve the routing peer")
+
+ for _, name := range []string{"policies", "routes", "nameservers", "dnssettings", "routers", "resources"} {
+ assert.LessOrEqualf(t, cs.count(name), 1,
+ "%s must be loaded at most once per Resolve, got %d", name, cs.count(name))
+ }
+ assert.Equal(t, 0, cs.count("services"),
+ "services must not be loaded when the account has no embedded proxy peers")
+}
+
+// TestAffectedPeers_QueryCount_NarrowChangeSkipsLoads asserts that a change with
+// no group/peer signal touches no per-account collections beyond what its inputs
+// require.
+func TestAffectedPeers_QueryCount_NarrowChangeSkipsLoads(t *testing.T) {
+ s := setupRouterScenario(t, true)
+ ctx := context.Background()
+
+ cs := newCountingStore(s.manager.Store)
+
+ // A bare network change drives only the router->source bridge: routers and
+ // resources are needed, but routes/nameservers/dnssettings/services are not.
+ _, err := affectedpeers.Load(ctx, cs, s.accountID, affectedpeers.Change{Networks: []*networkTypes.Network{{ID: s.networkID}}})
+ require.NoError(t, err)
+
+ assert.Equal(t, 0, cs.count("routes"), "routes must not be loaded for a network-only change")
+ assert.Equal(t, 0, cs.count("nameservers"), "nameservers must not be loaded for a network-only change")
+ assert.Equal(t, 0, cs.count("dnssettings"), "dnssettings must not be loaded for a network-only change")
+ assert.Equal(t, 0, cs.count("services"), "services must not be loaded for a network-only change")
+}
+
+// TestAffectedPeers_QueryCount_ExpandReadsNothing is the core invariant of the
+// Load/Expand split: Load (run inside the transaction) does all store reads;
+// Expand (run after commit) must touch the store ZERO times, so it never holds
+// the write lock and never reads post-commit state.
+func TestAffectedPeers_QueryCount_ExpandReadsNothing(t *testing.T) {
+ s := setupRouterScenario(t, true)
+ ctx := context.Background()
+
+ _, err := s.manager.SavePolicy(ctx, s.accountID, userID, peerToResourcePolicyByGroup(s.sourceGroupID, s.resourceGroupID), true)
+ require.NoError(t, err)
+
+ change := affectedpeers.Change{ChangedGroupIDs: []string{s.sourceGroupID}}
+
+ cs := newCountingStore(s.manager.Store)
+ snap, err := affectedpeers.Load(ctx, cs, s.accountID, change)
+ require.NoError(t, err)
+ require.Greater(t, cs.total(), 0, "Load must read the store")
+
+ // Any store access during Expand would increment the same counter. Expand
+ // operates purely on the snapshot, so the count must not move.
+ readsAfterLoad := cs.total()
+ affected := snap.Expand(ctx, s.accountID, change)
+ assert.Contains(t, affected, s.routerPeerID, "Expand must still produce the affected peers from the snapshot")
+ assert.Equal(t, readsAfterLoad, cs.total(), "Expand must perform zero store reads — it operates purely on the loaded snapshot")
+}
diff --git a/management/server/affected_peers_router_paths_test.go b/management/server/affected_peers_router_paths_test.go
new file mode 100644
index 000000000..11313c387
--- /dev/null
+++ b/management/server/affected_peers_router_paths_test.go
@@ -0,0 +1,333 @@
+package server
+
+import (
+ "context"
+ "testing"
+ "time"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+
+ "github.com/netbirdio/netbird/management/server/affectedpeers"
+ resourceTypes "github.com/netbirdio/netbird/management/server/networks/resources/types"
+ routerTypes "github.com/netbirdio/netbird/management/server/networks/routers/types"
+ "github.com/netbirdio/netbird/management/server/posture"
+ "github.com/netbirdio/netbird/management/server/types"
+)
+
+func (s *routerScenario) resolveGroupChangeAffected(ctx context.Context, changedGroupIDs []string) []string {
+ change := affectedpeers.Change{ChangedGroupIDs: changedGroupIDs}
+ snap, err := affectedpeers.Load(ctx, s.manager.Store, s.accountID, change)
+ if err != nil {
+ return nil
+ }
+ return snap.Expand(ctx, s.accountID, change)
+}
+
+func (s *routerScenario) resolvePeerChangeAffected(ctx context.Context, changedPeerIDs []string) []string {
+ change := affectedpeers.Change{ChangedPeerIDs: changedPeerIDs}
+ snap, err := affectedpeers.Load(ctx, s.manager.Store, s.accountID, change)
+ if err != nil {
+ return nil
+ }
+ return snap.Expand(ctx, s.accountID, change)
+}
+
+func TestAffectedPeers_GroupChange_SourceGroupMembership_RefreshesRoutingPeer_DirectRouter(t *testing.T) {
+ s := setupRouterScenario(t, true)
+ ctx := context.Background()
+
+ _, err := s.manager.SavePolicy(ctx, s.accountID, userID, peerToResourcePolicyByGroup(s.sourceGroupID, s.resourceGroupID), true)
+ require.NoError(t, err)
+
+ affected := s.resolveGroupChangeAffected(ctx, []string{s.sourceGroupID})
+
+ assert.Contains(t, affected, s.sourcePeerID, "source group member must be affected")
+ assert.Contains(t, affected, s.routerPeerID,
+ "changing the source group of a peer->resource policy must refresh the resource's routing peer")
+ assert.NotContains(t, affected, s.unrelatedPeerID, "unrelated peer must not be affected")
+}
+
+func TestAffectedPeers_GroupChange_SourceGroupMembership_RefreshesRoutingPeer_RouterPeerGroups(t *testing.T) {
+ s := setupRouterScenario(t, false)
+ ctx := context.Background()
+
+ _, err := s.manager.SavePolicy(ctx, s.accountID, userID, peerToResourcePolicyByGroup(s.sourceGroupID, s.resourceGroupID), true)
+ require.NoError(t, err)
+
+ affected := s.resolveGroupChangeAffected(ctx, []string{s.sourceGroupID})
+
+ assert.Contains(t, affected, s.routerGroupPeerID,
+ "changing the source group must refresh the routing peer defined via router.PeerGroups")
+ assert.NotContains(t, affected, s.unrelatedPeerID, "unrelated peer must not be affected")
+}
+
+func TestAffectedPeers_GroupChange_RouterPeerGroupMembership_RefreshesPolicySources(t *testing.T) {
+ s := setupRouterScenario(t, false)
+ ctx := context.Background()
+
+ _, err := s.manager.SavePolicy(ctx, s.accountID, userID, peerToResourcePolicyByGroup(s.sourceGroupID, s.resourceGroupID), true)
+ require.NoError(t, err)
+
+ affected := s.resolveGroupChangeAffected(ctx, []string{s.routerPeerGroupID})
+
+ assert.Contains(t, affected, s.routerGroupPeerID, "the routing peer itself must be affected")
+ assert.Contains(t, affected, s.sourcePeerID,
+ "changing the router's PeerGroups must refresh the source peers of policies serving the resource")
+ assert.NotContains(t, affected, s.unrelatedPeerID, "unrelated peer must not be affected")
+}
+
+func TestAffectedPeers_PeerChange_SourcePeer_RefreshesRoutingPeer(t *testing.T) {
+ s := setupRouterScenario(t, true)
+ ctx := context.Background()
+
+ _, err := s.manager.SavePolicy(ctx, s.accountID, userID, peerToResourcePolicyByGroup(s.sourceGroupID, s.resourceGroupID), true)
+ require.NoError(t, err)
+
+ affected := s.resolvePeerChangeAffected(ctx, []string{s.sourcePeerID})
+
+ assert.Contains(t, affected, s.routerPeerID,
+ "a status change on a source peer must refresh the resource's routing peer that serves it")
+ assert.NotContains(t, affected, s.unrelatedPeerID, "unrelated peer must not be affected")
+}
+
+func TestAffectedPeers_PeerChange_SourcePeer_ByDestinationResource_RefreshesRoutingPeer(t *testing.T) {
+ s := setupRouterScenario(t, true)
+ ctx := context.Background()
+
+ _, err := s.manager.SavePolicy(ctx, s.accountID, userID, peerToResourcePolicyByResource(s.sourceGroupID, s.resourceID), true)
+ require.NoError(t, err)
+
+ affected := s.resolvePeerChangeAffected(ctx, []string{s.sourcePeerID})
+
+ assert.Contains(t, affected, s.routerPeerID,
+ "DestinationResource-targeted policy must still bridge a source-peer change to the routing peer")
+ assert.NotContains(t, affected, s.unrelatedPeerID, "unrelated peer must not be affected")
+}
+
+func TestAffectedPeers_E2E_DeleteGroup_ResolvesAffectedPeers(t *testing.T) {
+ s := setupRouterScenario(t, true)
+ ctx := context.Background()
+
+ const memberOnlyGroupID = "rs-memberonly-grp"
+ require.NoError(t, s.manager.CreateGroup(ctx, s.accountID, userID, &types.Group{
+ ID: memberOnlyGroupID, Name: "rs-memberonly", Peers: []string{s.sourcePeerID},
+ }))
+
+ affected := s.resolveGroupChangeAffected(ctx, []string{memberOnlyGroupID})
+ assert.Empty(t, affected, "an unlinked group has no network-map impact, so no peer is affected")
+
+ require.NoError(t, s.manager.DeleteGroup(ctx, s.accountID, userID, memberOnlyGroupID))
+}
+
+func TestAffectedPeers_GroupAddResource_RefreshesRoutingPeer(t *testing.T) {
+ s := setupRouterScenario(t, true)
+ ctx := context.Background()
+
+ const extraResourceGroupID = "rs-resource-grp-extra"
+ require.NoError(t, s.manager.CreateGroup(ctx, s.accountID, userID, &types.Group{
+ ID: extraResourceGroupID, Name: "rs-resource-extra",
+ }))
+
+ _, err := s.manager.SavePolicy(ctx, s.accountID, userID, peerToResourcePolicyByGroup(s.sourceGroupID, extraResourceGroupID), true)
+ require.NoError(t, err)
+
+ require.NoError(t, s.manager.GroupAddResource(ctx, s.accountID, extraResourceGroupID, types.Resource{
+ ID: s.resourceID,
+ Type: types.ResourceTypeHost,
+ }))
+
+ affected := s.resolveGroupChangeAffected(ctx, []string{extraResourceGroupID})
+
+ assert.Contains(t, affected, s.routerPeerID,
+ "attaching a resource to a policy destination group must refresh the resource's routing peer")
+ assert.Contains(t, affected, s.sourcePeerID, "policy source peers must refresh")
+ assert.NotContains(t, affected, s.unrelatedPeerID, "unrelated peer must not be affected")
+}
+
+func (s *routerScenario) createPostureCheckGatedPolicy(t *testing.T, ctx context.Context) string {
+ t.Helper()
+
+ check, err := s.manager.SavePostureChecks(ctx, s.accountID, userID, &posture.Checks{
+ Name: "rs-min-version",
+ Checks: posture.ChecksDefinition{
+ NBVersionCheck: &posture.NBVersionCheck{MinVersion: "0.30.0"},
+ },
+ }, true)
+ require.NoError(t, err)
+
+ policy := peerToResourcePolicyByGroup(s.sourceGroupID, s.resourceGroupID)
+ policy.SourcePostureChecks = []string{check.ID}
+ _, err = s.manager.SavePolicy(ctx, s.accountID, userID, policy, true)
+ require.NoError(t, err)
+
+ return check.ID
+}
+
+func TestAffectedPeers_E2E_SavePostureCheck_RefreshesRoutingPeer(t *testing.T) {
+ s := setupRouterScenario(t, true)
+ ctx := context.Background()
+
+ checkID := s.createPostureCheckGatedPolicy(t, ctx)
+
+ srcCh := s.updateManager.CreateChannel(ctx, s.sourcePeerID)
+ routerCh := s.updateManager.CreateChannel(ctx, s.routerPeerID)
+ unrelatedCh := s.updateManager.CreateChannel(ctx, s.unrelatedPeerID)
+ t.Cleanup(func() {
+ s.updateManager.CloseChannel(ctx, s.sourcePeerID)
+ s.updateManager.CloseChannel(ctx, s.routerPeerID)
+ s.updateManager.CloseChannel(ctx, s.unrelatedPeerID)
+ })
+
+ settleAffectedUpdates(srcCh, routerCh, unrelatedCh)
+
+ done := make(chan struct{})
+ go func() {
+ peerShouldReceiveUpdate(t, srcCh)
+ peerShouldReceiveUpdate(t, routerCh)
+ peerShouldNotReceiveUpdate(t, unrelatedCh)
+ close(done)
+ }()
+
+ _, err := s.manager.SavePostureChecks(ctx, s.accountID, userID, &posture.Checks{
+ ID: checkID,
+ Name: "rs-min-version",
+ Checks: posture.ChecksDefinition{
+ NBVersionCheck: &posture.NBVersionCheck{MinVersion: "0.31.0"},
+ },
+ }, false)
+ require.NoError(t, err)
+
+ select {
+ case <-done:
+ case <-time.After(peerUpdateTimeout):
+ t.Error("timeout: editing a posture check did not refresh source + routing peers")
+ }
+}
+
+func TestAffectedPeers_E2E_UpdateResource_DestinationResourcePolicy_RefreshesSourcePeer(t *testing.T) {
+ s := setupRouterScenario(t, true)
+ ctx := context.Background()
+
+ _, err := s.manager.SavePolicy(ctx, s.accountID, userID, peerToResourcePolicyByResource(s.sourceGroupID, s.resourceID), true)
+ require.NoError(t, err)
+
+ resourcesManager, _, _ := s.managers()
+
+ srcCh := s.updateManager.CreateChannel(ctx, s.sourcePeerID)
+ routerCh := s.updateManager.CreateChannel(ctx, s.routerPeerID)
+ unrelatedCh := s.updateManager.CreateChannel(ctx, s.unrelatedPeerID)
+ t.Cleanup(func() {
+ s.updateManager.CloseChannel(ctx, s.sourcePeerID)
+ s.updateManager.CloseChannel(ctx, s.routerPeerID)
+ s.updateManager.CloseChannel(ctx, s.unrelatedPeerID)
+ })
+
+ settleAffectedUpdates(srcCh, routerCh, unrelatedCh)
+
+ done := make(chan struct{})
+ go func() {
+ peerShouldReceiveUpdate(t, srcCh)
+ peerShouldReceiveUpdate(t, routerCh)
+ peerShouldNotReceiveUpdate(t, unrelatedCh)
+ close(done)
+ }()
+
+ _, err = resourcesManager.UpdateResource(ctx, userID, &resourceTypes.NetworkResource{
+ ID: s.resourceID,
+ AccountID: s.accountID,
+ NetworkID: s.networkID,
+ Name: "rs-resource-host",
+ Address: "10.20.30.0/25",
+ GroupIDs: []string{s.resourceGroupID},
+ Enabled: true,
+ })
+ require.NoError(t, err)
+
+ select {
+ case <-done:
+ case <-time.After(peerUpdateTimeout):
+ t.Error("timeout: updating a DestinationResource-targeted resource did not refresh its policy source peer")
+ }
+}
+
+func TestAffectedPeers_E2E_UpdateResource_DisabledSiblingRouter_StillBridged(t *testing.T) {
+ s := setupRouterScenario(t, true)
+ ctx := context.Background()
+
+ _, err := s.manager.SavePolicy(ctx, s.accountID, userID, peerToResourcePolicyByGroup(s.sourceGroupID, s.resourceGroupID), true)
+ require.NoError(t, err)
+
+ resourcesManager, routersManager, _ := s.managers()
+
+ setupKey, err := s.manager.CreateSetupKey(ctx, s.accountID, "rs-key-disabled", types.SetupKeyReusable, time.Hour, nil, 999, userID, false, false)
+ require.NoError(t, err)
+ disabledRouterPeer := addPeerToAccount(t, s.manager, s.accountID, setupKey.Key)
+ _, err = routersManager.CreateRouter(ctx, userID, &routerTypes.NetworkRouter{
+ NetworkID: s.networkID,
+ AccountID: s.accountID,
+ Peer: disabledRouterPeer.ID,
+ Masquerade: true,
+ Metric: 9000,
+ Enabled: false,
+ })
+ require.NoError(t, err)
+
+ disabledCh := s.updateManager.CreateChannel(ctx, disabledRouterPeer.ID)
+ t.Cleanup(func() { s.updateManager.CloseChannel(ctx, disabledRouterPeer.ID) })
+
+ settleAffectedUpdates(disabledCh)
+
+ done := make(chan struct{})
+ go func() {
+ peerShouldReceiveUpdate(t, disabledCh)
+ close(done)
+ }()
+
+ _, err = resourcesManager.UpdateResource(ctx, userID, &resourceTypes.NetworkResource{
+ ID: s.resourceID,
+ AccountID: s.accountID,
+ NetworkID: s.networkID,
+ Name: "rs-resource-host",
+ Address: "10.20.30.0/25",
+ GroupIDs: []string{s.resourceGroupID},
+ Enabled: true,
+ })
+ require.NoError(t, err)
+
+ select {
+ case <-done:
+ case <-time.After(peerUpdateTimeout):
+ t.Error("timeout: resource update did not refresh the disabled sibling router's peer")
+ }
+}
+
+func TestAffectedPeers_GroupChange_RouterInOtherNetworkNotAffected(t *testing.T) {
+ s := setupRouterScenario(t, true)
+ second := s.addSecondTopology(t, "groupiso")
+ ctx := context.Background()
+
+ _, err := s.manager.SavePolicy(ctx, s.accountID, userID, peerToResourcePolicyByGroup(s.sourceGroupID, s.resourceGroupID), true)
+ require.NoError(t, err)
+
+ affected := s.resolveGroupChangeAffected(ctx, []string{s.sourceGroupID})
+
+ assert.Contains(t, affected, s.routerPeerID, "network A's routing peer must be affected")
+ assert.NotContains(t, affected, second.routerPeerID,
+ "a router in an unrelated network must not be affected by a source-group change for another resource")
+}
+
+func TestAffectedPeers_PeerChange_RouterInOtherNetworkNotAffected(t *testing.T) {
+ s := setupRouterScenario(t, true)
+ second := s.addSecondTopology(t, "peeriso")
+ ctx := context.Background()
+
+ _, err := s.manager.SavePolicy(ctx, s.accountID, userID, peerToResourcePolicyByGroup(s.sourceGroupID, s.resourceGroupID), true)
+ require.NoError(t, err)
+
+ affected := s.resolvePeerChangeAffected(ctx, []string{s.sourcePeerID})
+
+ assert.Contains(t, affected, s.routerPeerID, "network A's routing peer must be affected")
+ assert.NotContains(t, affected, second.routerPeerID,
+ "a router in an unrelated network must not be affected by a source-peer change for another resource")
+}
diff --git a/management/server/affected_peers_router_test.go b/management/server/affected_peers_router_test.go
new file mode 100644
index 000000000..dc064e787
--- /dev/null
+++ b/management/server/affected_peers_router_test.go
@@ -0,0 +1,771 @@
+package server
+
+import (
+ "context"
+ "testing"
+ "time"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+
+ "github.com/netbirdio/netbird/management/internals/controllers/network_map"
+ "github.com/netbirdio/netbird/management/internals/controllers/network_map/update_channel"
+ "github.com/netbirdio/netbird/management/server/affectedpeers"
+ "github.com/netbirdio/netbird/management/server/groups"
+ "github.com/netbirdio/netbird/management/server/networks"
+ "github.com/netbirdio/netbird/management/server/networks/resources"
+ resourceTypes "github.com/netbirdio/netbird/management/server/networks/resources/types"
+ "github.com/netbirdio/netbird/management/server/networks/routers"
+ routerTypes "github.com/netbirdio/netbird/management/server/networks/routers/types"
+ networkTypes "github.com/netbirdio/netbird/management/server/networks/types"
+ "github.com/netbirdio/netbird/management/server/permissions"
+ "github.com/netbirdio/netbird/management/server/store"
+ "github.com/netbirdio/netbird/management/server/types"
+)
+
+// routerScenario captures the topology from the bug report:
+//
+// network ── router (routing peer) ── resource (in resourceGroup)
+// independent peer ──(policy: source -> resource)──> resource
+//
+// The routing peer must be refreshed when a policy grants a source peer access
+// to the resource, because the network map connects the source peer to the
+// routing peer at compute time (Account.GetPoliciesForNetworkResource +
+// addNetworksRoutingPeers). The routing peer is NOT a member of the resource
+// group, so static group/peer resolution alone cannot find it.
+type routerScenario struct {
+ manager *DefaultAccountManager
+ updateManager *update_channel.PeersUpdateManager
+ accountID string
+ networkID string
+
+ sourcePeerID string // independent peer that the policy grants access from
+ sourceGroupID string // group containing the source peer
+
+ routerPeerID string // peer acting as the routing peer (direct router.Peer)
+ routerGroupPeerID string // peer that is a member of routerPeerGroup
+ routerPeerGroupID string // group used for router.PeerGroups
+
+ resourceID string // network resource
+ resourceGroupID string // group whose member is the resource (no peers)
+
+ unrelatedPeerID string // peer in no relevant entity
+}
+
+// setupRouterScenario builds the topology above with the default policy removed
+// and channels NOT yet created, so callers control exactly when updates can flow.
+func setupRouterScenario(t *testing.T, directRouterPeer bool) *routerScenario {
+ t.Helper()
+
+ manager, updateManager, err := createManager(t)
+ require.NoError(t, err)
+
+ ctx := context.Background()
+
+ account, err := createAccount(manager, "router_scenario", userID, "")
+ require.NoError(t, err)
+ accountID := account.Id
+
+ // Remove the default policy so AddPeer/CreateGroup don't schedule unrelated updates.
+ policies, err := manager.Store.GetAccountPolicies(ctx, store.LockingStrengthNone, accountID)
+ require.NoError(t, err)
+ for _, p := range policies {
+ require.NoError(t, manager.Store.DeletePolicy(ctx, accountID, p.ID))
+ }
+
+ setupKey, err := manager.CreateSetupKey(ctx, accountID, "rs-key", types.SetupKeyReusable, time.Hour, nil, 999, userID, false, false)
+ require.NoError(t, err)
+
+ sourcePeer := addPeerToAccount(t, manager, accountID, setupKey.Key)
+ routerPeer := addPeerToAccount(t, manager, accountID, setupKey.Key)
+ routerGroupPeer := addPeerToAccount(t, manager, accountID, setupKey.Key)
+ unrelatedPeer := addPeerToAccount(t, manager, accountID, setupKey.Key)
+
+ const (
+ sourceGroupID = "rs-source-grp"
+ routerPeerGroupID = "rs-router-grp"
+ resourceGroupID = "rs-resource-grp"
+ )
+
+ for _, g := range []*types.Group{
+ {ID: sourceGroupID, Name: "rs-source", Peers: []string{sourcePeer.ID}},
+ {ID: routerPeerGroupID, Name: "rs-router", Peers: []string{routerGroupPeer.ID}},
+ {ID: resourceGroupID, Name: "rs-resource"}, // intentionally peerless; the resource is its only member
+ } {
+ require.NoError(t, manager.CreateGroup(ctx, accountID, userID, g))
+ }
+
+ permissionsManager := permissions.NewManager(manager.Store)
+ groupsManager := groups.NewManager(manager.Store, permissionsManager, manager)
+ resourcesManager := resources.NewManager(manager.Store, permissionsManager, groupsManager, manager, manager.serviceManager)
+ routersManager := routers.NewManager(manager.Store, permissionsManager, manager)
+ networksManager := networks.NewManager(manager.Store, permissionsManager, resourcesManager, routersManager, manager)
+
+ network, err := networksManager.CreateNetwork(ctx, userID, &networkTypes.Network{
+ ID: "rs-network",
+ AccountID: accountID,
+ Name: "rs-network",
+ })
+ require.NoError(t, err)
+
+ resource, err := resourcesManager.CreateResource(ctx, userID, &resourceTypes.NetworkResource{
+ AccountID: accountID,
+ NetworkID: network.ID,
+ Name: "rs-resource-host",
+ Address: "10.20.30.0/24",
+ GroupIDs: []string{resourceGroupID},
+ Enabled: true,
+ })
+ require.NoError(t, err)
+
+ router := &routerTypes.NetworkRouter{
+ ID: "rs-router",
+ NetworkID: network.ID,
+ AccountID: accountID,
+ Masquerade: true,
+ Metric: 9999,
+ Enabled: true,
+ }
+ if directRouterPeer {
+ router.Peer = routerPeer.ID
+ } else {
+ router.PeerGroups = []string{routerPeerGroupID}
+ }
+ _, err = routersManager.CreateRouter(ctx, userID, router)
+ require.NoError(t, err)
+
+ return &routerScenario{
+ manager: manager,
+ updateManager: updateManager,
+ accountID: accountID,
+ networkID: network.ID,
+ sourcePeerID: sourcePeer.ID,
+ sourceGroupID: sourceGroupID,
+ routerPeerID: routerPeer.ID,
+ routerGroupPeerID: routerGroupPeer.ID,
+ routerPeerGroupID: routerPeerGroupID,
+ resourceID: resource.ID,
+ resourceGroupID: resourceGroupID,
+ unrelatedPeerID: unrelatedPeer.ID,
+ }
+}
+
+// peerToResourcePolicy builds a policy granting the source group access to the
+// resource, referencing the resource by its group in the rule destination.
+func peerToResourcePolicyByGroup(sourceGroupID, resourceGroupID string) *types.Policy {
+ return &types.Policy{
+ Enabled: true,
+ Name: "peer-to-resource-by-group",
+ Rules: []*types.PolicyRule{
+ {
+ Enabled: true,
+ Sources: []string{sourceGroupID},
+ Destinations: []string{resourceGroupID},
+ Action: types.PolicyTrafficActionAccept,
+ },
+ },
+ }
+}
+
+// peerToResourcePolicyByResource builds a policy referencing the resource
+// directly via DestinationResource rather than its group.
+func peerToResourcePolicyByResource(sourceGroupID, resourceID string) *types.Policy {
+ return &types.Policy{
+ Enabled: true,
+ Name: "peer-to-resource-by-resource",
+ Rules: []*types.PolicyRule{
+ {
+ Enabled: true,
+ Sources: []string{sourceGroupID},
+ DestinationResource: types.Resource{ID: resourceID, Type: types.ResourceTypeHost},
+ Action: types.PolicyTrafficActionAccept,
+ },
+ },
+ }
+}
+
+// resolvePolicyAffected mirrors SavePolicy's resolution: resolve the affected
+// peers for the given policy.
+func (s *routerScenario) resolvePolicyAffected(ctx context.Context, policy *types.Policy) []string {
+ change := affectedpeers.Change{Policies: []*types.Policy{policy}}
+ snap, err := affectedpeers.Load(ctx, s.manager.Store, s.accountID, change)
+ if err != nil {
+ return nil
+ }
+ return snap.Expand(ctx, s.accountID, change)
+}
+
+func TestAffectedPeers_SourcePeer_DirectRouter(t *testing.T) {
+ s := setupRouterScenario(t, true)
+ ctx := context.Background()
+
+ policy := peerToResourcePolicyByGroup(s.sourceGroupID, s.resourceGroupID)
+ affected := s.resolvePolicyAffected(ctx, policy)
+
+ assert.Contains(t, affected, s.sourcePeerID, "source peer must be affected")
+}
+
+func TestAffectedPeers_RoutingPeer_DirectRouter(t *testing.T) {
+ s := setupRouterScenario(t, true)
+ ctx := context.Background()
+
+ policy := peerToResourcePolicyByGroup(s.sourceGroupID, s.resourceGroupID)
+ affected := s.resolvePolicyAffected(ctx, policy)
+
+ // BUG: the direct routing peer serves the resource's subnet to the source
+ // peer, so it must be refreshed when the policy is created. The policy path
+ // only resolves the literal rule groups (source group + resource group);
+ // the resource group has no peer members and the router peer is reachable
+ // only through the network, so it is dropped.
+ assert.Contains(t, affected, s.routerPeerID,
+ "routing peer (router.Peer) serving the resource must be affected by a policy granting access to it")
+}
+
+func TestAffectedPeers_RoutingPeer_RouterPeerGroups(t *testing.T) {
+ s := setupRouterScenario(t, false)
+ ctx := context.Background()
+
+ policy := peerToResourcePolicyByGroup(s.sourceGroupID, s.resourceGroupID)
+ affected := s.resolvePolicyAffected(ctx, policy)
+
+ // Router defined via PeerGroups instead of a direct peer.
+ assert.Contains(t, affected, s.routerGroupPeerID,
+ "routing peer (router.PeerGroups member) serving the resource must be affected")
+}
+
+func TestAffectedPeers_DestResource_RoutingPeer_DirectRouter(t *testing.T) {
+ s := setupRouterScenario(t, true)
+ ctx := context.Background()
+
+ policy := peerToResourcePolicyByResource(s.sourceGroupID, s.resourceID)
+ affected := s.resolvePolicyAffected(ctx, policy)
+
+ // When the resource is referenced via DestinationResource, RuleGroups()
+ // returns only the source group and the resource ID is not a peer, so
+ // collectPolicyAffectedGroupsAndPeers yields nothing for the destination at
+ // all. The routing peer is dropped here too.
+ assert.Contains(t, affected, s.routerPeerID,
+ "routing peer must be affected when the resource is referenced via DestinationResource")
+}
+
+func TestAffectedPeers_DestResource_RoutingPeer_RouterPeerGroups(t *testing.T) {
+ s := setupRouterScenario(t, false)
+ ctx := context.Background()
+
+ policy := peerToResourcePolicyByResource(s.sourceGroupID, s.resourceID)
+ affected := s.resolvePolicyAffected(ctx, policy)
+
+ assert.Contains(t, affected, s.routerGroupPeerID,
+ "routing peer (PeerGroups) must be affected when the resource is referenced via DestinationResource")
+}
+
+func TestAffectedPeers_SourceResourcePeer_RoutingPeer(t *testing.T) {
+ s := setupRouterScenario(t, true)
+ ctx := context.Background()
+
+ // Source expressed as a direct peer (SourceResource), destination as resource group.
+ policy := &types.Policy{
+ Enabled: true,
+ Name: "sourceResource-peer-to-resource",
+ Rules: []*types.PolicyRule{
+ {
+ Enabled: true,
+ SourceResource: types.Resource{ID: s.sourcePeerID, Type: types.ResourceTypePeer},
+ Destinations: []string{s.resourceGroupID},
+ Action: types.PolicyTrafficActionAccept,
+ },
+ },
+ }
+ affected := s.resolvePolicyAffected(ctx, policy)
+
+ // The direct source peer IS picked up (collectPolicyAffectedGroupsAndPeers
+ // handles SourceResource peers), but the routing peer is still missing.
+ assert.Contains(t, affected, s.sourcePeerID, "direct source peer must be affected")
+ assert.Contains(t, affected, s.routerPeerID, "routing peer must be affected")
+}
+
+func TestAffectedPeers_PolicyToResource_UnrelatedPeerNotAffected(t *testing.T) {
+ s := setupRouterScenario(t, true)
+ ctx := context.Background()
+
+ policy := peerToResourcePolicyByGroup(s.sourceGroupID, s.resourceGroupID)
+ affected := s.resolvePolicyAffected(ctx, policy)
+
+ // Guard against an over-broad fix: a peer in no relevant entity must never
+ // be pulled in.
+ assert.NotContains(t, affected, s.unrelatedPeerID, "unrelated peer must not be affected")
+}
+
+func TestAffectedPeers_ResourceSideBridgesToRoutingPeer_DirectRouter(t *testing.T) {
+ s := setupRouterScenario(t, true)
+ ctx := context.Background()
+
+ // A pre-existing policy grants the source group access to the resource.
+ _, err := s.manager.SavePolicy(ctx, s.accountID, userID, peerToResourcePolicyByGroup(s.sourceGroupID, s.resourceGroupID), true)
+ require.NoError(t, err)
+
+ // Drive an update through the resource manager and assert the routing peer
+ // is among the affected set by observing the channel. This path walks
+ // policies whose destinations reference the resource's groups, folds in the
+ // source groups, and loads the network's routers, so it reaches both the
+ // source peer and the routing peer.
+ permissionsManager := permissions.NewManager(s.manager.Store)
+ groupsManager := groups.NewManager(s.manager.Store, permissionsManager, s.manager)
+ rm := resources.NewManager(s.manager.Store, permissionsManager, groupsManager, s.manager, s.manager.serviceManager)
+
+ srcCh := s.updateManager.CreateChannel(ctx, s.sourcePeerID)
+ routerCh := s.updateManager.CreateChannel(ctx, s.routerPeerID)
+ t.Cleanup(func() {
+ s.updateManager.CloseChannel(ctx, s.sourcePeerID)
+ s.updateManager.CloseChannel(ctx, s.routerPeerID)
+ })
+
+ done := make(chan struct{})
+ go func() {
+ peerShouldReceiveUpdate(t, srcCh)
+ peerShouldReceiveUpdate(t, routerCh)
+ close(done)
+ }()
+
+ _, err = rm.UpdateResource(ctx, userID, &resourceTypes.NetworkResource{
+ ID: s.resourceID,
+ AccountID: s.accountID,
+ NetworkID: s.networkID,
+ Name: "rs-resource-host",
+ Address: "10.20.30.0/24",
+ GroupIDs: []string{s.resourceGroupID},
+ Enabled: true,
+ })
+ require.NoError(t, err)
+
+ select {
+ case <-done:
+ case <-time.After(peerUpdateTimeout):
+ t.Error("timeout: resource update did not refresh source peer + routing peer")
+ }
+}
+
+// settleAffectedUpdates waits for in-flight async updates to arrive, then drains
+// every given channel so subsequent assertions start from a clean slate.
+//
+// Setup (CreateNetwork/CreateResource/CreateRouter) fires async UpdateAffectedPeers
+// goroutines; draining first means the assertion only observes updates from the
+// action under test, not setup stragglers.
+func settleAffectedUpdates(chans ...<-chan *network_map.UpdateMessage) {
+ time.Sleep(300 * time.Millisecond)
+ for _, ch := range chans {
+ drainPeerUpdates(ch)
+ }
+}
+
+func TestAffectedPeers_E2E_CreatePolicy_RoutingPeer_DirectRouter(t *testing.T) {
+ s := setupRouterScenario(t, true)
+ ctx := context.Background()
+
+ srcCh := s.updateManager.CreateChannel(ctx, s.sourcePeerID)
+ routerCh := s.updateManager.CreateChannel(ctx, s.routerPeerID)
+ unrelatedCh := s.updateManager.CreateChannel(ctx, s.unrelatedPeerID)
+ t.Cleanup(func() {
+ s.updateManager.CloseChannel(ctx, s.sourcePeerID)
+ s.updateManager.CloseChannel(ctx, s.routerPeerID)
+ s.updateManager.CloseChannel(ctx, s.unrelatedPeerID)
+ })
+
+ settleAffectedUpdates(srcCh, routerCh, unrelatedCh)
+
+ done := make(chan struct{})
+ go func() {
+ peerShouldReceiveUpdate(t, srcCh)
+ peerShouldReceiveUpdate(t, routerCh)
+ peerShouldNotReceiveUpdate(t, unrelatedCh)
+ close(done)
+ }()
+
+ _, err := s.manager.SavePolicy(ctx, s.accountID, userID, peerToResourcePolicyByGroup(s.sourceGroupID, s.resourceGroupID), true)
+ require.NoError(t, err)
+
+ select {
+ case <-done:
+ case <-time.After(peerUpdateTimeout):
+ t.Error("timeout: creating peer->resource policy did not refresh the routing peer")
+ }
+}
+
+func TestAffectedPeers_E2E_CreatePolicy_RoutingPeer_RouterPeerGroups(t *testing.T) {
+ s := setupRouterScenario(t, false)
+ ctx := context.Background()
+
+ srcCh := s.updateManager.CreateChannel(ctx, s.sourcePeerID)
+ routerCh := s.updateManager.CreateChannel(ctx, s.routerGroupPeerID)
+ t.Cleanup(func() {
+ s.updateManager.CloseChannel(ctx, s.sourcePeerID)
+ s.updateManager.CloseChannel(ctx, s.routerGroupPeerID)
+ })
+
+ settleAffectedUpdates(srcCh, routerCh)
+
+ done := make(chan struct{})
+ go func() {
+ peerShouldReceiveUpdate(t, srcCh)
+ peerShouldReceiveUpdate(t, routerCh)
+ close(done)
+ }()
+
+ _, err := s.manager.SavePolicy(ctx, s.accountID, userID, peerToResourcePolicyByGroup(s.sourceGroupID, s.resourceGroupID), true)
+ require.NoError(t, err)
+
+ select {
+ case <-done:
+ case <-time.After(peerUpdateTimeout):
+ t.Error("timeout: routing peer (PeerGroups) not refreshed on policy create")
+ }
+}
+
+func TestAffectedPeers_E2E_DestResource_RoutingPeer(t *testing.T) {
+ s := setupRouterScenario(t, true)
+ ctx := context.Background()
+
+ srcCh := s.updateManager.CreateChannel(ctx, s.sourcePeerID)
+ routerCh := s.updateManager.CreateChannel(ctx, s.routerPeerID)
+ t.Cleanup(func() {
+ s.updateManager.CloseChannel(ctx, s.sourcePeerID)
+ s.updateManager.CloseChannel(ctx, s.routerPeerID)
+ })
+
+ settleAffectedUpdates(srcCh, routerCh)
+
+ done := make(chan struct{})
+ go func() {
+ peerShouldReceiveUpdate(t, srcCh)
+ peerShouldReceiveUpdate(t, routerCh)
+ close(done)
+ }()
+
+ _, err := s.manager.SavePolicy(ctx, s.accountID, userID, peerToResourcePolicyByResource(s.sourceGroupID, s.resourceID), true)
+ require.NoError(t, err)
+
+ select {
+ case <-done:
+ case <-time.After(peerUpdateTimeout):
+ t.Error("timeout: routing peer not refreshed when policy targets DestinationResource")
+ }
+}
+
+func TestAffectedPeers_E2E_DeletePolicy_RoutingPeer(t *testing.T) {
+ s := setupRouterScenario(t, true)
+ ctx := context.Background()
+
+ policy, err := s.manager.SavePolicy(ctx, s.accountID, userID, peerToResourcePolicyByGroup(s.sourceGroupID, s.resourceGroupID), true)
+ require.NoError(t, err)
+
+ srcCh := s.updateManager.CreateChannel(ctx, s.sourcePeerID)
+ routerCh := s.updateManager.CreateChannel(ctx, s.routerPeerID)
+ t.Cleanup(func() {
+ s.updateManager.CloseChannel(ctx, s.sourcePeerID)
+ s.updateManager.CloseChannel(ctx, s.routerPeerID)
+ })
+
+ settleAffectedUpdates(srcCh, routerCh)
+
+ done := make(chan struct{})
+ go func() {
+ peerShouldReceiveUpdate(t, srcCh)
+ peerShouldReceiveUpdate(t, routerCh)
+ close(done)
+ }()
+
+ require.NoError(t, s.manager.DeletePolicy(ctx, s.accountID, policy.ID, userID))
+
+ select {
+ case <-done:
+ case <-time.After(peerUpdateTimeout):
+ t.Error("timeout: deleting peer->resource policy did not refresh the routing peer")
+ }
+}
+
+func (s *routerScenario) managers() (resources.Manager, routers.Manager, networks.Manager) {
+ permissionsManager := permissions.NewManager(s.manager.Store)
+ groupsManager := groups.NewManager(s.manager.Store, permissionsManager, s.manager)
+ resourcesManager := resources.NewManager(s.manager.Store, permissionsManager, groupsManager, s.manager, s.manager.serviceManager)
+ routersManager := routers.NewManager(s.manager.Store, permissionsManager, s.manager)
+ networksManager := networks.NewManager(s.manager.Store, permissionsManager, resourcesManager, routersManager, s.manager)
+ return resourcesManager, routersManager, networksManager
+}
+
+type secondTopology struct {
+ networkID string
+ resourceID string
+ resourceGroupID string
+ routerPeerID string
+}
+
+func (s *routerScenario) addSecondTopology(t *testing.T, suffix string) secondTopology {
+ t.Helper()
+ ctx := context.Background()
+ resourcesManager, routersManager, networksManager := s.managers()
+
+ setupKey, err := s.manager.CreateSetupKey(ctx, s.accountID, "rs-key-"+suffix, types.SetupKeyReusable, time.Hour, nil, 999, userID, false, false)
+ require.NoError(t, err)
+ routerPeer := addPeerToAccount(t, s.manager, s.accountID, setupKey.Key)
+
+ resourceGroupID := "rs-resource-grp-" + suffix
+ require.NoError(t, s.manager.CreateGroup(ctx, s.accountID, userID, &types.Group{
+ ID: resourceGroupID, Name: "rs-resource-" + suffix,
+ }))
+
+ network, err := networksManager.CreateNetwork(ctx, userID, &networkTypes.Network{
+ ID: "rs-network-" + suffix,
+ AccountID: s.accountID,
+ Name: "rs-network-" + suffix,
+ })
+ require.NoError(t, err)
+
+ resource, err := resourcesManager.CreateResource(ctx, userID, &resourceTypes.NetworkResource{
+ AccountID: s.accountID,
+ NetworkID: network.ID,
+ Name: "rs-resource-host-" + suffix,
+ Address: "10.40.50.0/24",
+ GroupIDs: []string{resourceGroupID},
+ Enabled: true,
+ })
+ require.NoError(t, err)
+
+ _, err = routersManager.CreateRouter(ctx, userID, &routerTypes.NetworkRouter{
+ NetworkID: network.ID,
+ AccountID: s.accountID,
+ Peer: routerPeer.ID,
+ Masquerade: true,
+ Metric: 9999,
+ Enabled: true,
+ })
+ require.NoError(t, err)
+
+ return secondTopology{
+ networkID: network.ID,
+ resourceID: resource.ID,
+ resourceGroupID: resourceGroupID,
+ routerPeerID: routerPeer.ID,
+ }
+}
+
+func TestAffectedPeers_E2E_UpdatePolicy_BothRoutingPeers(t *testing.T) {
+ s := setupRouterScenario(t, true)
+ second := s.addSecondTopology(t, "b")
+ ctx := context.Background()
+
+ policy, err := s.manager.SavePolicy(ctx, s.accountID, userID, peerToResourcePolicyByGroup(s.sourceGroupID, s.resourceGroupID), true)
+ require.NoError(t, err)
+
+ srcCh := s.updateManager.CreateChannel(ctx, s.sourcePeerID)
+ routerACh := s.updateManager.CreateChannel(ctx, s.routerPeerID)
+ routerBCh := s.updateManager.CreateChannel(ctx, second.routerPeerID)
+ t.Cleanup(func() {
+ s.updateManager.CloseChannel(ctx, s.sourcePeerID)
+ s.updateManager.CloseChannel(ctx, s.routerPeerID)
+ s.updateManager.CloseChannel(ctx, second.routerPeerID)
+ })
+
+ settleAffectedUpdates(srcCh, routerACh, routerBCh)
+
+ done := make(chan struct{})
+ go func() {
+ peerShouldReceiveUpdate(t, srcCh)
+ peerShouldReceiveUpdate(t, routerACh)
+ peerShouldReceiveUpdate(t, routerBCh)
+ close(done)
+ }()
+
+ policy.Rules[0].Destinations = []string{second.resourceGroupID}
+ _, err = s.manager.SavePolicy(ctx, s.accountID, userID, policy, false)
+ require.NoError(t, err)
+
+ select {
+ case <-done:
+ case <-time.After(peerUpdateTimeout):
+ t.Error("timeout: re-pointing the policy destination did not refresh both routing peers")
+ }
+}
+
+func TestAffectedPeers_E2E_UpdatePolicy_AddSource(t *testing.T) {
+ s := setupRouterScenario(t, true)
+ ctx := context.Background()
+
+ const secondSourceGroupID = "rs-source-grp-2"
+ setupKey, err := s.manager.CreateSetupKey(ctx, s.accountID, "rs-key-2", types.SetupKeyReusable, time.Hour, nil, 999, userID, false, false)
+ require.NoError(t, err)
+ secondSourcePeer := addPeerToAccount(t, s.manager, s.accountID, setupKey.Key)
+ require.NoError(t, s.manager.CreateGroup(ctx, s.accountID, userID, &types.Group{
+ ID: secondSourceGroupID, Name: "rs-source-2", Peers: []string{secondSourcePeer.ID},
+ }))
+
+ policy, err := s.manager.SavePolicy(ctx, s.accountID, userID, peerToResourcePolicyByGroup(s.sourceGroupID, s.resourceGroupID), true)
+ require.NoError(t, err)
+
+ newSrcCh := s.updateManager.CreateChannel(ctx, secondSourcePeer.ID)
+ routerCh := s.updateManager.CreateChannel(ctx, s.routerPeerID)
+ t.Cleanup(func() {
+ s.updateManager.CloseChannel(ctx, secondSourcePeer.ID)
+ s.updateManager.CloseChannel(ctx, s.routerPeerID)
+ })
+
+ settleAffectedUpdates(newSrcCh, routerCh)
+
+ done := make(chan struct{})
+ go func() {
+ peerShouldReceiveUpdate(t, newSrcCh)
+ peerShouldReceiveUpdate(t, routerCh)
+ close(done)
+ }()
+
+ policy.Rules[0].Sources = []string{s.sourceGroupID, secondSourceGroupID}
+ _, err = s.manager.SavePolicy(ctx, s.accountID, userID, policy, false)
+ require.NoError(t, err)
+
+ select {
+ case <-done:
+ case <-time.After(peerUpdateTimeout):
+ t.Error("timeout: adding a source group did not refresh the new source peer + routing peer")
+ }
+}
+
+func TestAffectedPeers_E2E_DestResource_RouterPeerGroups(t *testing.T) {
+ s := setupRouterScenario(t, false)
+ ctx := context.Background()
+
+ srcCh := s.updateManager.CreateChannel(ctx, s.sourcePeerID)
+ routerCh := s.updateManager.CreateChannel(ctx, s.routerGroupPeerID)
+ t.Cleanup(func() {
+ s.updateManager.CloseChannel(ctx, s.sourcePeerID)
+ s.updateManager.CloseChannel(ctx, s.routerGroupPeerID)
+ })
+
+ settleAffectedUpdates(srcCh, routerCh)
+
+ done := make(chan struct{})
+ go func() {
+ peerShouldReceiveUpdate(t, srcCh)
+ peerShouldReceiveUpdate(t, routerCh)
+ close(done)
+ }()
+
+ _, err := s.manager.SavePolicy(ctx, s.accountID, userID, peerToResourcePolicyByResource(s.sourceGroupID, s.resourceID), true)
+ require.NoError(t, err)
+
+ select {
+ case <-done:
+ case <-time.After(peerUpdateTimeout):
+ t.Error("timeout: DestinationResource policy with PeerGroups router did not refresh the routing peer")
+ }
+}
+
+func TestAffectedPeers_AllRoutingPeers_Network(t *testing.T) {
+ s := setupRouterScenario(t, true)
+ ctx := context.Background()
+
+ _, routersManager, _ := s.managers()
+ setupKey, err := s.manager.CreateSetupKey(ctx, s.accountID, "rs-key-r2", types.SetupKeyReusable, time.Hour, nil, 999, userID, false, false)
+ require.NoError(t, err)
+ secondRouterPeer := addPeerToAccount(t, s.manager, s.accountID, setupKey.Key)
+ _, err = routersManager.CreateRouter(ctx, userID, &routerTypes.NetworkRouter{
+ NetworkID: s.networkID,
+ AccountID: s.accountID,
+ Peer: secondRouterPeer.ID,
+ Masquerade: true,
+ Metric: 9998,
+ Enabled: true,
+ })
+ require.NoError(t, err)
+
+ affected := s.resolvePolicyAffected(ctx, peerToResourcePolicyByGroup(s.sourceGroupID, s.resourceGroupID))
+
+ assert.Contains(t, affected, s.routerPeerID, "first routing peer must be affected")
+ assert.Contains(t, affected, secondRouterPeer.ID, "second routing peer on the same network must also be affected")
+}
+
+func TestAffectedPeers_DisabledRouter(t *testing.T) {
+ s := setupRouterScenario(t, true)
+ ctx := context.Background()
+
+ routers, err := s.manager.Store.GetNetworkRoutersByNetID(ctx, store.LockingStrengthNone, s.accountID, s.networkID)
+ require.NoError(t, err)
+ require.Len(t, routers, 1)
+ routers[0].Enabled = false
+ require.NoError(t, s.manager.Store.UpdateNetworkRouter(ctx, routers[0]))
+
+ affected := s.resolvePolicyAffected(ctx, peerToResourcePolicyByGroup(s.sourceGroupID, s.resourceGroupID))
+
+ assert.Contains(t, affected, s.sourcePeerID, "source peer must be affected")
+ assert.Contains(t, affected, s.routerPeerID,
+ "disabled router's peer must still be affected: Enabled must not gate affected-peers")
+}
+
+func TestAffectedPeers_DisabledResource(t *testing.T) {
+ s := setupRouterScenario(t, true)
+ ctx := context.Background()
+
+ res, err := s.manager.Store.GetNetworkResourceByID(ctx, store.LockingStrengthNone, s.accountID, s.resourceID)
+ require.NoError(t, err)
+ res.Enabled = false
+ require.NoError(t, s.manager.Store.SaveNetworkResource(ctx, res))
+
+ affected := s.resolvePolicyAffected(ctx, peerToResourcePolicyByGroup(s.sourceGroupID, s.resourceGroupID))
+
+ assert.Contains(t, affected, s.sourcePeerID, "source peer must be affected")
+ assert.Contains(t, affected, s.routerPeerID,
+ "disabled resource must still resolve the routing peer: Enabled must not gate affected-peers")
+}
+
+func TestAffectedPeers_DisabledRule(t *testing.T) {
+ s := setupRouterScenario(t, true)
+ ctx := context.Background()
+
+ policy := peerToResourcePolicyByGroup(s.sourceGroupID, s.resourceGroupID)
+ policy.Rules[0].Enabled = false
+
+ affected := s.resolvePolicyAffected(ctx, policy)
+
+ assert.Contains(t, affected, s.routerPeerID,
+ "disabled rule must still resolve the routing peer: Enabled must not gate affected-peers")
+}
+
+func TestAffectedPeers_MultiRule(t *testing.T) {
+ s := setupRouterScenario(t, true)
+ second := s.addSecondTopology(t, "c")
+ ctx := context.Background()
+
+ policy := &types.Policy{
+ Enabled: true,
+ Name: "multi-rule-two-resources",
+ Rules: []*types.PolicyRule{
+ {
+ Enabled: true,
+ Sources: []string{s.sourceGroupID},
+ Destinations: []string{s.resourceGroupID},
+ Action: types.PolicyTrafficActionAccept,
+ },
+ {
+ Enabled: true,
+ Sources: []string{s.sourceGroupID},
+ Destinations: []string{second.resourceGroupID},
+ Action: types.PolicyTrafficActionAccept,
+ },
+ },
+ }
+
+ affected := s.resolvePolicyAffected(ctx, policy)
+
+ assert.Contains(t, affected, s.routerPeerID, "routing peer for resource A must be affected")
+ assert.Contains(t, affected, second.routerPeerID, "routing peer for resource B must be affected")
+}
+
+func TestAffectedPeers_RouterOtherNetwork(t *testing.T) {
+ s := setupRouterScenario(t, true)
+ second := s.addSecondTopology(t, "d")
+ ctx := context.Background()
+
+ affected := s.resolvePolicyAffected(ctx, peerToResourcePolicyByGroup(s.sourceGroupID, s.resourceGroupID))
+
+ assert.Contains(t, affected, s.routerPeerID, "network A's routing peer must be affected")
+ assert.NotContains(t, affected, second.routerPeerID,
+ "a router in an unrelated network must not be affected by a policy that does not target its resource")
+}
diff --git a/management/server/affected_peers_test.go b/management/server/affected_peers_test.go
new file mode 100644
index 000000000..e2dcd830b
--- /dev/null
+++ b/management/server/affected_peers_test.go
@@ -0,0 +1,1802 @@
+package server
+
+import (
+ "context"
+ "fmt"
+ "net/netip"
+ "testing"
+ "time"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+ "golang.zx2c4.com/wireguard/wgctrl/wgtypes"
+
+ nbdns "github.com/netbirdio/netbird/dns"
+ rpservice "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/service"
+ "github.com/netbirdio/netbird/management/server/affectedpeers"
+ routerTypes "github.com/netbirdio/netbird/management/server/networks/routers/types"
+ networkTypes "github.com/netbirdio/netbird/management/server/networks/types"
+ nbpeer "github.com/netbirdio/netbird/management/server/peer"
+ "github.com/netbirdio/netbird/management/server/posture"
+ "github.com/netbirdio/netbird/management/server/store"
+ "github.com/netbirdio/netbird/management/server/types"
+ "github.com/netbirdio/netbird/route"
+)
+
+// resolveAffected is a test helper for the resolver's Load+Expand, used where a
+// test asserts on the fully expanded affected peer set.
+func resolveAffected(t *testing.T, s store.Store, accountID string, change affectedpeers.Change) []string {
+ t.Helper()
+ ctx := context.Background()
+ snap, err := affectedpeers.Load(ctx, s, accountID, change)
+ require.NoError(t, err)
+ return snap.Expand(ctx, accountID, change)
+}
+
+// Thin test adapters over affectedpeers.Collect, preserving the (groups, peers)
+// shape these tests assert on after the resolver was unified.
+func collectGroupChangeAffectedGroups(ctx context.Context, s store.Store, accountID string, changedGroupIDs []string) ([]string, []string) {
+ return affectedpeers.Collect(ctx, s, accountID, affectedpeers.Change{ChangedGroupIDs: changedGroupIDs})
+}
+
+func collectPeerChangeAffectedGroups(ctx context.Context, s store.Store, accountID string, changedGroupIDs, changedPeerIDs []string) ([]string, []string) {
+ return affectedpeers.Collect(ctx, s, accountID, affectedpeers.Change{ChangedGroupIDs: changedGroupIDs, ChangedPeerIDs: changedPeerIDs})
+}
+
+func collectPostureCheckAffectedGroupsAndPeers(ctx context.Context, s store.Store, accountID, postureCheckID string) ([]string, []string) {
+ return affectedpeers.Collect(ctx, s, accountID, affectedpeers.Change{PostureCheckIDs: []string{postureCheckID}})
+}
+
+// setupAffectedPeersTest creates a manager with a clean account (default policy deleted)
+// and 5 peers, each in its own group: peer0->group0, peer1->group1, ..., peer4->group4.
+func setupAffectedPeersTest(t *testing.T) (*DefaultAccountManager, store.Store, string, []string, []string) {
+ t.Helper()
+
+ manager, _, err := createManager(t)
+ require.NoError(t, err)
+
+ account, err := createAccount(manager, "affected_test", userID, "")
+ require.NoError(t, err)
+
+ ctx := context.Background()
+ accountID := account.Id
+
+ policies, err := manager.Store.GetAccountPolicies(ctx, store.LockingStrengthNone, accountID)
+ require.NoError(t, err)
+ for _, p := range policies {
+ err := manager.Store.DeletePolicy(ctx, accountID, p.ID)
+ require.NoError(t, err)
+ }
+
+ setupKey, err := manager.CreateSetupKey(ctx, accountID, "test-key", types.SetupKeyReusable, time.Hour, nil, 999, userID, false, false)
+ require.NoError(t, err)
+
+ peerIDs := make([]string, 5)
+ for i := 0; i < 5; i++ {
+ peer := addPeerToAccount(t, manager, accountID, setupKey.Key)
+ peerIDs[i] = peer.ID
+ }
+
+ groupIDs := make([]string, 5)
+ for i := 0; i < 5; i++ {
+ g := &types.Group{
+ ID: affectedGroupID(i),
+ Name: affectedGroupName(i),
+ Peers: []string{peerIDs[i]},
+ }
+ err := manager.CreateGroup(ctx, accountID, userID, g)
+ require.NoError(t, err)
+ groupIDs[i] = g.ID
+ }
+
+ return manager, manager.Store, accountID, peerIDs, groupIDs
+}
+
+func affectedGroupID(i int) string { return fmt.Sprintf("affected-grp-%d", i) }
+func affectedGroupName(i int) string { return fmt.Sprintf("AffectedGroup%d", i) }
+
+func TestCollectGroupChange_PolicyLinked(t *testing.T) {
+ manager, s, accountID, _, groupIDs := setupAffectedPeersTest(t)
+ ctx := context.Background()
+
+ _, err := manager.SavePolicy(ctx, accountID, userID, &types.Policy{
+ Enabled: true,
+ Rules: []*types.PolicyRule{
+ {
+ Enabled: true,
+ Sources: []string{groupIDs[0]},
+ Destinations: []string{groupIDs[1]},
+ Bidirectional: true,
+ Action: types.PolicyTrafficActionAccept,
+ },
+ },
+ }, true)
+ require.NoError(t, err)
+
+ groups, _ := collectGroupChangeAffectedGroups(ctx, s, accountID, []string{groupIDs[0]})
+ assert.Contains(t, groups, groupIDs[0])
+ assert.Contains(t, groups, groupIDs[1])
+
+ groups, _ = collectGroupChangeAffectedGroups(ctx, s, accountID, []string{groupIDs[1]})
+ assert.Contains(t, groups, groupIDs[0])
+ assert.Contains(t, groups, groupIDs[1])
+
+ groups, _ = collectGroupChangeAffectedGroups(ctx, s, accountID, []string{groupIDs[2]})
+ assert.Empty(t, groups)
+}
+
+func TestCollectGroupChange_PolicyWithDirectPeerResource(t *testing.T) {
+ manager, s, accountID, peerIDs, groupIDs := setupAffectedPeersTest(t)
+ ctx := context.Background()
+
+ _, err := manager.SavePolicy(ctx, accountID, userID, &types.Policy{
+ Enabled: true,
+ Rules: []*types.PolicyRule{
+ {
+ Enabled: true,
+ Sources: []string{groupIDs[0]},
+ SourceResource: types.Resource{ID: peerIDs[3], Type: types.ResourceTypePeer},
+ Destinations: []string{groupIDs[1]},
+ Action: types.PolicyTrafficActionAccept,
+ },
+ },
+ }, true)
+ require.NoError(t, err)
+
+ groups, directPeers := collectGroupChangeAffectedGroups(ctx, s, accountID, []string{groupIDs[0]})
+ assert.Contains(t, groups, groupIDs[0])
+ assert.Contains(t, groups, groupIDs[1])
+ assert.Contains(t, directPeers, peerIDs[3])
+}
+
+func TestCollectGroupChange_PolicyWithNonPeerResource_NoDirectPeers(t *testing.T) {
+ manager, s, accountID, _, groupIDs := setupAffectedPeersTest(t)
+ ctx := context.Background()
+
+ _, err := manager.SavePolicy(ctx, accountID, userID, &types.Policy{
+ Enabled: true,
+ Rules: []*types.PolicyRule{
+ {
+ Enabled: true,
+ Sources: []string{groupIDs[0]},
+ SourceResource: types.Resource{ID: "some-domain", Type: types.ResourceTypeDomain},
+ Destinations: []string{groupIDs[1]},
+ Action: types.PolicyTrafficActionAccept,
+ },
+ },
+ }, true)
+ require.NoError(t, err)
+
+ groups, directPeers := collectGroupChangeAffectedGroups(ctx, s, accountID, []string{groupIDs[0]})
+ assert.Contains(t, groups, groupIDs[0])
+ assert.Contains(t, groups, groupIDs[1])
+ assert.Empty(t, directPeers, "non-peer resources should not produce direct peer IDs")
+}
+
+func TestCollectGroupChange_RouteLinked(t *testing.T) {
+ manager, s, accountID, _, groupIDs := setupAffectedPeersTest(t)
+ ctx := context.Background()
+
+ _, err := manager.CreateRoute(ctx, accountID,
+ netip.MustParsePrefix("10.0.0.0/24"),
+ route.IPv4Network,
+ nil,
+ "",
+ []string{groupIDs[0]},
+ "test route",
+ "testnet",
+ false,
+ 9999,
+ []string{groupIDs[1]},
+ []string{groupIDs[2]},
+ true,
+ userID,
+ false,
+ false,
+ )
+ require.NoError(t, err)
+
+ groups, _ := collectGroupChangeAffectedGroups(ctx, s, accountID, []string{groupIDs[0]})
+ assert.Contains(t, groups, groupIDs[0])
+ assert.Contains(t, groups, groupIDs[1])
+ assert.Contains(t, groups, groupIDs[2])
+
+ groups, _ = collectGroupChangeAffectedGroups(ctx, s, accountID, []string{groupIDs[1]})
+ assert.Contains(t, groups, groupIDs[0])
+ assert.Contains(t, groups, groupIDs[1])
+ assert.Contains(t, groups, groupIDs[2])
+
+ groups, _ = collectGroupChangeAffectedGroups(ctx, s, accountID, []string{groupIDs[3]})
+ assert.Empty(t, groups)
+}
+
+func TestCollectGroupChange_RouteWithDirectPeer(t *testing.T) {
+ manager, s, accountID, peerIDs, groupIDs := setupAffectedPeersTest(t)
+ ctx := context.Background()
+
+ _, err := manager.CreateRoute(ctx, accountID,
+ netip.MustParsePrefix("10.1.0.0/24"),
+ route.IPv4Network,
+ nil,
+ peerIDs[4],
+ nil,
+ "test route peer",
+ "testnet2",
+ false,
+ 9999,
+ []string{groupIDs[1]},
+ nil,
+ true,
+ userID,
+ false,
+ false,
+ )
+ require.NoError(t, err)
+
+ groups, directPeers := collectGroupChangeAffectedGroups(ctx, s, accountID, []string{groupIDs[1]})
+ assert.Contains(t, groups, groupIDs[1])
+ assert.Contains(t, directPeers, peerIDs[4])
+}
+
+func TestCollectGroupChange_NameServerGroupLinked(t *testing.T) {
+ manager, s, accountID, _, groupIDs := setupAffectedPeersTest(t)
+ ctx := context.Background()
+
+ _, err := manager.CreateNameServerGroup(ctx, accountID, "ns1", "NS Group 1",
+ []nbdns.NameServer{{
+ IP: netip.MustParseAddr("1.1.1.1"),
+ NSType: nbdns.UDPNameServerType,
+ Port: nbdns.DefaultDNSPort,
+ }},
+ []string{groupIDs[0]},
+ true, nil, true, userID, false,
+ )
+ require.NoError(t, err)
+
+ groups, _ := collectGroupChangeAffectedGroups(ctx, s, accountID, []string{groupIDs[0]})
+ assert.Contains(t, groups, groupIDs[0])
+
+ groups, _ = collectGroupChangeAffectedGroups(ctx, s, accountID, []string{groupIDs[1]})
+ assert.Empty(t, groups)
+}
+
+func TestCollectGroupChange_DNSSettingsLinked(t *testing.T) {
+ manager, s, accountID, _, groupIDs := setupAffectedPeersTest(t)
+ ctx := context.Background()
+
+ err := manager.SaveDNSSettings(ctx, accountID, userID, &types.DNSSettings{
+ DisabledManagementGroups: []string{groupIDs[2]},
+ })
+ require.NoError(t, err)
+
+ groups, _ := collectGroupChangeAffectedGroups(ctx, s, accountID, []string{groupIDs[2]})
+ assert.Contains(t, groups, groupIDs[2])
+
+ groups, _ = collectGroupChangeAffectedGroups(ctx, s, accountID, []string{groupIDs[0]})
+ assert.Empty(t, groups)
+}
+
+func TestCollectGroupChange_NetworkRouterLinked(t *testing.T) {
+ manager, s, accountID, peerIDs, groupIDs := setupAffectedPeersTest(t)
+ ctx := context.Background()
+
+ net1 := &networkTypes.Network{
+ ID: "net-test-1",
+ AccountID: accountID,
+ Name: "test-network",
+ }
+ err := manager.Store.SaveNetwork(ctx, net1)
+ require.NoError(t, err)
+
+ err = manager.Store.CreateNetworkRouter(ctx, &routerTypes.NetworkRouter{
+ ID: "router1",
+ NetworkID: net1.ID,
+ AccountID: accountID,
+ PeerGroups: []string{groupIDs[0]},
+ Peer: peerIDs[3],
+ })
+ require.NoError(t, err)
+
+ groups, directPeers := collectGroupChangeAffectedGroups(ctx, s, accountID, []string{groupIDs[0]})
+ assert.Contains(t, groups, groupIDs[0])
+ assert.Contains(t, directPeers, peerIDs[3])
+
+ groups, directPeers = collectGroupChangeAffectedGroups(ctx, s, accountID, []string{groupIDs[1]})
+ assert.Empty(t, groups)
+ assert.Empty(t, directPeers)
+}
+
+func TestCollectGroupChange_NetworkRouterPeerOnlyNoGroups(t *testing.T) {
+ manager, s, accountID, peerIDs, groupIDs := setupAffectedPeersTest(t)
+ ctx := context.Background()
+
+ net1 := &networkTypes.Network{
+ ID: "net-peer-only",
+ AccountID: accountID,
+ Name: "peer-only-network",
+ }
+ err := manager.Store.SaveNetwork(ctx, net1)
+ require.NoError(t, err)
+
+ // Router with only a direct peer, no PeerGroups
+ err = manager.Store.CreateNetworkRouter(ctx, &routerTypes.NetworkRouter{
+ ID: "router-peer-only",
+ NetworkID: net1.ID,
+ AccountID: accountID,
+ Peer: peerIDs[4],
+ })
+ require.NoError(t, err)
+
+ // None of the groups should match since router has no PeerGroups
+ for i := 0; i < 5; i++ {
+ groups, directPeers := collectGroupChangeAffectedGroups(ctx, s, accountID, []string{groupIDs[i]})
+ assert.Empty(t, groups, "group%d should not match router with only direct peer", i)
+ assert.Empty(t, directPeers, "group%d should not produce direct peers", i)
+ }
+}
+
+func TestCollectGroupChange_MultipleEntities(t *testing.T) {
+ manager, s, accountID, _, groupIDs := setupAffectedPeersTest(t)
+ ctx := context.Background()
+
+ _, err := manager.SavePolicy(ctx, accountID, userID, &types.Policy{
+ Enabled: true,
+ Rules: []*types.PolicyRule{
+ {
+ Enabled: true,
+ Sources: []string{groupIDs[0]},
+ Destinations: []string{groupIDs[1]},
+ Bidirectional: true,
+ Action: types.PolicyTrafficActionAccept,
+ },
+ },
+ }, true)
+ require.NoError(t, err)
+
+ _, err = manager.CreateRoute(ctx, accountID,
+ netip.MustParsePrefix("10.2.0.0/24"),
+ route.IPv4Network,
+ nil,
+ "",
+ []string{groupIDs[2]},
+ "multi route",
+ "multinet",
+ false,
+ 9999,
+ []string{groupIDs[3]},
+ nil,
+ true,
+ userID,
+ false,
+ false,
+ )
+ require.NoError(t, err)
+
+ groups, directPeers := collectGroupChangeAffectedGroups(ctx, s, accountID, []string{groupIDs[0]})
+ assert.Contains(t, groups, groupIDs[0])
+ assert.Contains(t, groups, groupIDs[1])
+ assert.NotContains(t, groups, groupIDs[2])
+ assert.NotContains(t, groups, groupIDs[3])
+ assert.Empty(t, directPeers)
+
+ groups, directPeers = collectGroupChangeAffectedGroups(ctx, s, accountID, []string{groupIDs[3]})
+ assert.Contains(t, groups, groupIDs[2])
+ assert.Contains(t, groups, groupIDs[3])
+ assert.NotContains(t, groups, groupIDs[0])
+ assert.NotContains(t, groups, groupIDs[1])
+ assert.Empty(t, directPeers)
+}
+
+func TestCollectGroupChange_MultipleNameServerGroups_OnlyLinkedAffected(t *testing.T) {
+ manager, s, accountID, _, groupIDs := setupAffectedPeersTest(t)
+ ctx := context.Background()
+
+ // Create two nameserver groups using different groups
+ _, err := manager.CreateNameServerGroup(ctx, accountID, "ns-a", "NS-A",
+ []nbdns.NameServer{{
+ IP: netip.MustParseAddr("1.1.1.1"),
+ NSType: nbdns.UDPNameServerType,
+ Port: nbdns.DefaultDNSPort,
+ }},
+ []string{groupIDs[0]},
+ true, nil, true, userID, false,
+ )
+ require.NoError(t, err)
+
+ _, err = manager.CreateNameServerGroup(ctx, accountID, "ns-b", "NS-B",
+ []nbdns.NameServer{{
+ IP: netip.MustParseAddr("8.8.8.8"),
+ NSType: nbdns.UDPNameServerType,
+ Port: nbdns.DefaultDNSPort,
+ }},
+ []string{groupIDs[2]},
+ true, nil, true, userID, false,
+ )
+ require.NoError(t, err)
+
+ // Changing group0 should only find group0 (from ns-a), not group2 (from ns-b)
+ groups, _ := collectGroupChangeAffectedGroups(ctx, s, accountID, []string{groupIDs[0]})
+ assert.Contains(t, groups, groupIDs[0])
+ assert.NotContains(t, groups, groupIDs[2])
+
+ groups, _ = collectGroupChangeAffectedGroups(ctx, s, accountID, []string{groupIDs[2]})
+ assert.Contains(t, groups, groupIDs[2])
+ assert.NotContains(t, groups, groupIDs[0])
+
+ // Unrelated group
+ groups, _ = collectGroupChangeAffectedGroups(ctx, s, accountID, []string{groupIDs[4]})
+ assert.Empty(t, groups)
+}
+
+func TestResolveAffectedPeers_PolicyBetweenTwoGroups(t *testing.T) {
+ manager, s, accountID, peerIDs, groupIDs := setupAffectedPeersTest(t)
+ ctx := context.Background()
+
+ _, err := manager.SavePolicy(ctx, accountID, userID, &types.Policy{
+ Enabled: true,
+ Rules: []*types.PolicyRule{
+ {
+ Enabled: true,
+ Sources: []string{groupIDs[0]},
+ Destinations: []string{groupIDs[1]},
+ Bidirectional: true,
+ Action: types.PolicyTrafficActionAccept,
+ },
+ },
+ }, true)
+ require.NoError(t, err)
+
+ result := manager.resolveAffectedPeersForPeerChanges(ctx, s, accountID, []string{peerIDs[0]})
+ assert.ElementsMatch(t, []string{peerIDs[0], peerIDs[1]}, result)
+
+ result = manager.resolveAffectedPeersForPeerChanges(ctx, s, accountID, []string{peerIDs[1]})
+ assert.ElementsMatch(t, []string{peerIDs[0], peerIDs[1]}, result)
+
+ result = manager.resolveAffectedPeersForPeerChanges(ctx, s, accountID, []string{peerIDs[2]})
+ assert.Empty(t, result)
+}
+
+func TestResolveAffectedPeers_PolicyThreeGroups(t *testing.T) {
+ manager, s, accountID, peerIDs, groupIDs := setupAffectedPeersTest(t)
+ ctx := context.Background()
+
+ _, err := manager.SavePolicy(ctx, accountID, userID, &types.Policy{
+ Enabled: true,
+ Rules: []*types.PolicyRule{
+ {
+ Enabled: true,
+ Sources: []string{groupIDs[0], groupIDs[1]},
+ Destinations: []string{groupIDs[2]},
+ Action: types.PolicyTrafficActionAccept,
+ },
+ },
+ }, true)
+ require.NoError(t, err)
+
+ result := manager.resolveAffectedPeersForPeerChanges(ctx, s, accountID, []string{peerIDs[0]})
+ assert.ElementsMatch(t, []string{peerIDs[0], peerIDs[1], peerIDs[2]}, result)
+}
+
+func TestResolveAffectedPeers_RoutePeerGroups(t *testing.T) {
+ manager, s, accountID, peerIDs, groupIDs := setupAffectedPeersTest(t)
+ ctx := context.Background()
+
+ _, err := manager.CreateRoute(ctx, accountID,
+ netip.MustParsePrefix("10.3.0.0/24"),
+ route.IPv4Network,
+ nil,
+ "",
+ []string{groupIDs[0]},
+ "test route",
+ "routenet",
+ false,
+ 9999,
+ []string{groupIDs[1]},
+ nil,
+ true,
+ userID,
+ false,
+ false,
+ )
+ require.NoError(t, err)
+
+ result := manager.resolveAffectedPeersForPeerChanges(ctx, s, accountID, []string{peerIDs[0]})
+ assert.ElementsMatch(t, []string{peerIDs[0], peerIDs[1]}, result)
+
+ result = manager.resolveAffectedPeersForPeerChanges(ctx, s, accountID, []string{peerIDs[1]})
+ assert.ElementsMatch(t, []string{peerIDs[0], peerIDs[1]}, result)
+
+ result = manager.resolveAffectedPeersForPeerChanges(ctx, s, accountID, []string{peerIDs[2]})
+ assert.Empty(t, result)
+}
+
+func TestResolveAffectedPeers_RouteWithDirectPeer(t *testing.T) {
+ manager, s, accountID, peerIDs, groupIDs := setupAffectedPeersTest(t)
+ ctx := context.Background()
+
+ _, err := manager.CreateRoute(ctx, accountID,
+ netip.MustParsePrefix("10.4.0.0/24"),
+ route.IPv4Network,
+ nil,
+ peerIDs[4],
+ nil,
+ "route with peer",
+ "routenet2",
+ false,
+ 9999,
+ []string{groupIDs[1]},
+ nil,
+ true,
+ userID,
+ false,
+ false,
+ )
+ require.NoError(t, err)
+
+ result := manager.resolveAffectedPeersForPeerChanges(ctx, s, accountID, []string{peerIDs[1]})
+ assert.ElementsMatch(t, []string{peerIDs[1], peerIDs[4]}, result)
+}
+
+func TestResolveAffectedPeers_RouteWithAccessControlGroups(t *testing.T) {
+ manager, s, accountID, peerIDs, groupIDs := setupAffectedPeersTest(t)
+ ctx := context.Background()
+
+ _, err := manager.CreateRoute(ctx, accountID,
+ netip.MustParsePrefix("10.7.0.0/24"),
+ route.IPv4Network,
+ nil,
+ "",
+ []string{groupIDs[0]},
+ "acl route",
+ "aclnet",
+ false,
+ 9999,
+ []string{groupIDs[1]},
+ []string{groupIDs[2]},
+ true,
+ userID,
+ false,
+ false,
+ )
+ require.NoError(t, err)
+
+ // peer2 is only in AccessControlGroups, still should be affected
+ result := manager.resolveAffectedPeersForPeerChanges(ctx, s, accountID, []string{peerIDs[2]})
+ assert.ElementsMatch(t, []string{peerIDs[0], peerIDs[1], peerIDs[2]}, result)
+
+ // peer3 is unrelated
+ result = manager.resolveAffectedPeersForPeerChanges(ctx, s, accountID, []string{peerIDs[3]})
+ assert.Empty(t, result)
+}
+
+func TestResolveAffectedPeers_NetworkRouter(t *testing.T) {
+ manager, s, accountID, peerIDs, groupIDs := setupAffectedPeersTest(t)
+ ctx := context.Background()
+
+ net1 := &networkTypes.Network{
+ ID: "net-test-2",
+ AccountID: accountID,
+ Name: "test-net",
+ }
+ err := manager.Store.SaveNetwork(ctx, net1)
+ require.NoError(t, err)
+
+ err = manager.Store.CreateNetworkRouter(ctx, &routerTypes.NetworkRouter{
+ ID: "router-test",
+ NetworkID: net1.ID,
+ AccountID: accountID,
+ PeerGroups: []string{groupIDs[0]},
+ Peer: peerIDs[3],
+ })
+ require.NoError(t, err)
+
+ result := manager.resolveAffectedPeersForPeerChanges(ctx, s, accountID, []string{peerIDs[0]})
+ assert.ElementsMatch(t, []string{peerIDs[0], peerIDs[3]}, result)
+}
+
+func TestResolveAffectedPeers_NameServerGroup(t *testing.T) {
+ manager, s, accountID, peerIDs, groupIDs := setupAffectedPeersTest(t)
+ ctx := context.Background()
+
+ _, err := manager.CreateNameServerGroup(ctx, accountID, "ns-test", "NS Test",
+ []nbdns.NameServer{{
+ IP: netip.MustParseAddr("8.8.8.8"),
+ NSType: nbdns.UDPNameServerType,
+ Port: nbdns.DefaultDNSPort,
+ }},
+ []string{groupIDs[0]},
+ true, nil, true, userID, false,
+ )
+ require.NoError(t, err)
+
+ result := manager.resolveAffectedPeersForPeerChanges(ctx, s, accountID, []string{peerIDs[0]})
+ assert.Contains(t, result, peerIDs[0])
+}
+
+func TestResolveAffectedPeers_DNSSettings(t *testing.T) {
+ manager, s, accountID, peerIDs, groupIDs := setupAffectedPeersTest(t)
+ ctx := context.Background()
+
+ err := manager.SaveDNSSettings(ctx, accountID, userID, &types.DNSSettings{
+ DisabledManagementGroups: []string{groupIDs[0]},
+ })
+ require.NoError(t, err)
+
+ result := manager.resolveAffectedPeersForPeerChanges(ctx, s, accountID, []string{peerIDs[0]})
+ assert.Contains(t, result, peerIDs[0])
+}
+
+func TestResolveAffectedPeers_PeerInMultipleGroups(t *testing.T) {
+ manager, s, accountID, peerIDs, groupIDs := setupAffectedPeersTest(t)
+ ctx := context.Background()
+
+ err := manager.GroupAddPeer(ctx, accountID, groupIDs[1], peerIDs[0])
+ require.NoError(t, err)
+
+ _, err = manager.SavePolicy(ctx, accountID, userID, &types.Policy{
+ Enabled: true,
+ Rules: []*types.PolicyRule{
+ {
+ Enabled: true,
+ Sources: []string{groupIDs[0]},
+ Destinations: []string{groupIDs[2]},
+ Action: types.PolicyTrafficActionAccept,
+ },
+ },
+ }, true)
+ require.NoError(t, err)
+
+ _, err = manager.SavePolicy(ctx, accountID, userID, &types.Policy{
+ Enabled: true,
+ Rules: []*types.PolicyRule{
+ {
+ Enabled: true,
+ Sources: []string{groupIDs[1]},
+ Destinations: []string{groupIDs[3]},
+ Action: types.PolicyTrafficActionAccept,
+ },
+ },
+ }, true)
+ require.NoError(t, err)
+
+ // peer0 is in group0 AND group1, so both policies apply
+ result := manager.resolveAffectedPeersForPeerChanges(ctx, s, accountID, []string{peerIDs[0]})
+ assert.ElementsMatch(t, []string{peerIDs[0], peerIDs[1], peerIDs[2], peerIDs[3]}, result)
+}
+
+func TestResolveAffectedPeers_MultipleChangedPeers(t *testing.T) {
+ manager, s, accountID, peerIDs, groupIDs := setupAffectedPeersTest(t)
+ ctx := context.Background()
+
+ _, err := manager.SavePolicy(ctx, accountID, userID, &types.Policy{
+ Enabled: true,
+ Rules: []*types.PolicyRule{
+ {
+ Enabled: true,
+ Sources: []string{groupIDs[0]},
+ Destinations: []string{groupIDs[1]},
+ Bidirectional: true,
+ Action: types.PolicyTrafficActionAccept,
+ },
+ },
+ }, true)
+ require.NoError(t, err)
+
+ _, err = manager.SavePolicy(ctx, accountID, userID, &types.Policy{
+ Enabled: true,
+ Rules: []*types.PolicyRule{
+ {
+ Enabled: true,
+ Sources: []string{groupIDs[2]},
+ Destinations: []string{groupIDs[3]},
+ Bidirectional: true,
+ Action: types.PolicyTrafficActionAccept,
+ },
+ },
+ }, true)
+ require.NoError(t, err)
+
+ result := manager.resolveAffectedPeersForPeerChanges(ctx, s, accountID, []string{peerIDs[0], peerIDs[2]})
+ assert.ElementsMatch(t, []string{peerIDs[0], peerIDs[1], peerIDs[2], peerIDs[3]}, result)
+}
+
+func TestResolveAffectedPeers_SharedGroupAcrossPolicyAndRoute(t *testing.T) {
+ manager, s, accountID, peerIDs, groupIDs := setupAffectedPeersTest(t)
+ ctx := context.Background()
+
+ _, err := manager.SavePolicy(ctx, accountID, userID, &types.Policy{
+ Enabled: true,
+ Rules: []*types.PolicyRule{
+ {
+ Enabled: true,
+ Sources: []string{groupIDs[0]},
+ Destinations: []string{groupIDs[1]},
+ Bidirectional: true,
+ Action: types.PolicyTrafficActionAccept,
+ },
+ },
+ }, true)
+ require.NoError(t, err)
+
+ _, err = manager.CreateRoute(ctx, accountID,
+ netip.MustParsePrefix("10.5.0.0/24"),
+ route.IPv4Network,
+ nil,
+ "",
+ []string{groupIDs[2]},
+ "shared group route",
+ "sharednet",
+ false,
+ 9999,
+ []string{groupIDs[0]},
+ nil,
+ true,
+ userID,
+ false,
+ false,
+ )
+ require.NoError(t, err)
+
+ // group0 is shared: policy gives peer0+peer1, route gives peer0+peer2
+ result := manager.resolveAffectedPeersForPeerChanges(ctx, s, accountID, []string{peerIDs[0]})
+ assert.ElementsMatch(t, []string{peerIDs[0], peerIDs[1], peerIDs[2]}, result)
+}
+
+func TestResolveAffectedPeers_NoDuplicates(t *testing.T) {
+ manager, s, accountID, peerIDs, groupIDs := setupAffectedPeersTest(t)
+ ctx := context.Background()
+
+ err := manager.GroupAddPeer(ctx, accountID, groupIDs[1], peerIDs[0])
+ require.NoError(t, err)
+ err = manager.GroupAddPeer(ctx, accountID, groupIDs[2], peerIDs[0])
+ require.NoError(t, err)
+
+ _, err = manager.SavePolicy(ctx, accountID, userID, &types.Policy{
+ Enabled: true,
+ Rules: []*types.PolicyRule{
+ {
+ Enabled: true,
+ Sources: []string{groupIDs[0], groupIDs[1]},
+ Destinations: []string{groupIDs[2]},
+ Action: types.PolicyTrafficActionAccept,
+ },
+ },
+ }, true)
+ require.NoError(t, err)
+
+ result := manager.resolveAffectedPeersForPeerChanges(ctx, s, accountID, []string{peerIDs[0]})
+ count := 0
+ for _, id := range result {
+ if id == peerIDs[0] {
+ count++
+ }
+ }
+ assert.Equal(t, 1, count, "peer0 should appear exactly once")
+}
+
+func TestCollectPostureCheckAffected_LinkedToPolicy(t *testing.T) {
+ manager, s, accountID, _, groupIDs := setupAffectedPeersTest(t)
+ ctx := context.Background()
+
+ // Create the posture check in the store so the policy validation keeps the reference.
+ err := s.SavePostureChecks(ctx, &posture.Checks{
+ ID: "pc-1",
+ Name: "test-posture-check",
+ AccountID: accountID,
+ })
+ require.NoError(t, err)
+
+ policy, err := manager.SavePolicy(ctx, accountID, userID, &types.Policy{
+ Enabled: true,
+ SourcePostureChecks: []string{"pc-1"},
+ Rules: []*types.PolicyRule{
+ {
+ Enabled: true,
+ Sources: []string{groupIDs[0]},
+ Destinations: []string{groupIDs[1]},
+ Action: types.PolicyTrafficActionAccept,
+ },
+ },
+ }, true)
+ require.NoError(t, err)
+ _ = policy
+
+ groups, directPeers := collectPostureCheckAffectedGroupsAndPeers(ctx, s, accountID, "pc-1")
+ assert.Contains(t, groups, groupIDs[0])
+ assert.Contains(t, groups, groupIDs[1])
+ assert.Empty(t, directPeers)
+
+ // Different posture check ID should not match
+ groups, directPeers = collectPostureCheckAffectedGroupsAndPeers(ctx, s, accountID, "pc-other")
+ assert.Empty(t, groups)
+ assert.Empty(t, directPeers)
+}
+
+func TestAffectedPeers_IsolatedPolicies(t *testing.T) {
+ manager, s, accountID, peerIDs, groupIDs := setupAffectedPeersTest(t)
+ ctx := context.Background()
+
+ _, err := manager.SavePolicy(ctx, accountID, userID, &types.Policy{
+ Enabled: true,
+ Rules: []*types.PolicyRule{
+ {
+ Enabled: true,
+ Sources: []string{groupIDs[0]},
+ Destinations: []string{groupIDs[1]},
+ Bidirectional: true,
+ Action: types.PolicyTrafficActionAccept,
+ },
+ },
+ }, true)
+ require.NoError(t, err)
+
+ _, err = manager.SavePolicy(ctx, accountID, userID, &types.Policy{
+ Enabled: true,
+ Rules: []*types.PolicyRule{
+ {
+ Enabled: true,
+ Sources: []string{groupIDs[2]},
+ Destinations: []string{groupIDs[3]},
+ Bidirectional: true,
+ Action: types.PolicyTrafficActionAccept,
+ },
+ },
+ }, true)
+ require.NoError(t, err)
+
+ result := manager.resolveAffectedPeersForPeerChanges(ctx, s, accountID, []string{peerIDs[0]})
+ assert.ElementsMatch(t, []string{peerIDs[0], peerIDs[1]}, result)
+ assert.NotContains(t, result, peerIDs[2])
+ assert.NotContains(t, result, peerIDs[3])
+
+ result = manager.resolveAffectedPeersForPeerChanges(ctx, s, accountID, []string{peerIDs[2]})
+ assert.ElementsMatch(t, []string{peerIDs[2], peerIDs[3]}, result)
+ assert.NotContains(t, result, peerIDs[0])
+ assert.NotContains(t, result, peerIDs[1])
+
+ result = manager.resolveAffectedPeersForPeerChanges(ctx, s, accountID, []string{peerIDs[4]})
+ assert.Empty(t, result)
+}
+
+func TestAffectedPeers_IsolatedRouteAndPolicy(t *testing.T) {
+ manager, s, accountID, peerIDs, groupIDs := setupAffectedPeersTest(t)
+ ctx := context.Background()
+
+ _, err := manager.SavePolicy(ctx, accountID, userID, &types.Policy{
+ Enabled: true,
+ Rules: []*types.PolicyRule{
+ {
+ Enabled: true,
+ Sources: []string{groupIDs[0]},
+ Destinations: []string{groupIDs[1]},
+ Bidirectional: true,
+ Action: types.PolicyTrafficActionAccept,
+ },
+ },
+ }, true)
+ require.NoError(t, err)
+
+ _, err = manager.CreateRoute(ctx, accountID,
+ netip.MustParsePrefix("10.6.0.0/24"),
+ route.IPv4Network,
+ nil,
+ "",
+ []string{groupIDs[2]},
+ "isolated route",
+ "isonet",
+ false,
+ 9999,
+ []string{groupIDs[3]},
+ nil,
+ true,
+ userID,
+ false,
+ false,
+ )
+ require.NoError(t, err)
+
+ result := manager.resolveAffectedPeersForPeerChanges(ctx, s, accountID, []string{peerIDs[0]})
+ assert.ElementsMatch(t, []string{peerIDs[0], peerIDs[1]}, result)
+ assert.NotContains(t, result, peerIDs[2])
+ assert.NotContains(t, result, peerIDs[3])
+
+ result = manager.resolveAffectedPeersForPeerChanges(ctx, s, accountID, []string{peerIDs[2]})
+ assert.ElementsMatch(t, []string{peerIDs[2], peerIDs[3]}, result)
+ assert.NotContains(t, result, peerIDs[0])
+ assert.NotContains(t, result, peerIDs[1])
+}
+
+func TestAffectedPeers_GroupUpdateOnlyAffectsLinkedPeers(t *testing.T) {
+ manager, updateManager, account, peer1, peer2, peer3 := setupNetworkMapTest(t)
+ ctx := context.Background()
+ accountID := account.Id
+
+ policies, err := manager.Store.GetAccountPolicies(ctx, store.LockingStrengthNone, accountID)
+ require.NoError(t, err)
+ for _, p := range policies {
+ err := manager.Store.DeletePolicy(ctx, accountID, p.ID)
+ require.NoError(t, err)
+ }
+
+ for _, g := range []*types.Group{
+ {ID: "ap-grpA", Name: "AP-A", Peers: []string{peer1.ID}},
+ {ID: "ap-grpB", Name: "AP-B", Peers: []string{peer2.ID}},
+ {ID: "ap-grpC", Name: "AP-C", Peers: []string{peer3.ID}},
+ } {
+ err := manager.CreateGroup(ctx, accountID, userID, g)
+ require.NoError(t, err)
+ }
+
+ _, err = manager.SavePolicy(ctx, accountID, userID, &types.Policy{
+ Enabled: true,
+ Rules: []*types.PolicyRule{
+ {
+ Enabled: true,
+ Sources: []string{"ap-grpA"},
+ Destinations: []string{"ap-grpB"},
+ Bidirectional: true,
+ Action: types.PolicyTrafficActionAccept,
+ },
+ },
+ }, true)
+ require.NoError(t, err)
+
+ updMsg1 := updateManager.CreateChannel(ctx, peer1.ID)
+ updMsg2 := updateManager.CreateChannel(ctx, peer2.ID)
+ updMsg3 := updateManager.CreateChannel(ctx, peer3.ID)
+ t.Cleanup(func() {
+ updateManager.CloseChannel(ctx, peer1.ID)
+ updateManager.CloseChannel(ctx, peer2.ID)
+ updateManager.CloseChannel(ctx, peer3.ID)
+ })
+
+ result := manager.resolveAffectedPeersForPeerChanges(ctx, manager.Store, accountID, []string{peer1.ID})
+ assert.ElementsMatch(t, []string{peer1.ID, peer2.ID}, result)
+
+ t.Run("group change updates all peers in policy groups", func(t *testing.T) {
+ done := make(chan struct{})
+ go func() {
+ peerShouldReceiveUpdate(t, updMsg1)
+ peerShouldReceiveUpdate(t, updMsg2)
+ peerShouldReceiveUpdate(t, updMsg3)
+ close(done)
+ }()
+
+ err := manager.UpdateGroup(ctx, accountID, userID, &types.Group{
+ ID: "ap-grpA",
+ Name: "AP-A",
+ Peers: []string{peer1.ID, peer3.ID},
+ })
+ assert.NoError(t, err)
+
+ select {
+ case <-done:
+ case <-time.After(peerUpdateTimeout):
+ t.Error("timeout")
+ }
+ })
+}
+
+func TestAffectedPeers_UnlinkedGroupChange_NoUpdates(t *testing.T) {
+ manager, s, accountID, peerIDs, _ := setupAffectedPeersTest(t)
+ ctx := context.Background()
+
+ result := manager.resolveAffectedPeersForPeerChanges(ctx, s, accountID, []string{peerIDs[0]})
+ assert.Empty(t, result)
+}
+
+// TestAffectedPeers_PolicyChange_UnrelatedPeerNoUpdate verifies that creating/deleting a
+// policy only sends updates to peers in the policy's groups, not to unrelated peers.
+func TestAffectedPeers_PolicyChange_UnrelatedPeerNoUpdate(t *testing.T) {
+ manager, updateManager, account, peer1, peer2, peer3 := setupNetworkMapTest(t)
+ ctx := context.Background()
+ accountID := account.Id
+
+ policies, err := manager.Store.GetAccountPolicies(ctx, store.LockingStrengthNone, accountID)
+ require.NoError(t, err)
+ for _, p := range policies {
+ err := manager.Store.DeletePolicy(ctx, accountID, p.ID)
+ require.NoError(t, err)
+ }
+
+ for _, g := range []*types.Group{
+ {ID: "pol-grpA", Name: "Pol-A", Peers: []string{peer1.ID}},
+ {ID: "pol-grpB", Name: "Pol-B", Peers: []string{peer2.ID}},
+ {ID: "pol-grpC", Name: "Pol-C", Peers: []string{peer3.ID}},
+ } {
+ err := manager.CreateGroup(ctx, accountID, userID, g)
+ require.NoError(t, err)
+ }
+
+ updMsg1 := updateManager.CreateChannel(ctx, peer1.ID)
+ updMsg2 := updateManager.CreateChannel(ctx, peer2.ID)
+ updMsg3 := updateManager.CreateChannel(ctx, peer3.ID)
+ t.Cleanup(func() {
+ updateManager.CloseChannel(ctx, peer1.ID)
+ updateManager.CloseChannel(ctx, peer2.ID)
+ updateManager.CloseChannel(ctx, peer3.ID)
+ })
+
+ t.Run("create policy only affects linked peers", func(t *testing.T) {
+ done := make(chan struct{})
+ go func() {
+ peerShouldReceiveUpdate(t, updMsg1)
+ peerShouldReceiveUpdate(t, updMsg2)
+ peerShouldNotReceiveUpdate(t, updMsg3)
+ close(done)
+ }()
+
+ _, err := manager.SavePolicy(ctx, accountID, userID, &types.Policy{
+ Enabled: true,
+ Rules: []*types.PolicyRule{
+ {
+ Enabled: true,
+ Sources: []string{"pol-grpA"},
+ Destinations: []string{"pol-grpB"},
+ Bidirectional: true,
+ Action: types.PolicyTrafficActionAccept,
+ },
+ },
+ }, true)
+ assert.NoError(t, err)
+
+ select {
+ case <-done:
+ case <-time.After(peerUpdateTimeout):
+ t.Error("timeout")
+ }
+ })
+}
+
+// TestAffectedPeers_RouteChange_UnrelatedPeerNoUpdate verifies that creating a route
+// only sends updates to peers in the route's groups, not to unrelated peers.
+func TestAffectedPeers_RouteChange_UnrelatedPeerNoUpdate(t *testing.T) {
+ manager, updateManager, account, peer1, peer2, peer3 := setupNetworkMapTest(t)
+ ctx := context.Background()
+ accountID := account.Id
+
+ policies, err := manager.Store.GetAccountPolicies(ctx, store.LockingStrengthNone, accountID)
+ require.NoError(t, err)
+ for _, p := range policies {
+ err := manager.Store.DeletePolicy(ctx, accountID, p.ID)
+ require.NoError(t, err)
+ }
+
+ for _, g := range []*types.Group{
+ {ID: "rt-grpA", Name: "Rt-A", Peers: []string{peer1.ID}},
+ {ID: "rt-grpB", Name: "Rt-B", Peers: []string{peer2.ID}},
+ {ID: "rt-grpC", Name: "Rt-C", Peers: []string{peer3.ID}},
+ } {
+ err := manager.CreateGroup(ctx, accountID, userID, g)
+ require.NoError(t, err)
+ }
+
+ updMsg1 := updateManager.CreateChannel(ctx, peer1.ID)
+ updMsg2 := updateManager.CreateChannel(ctx, peer2.ID)
+ updMsg3 := updateManager.CreateChannel(ctx, peer3.ID)
+ t.Cleanup(func() {
+ updateManager.CloseChannel(ctx, peer1.ID)
+ updateManager.CloseChannel(ctx, peer2.ID)
+ updateManager.CloseChannel(ctx, peer3.ID)
+ })
+
+ t.Run("create route only affects linked peers", func(t *testing.T) {
+ done := make(chan struct{})
+ go func() {
+ peerShouldReceiveUpdate(t, updMsg1)
+ peerShouldReceiveUpdate(t, updMsg2)
+ peerShouldNotReceiveUpdate(t, updMsg3)
+ close(done)
+ }()
+
+ _, err := manager.CreateRoute(ctx, accountID,
+ netip.MustParsePrefix("10.10.0.0/24"),
+ route.IPv4Network,
+ nil,
+ "",
+ []string{"rt-grpA"},
+ "test route",
+ "routenoaffect",
+ false,
+ 9999,
+ []string{"rt-grpB"},
+ nil,
+ true,
+ userID,
+ false,
+ false,
+ )
+ assert.NoError(t, err)
+
+ select {
+ case <-done:
+ case <-time.After(peerUpdateTimeout):
+ t.Error("timeout")
+ }
+ })
+}
+
+// TestAffectedPeers_NameServerChange_UnrelatedPeerNoUpdate verifies that creating a
+// nameserver group only sends updates to peers in its groups, not to unrelated peers.
+func TestAffectedPeers_NameServerChange_UnrelatedPeerNoUpdate(t *testing.T) {
+ manager, updateManager, account, peer1, peer2, peer3 := setupNetworkMapTest(t)
+ ctx := context.Background()
+ accountID := account.Id
+
+ policies, err := manager.Store.GetAccountPolicies(ctx, store.LockingStrengthNone, accountID)
+ require.NoError(t, err)
+ for _, p := range policies {
+ err := manager.Store.DeletePolicy(ctx, accountID, p.ID)
+ require.NoError(t, err)
+ }
+
+ for _, g := range []*types.Group{
+ {ID: "ns-grpA", Name: "NS-A", Peers: []string{peer1.ID}},
+ {ID: "ns-grpB", Name: "NS-B", Peers: []string{peer2.ID}},
+ } {
+ err := manager.CreateGroup(ctx, accountID, userID, g)
+ require.NoError(t, err)
+ }
+
+ updMsg1 := updateManager.CreateChannel(ctx, peer1.ID)
+ updMsg2 := updateManager.CreateChannel(ctx, peer2.ID)
+ updMsg3 := updateManager.CreateChannel(ctx, peer3.ID)
+ t.Cleanup(func() {
+ updateManager.CloseChannel(ctx, peer1.ID)
+ updateManager.CloseChannel(ctx, peer2.ID)
+ updateManager.CloseChannel(ctx, peer3.ID)
+ })
+
+ t.Run("create nameserver group only affects linked peers", func(t *testing.T) {
+ done := make(chan struct{})
+ go func() {
+ peerShouldReceiveUpdate(t, updMsg1)
+ peerShouldNotReceiveUpdate(t, updMsg2)
+ peerShouldNotReceiveUpdate(t, updMsg3)
+ close(done)
+ }()
+
+ _, err := manager.CreateNameServerGroup(ctx, accountID, "ns-unrelated", "NS Unrelated",
+ []nbdns.NameServer{{
+ IP: netip.MustParseAddr("1.1.1.1"),
+ NSType: nbdns.UDPNameServerType,
+ Port: nbdns.DefaultDNSPort,
+ }},
+ []string{"ns-grpA"},
+ true, nil, true, userID, false,
+ )
+ assert.NoError(t, err)
+
+ select {
+ case <-done:
+ case <-time.After(peerUpdateTimeout):
+ t.Error("timeout")
+ }
+ })
+}
+
+// TestAffectedPeers_DNSSettingsChange_UnrelatedPeerNoUpdate verifies that changing DNS
+// settings only sends updates to peers in the affected groups, not to unrelated peers.
+func TestAffectedPeers_DNSSettingsChange_UnrelatedPeerNoUpdate(t *testing.T) {
+ manager, updateManager, account, peer1, peer2, peer3 := setupNetworkMapTest(t)
+ ctx := context.Background()
+ accountID := account.Id
+
+ policies, err := manager.Store.GetAccountPolicies(ctx, store.LockingStrengthNone, accountID)
+ require.NoError(t, err)
+ for _, p := range policies {
+ err := manager.Store.DeletePolicy(ctx, accountID, p.ID)
+ require.NoError(t, err)
+ }
+
+ for _, g := range []*types.Group{
+ {ID: "dns-grpA", Name: "DNS-A", Peers: []string{peer1.ID}},
+ {ID: "dns-grpB", Name: "DNS-B", Peers: []string{peer2.ID}},
+ } {
+ err := manager.CreateGroup(ctx, accountID, userID, g)
+ require.NoError(t, err)
+ }
+
+ updMsg1 := updateManager.CreateChannel(ctx, peer1.ID)
+ updMsg2 := updateManager.CreateChannel(ctx, peer2.ID)
+ updMsg3 := updateManager.CreateChannel(ctx, peer3.ID)
+ t.Cleanup(func() {
+ updateManager.CloseChannel(ctx, peer1.ID)
+ updateManager.CloseChannel(ctx, peer2.ID)
+ updateManager.CloseChannel(ctx, peer3.ID)
+ })
+
+ t.Run("dns settings change only affects linked peers", func(t *testing.T) {
+ done := make(chan struct{})
+ go func() {
+ peerShouldReceiveUpdate(t, updMsg1)
+ peerShouldNotReceiveUpdate(t, updMsg2)
+ peerShouldNotReceiveUpdate(t, updMsg3)
+ close(done)
+ }()
+
+ err := manager.SaveDNSSettings(ctx, accountID, userID, &types.DNSSettings{
+ DisabledManagementGroups: []string{"dns-grpA"},
+ })
+ assert.NoError(t, err)
+
+ select {
+ case <-done:
+ case <-time.After(peerUpdateTimeout):
+ t.Error("timeout")
+ }
+ })
+}
+
+// TestAffectedPeers_UnlinkedGroupChange_NoUpdateIntegration tests the full integration:
+// updating a group that is NOT referenced by any policy/route/ns/dns should not send
+// updates to any peer.
+func TestAffectedPeers_UnlinkedGroupChange_NoUpdateIntegration(t *testing.T) {
+ manager, updateManager, account, peer1, peer2, peer3 := setupNetworkMapTest(t)
+ ctx := context.Background()
+ accountID := account.Id
+
+ policies, err := manager.Store.GetAccountPolicies(ctx, store.LockingStrengthNone, accountID)
+ require.NoError(t, err)
+ for _, p := range policies {
+ err := manager.Store.DeletePolicy(ctx, accountID, p.ID)
+ require.NoError(t, err)
+ }
+
+ err = manager.CreateGroup(ctx, accountID, userID, &types.Group{
+ ID: "unlinked-grp",
+ Name: "Unlinked",
+ Peers: []string{peer1.ID},
+ })
+ require.NoError(t, err)
+
+ updMsg1 := updateManager.CreateChannel(ctx, peer1.ID)
+ updMsg2 := updateManager.CreateChannel(ctx, peer2.ID)
+ updMsg3 := updateManager.CreateChannel(ctx, peer3.ID)
+ t.Cleanup(func() {
+ updateManager.CloseChannel(ctx, peer1.ID)
+ updateManager.CloseChannel(ctx, peer2.ID)
+ updateManager.CloseChannel(ctx, peer3.ID)
+ })
+
+ t.Run("updating unlinked group sends no peer updates", func(t *testing.T) {
+ done := make(chan struct{})
+ go func() {
+ peerShouldNotReceiveUpdate(t, updMsg1)
+ peerShouldNotReceiveUpdate(t, updMsg2)
+ peerShouldNotReceiveUpdate(t, updMsg3)
+ close(done)
+ }()
+
+ err := manager.UpdateGroup(ctx, accountID, userID, &types.Group{
+ ID: "unlinked-grp",
+ Name: "Unlinked",
+ Peers: []string{peer1.ID, peer2.ID},
+ })
+ assert.NoError(t, err)
+
+ select {
+ case <-done:
+ case <-time.After(peerUpdateTimeout):
+ t.Error("timeout")
+ }
+ })
+}
+
+// TestAffectedPeers_NetworkRouterUnlinkedPeerNoUpdate: a network router with peer
+// groups updates only those groups' peers (and resource policy sources), not others.
+func TestAffectedPeers_NetworkRouterUnlinkedPeerNoUpdate(t *testing.T) {
+ // Delete the default policy before adding peers so AddPeer schedules no async
+ // update that races with the test.
+ manager, updateManager, err := createManager(t)
+ require.NoError(t, err)
+
+ ctx := context.Background()
+
+ account, err := createAccount(manager, "nr_test_account", userID, "")
+ require.NoError(t, err)
+ accountID := account.Id
+
+ policies, err := manager.Store.GetAccountPolicies(ctx, store.LockingStrengthNone, accountID)
+ require.NoError(t, err)
+ for _, p := range policies {
+ err := manager.Store.DeletePolicy(ctx, accountID, p.ID)
+ require.NoError(t, err)
+ }
+
+ setupKey, err := manager.CreateSetupKey(ctx, accountID, "test-key", types.SetupKeyReusable, time.Hour, nil, 999, userID, false, false)
+ require.NoError(t, err)
+
+ peer1 := addPeerToAccount(t, manager, accountID, setupKey.Key)
+ peer2 := addPeerToAccount(t, manager, accountID, setupKey.Key)
+ peer3 := addPeerToAccount(t, manager, accountID, setupKey.Key)
+
+ for _, g := range []*types.Group{
+ {ID: "nr-grpA", Name: "NR-A", Peers: []string{peer1.ID}},
+ {ID: "nr-grpB", Name: "NR-B", Peers: []string{peer2.ID}},
+ } {
+ err := manager.CreateGroup(ctx, accountID, userID, g)
+ require.NoError(t, err)
+ }
+
+ net1 := &networkTypes.Network{
+ ID: "nr-net-test",
+ AccountID: accountID,
+ Name: "nr-test-network",
+ }
+ err = manager.Store.SaveNetwork(ctx, net1)
+ require.NoError(t, err)
+
+ err = manager.Store.CreateNetworkRouter(ctx, &routerTypes.NetworkRouter{
+ ID: "nr-router-test",
+ NetworkID: net1.ID,
+ AccountID: accountID,
+ PeerGroups: []string{"nr-grpA"},
+ })
+ require.NoError(t, err)
+
+ updMsg1 := updateManager.CreateChannel(ctx, peer1.ID)
+ updMsg2 := updateManager.CreateChannel(ctx, peer2.ID)
+ updMsg3 := updateManager.CreateChannel(ctx, peer3.ID)
+ t.Cleanup(func() {
+ updateManager.CloseChannel(ctx, peer1.ID)
+ updateManager.CloseChannel(ctx, peer2.ID)
+ updateManager.CloseChannel(ctx, peer3.ID)
+ })
+
+ t.Run("network router group change only affects linked peers", func(t *testing.T) {
+ done := make(chan struct{})
+ go func() {
+ peerShouldReceiveUpdate(t, updMsg1)
+ peerShouldNotReceiveUpdate(t, updMsg2)
+ peerShouldReceiveUpdate(t, updMsg3)
+ close(done)
+ }()
+
+ err = manager.UpdateGroup(ctx, accountID, userID, &types.Group{
+ ID: "nr-grpA",
+ Name: "NR-A",
+ Peers: []string{peer1.ID, peer3.ID},
+ })
+ assert.NoError(t, err)
+
+ select {
+ case <-done:
+ case <-time.After(peerUpdateTimeout):
+ t.Error("timeout")
+ }
+ })
+}
+
+// TestAffectedPeers_IsolatedEntitiesOnlyAffectTheirPeers: with a policy (peer1<->peer2)
+// and a separate route (peer3), changing one entity's groups affects only its peers.
+func TestAffectedPeers_IsolatedEntitiesOnlyAffectTheirPeers(t *testing.T) {
+ manager, updateManager, account, peer1, peer2, peer3 := setupNetworkMapTest(t)
+ ctx := context.Background()
+ accountID := account.Id
+
+ policies, err := manager.Store.GetAccountPolicies(ctx, store.LockingStrengthNone, accountID)
+ require.NoError(t, err)
+ for _, p := range policies {
+ err := manager.Store.DeletePolicy(ctx, accountID, p.ID)
+ require.NoError(t, err)
+ }
+
+ for _, g := range []*types.Group{
+ {ID: "iso-grpA", Name: "ISO-A", Peers: []string{peer1.ID}},
+ {ID: "iso-grpB", Name: "ISO-B", Peers: []string{peer2.ID}},
+ {ID: "iso-grpC", Name: "ISO-C", Peers: []string{peer3.ID}},
+ } {
+ err := manager.CreateGroup(ctx, accountID, userID, g)
+ require.NoError(t, err)
+ }
+
+ _, err = manager.SavePolicy(ctx, accountID, userID, &types.Policy{
+ Enabled: true,
+ Rules: []*types.PolicyRule{
+ {
+ Enabled: true,
+ Sources: []string{"iso-grpA"},
+ Destinations: []string{"iso-grpB"},
+ Bidirectional: true,
+ Action: types.PolicyTrafficActionAccept,
+ },
+ },
+ }, true)
+ require.NoError(t, err)
+
+ _, err = manager.CreateRoute(ctx, accountID,
+ netip.MustParsePrefix("10.20.0.0/24"),
+ route.IPv4Network,
+ nil,
+ "",
+ []string{"iso-grpC"},
+ "isolated route",
+ "isonet2",
+ false,
+ 9999,
+ []string{"iso-grpC"},
+ nil,
+ true,
+ userID,
+ false,
+ false,
+ )
+ require.NoError(t, err)
+
+ updMsg1 := updateManager.CreateChannel(ctx, peer1.ID)
+ updMsg2 := updateManager.CreateChannel(ctx, peer2.ID)
+ updMsg3 := updateManager.CreateChannel(ctx, peer3.ID)
+ t.Cleanup(func() {
+ updateManager.CloseChannel(ctx, peer1.ID)
+ updateManager.CloseChannel(ctx, peer2.ID)
+ updateManager.CloseChannel(ctx, peer3.ID)
+ })
+
+ // The setup policy/route above dispatch affected-peer updates asynchronously;
+ // drain any in-flight ones so the assertions only observe the UpdateGroup below.
+ settleAffectedUpdates(updMsg1, updMsg2, updMsg3)
+
+ t.Run("policy group change does not affect route-only peer", func(t *testing.T) {
+ done := make(chan struct{})
+ go func() {
+ peerShouldReceiveUpdate(t, updMsg1)
+ peerShouldReceiveUpdate(t, updMsg2)
+ peerShouldNotReceiveUpdate(t, updMsg3)
+ close(done)
+ }()
+
+ err := manager.UpdateGroup(ctx, accountID, userID, &types.Group{
+ ID: "iso-grpA",
+ Name: "ISO-A-updated",
+ Peers: []string{peer1.ID},
+ })
+ assert.NoError(t, err)
+
+ select {
+ case <-done:
+ case <-time.After(peerUpdateTimeout):
+ t.Error("timeout")
+ }
+ })
+}
+
+// TestAffectedPeers_DeleteRoute_UnrelatedPeerNoUpdate verifies that deleting a route
+// only sends updates to peers in the route's groups.
+func TestAffectedPeers_DeleteRoute_UnrelatedPeerNoUpdate(t *testing.T) {
+ manager, updateManager, account, peer1, peer2, peer3 := setupNetworkMapTest(t)
+ ctx := context.Background()
+ accountID := account.Id
+
+ policies, err := manager.Store.GetAccountPolicies(ctx, store.LockingStrengthNone, accountID)
+ require.NoError(t, err)
+ for _, p := range policies {
+ err := manager.Store.DeletePolicy(ctx, accountID, p.ID)
+ require.NoError(t, err)
+ }
+
+ for _, g := range []*types.Group{
+ {ID: "del-rt-grpA", Name: "Del-Rt-A", Peers: []string{peer1.ID}},
+ {ID: "del-rt-grpB", Name: "Del-Rt-B", Peers: []string{peer2.ID}},
+ } {
+ err := manager.CreateGroup(ctx, accountID, userID, g)
+ require.NoError(t, err)
+ }
+
+ newRoute, err := manager.CreateRoute(ctx, accountID,
+ netip.MustParsePrefix("10.30.0.0/24"),
+ route.IPv4Network,
+ nil,
+ "",
+ []string{"del-rt-grpA"},
+ "deletable route",
+ "delnet",
+ false,
+ 9999,
+ []string{"del-rt-grpB"},
+ nil,
+ true,
+ userID,
+ false,
+ false,
+ )
+ require.NoError(t, err)
+
+ updMsg1 := updateManager.CreateChannel(ctx, peer1.ID)
+ updMsg2 := updateManager.CreateChannel(ctx, peer2.ID)
+ updMsg3 := updateManager.CreateChannel(ctx, peer3.ID)
+ t.Cleanup(func() {
+ updateManager.CloseChannel(ctx, peer1.ID)
+ updateManager.CloseChannel(ctx, peer2.ID)
+ updateManager.CloseChannel(ctx, peer3.ID)
+ })
+
+ t.Run("delete route only affects linked peers", func(t *testing.T) {
+ done := make(chan struct{})
+ go func() {
+ peerShouldReceiveUpdate(t, updMsg1)
+ peerShouldReceiveUpdate(t, updMsg2)
+ peerShouldNotReceiveUpdate(t, updMsg3)
+ close(done)
+ }()
+
+ err := manager.DeleteRoute(ctx, accountID, newRoute.ID, userID)
+ assert.NoError(t, err)
+
+ select {
+ case <-done:
+ case <-time.After(peerUpdateTimeout):
+ t.Error("timeout")
+ }
+ })
+}
+
+// TestAffectedPeers_DeletePolicy_UnrelatedPeerNoUpdate verifies that deleting a policy
+// only sends updates to peers in the policy's groups.
+func TestAffectedPeers_DeletePolicy_UnrelatedPeerNoUpdate(t *testing.T) {
+ manager, updateManager, account, peer1, peer2, peer3 := setupNetworkMapTest(t)
+ ctx := context.Background()
+ accountID := account.Id
+
+ policies, err := manager.Store.GetAccountPolicies(ctx, store.LockingStrengthNone, accountID)
+ require.NoError(t, err)
+ for _, p := range policies {
+ err := manager.Store.DeletePolicy(ctx, accountID, p.ID)
+ require.NoError(t, err)
+ }
+
+ for _, g := range []*types.Group{
+ {ID: "del-pol-grpA", Name: "Del-Pol-A", Peers: []string{peer1.ID}},
+ {ID: "del-pol-grpB", Name: "Del-Pol-B", Peers: []string{peer2.ID}},
+ } {
+ err := manager.CreateGroup(ctx, accountID, userID, g)
+ require.NoError(t, err)
+ }
+
+ policy, err := manager.SavePolicy(ctx, accountID, userID, &types.Policy{
+ Enabled: true,
+ Rules: []*types.PolicyRule{
+ {
+ Enabled: true,
+ Sources: []string{"del-pol-grpA"},
+ Destinations: []string{"del-pol-grpB"},
+ Bidirectional: true,
+ Action: types.PolicyTrafficActionAccept,
+ },
+ },
+ }, true)
+ require.NoError(t, err)
+
+ updMsg1 := updateManager.CreateChannel(ctx, peer1.ID)
+ updMsg2 := updateManager.CreateChannel(ctx, peer2.ID)
+ updMsg3 := updateManager.CreateChannel(ctx, peer3.ID)
+ t.Cleanup(func() {
+ updateManager.CloseChannel(ctx, peer1.ID)
+ updateManager.CloseChannel(ctx, peer2.ID)
+ updateManager.CloseChannel(ctx, peer3.ID)
+ })
+
+ t.Run("delete policy only affects linked peers", func(t *testing.T) {
+ done := make(chan struct{})
+ go func() {
+ peerShouldReceiveUpdate(t, updMsg1)
+ peerShouldReceiveUpdate(t, updMsg2)
+ peerShouldNotReceiveUpdate(t, updMsg3)
+ close(done)
+ }()
+
+ err := manager.DeletePolicy(ctx, accountID, policy.ID, userID)
+ assert.NoError(t, err)
+
+ select {
+ case <-done:
+ case <-time.After(peerUpdateTimeout):
+ t.Error("timeout")
+ }
+ })
+}
+
+// TestAffectedPeers_DeleteNameServer_UnrelatedPeerNoUpdate verifies that deleting a
+// nameserver group only sends updates to peers in its groups.
+func TestAffectedPeers_DeleteNameServer_UnrelatedPeerNoUpdate(t *testing.T) {
+ manager, updateManager, account, peer1, peer2, peer3 := setupNetworkMapTest(t)
+ ctx := context.Background()
+ accountID := account.Id
+
+ policies, err := manager.Store.GetAccountPolicies(ctx, store.LockingStrengthNone, accountID)
+ require.NoError(t, err)
+ for _, p := range policies {
+ err := manager.Store.DeletePolicy(ctx, accountID, p.ID)
+ require.NoError(t, err)
+ }
+
+ err = manager.CreateGroup(ctx, accountID, userID, &types.Group{
+ ID: "del-ns-grpA",
+ Name: "Del-NS-A",
+ Peers: []string{peer1.ID},
+ })
+ require.NoError(t, err)
+
+ nsGroup, err := manager.CreateNameServerGroup(ctx, accountID, "del-ns", "Del NS",
+ []nbdns.NameServer{{
+ IP: netip.MustParseAddr("8.8.4.4"),
+ NSType: nbdns.UDPNameServerType,
+ Port: nbdns.DefaultDNSPort,
+ }},
+ []string{"del-ns-grpA"},
+ true, nil, true, userID, false,
+ )
+ require.NoError(t, err)
+
+ updMsg1 := updateManager.CreateChannel(ctx, peer1.ID)
+ updMsg2 := updateManager.CreateChannel(ctx, peer2.ID)
+ updMsg3 := updateManager.CreateChannel(ctx, peer3.ID)
+ t.Cleanup(func() {
+ updateManager.CloseChannel(ctx, peer1.ID)
+ updateManager.CloseChannel(ctx, peer2.ID)
+ updateManager.CloseChannel(ctx, peer3.ID)
+ })
+
+ t.Run("delete nameserver group only affects linked peers", func(t *testing.T) {
+ done := make(chan struct{})
+ go func() {
+ peerShouldReceiveUpdate(t, updMsg1)
+ peerShouldNotReceiveUpdate(t, updMsg2)
+ peerShouldNotReceiveUpdate(t, updMsg3)
+ close(done)
+ }()
+
+ err := manager.DeleteNameServerGroup(ctx, accountID, nsGroup.ID, userID)
+ assert.NoError(t, err)
+
+ select {
+ case <-done:
+ case <-time.After(peerUpdateTimeout):
+ t.Error("timeout")
+ }
+ })
+}
+
+func addPeerToAccount(t *testing.T, manager *DefaultAccountManager, _, setupKeyKey string) *nbpeer.Peer {
+ t.Helper()
+
+ key, err := wgtypes.GeneratePrivateKey()
+ require.NoError(t, err)
+
+ peer, _, _, _, err := manager.AddPeer(context.Background(), "", setupKeyKey, "", &nbpeer.Peer{
+ Key: key.PublicKey().String(),
+ Meta: nbpeer.PeerSystemMeta{Hostname: key.PublicKey().String()},
+ }, false)
+ require.NoError(t, err)
+ return peer
+}
+
+// markPeerAsProxy flips an existing peer's ProxyMeta to mark it as an embedded
+// proxy peer in the given cluster.
+func markPeerAsProxy(t *testing.T, s store.Store, accountID, peerID, cluster string) {
+ t.Helper()
+ ctx := context.Background()
+ peer, err := s.GetPeerByID(ctx, store.LockingStrengthNone, accountID, peerID)
+ require.NoError(t, err)
+ peer.ProxyMeta = nbpeer.ProxyMeta{Embedded: true, Cluster: cluster}
+ require.NoError(t, s.SavePeer(ctx, accountID, peer))
+}
+
+// createServiceWithTargets persists a service with the given cluster and targets
+// directly in the store, bypassing the proxy-service manager (which would also
+// run cluster derivation and trigger UpdateAccountPeers).
+func createServiceWithTargets(t *testing.T, s store.Store, accountID, cluster string, targets []*rpservice.Target) *rpservice.Service {
+ t.Helper()
+ svc := &rpservice.Service{
+ AccountID: accountID,
+ Name: fmt.Sprintf("svc-%s", cluster),
+ Domain: fmt.Sprintf("%s.example.com", cluster),
+ ProxyCluster: cluster,
+ Enabled: true,
+ Mode: "tcp",
+ Targets: targets,
+ }
+ svc.InitNewRecord()
+ for _, target := range targets {
+ target.AccountID = accountID
+ target.ServiceID = svc.ID
+ }
+ require.NoError(t, s.CreateService(context.Background(), svc))
+ return svc
+}
+
+func TestCollectAffectedFromProxyServices_TargetPeerChanged(t *testing.T) {
+ manager, s, accountID, peerIDs, _ := setupAffectedPeersTest(t)
+ ctx := context.Background()
+
+ cluster := "cluster-a"
+ markPeerAsProxy(t, s, accountID, peerIDs[0], cluster)
+
+ createServiceWithTargets(t, s, accountID, cluster, []*rpservice.Target{
+ {TargetType: rpservice.TargetTypePeer, TargetId: peerIDs[1], Enabled: true, Port: 80, Protocol: "tcp"},
+ })
+
+ _, directPeers := collectPeerChangeAffectedGroups(ctx, manager.Store, accountID, nil, []string{peerIDs[1]})
+ assert.Contains(t, directPeers, peerIDs[0], "proxy peer must be refreshed when its target peer changes")
+ assert.Contains(t, directPeers, peerIDs[1], "target peer must be refreshed")
+}
+
+func TestCollectAffectedFromProxyServices_ProxyPeerChanged(t *testing.T) {
+ manager, s, accountID, peerIDs, _ := setupAffectedPeersTest(t)
+ ctx := context.Background()
+
+ cluster := "cluster-a"
+ markPeerAsProxy(t, s, accountID, peerIDs[0], cluster)
+
+ createServiceWithTargets(t, s, accountID, cluster, []*rpservice.Target{
+ {TargetType: rpservice.TargetTypePeer, TargetId: peerIDs[1], Enabled: true, Port: 80, Protocol: "tcp"},
+ {TargetType: rpservice.TargetTypePeer, TargetId: peerIDs[2], Enabled: true, Port: 80, Protocol: "tcp"},
+ })
+
+ _, directPeers := collectPeerChangeAffectedGroups(ctx, manager.Store, accountID, nil, []string{peerIDs[0]})
+ assert.Contains(t, directPeers, peerIDs[0], "changed proxy peer is itself refreshed")
+ assert.Contains(t, directPeers, peerIDs[1], "target peer 1 must be refreshed when proxy peer changes")
+ assert.Contains(t, directPeers, peerIDs[2], "target peer 2 must be refreshed when proxy peer changes")
+}
+
+func TestCollectAffectedFromProxyServices_GroupContainingTargetPeerChanged(t *testing.T) {
+ manager, s, accountID, peerIDs, groupIDs := setupAffectedPeersTest(t)
+ ctx := context.Background()
+
+ cluster := "cluster-a"
+ markPeerAsProxy(t, s, accountID, peerIDs[0], cluster)
+
+ createServiceWithTargets(t, s, accountID, cluster, []*rpservice.Target{
+ {TargetType: rpservice.TargetTypePeer, TargetId: peerIDs[1], Enabled: true, Port: 80, Protocol: "tcp"},
+ })
+
+ _, directPeers := collectPeerChangeAffectedGroups(ctx, manager.Store, accountID, []string{groupIDs[1]}, nil)
+ assert.Contains(t, directPeers, peerIDs[0], "proxy peer must be refreshed when a group containing its target peer changes")
+ assert.Contains(t, directPeers, peerIDs[1], "target peer must be refreshed")
+}
+
+func TestCollectAffectedFromProxyServices_DisabledServiceStillMatches(t *testing.T) {
+ manager, s, accountID, peerIDs, _ := setupAffectedPeersTest(t)
+ ctx := context.Background()
+
+ cluster := "cluster-a"
+ markPeerAsProxy(t, s, accountID, peerIDs[0], cluster)
+
+ svc := &rpservice.Service{
+ AccountID: accountID,
+ Name: "disabled-svc",
+ Domain: "disabled.example.com",
+ ProxyCluster: cluster,
+ Enabled: false,
+ Mode: "tcp",
+ Targets: []*rpservice.Target{
+ {TargetType: rpservice.TargetTypePeer, TargetId: peerIDs[1], Enabled: false, Port: 80, Protocol: "tcp"},
+ },
+ }
+ svc.InitNewRecord()
+ for _, target := range svc.Targets {
+ target.AccountID = accountID
+ target.ServiceID = svc.ID
+ }
+ require.NoError(t, s.CreateService(ctx, svc))
+
+ _, directPeers := collectPeerChangeAffectedGroups(ctx, manager.Store, accountID, nil, []string{peerIDs[1]})
+ assert.Contains(t, directPeers, peerIDs[0], "disabled service should still trigger a refresh so peers are ready when re-enabled")
+ assert.Contains(t, directPeers, peerIDs[1], "disabled target should still trigger a refresh")
+}
+
+func TestCollectAffectedFromProxyServices_NonPeerTargetType(t *testing.T) {
+ manager, s, accountID, peerIDs, _ := setupAffectedPeersTest(t)
+ ctx := context.Background()
+
+ cluster := "cluster-a"
+ markPeerAsProxy(t, s, accountID, peerIDs[0], cluster)
+
+ createServiceWithTargets(t, s, accountID, cluster, []*rpservice.Target{
+ {TargetType: rpservice.TargetTypeHost, TargetId: "10.0.0.1", Host: "10.0.0.1", Enabled: true, Port: 80, Protocol: "tcp"},
+ })
+
+ _, directPeers := collectPeerChangeAffectedGroups(ctx, manager.Store, accountID, nil, []string{peerIDs[0]})
+ assert.Contains(t, directPeers, peerIDs[0], "host target service still refreshes its proxy peer when the proxy peer changes")
+ assert.NotContains(t, directPeers, "10.0.0.1", "non-peer target ids must not appear as affected peer IDs")
+}
diff --git a/management/server/affectedpeers/resolver.go b/management/server/affectedpeers/resolver.go
new file mode 100644
index 000000000..4ef986345
--- /dev/null
+++ b/management/server/affectedpeers/resolver.go
@@ -0,0 +1,825 @@
+// Package affectedpeers computes which peers' network maps a change touches, so
+// only those peers are refreshed instead of the whole account.
+//
+// Two phases keep the dependency walk off the write transaction:
+// - Load: reads the needed collections. Call INSIDE the mutating tx (consistent,
+// and before a delete/removal severs the old state).
+// - Snapshot.Expand: in-memory walk, no store access. Run AFTER the tx commits.
+//
+// Enabled is never consulted: toggling it is itself an observable change.
+package affectedpeers
+
+import (
+ "context"
+
+ log "github.com/sirupsen/logrus"
+
+ nbdns "github.com/netbirdio/netbird/dns"
+ rpservice "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/service"
+ resourceTypes "github.com/netbirdio/netbird/management/server/networks/resources/types"
+ routerTypes "github.com/netbirdio/netbird/management/server/networks/routers/types"
+ networkTypes "github.com/netbirdio/netbird/management/server/networks/types"
+ "github.com/netbirdio/netbird/management/server/store"
+ "github.com/netbirdio/netbird/management/server/types"
+ "github.com/netbirdio/netbird/route"
+)
+
+// Snapshot is an in-memory view of the collections needed to expand a Change.
+// Loaded in-tx, walked by Expand after commit. Only the collections the Change
+// can touch are loaded; the rest stay nil (see Load).
+type Snapshot struct {
+ policies []*types.Policy
+ routes []*route.Route
+ nsGroups []*nbdns.NameServerGroup
+ dnsSettings *types.DNSSettings
+ routers []*routerTypes.NetworkRouter
+ resources []*resourceTypes.NetworkResource
+ services []*rpservice.Service
+ proxyByCluster map[string][]string
+ groups map[string]*types.Group
+ groupPeers map[string]map[string]struct{} // groupID -> member peer IDs
+}
+
+// Load reads the collections a Change requires, inside the caller's tx. It mirrors
+// Expand's walker preconditions, loading only what the change can touch.
+func Load(ctx context.Context, s store.Store, accountID string, c Change) (*Snapshot, error) {
+ snap := &Snapshot{}
+ if c.isEmpty() {
+ return snap, nil
+ }
+
+ if err := snap.loadCollections(ctx, s, accountID, c); err != nil {
+ return nil, err
+ }
+ if err := snap.loadGroupIndex(ctx, s, accountID); err != nil {
+ return nil, err
+ }
+
+ return snap, nil
+}
+
+// loadCollections reads the policy/route/nameserver/dns/router/resource/proxy
+// collections a Change can touch, gated to what the walk needs.
+func (snap *Snapshot) loadCollections(ctx context.Context, s store.Store, accountID string, c Change) error {
+ hasGroupOrPeerChange := len(c.ChangedGroupIDs) > 0 || len(c.ChangedPeerIDs) > 0 || len(c.Resources) > 0
+ hasNetworkObject := len(c.Routers) > 0 || len(c.Resources) > 0 || len(c.Networks) > 0
+ // the resource<->router bridge can fire for any of these
+ needsRoutersResources := hasGroupOrPeerChange || len(c.PostureCheckIDs) > 0 || len(c.Policies) > 0 || hasNetworkObject
+
+ if needsRoutersResources {
+ if err := snap.loadPolicyRoutersResources(ctx, s, accountID); err != nil {
+ return err
+ }
+ }
+ if hasGroupOrPeerChange {
+ if err := snap.loadRoutesAndProxy(ctx, s, accountID); err != nil {
+ return err
+ }
+ }
+ if len(c.ChangedGroupIDs) > 0 || len(c.ChangedPeerIDs) > 0 {
+ if err := snap.loadDNS(ctx, s, accountID); err != nil {
+ return err
+ }
+ }
+ return nil
+}
+
+// loadPolicyRoutersResources loads the policies plus the routers and resources
+// the resource<->router bridge walks.
+func (snap *Snapshot) loadPolicyRoutersResources(ctx context.Context, s store.Store, accountID string) error {
+ var err error
+ if snap.policies, err = s.GetAccountPolicies(ctx, store.LockingStrengthNone, accountID); err != nil {
+ return err
+ }
+ if snap.routers, err = s.GetNetworkRoutersByAccountID(ctx, store.LockingStrengthNone, accountID); err != nil {
+ return err
+ }
+ snap.resources, err = s.GetNetworkResourcesByAccountID(ctx, store.LockingStrengthNone, accountID)
+ return err
+}
+
+// loadRoutesAndProxy loads the routes and the embedded-proxy services index.
+func (snap *Snapshot) loadRoutesAndProxy(ctx context.Context, s store.Store, accountID string) error {
+ var err error
+ if snap.routes, err = s.GetAccountRoutes(ctx, store.LockingStrengthNone, accountID); err != nil {
+ return err
+ }
+ return snap.loadProxyServices(ctx, s, accountID)
+}
+
+// loadDNS loads the nameserver groups and account DNS settings.
+func (snap *Snapshot) loadDNS(ctx context.Context, s store.Store, accountID string) error {
+ var err error
+ if snap.nsGroups, err = s.GetAccountNameServerGroups(ctx, store.LockingStrengthNone, accountID); err != nil {
+ return err
+ }
+ snap.dnsSettings, err = s.GetAccountDNSSettings(ctx, store.LockingStrengthNone, accountID)
+ return err
+}
+
+// loadProxyServices loads the embedded-proxy cluster index, and the services only
+// when the account actually has embedded proxy peers.
+func (snap *Snapshot) loadProxyServices(ctx context.Context, s store.Store, accountID string) error {
+ var err error
+ if snap.proxyByCluster, err = s.GetEmbeddedProxyPeerIDsByCluster(ctx, accountID); err != nil {
+ return err
+ }
+ if len(snap.proxyByCluster) == 0 {
+ return nil
+ }
+ snap.services, err = s.GetAccountServices(ctx, store.LockingStrengthNone, accountID)
+ return err
+}
+
+// loadGroupIndex loads all groups (for group.Resources) and builds the
+// group->member-peers index. Always needed: the bridge resolves group.Resources
+// and Expand maps groups to member peers.
+func (snap *Snapshot) loadGroupIndex(ctx context.Context, s store.Store, accountID string) error {
+ groups, err := s.GetAccountGroups(ctx, store.LockingStrengthNone, accountID)
+ if err != nil {
+ return err
+ }
+ snap.groups = make(map[string]*types.Group, len(groups))
+ snap.groupPeers = make(map[string]map[string]struct{}, len(groups))
+ for _, g := range groups {
+ snap.groups[g.ID] = g
+ members := make(map[string]struct{}, len(g.Peers))
+ for _, pID := range g.Peers {
+ members[pID] = struct{}{}
+ }
+ snap.groupPeers[g.ID] = members
+ }
+ return nil
+}
+
+// Change describes what changed in an account.
+type Change struct {
+ ChangedGroupIDs []string
+ ChangedPeerIDs []string
+ Policies []*types.Policy
+ Routes []*route.Route
+ Routers []*routerTypes.NetworkRouter
+ Resources []*resourceTypes.NetworkResource
+ Networks []*networkTypes.Network
+ PostureCheckIDs []string
+
+ // DistributionGroupIDs are groups whose members are directly affected, with no
+ // dependency walk — the change distributes config to the groups' member peers
+ // only (nameserver groups, DNS DisabledManagementGroups), not through the
+ // policy/route reachability graph. Pass old∪new so both states refresh.
+ DistributionGroupIDs []string
+
+ // RemovedPeersByGroup: peers that left a group, keyed by that group. They are no
+ // longer in the group's member index but still lose its reachability, so they are
+ // folded in — but only when the group is linked (an unlinked group has no map
+ // impact), matching how current members are handled.
+ RemovedPeersByGroup map[string][]string
+}
+
+func (c Change) isEmpty() bool {
+ return len(c.ChangedGroupIDs) == 0 &&
+ len(c.ChangedPeerIDs) == 0 &&
+ len(c.Policies) == 0 &&
+ len(c.Routes) == 0 &&
+ len(c.Routers) == 0 &&
+ len(c.Resources) == 0 &&
+ len(c.Networks) == 0 &&
+ len(c.PostureCheckIDs) == 0 &&
+ len(c.DistributionGroupIDs) == 0 &&
+ len(c.RemovedPeersByGroup) == 0
+}
+
+// Expand returns the deduplicated affected peer IDs from the preloaded Snapshot,
+// no store access. Run after the producing tx commits. Logs the full walk at
+// trace level for diagnosing a miscalculation.
+func (snap *Snapshot) Expand(ctx context.Context, accountID string, c Change) []string {
+ if c.isEmpty() {
+ return nil
+ }
+ r := newResolver(ctx, snap, accountID, c)
+ log.WithContext(ctx).Tracef("affectedpeers expand start: account=%s changedGroups=%v changedPeers=%v policies=%d routes=%d routers=%d resources=%d networks=%d postureChecks=%v distributionGroups=%v",
+ accountID, c.ChangedGroupIDs, c.ChangedPeerIDs, len(c.Policies), len(c.Routes), len(c.Routers), len(c.Resources), len(c.Networks), c.PostureCheckIDs, c.DistributionGroupIDs)
+ r.walk()
+ return r.expand()
+}
+
+// Collect returns the affected group and direct-peer IDs without expanding groups
+// to members. Test-only introspection; use Resolve otherwise.
+func Collect(ctx context.Context, s store.Store, accountID string, c Change) (groupIDs []string, directPeerIDs []string) {
+ if c.isEmpty() {
+ return nil, nil
+ }
+ snap, err := Load(ctx, s, accountID, c)
+ if err != nil {
+ log.WithContext(ctx).Errorf("failed to load snapshot for affected peers collect: %v", err)
+ return nil, nil
+ }
+ r := newResolver(ctx, snap, accountID, c)
+ r.walk()
+ return setToSlice(r.groupSet), setToSlice(r.peerSet)
+}
+
+func newResolver(ctx context.Context, snap *Snapshot, accountID string, c Change) *resolver {
+ r := &resolver{
+ ctx: ctx,
+ snap: snap,
+ accountID: accountID,
+ change: c,
+ changedGroupSet: toSet(c.ChangedGroupIDs),
+ changedPeerSet: toSet(c.ChangedPeerIDs),
+ groupSet: make(map[string]struct{}),
+ peerSet: make(map[string]struct{}),
+ networkIDs: make(map[string]struct{}),
+ }
+ // Resolve each changed peer to its groups here so callers pass only ChangedPeerIDs.
+ r.seedChangedGroupsFromPeers()
+ r.matchedPolicies = append(r.matchedPolicies, c.Policies...)
+ return r
+}
+
+// seedChangedGroupsFromPeers adds each changed peer's groups to changedGroupSet so
+// the group-driven walkers fire for memberships, not just direct peer references.
+func (r *resolver) seedChangedGroupsFromPeers() {
+ if len(r.changedPeerSet) == 0 {
+ return
+ }
+ for groupID, members := range r.snap.groupPeers {
+ for pID := range r.changedPeerSet {
+ if _, ok := members[pID]; ok {
+ r.changedGroupSet[groupID] = struct{}{}
+ break
+ }
+ }
+ }
+}
+
+func (r *resolver) walk() {
+ r.collectFromExplicitPolicies()
+ r.collectFromExplicitRoutes(r.change.Routes)
+ r.collectFromExplicitRouters(r.change.Routers)
+ r.collectFromExplicitResources(r.change.Resources)
+ r.collectFromExplicitNetworks(r.change.Networks)
+ r.collectFromPostureChecks(r.change.PostureCheckIDs)
+
+ // Distribution groups (nameserver/DNS) affect only their member peers: fold them
+ // straight into groupSet so expand() maps them to members, without the policy/
+ // route walk that changedGroupSet would trigger.
+ addAll(r.groupSet, r.change.DistributionGroupIDs)
+
+ if len(r.changedGroupSet) > 0 || len(r.changedPeerSet) > 0 {
+ r.collectFromPolicies()
+ r.collectFromRoutes()
+ r.collectFromNameServers()
+ r.collectFromDNSSettings()
+ r.collectFromNetworkRouters()
+ r.collectFromProxyServices()
+ }
+
+ r.collectResourceRouterBridge()
+}
+
+type resolver struct {
+ ctx context.Context
+ snap *Snapshot
+ accountID string
+ change Change
+
+ changedGroupSet map[string]struct{}
+ changedPeerSet map[string]struct{}
+
+ groupSet map[string]struct{}
+ peerSet map[string]struct{}
+
+ matchedPolicies []*types.Policy
+ networkIDs map[string]struct{}
+}
+
+func (r *resolver) policies() []*types.Policy { return r.snap.policies }
+
+func (r *resolver) networkResources() []*resourceTypes.NetworkResource { return r.snap.resources }
+
+func (r *resolver) networkRouters() []*routerTypes.NetworkRouter { return r.snap.routers }
+
+// peerIDsForGroups maps a group set to its member peer IDs via the preloaded index.
+func (r *resolver) peerIDsForGroups(groupSet map[string]struct{}) []string {
+ seen := make(map[string]struct{})
+ var ids []string
+ for gID := range groupSet {
+ for pID := range r.snap.groupPeers[gID] {
+ if _, ok := seen[pID]; ok {
+ continue
+ }
+ seen[pID] = struct{}{}
+ ids = append(ids, pID)
+ }
+ }
+ return ids
+}
+
+func (r *resolver) expand() []string {
+ peerIDs := r.peerIDsForGroups(r.groupSet)
+
+ log.WithContext(r.ctx).Tracef("affectedpeers expand: account=%s affectedGroups=%v -> %d group-member peers; direct peers=%v",
+ r.accountID, setToSlice(r.groupSet), len(peerIDs), setToSlice(r.peerSet))
+
+ seen := make(map[string]struct{}, len(peerIDs))
+ for _, id := range peerIDs {
+ seen[id] = struct{}{}
+ }
+ for id := range r.peerSet {
+ if _, ok := seen[id]; !ok {
+ peerIDs = append(peerIDs, id)
+ seen[id] = struct{}{}
+ }
+ }
+
+ // Fold in removed peers only when their group is linked (in groupSet).
+ for groupID, removed := range r.change.RemovedPeersByGroup {
+ if _, linked := r.groupSet[groupID]; !linked {
+ continue
+ }
+ for _, id := range removed {
+ if _, ok := seen[id]; !ok {
+ peerIDs = append(peerIDs, id)
+ seen[id] = struct{}{}
+ log.WithContext(r.ctx).Tracef("affectedpeers expand: removed peer %s from linked group %s -> affected", id, groupID)
+ }
+ }
+ }
+
+ log.WithContext(r.ctx).Tracef("affectedpeers expand done: account=%s -> %d affected peers: %v", r.accountID, len(peerIDs), peerIDs)
+ return peerIDs
+}
+
+func (r *resolver) collectFromExplicitPolicies() {
+ for _, policy := range r.matchedPolicies {
+ if policy == nil {
+ continue
+ }
+ log.WithContext(r.ctx).Tracef("collectFromExplicitPolicies: changed policy %s (%s) -> folding rule groups %v + direct peers",
+ policy.ID, policy.Name, policy.RuleGroups())
+ addAll(r.groupSet, policy.RuleGroups())
+ collectPolicyDirectPeers(policy, r.peerSet)
+ }
+}
+
+func (r *resolver) collectFromExplicitRoutes(routes []*route.Route) {
+ for _, rt := range routes {
+ if rt == nil {
+ continue
+ }
+ log.WithContext(r.ctx).Tracef("collectFromExplicitRoutes: changed route %s -> folding groups=%v peerGroups=%v accessControlGroups=%v peer=%q",
+ rt.ID, rt.Groups, rt.PeerGroups, rt.AccessControlGroups, rt.Peer)
+ addAll(r.groupSet, rt.Groups, rt.PeerGroups, rt.AccessControlGroups)
+ if rt.Peer != "" {
+ r.peerSet[rt.Peer] = struct{}{}
+ }
+ }
+}
+
+// collectFromExplicitRouters folds changed routers' peers and marks their networks
+// for the bridge. Passing the old router keeps a repointed router's previous peers
+// affected without a post-commit read.
+func (r *resolver) collectFromExplicitRouters(routers []*routerTypes.NetworkRouter) {
+ for _, router := range routers {
+ if router == nil {
+ continue
+ }
+ log.WithContext(r.ctx).Tracef("collectFromExplicitRouters: changed router %s on network %s -> folding peerGroups=%v peer=%q and marking network for source bridge",
+ router.ID, router.NetworkID, router.PeerGroups, router.Peer)
+ addAll(r.groupSet, router.PeerGroups)
+ if router.Peer != "" {
+ r.peerSet[router.Peer] = struct{}{}
+ }
+ if router.NetworkID != "" {
+ r.networkIDs[router.NetworkID] = struct{}{}
+ }
+ }
+}
+
+// collectFromExplicitResources marks changed resources' networks for the bridge and
+// treats their group IDs as changed, so policies targeting the resource via a
+// now-detached (old) group still refresh.
+func (r *resolver) collectFromExplicitResources(resources []*resourceTypes.NetworkResource) {
+ for _, resource := range resources {
+ if resource == nil {
+ continue
+ }
+ log.WithContext(r.ctx).Tracef("collectFromExplicitResources: changed resource %s on network %s -> marking network for bridge and treating groups %v as changed",
+ resource.ID, resource.NetworkID, resource.GroupIDs)
+ addAll(r.changedGroupSet, resource.GroupIDs)
+ if resource.NetworkID != "" {
+ r.networkIDs[resource.NetworkID] = struct{}{}
+ }
+ }
+}
+
+// collectFromExplicitNetworks marks changed networks for the bridge. A network has
+// no groups/peers of its own.
+func (r *resolver) collectFromExplicitNetworks(networks []*networkTypes.Network) {
+ for _, network := range networks {
+ if network == nil {
+ continue
+ }
+ log.WithContext(r.ctx).Tracef("collectFromExplicitNetworks: changed network %s -> marking for bridge", network.ID)
+ if network.ID != "" {
+ r.networkIDs[network.ID] = struct{}{}
+ }
+ }
+}
+
+func (r *resolver) collectFromPostureChecks(postureCheckIDs []string) {
+ if len(postureCheckIDs) == 0 {
+ return
+ }
+ ids := toSet(postureCheckIDs)
+ for _, policy := range r.policies() {
+ if !policyReferencesPostureChecks(policy, ids) {
+ continue
+ }
+ log.WithContext(r.ctx).Tracef("collectFromPostureChecks: policy %s (%s) references changed posture checks %v -> folding rule groups %v + direct peers",
+ policy.ID, policy.Name, postureCheckIDs, policy.RuleGroups())
+ addAll(r.groupSet, policy.RuleGroups())
+ collectPolicyDirectPeers(policy, r.peerSet)
+ r.matchedPolicies = append(r.matchedPolicies, policy)
+ }
+}
+
+func (r *resolver) collectFromPolicies() {
+ for _, policy := range r.policies() {
+ matchedByGroup := policyReferencesGroups(policy, r.changedGroupSet)
+ matchedByPeer := len(r.changedPeerSet) > 0 && policyReferencesDirectPeers(policy, r.changedPeerSet)
+ if !matchedByGroup && !matchedByPeer {
+ continue
+ }
+ log.WithContext(r.ctx).Tracef("collectFromPolicies: policy %s (%s) matched (byGroup=%t byPeer=%t) -> folding rule groups %v + direct peers",
+ policy.ID, policy.Name, matchedByGroup, matchedByPeer, policy.RuleGroups())
+ addAll(r.groupSet, policy.RuleGroups())
+ collectPolicyDirectPeers(policy, r.peerSet)
+ r.matchedPolicies = append(r.matchedPolicies, policy)
+ }
+}
+
+func (r *resolver) collectFromRoutes() {
+ for _, rt := range r.snap.routes {
+ matchedByGroup := anyInSet(rt.Groups, r.changedGroupSet) || anyInSet(rt.PeerGroups, r.changedGroupSet) || anyInSet(rt.AccessControlGroups, r.changedGroupSet)
+ matchedByPeer := rt.Peer != "" && len(r.changedPeerSet) > 0 && isInSet(rt.Peer, r.changedPeerSet)
+ if !matchedByGroup && !matchedByPeer {
+ continue
+ }
+ log.WithContext(r.ctx).Tracef("collectFromRoutes: route %s matched (byGroup=%t byPeer=%t) -> folding groups=%v peerGroups=%v accessControlGroups=%v peer=%q",
+ rt.ID, matchedByGroup, matchedByPeer, rt.Groups, rt.PeerGroups, rt.AccessControlGroups, rt.Peer)
+ addAll(r.groupSet, rt.Groups, rt.PeerGroups, rt.AccessControlGroups)
+ if rt.Peer != "" {
+ r.peerSet[rt.Peer] = struct{}{}
+ }
+ }
+}
+
+func (r *resolver) collectFromNameServers() {
+ if len(r.changedGroupSet) == 0 {
+ return
+ }
+ for _, ns := range r.snap.nsGroups {
+ if anyInSet(ns.Groups, r.changedGroupSet) {
+ log.WithContext(r.ctx).Tracef("collectFromNameServers: nameserver group %s references a changed group -> folding its groups %v", ns.ID, ns.Groups)
+ addAll(r.groupSet, ns.Groups)
+ }
+ }
+}
+
+func (r *resolver) collectFromDNSSettings() {
+ if len(r.changedGroupSet) == 0 || r.snap.dnsSettings == nil {
+ return
+ }
+ for _, gID := range r.snap.dnsSettings.DisabledManagementGroups {
+ if _, ok := r.changedGroupSet[gID]; ok {
+ log.WithContext(r.ctx).Tracef("collectFromDNSSettings: changed group %s is in DisabledManagementGroups -> folding it", gID)
+ r.groupSet[gID] = struct{}{}
+ }
+ }
+}
+
+func (r *resolver) collectFromNetworkRouters() {
+ for _, router := range r.networkRouters() {
+ matchedByGroup := anyInSet(router.PeerGroups, r.changedGroupSet)
+ matchedByPeer := router.Peer != "" && len(r.changedPeerSet) > 0 && isInSet(router.Peer, r.changedPeerSet)
+ if !matchedByGroup && !matchedByPeer {
+ continue
+ }
+ log.WithContext(r.ctx).Tracef("collectFromNetworkRouters: router %s on network %s matched (byGroup=%t byPeer=%t) -> folding peerGroups=%v peer=%q and marking network for source bridge",
+ router.ID, router.NetworkID, matchedByGroup, matchedByPeer, router.PeerGroups, router.Peer)
+ addAll(r.groupSet, router.PeerGroups)
+ if router.Peer != "" {
+ r.peerSet[router.Peer] = struct{}{}
+ }
+ r.networkIDs[router.NetworkID] = struct{}{}
+ }
+}
+
+func (r *resolver) collectFromProxyServices() {
+ if len(r.snap.proxyByCluster) == 0 || len(r.snap.services) == 0 {
+ return
+ }
+ services, proxyByCluster := r.snap.services, r.snap.proxyByCluster
+
+ expanded := r.expandChangedPeersWithGroups()
+
+ for _, svc := range services {
+ if svc == nil {
+ continue
+ }
+ proxyPeers := proxyByCluster[svc.ProxyCluster]
+ if len(proxyPeers) == 0 {
+ continue
+ }
+ matchedByPeer := serviceMatchesChangedPeers(svc, proxyPeers, expanded)
+ matchedByAccessGroup := anyInSet(svc.AccessGroups, r.changedGroupSet)
+ if !matchedByPeer && !matchedByAccessGroup {
+ continue
+ }
+ log.WithContext(r.ctx).Tracef("collectFromProxyServices: service %s (cluster=%s) matched (byProxyOrTargetPeer=%t byAccessGroup=%t) -> folding %d proxy peers, peer targets and access groups %v",
+ svc.ID, svc.ProxyCluster, matchedByPeer, matchedByAccessGroup, len(proxyPeers), svc.AccessGroups)
+ for _, pid := range proxyPeers {
+ r.peerSet[pid] = struct{}{}
+ }
+ for _, target := range svc.Targets {
+ if target.TargetType == rpservice.TargetTypePeer && target.TargetId != "" {
+ r.peerSet[target.TargetId] = struct{}{}
+ }
+ }
+ addAll(r.groupSet, svc.AccessGroups)
+ }
+}
+
+func (r *resolver) expandChangedPeersWithGroups() map[string]struct{} {
+ if len(r.changedGroupSet) == 0 {
+ return r.changedPeerSet
+ }
+ ids := r.peerIDsForGroups(r.changedGroupSet)
+ if len(ids) == 0 {
+ return r.changedPeerSet
+ }
+ merged := make(map[string]struct{}, len(r.changedPeerSet)+len(ids))
+ for id := range r.changedPeerSet {
+ merged[id] = struct{}{}
+ }
+ for _, id := range ids {
+ merged[id] = struct{}{}
+ }
+ return merged
+}
+
+// collectResourceRouterBridge crosses between source peers and routing peers, which
+// are reachable only via resource -> network -> router, not through the policy's own
+// groups: source -> router (targeted resources' networks), then router -> source.
+func (r *resolver) collectResourceRouterBridge() {
+ r.bridgeSourceToRouters()
+ r.bridgeRoutersToSources()
+}
+
+func (r *resolver) bridgeSourceToRouters() {
+ resourceIDs := r.policyDestinationResourceIDs(r.matchedPolicies...)
+ if len(resourceIDs) == 0 {
+ return
+ }
+
+ networkIDs := r.resourceNetworkIDs(resourceIDs)
+ log.WithContext(r.ctx).Tracef("bridgeSourceToRouters: targeted resources %v -> networks %v (their routers become affected via the router->source pass)",
+ setToSlice(resourceIDs), setToSlice(networkIDs))
+ for id := range networkIDs {
+ r.networkIDs[id] = struct{}{}
+ }
+}
+
+func (r *resolver) bridgeRoutersToSources() {
+ if len(r.networkIDs) == 0 {
+ return
+ }
+
+ log.WithContext(r.ctx).Tracef("bridgeRoutersToSources: affected networks %v -> folding their routing peers and the source peers of policies targeting their resources",
+ setToSlice(r.networkIDs))
+
+ r.foldRoutersOnNetworks(r.networkIDs)
+
+ resourceIDs := make(map[string]struct{})
+ for _, resource := range r.networkResources() {
+ if _, ok := r.networkIDs[resource.NetworkID]; ok {
+ resourceIDs[resource.ID] = struct{}{}
+ }
+ }
+ if len(resourceIDs) == 0 {
+ return
+ }
+
+ for _, policy := range r.policies() {
+ if r.policyTargetsResources(policy, resourceIDs) {
+ log.WithContext(r.ctx).Tracef("bridgeRoutersToSources: policy %s (%s) targets an affected-network resource -> folding its source groups/peers", policy.ID, policy.Name)
+ collectPolicySources(policy, r.groupSet, r.peerSet)
+ }
+ }
+}
+
+func (r *resolver) foldRoutersOnNetworks(networkIDs map[string]struct{}) {
+ for _, router := range r.networkRouters() {
+ if _, ok := networkIDs[router.NetworkID]; !ok {
+ continue
+ }
+ log.WithContext(r.ctx).Tracef("bridgeRoutersToSources: router %s serves affected network %s -> folding peerGroups=%v peer=%q",
+ router.ID, router.NetworkID, router.PeerGroups, router.Peer)
+ addAll(r.groupSet, router.PeerGroups)
+ if router.Peer != "" {
+ r.peerSet[router.Peer] = struct{}{}
+ }
+ }
+}
+
+func (r *resolver) resourceNetworkIDs(resourceIDs map[string]struct{}) map[string]struct{} {
+ networkIDs := make(map[string]struct{})
+ for _, resource := range r.networkResources() {
+ if _, ok := resourceIDs[resource.ID]; ok {
+ networkIDs[resource.NetworkID] = struct{}{}
+ }
+ }
+ return networkIDs
+}
+
+func (r *resolver) policyTargetsResources(policy *types.Policy, resourceIDs map[string]struct{}) bool {
+ if policy == nil {
+ return false
+ }
+ destGroupSet := make(map[string]struct{})
+ for _, rule := range policy.Rules {
+ if rule.DestinationResource.Type != types.ResourceTypePeer && isInSet(rule.DestinationResource.ID, resourceIDs) {
+ return true
+ }
+ for _, gID := range rule.Destinations {
+ destGroupSet[gID] = struct{}{}
+ }
+ }
+ if len(destGroupSet) == 0 {
+ return false
+ }
+ for gID := range destGroupSet {
+ group := r.snap.groups[gID]
+ if group == nil {
+ continue
+ }
+ for _, res := range group.Resources {
+ if isInSet(res.ID, resourceIDs) {
+ return true
+ }
+ }
+ }
+ return false
+}
+
+func (r *resolver) policyDestinationResourceIDs(policies ...*types.Policy) map[string]struct{} {
+ resourceIDs := make(map[string]struct{})
+ destGroupSet := collectPolicyDestinations(resourceIDs, policies...)
+ r.addGroupResourceIDs(destGroupSet, resourceIDs)
+ return resourceIDs
+}
+
+// collectPolicyDestinations adds direct destination resource IDs to resourceIDs and
+// returns the referenced destination group IDs.
+func collectPolicyDestinations(resourceIDs map[string]struct{}, policies ...*types.Policy) map[string]struct{} {
+ destGroupSet := make(map[string]struct{})
+ for _, policy := range policies {
+ if policy == nil {
+ continue
+ }
+ for _, rule := range policy.Rules {
+ addAll(destGroupSet, rule.Destinations)
+ if rule.DestinationResource.Type != types.ResourceTypePeer && rule.DestinationResource.ID != "" {
+ resourceIDs[rule.DestinationResource.ID] = struct{}{}
+ }
+ }
+ }
+ return destGroupSet
+}
+
+// addGroupResourceIDs folds the resource IDs of the given groups into resourceIDs.
+func (r *resolver) addGroupResourceIDs(groupIDs map[string]struct{}, resourceIDs map[string]struct{}) {
+ for gID := range groupIDs {
+ group := r.snap.groups[gID]
+ if group == nil {
+ continue
+ }
+ for _, res := range group.Resources {
+ if res.ID != "" {
+ resourceIDs[res.ID] = struct{}{}
+ }
+ }
+ }
+}
+
+func collectPolicyDirectPeers(policy *types.Policy, peerSet map[string]struct{}) {
+ for _, rule := range policy.Rules {
+ if rule.SourceResource.Type == types.ResourceTypePeer && rule.SourceResource.ID != "" {
+ peerSet[rule.SourceResource.ID] = struct{}{}
+ }
+ if rule.DestinationResource.Type == types.ResourceTypePeer && rule.DestinationResource.ID != "" {
+ peerSet[rule.DestinationResource.ID] = struct{}{}
+ }
+ }
+}
+
+func collectPolicySources(policy *types.Policy, groupSet, peerSet map[string]struct{}) {
+ for _, rule := range policy.Rules {
+ addAll(groupSet, rule.Sources)
+ if rule.SourceResource.Type == types.ResourceTypePeer && rule.SourceResource.ID != "" {
+ peerSet[rule.SourceResource.ID] = struct{}{}
+ }
+ }
+}
+
+func policyReferencesGroups(policy *types.Policy, groupSet map[string]struct{}) bool {
+ for _, rule := range policy.Rules {
+ if anyInSet(rule.Sources, groupSet) || anyInSet(rule.Destinations, groupSet) {
+ return true
+ }
+ }
+ return false
+}
+
+func policyReferencesDirectPeers(policy *types.Policy, changedSet map[string]struct{}) bool {
+ for _, rule := range policy.Rules {
+ if isDirectPeerInSet(rule.SourceResource, changedSet) || isDirectPeerInSet(rule.DestinationResource, changedSet) {
+ return true
+ }
+ }
+ return false
+}
+
+func policyReferencesPostureChecks(policy *types.Policy, ids map[string]struct{}) bool {
+ for _, id := range policy.SourcePostureChecks {
+ if _, ok := ids[id]; ok {
+ return true
+ }
+ }
+ return false
+}
+
+func isDirectPeerInSet(res types.Resource, set map[string]struct{}) bool {
+ if res.Type != types.ResourceTypePeer || res.ID == "" {
+ return false
+ }
+ _, ok := set[res.ID]
+ return ok
+}
+
+func serviceMatchesChangedPeers(svc *rpservice.Service, proxyPeers []string, changedPeers map[string]struct{}) bool {
+ for _, pid := range proxyPeers {
+ if _, ok := changedPeers[pid]; ok {
+ return true
+ }
+ }
+ for _, target := range svc.Targets {
+ if target.TargetType != rpservice.TargetTypePeer || target.TargetId == "" {
+ continue
+ }
+ if _, ok := changedPeers[target.TargetId]; ok {
+ return true
+ }
+ }
+ return false
+}
+
+func anyInSet(ids []string, set map[string]struct{}) bool {
+ for _, id := range ids {
+ if _, ok := set[id]; ok {
+ return true
+ }
+ }
+ return false
+}
+
+func isInSet(id string, set map[string]struct{}) bool {
+ _, ok := set[id]
+ return ok
+}
+
+func addAll(set map[string]struct{}, slices ...[]string) {
+ for _, s := range slices {
+ for _, id := range s {
+ set[id] = struct{}{}
+ }
+ }
+}
+
+func toSet(ids []string) map[string]struct{} {
+ set := make(map[string]struct{}, len(ids))
+ for _, id := range ids {
+ set[id] = struct{}{}
+ }
+ return set
+}
+
+func setToSlice(set map[string]struct{}) []string {
+ s := make([]string, 0, len(set))
+ for id := range set {
+ s = append(s, id)
+ }
+ return s
+}
diff --git a/management/server/affectedpeers/resolver_test.go b/management/server/affectedpeers/resolver_test.go
new file mode 100644
index 000000000..dcd304a56
--- /dev/null
+++ b/management/server/affectedpeers/resolver_test.go
@@ -0,0 +1,140 @@
+package affectedpeers
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+
+ resourceTypes "github.com/netbirdio/netbird/management/server/networks/resources/types"
+ networkTypes "github.com/netbirdio/netbird/management/server/networks/types"
+ "github.com/netbirdio/netbird/management/server/types"
+)
+
+// policyGroupsAndPeers mirrors the explicit-policy extraction (RuleGroups +
+// direct peers) the resolver folds in, for asserting the pure logic.
+func policyGroupsAndPeers(policies ...*types.Policy) (groups []string, peers []string) {
+ peerSet := map[string]struct{}{}
+ for _, p := range policies {
+ if p == nil {
+ continue
+ }
+ groups = append(groups, p.RuleGroups()...)
+ collectPolicyDirectPeers(p, peerSet)
+ }
+ for id := range peerSet {
+ peers = append(peers, id)
+ }
+ return groups, peers
+}
+
+func TestPolicyGroupsAndPeers_Basic(t *testing.T) {
+ policy := &types.Policy{Rules: []*types.PolicyRule{{Sources: []string{"g1", "g2"}, Destinations: []string{"g3"}}}}
+ groups, peers := policyGroupsAndPeers(policy)
+ assert.ElementsMatch(t, []string{"g1", "g2", "g3"}, groups)
+ assert.Empty(t, peers)
+}
+
+func TestPolicyGroupsAndPeers_WithPeerResources(t *testing.T) {
+ policy := &types.Policy{Rules: []*types.PolicyRule{{
+ Sources: []string{"g1"},
+ SourceResource: types.Resource{ID: "p1", Type: types.ResourceTypePeer},
+ Destinations: []string{"g2"},
+ DestinationResource: types.Resource{ID: "p2", Type: types.ResourceTypePeer},
+ }}}
+ groups, peers := policyGroupsAndPeers(policy)
+ assert.ElementsMatch(t, []string{"g1", "g2"}, groups)
+ assert.ElementsMatch(t, []string{"p1", "p2"}, peers)
+}
+
+func TestPolicyGroupsAndPeers_NilPolicy(t *testing.T) {
+ groups, peers := policyGroupsAndPeers(nil)
+ assert.Nil(t, groups)
+ assert.Nil(t, peers)
+}
+
+func TestPolicyGroupsAndPeers_MultiplePolicies(t *testing.T) {
+ old := &types.Policy{Rules: []*types.PolicyRule{{Sources: []string{"g1"}, Destinations: []string{"g2"}}}}
+ updated := &types.Policy{Rules: []*types.PolicyRule{{Sources: []string{"g3"}, Destinations: []string{"g4"}}}}
+ groups, _ := policyGroupsAndPeers(updated, old)
+ assert.ElementsMatch(t, []string{"g1", "g2", "g3", "g4"}, groups)
+}
+
+func TestPolicyGroupsAndPeers_NonPeerResource(t *testing.T) {
+ policy := &types.Policy{Rules: []*types.PolicyRule{{
+ Sources: []string{"g1"},
+ SourceResource: types.Resource{ID: "domain-1", Type: types.ResourceTypeDomain},
+ Destinations: []string{"g2"},
+ }}}
+ groups, peers := policyGroupsAndPeers(policy)
+ assert.ElementsMatch(t, []string{"g1", "g2"}, groups)
+ assert.Empty(t, peers, "domain resource type should not produce direct peer IDs")
+}
+
+func TestChangeIsEmpty(t *testing.T) {
+ assert.True(t, Change{}.isEmpty())
+ assert.False(t, Change{ChangedGroupIDs: []string{"g"}}.isEmpty())
+ assert.False(t, Change{ChangedPeerIDs: []string{"p"}}.isEmpty())
+ assert.False(t, Change{Policies: []*types.Policy{{}}}.isEmpty())
+ assert.False(t, Change{Resources: []*resourceTypes.NetworkResource{{ID: "r"}}}.isEmpty())
+ assert.False(t, Change{Networks: []*networkTypes.Network{{ID: "n"}}}.isEmpty())
+ assert.False(t, Change{PostureCheckIDs: []string{"pc"}}.isEmpty())
+}
+
+func TestPolicyReferencesGroups(t *testing.T) {
+ policy := &types.Policy{Rules: []*types.PolicyRule{{Sources: []string{"g1", "g2"}, Destinations: []string{"g3"}}}}
+
+ assert.True(t, policyReferencesGroups(policy, map[string]struct{}{"g1": {}}))
+ assert.True(t, policyReferencesGroups(policy, map[string]struct{}{"g3": {}}))
+ assert.False(t, policyReferencesGroups(policy, map[string]struct{}{"g4": {}}))
+ assert.False(t, policyReferencesGroups(policy, map[string]struct{}{}))
+}
+
+func TestPolicyReferencesDirectPeers(t *testing.T) {
+ policy := &types.Policy{Rules: []*types.PolicyRule{{
+ SourceResource: types.Resource{Type: types.ResourceTypePeer, ID: "p1"},
+ DestinationResource: types.Resource{Type: types.ResourceTypeHost, ID: "r1"},
+ }}}
+
+ assert.True(t, policyReferencesDirectPeers(policy, map[string]struct{}{"p1": {}}))
+ assert.False(t, policyReferencesDirectPeers(policy, map[string]struct{}{"r1": {}}))
+ assert.False(t, policyReferencesDirectPeers(policy, map[string]struct{}{"p2": {}}))
+}
+
+func TestPolicyReferencesPostureChecks(t *testing.T) {
+ policy := &types.Policy{SourcePostureChecks: []string{"pc1", "pc2"}}
+
+ assert.True(t, policyReferencesPostureChecks(policy, map[string]struct{}{"pc1": {}}))
+ assert.False(t, policyReferencesPostureChecks(policy, map[string]struct{}{"pc3": {}}))
+}
+
+func TestCollectPolicyDirectPeers(t *testing.T) {
+ policy := &types.Policy{Rules: []*types.PolicyRule{{
+ SourceResource: types.Resource{Type: types.ResourceTypePeer, ID: "p1"},
+ DestinationResource: types.Resource{Type: types.ResourceTypePeer, ID: "p2"},
+ }, {
+ DestinationResource: types.Resource{Type: types.ResourceTypeHost, ID: "r1"},
+ }}}
+
+ peerSet := map[string]struct{}{}
+ collectPolicyDirectPeers(policy, peerSet)
+
+ assert.Contains(t, peerSet, "p1")
+ assert.Contains(t, peerSet, "p2")
+ assert.NotContains(t, peerSet, "r1")
+}
+
+func TestCollectPolicySources(t *testing.T) {
+ policy := &types.Policy{Rules: []*types.PolicyRule{{
+ Sources: []string{"g1"},
+ SourceResource: types.Resource{Type: types.ResourceTypePeer, ID: "p1"},
+ Destinations: []string{"g2"},
+ }}}
+
+ groupSet := map[string]struct{}{}
+ peerSet := map[string]struct{}{}
+ collectPolicySources(policy, groupSet, peerSet)
+
+ assert.Contains(t, groupSet, "g1")
+ assert.NotContains(t, groupSet, "g2", "destination groups must not be collected as sources")
+ assert.Contains(t, peerSet, "p1")
+}
diff --git a/management/server/auth/manager.go b/management/server/auth/manager.go
index 27346a604..9498789f2 100644
--- a/management/server/auth/manager.go
+++ b/management/server/auth/manager.go
@@ -12,6 +12,7 @@ import (
"github.com/netbirdio/netbird/shared/auth"
"github.com/netbirdio/netbird/base62"
+ "github.com/netbirdio/netbird/idp/dex"
"github.com/netbirdio/netbird/management/server/store"
"github.com/netbirdio/netbird/management/server/types"
nbjwt "github.com/netbirdio/netbird/shared/auth/jwt"
@@ -74,7 +75,10 @@ func (m *manager) ValidateAndParseToken(ctx context.Context, value string) (auth
}
func (m *manager) EnsureUserAccessByJWTGroups(ctx context.Context, userAuth auth.UserAuth, token *jwt.Token) (auth.UserAuth, error) {
- if userAuth.IsChild || userAuth.IsPAT {
+ // Child accounts and PAT-authenticated requests do not use JWT group access checks.
+ // Embedded-Dex local users also skip them because local password authentication
+ // does not provide external IdP group claims.
+ if userAuth.IsChild || userAuth.IsPAT || dex.IsLocalUserID(userAuth.UserId) {
return userAuth, nil
}
diff --git a/management/server/auth/manager_test.go b/management/server/auth/manager_test.go
index 469737f47..af8a30ef1 100644
--- a/management/server/auth/manager_test.go
+++ b/management/server/auth/manager_test.go
@@ -16,6 +16,7 @@ import (
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
+ "github.com/netbirdio/netbird/idp/dex"
"github.com/netbirdio/netbird/management/server/auth"
"github.com/netbirdio/netbird/management/server/store"
"github.com/netbirdio/netbird/management/server/types"
@@ -206,6 +207,43 @@ func TestAuthManager_EnsureUserAccessByJWTGroups(t *testing.T) {
_, err = manager.EnsureUserAccessByJWTGroups(context.Background(), userAuth, token)
require.Error(t, err, "ensure user access is not in allowed groups")
})
+
+ t.Run("Local embedded-Dex user is exempt from JWT allow-groups", func(t *testing.T) {
+ account.Settings.JWTGroupsEnabled = true
+ account.Settings.JWTGroupsClaimName = "idp-groups"
+ account.Settings.JWTAllowGroups = []string{"not-a-group"}
+ err := store.SaveAccount(context.Background(), account)
+ require.NoError(t, err, "save account failed")
+
+ // Local Dex users have a "local" connector encoded in their user ID.
+ localUserAuth := nbauth.UserAuth{
+ AccountId: account.Id,
+ Domain: domain,
+ UserId: dex.EncodeDexUserID("local-owner", "local"),
+ }
+
+ localUserAuth, err = manager.EnsureUserAccessByJWTGroups(context.Background(), localUserAuth, token)
+ require.NoError(t, err, "local user must not be locked out by JWT allow-groups (issue #5337)")
+ require.Len(t, localUserAuth.Groups, 0, "JWT groups must not be evaluated for local users")
+ })
+
+ t.Run("Federated embedded-Dex user is still subject to JWT allow-groups", func(t *testing.T) {
+ account.Settings.JWTGroupsEnabled = true
+ account.Settings.JWTGroupsClaimName = "idp-groups"
+ account.Settings.JWTAllowGroups = []string{"not-a-group"}
+ err := store.SaveAccount(context.Background(), account)
+ require.NoError(t, err, "save account failed")
+
+ // A federated user (non-"local" connector) must remain restricted.
+ fedUserAuth := nbauth.UserAuth{
+ AccountId: account.Id,
+ Domain: domain,
+ UserId: dex.EncodeDexUserID("entra-user", "entra"),
+ }
+
+ _, err = manager.EnsureUserAccessByJWTGroups(context.Background(), fedUserAuth, token)
+ require.Error(t, err, "federated user must still be restricted by JWT allow-groups")
+ })
}
func TestAuthManager_ValidateAndParseToken(t *testing.T) {
diff --git a/management/server/context/keys.go b/management/server/context/keys.go
index 9697997a8..aa534c5d9 100644
--- a/management/server/context/keys.go
+++ b/management/server/context/keys.go
@@ -1,10 +1,28 @@
package context
-import "github.com/netbirdio/netbird/shared/context"
+import (
+ "context"
+
+ nbcontext "github.com/netbirdio/netbird/shared/context"
+)
const (
- RequestIDKey = context.RequestIDKey
- AccountIDKey = context.AccountIDKey
- UserIDKey = context.UserIDKey
- PeerIDKey = context.PeerIDKey
+ RequestIDKey = nbcontext.RequestIDKey
+ AccountIDKey = nbcontext.AccountIDKey
+ RoleKey = nbcontext.RoleKey
+ UserIDKey = nbcontext.UserIDKey
+ PeerIDKey = nbcontext.PeerIDKey
+ UserAgentKey = nbcontext.UserAgentKey
)
+
+// RoleFromContext returns the role stored in ctx, or empty string and false if absent.
+func RoleFromContext(ctx context.Context) (string, bool) {
+ role, ok := ctx.Value(RoleKey).(string)
+ return role, ok
+}
+
+// WithRole returns a new context carrying the given role.
+func WithRole(ctx context.Context, role string) context.Context {
+ //nolint
+ return context.WithValue(ctx, RoleKey, role)
+}
diff --git a/management/server/dns.go b/management/server/dns.go
index c62fa5185..612c8ecba 100644
--- a/management/server/dns.go
+++ b/management/server/dns.go
@@ -8,6 +8,7 @@ import (
nbdns "github.com/netbirdio/netbird/dns"
"github.com/netbirdio/netbird/management/server/activity"
+ "github.com/netbirdio/netbird/management/server/affectedpeers"
"github.com/netbirdio/netbird/management/server/permissions/modules"
"github.com/netbirdio/netbird/management/server/permissions/operations"
"github.com/netbirdio/netbird/management/server/store"
@@ -22,7 +23,7 @@ const (
// GetDNSSettings validates a user role and returns the DNS settings for the provided account ID
func (am *DefaultAccountManager) GetDNSSettings(ctx context.Context, accountID string, userID string) (*types.DNSSettings, error) {
- allowed, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Dns, operations.Read)
+ allowed, ctx, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Dns, operations.Read)
if err != nil {
return nil, status.NewPermissionValidationError(err)
}
@@ -39,7 +40,7 @@ func (am *DefaultAccountManager) SaveDNSSettings(ctx context.Context, accountID
return status.Errorf(status.InvalidArgument, "the dns settings provided are nil")
}
- allowed, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Dns, operations.Update)
+ allowed, ctx, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Dns, operations.Update)
if err != nil {
return status.NewPermissionValidationError(err)
}
@@ -47,8 +48,9 @@ func (am *DefaultAccountManager) SaveDNSSettings(ctx context.Context, accountID
return status.NewPermissionDeniedError()
}
- var updateAccountPeers bool
var eventsToStore []func()
+ var snap *affectedpeers.Snapshot
+ var change affectedpeers.Change
err = am.Store.ExecuteInTransaction(ctx, func(transaction store.Store) error {
if err = validateDNSSettings(ctx, transaction, accountID, dnsSettingsToSave); err != nil {
@@ -63,11 +65,6 @@ func (am *DefaultAccountManager) SaveDNSSettings(ctx context.Context, accountID
addedGroups := util.Difference(dnsSettingsToSave.DisabledManagementGroups, oldSettings.DisabledManagementGroups)
removedGroups := util.Difference(oldSettings.DisabledManagementGroups, dnsSettingsToSave.DisabledManagementGroups)
- updateAccountPeers, err = areDNSSettingChangesAffectPeers(ctx, transaction, accountID, addedGroups, removedGroups)
- if err != nil {
- return err
- }
-
events := am.prepareDNSSettingsEvents(ctx, transaction, accountID, userID, addedGroups, removedGroups)
eventsToStore = append(eventsToStore, events...)
@@ -75,6 +72,11 @@ func (am *DefaultAccountManager) SaveDNSSettings(ctx context.Context, accountID
return err
}
+ change = affectedpeers.Change{DistributionGroupIDs: slices.Concat(addedGroups, removedGroups)}
+ if snap, err = affectedpeers.Load(ctx, transaction, accountID, change); err != nil {
+ return err
+ }
+
return transaction.IncrementNetworkSerial(ctx, accountID)
})
if err != nil {
@@ -85,9 +87,7 @@ func (am *DefaultAccountManager) SaveDNSSettings(ctx context.Context, accountID
storeEvent()
}
- if updateAccountPeers {
- am.UpdateAccountPeers(ctx, accountID, types.UpdateReason{Resource: types.UpdateResourceDNSSettings, Operation: types.UpdateOperationUpdate})
- }
+ am.ExpandAndUpdateAffected(ctx, accountID, snap, change)
return nil
}
@@ -133,20 +133,6 @@ func (am *DefaultAccountManager) prepareDNSSettingsEvents(ctx context.Context, t
return eventsToStore
}
-// areDNSSettingChangesAffectPeers checks if the DNS settings changes affect any peers.
-func areDNSSettingChangesAffectPeers(ctx context.Context, transaction store.Store, accountID string, addedGroups, removedGroups []string) (bool, error) {
- hasPeers, err := anyGroupHasPeersOrResources(ctx, transaction, accountID, addedGroups)
- if err != nil {
- return false, err
- }
-
- if hasPeers {
- return true, nil
- }
-
- return anyGroupHasPeersOrResources(ctx, transaction, accountID, removedGroups)
-}
-
// validateDNSSettings validates the DNS settings.
func validateDNSSettings(ctx context.Context, transaction store.Store, accountID string, settings *types.DNSSettings) error {
if len(settings.DisabledManagementGroups) == 0 {
diff --git a/management/server/dns_test.go b/management/server/dns_test.go
index c443223c6..8917902d9 100644
--- a/management/server/dns_test.go
+++ b/management/server/dns_test.go
@@ -298,11 +298,11 @@ func initTestDNSAccount(t *testing.T, am *DefaultAccountManager) (*types.Account
return nil, err
}
- savedPeer1, _, _, err := am.AddPeer(context.Background(), "", "", dnsAdminUserID, peer1, false)
+ savedPeer1, _, _, _, err := am.AddPeer(context.Background(), "", "", dnsAdminUserID, peer1, false)
if err != nil {
return nil, err
}
- _, _, _, err = am.AddPeer(context.Background(), "", "", dnsAdminUserID, peer2, false)
+ _, _, _, _, err = am.AddPeer(context.Background(), "", "", dnsAdminUserID, peer2, false)
if err != nil {
return nil, err
}
diff --git a/management/server/event.go b/management/server/event.go
index d26c569ae..4211f2dda 100644
--- a/management/server/event.go
+++ b/management/server/event.go
@@ -23,7 +23,7 @@ func isEnabled() bool {
// GetEvents returns a list of activity events of an account
func (am *DefaultAccountManager) GetEvents(ctx context.Context, accountID, userID string) ([]*activity.Event, error) {
- allowed, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Events, operations.Read)
+ allowed, ctx, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Events, operations.Read)
if err != nil {
return nil, status.NewPermissionValidationError(err)
}
diff --git a/management/server/group.go b/management/server/group.go
index dd1068ee1..95ca85733 100644
--- a/management/server/group.go
+++ b/management/server/group.go
@@ -11,6 +11,7 @@ import (
nbdns "github.com/netbirdio/netbird/dns"
"github.com/netbirdio/netbird/management/server/activity"
+ "github.com/netbirdio/netbird/management/server/affectedpeers"
routerTypes "github.com/netbirdio/netbird/management/server/networks/routers/types"
"github.com/netbirdio/netbird/management/server/permissions/modules"
"github.com/netbirdio/netbird/management/server/permissions/operations"
@@ -32,7 +33,7 @@ func (e *GroupLinkError) Error() string {
// CheckGroupPermissions validates if a user has the necessary permissions to view groups
func (am *DefaultAccountManager) CheckGroupPermissions(ctx context.Context, accountID, userID string) error {
- allowed, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Groups, operations.Read)
+ allowed, _, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Groups, operations.Read)
if err != nil {
return err
}
@@ -70,7 +71,7 @@ func (am *DefaultAccountManager) GetGroupByName(ctx context.Context, groupName,
// CreateGroup object of the peers
func (am *DefaultAccountManager) CreateGroup(ctx context.Context, accountID, userID string, newGroup *types.Group) error {
- allowed, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Groups, operations.Create)
+ allowed, ctx, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Groups, operations.Create)
if err != nil {
return status.NewPermissionValidationError(err)
}
@@ -79,7 +80,8 @@ func (am *DefaultAccountManager) CreateGroup(ctx context.Context, accountID, use
}
var eventsToStore []func()
- var updateAccountPeers bool
+ var snap *affectedpeers.Snapshot
+ change := affectedpeers.Change{ChangedGroupIDs: []string{newGroup.ID}}
err = am.Store.ExecuteInTransaction(ctx, func(transaction store.Store) error {
if err = validateNewGroup(ctx, transaction, accountID, newGroup); err != nil {
@@ -91,11 +93,6 @@ func (am *DefaultAccountManager) CreateGroup(ctx context.Context, accountID, use
events := am.prepareGroupEvents(ctx, transaction, accountID, userID, newGroup)
eventsToStore = append(eventsToStore, events...)
- updateAccountPeers, err = areGroupChangesAffectPeers(ctx, transaction, accountID, []string{newGroup.ID})
- if err != nil {
- return err
- }
-
seq, err := transaction.AllocateAccountSeqID(ctx, accountID, types.AccountSeqEntityGroup)
if err != nil {
return status.Errorf(status.Internal, "failed to allocate group seq id: %v", err)
@@ -112,6 +109,11 @@ func (am *DefaultAccountManager) CreateGroup(ctx context.Context, accountID, use
}
}
+ snap, err = affectedpeers.Load(ctx, transaction, accountID, change)
+ if err != nil {
+ return err
+ }
+
return transaction.IncrementNetworkSerial(ctx, accountID)
})
if err != nil {
@@ -122,16 +124,14 @@ func (am *DefaultAccountManager) CreateGroup(ctx context.Context, accountID, use
storeEvent()
}
- if updateAccountPeers {
- am.UpdateAccountPeers(ctx, accountID, types.UpdateReason{Resource: types.UpdateResourceGroup, Operation: types.UpdateOperationCreate})
- }
+ am.ExpandAndUpdateAffected(ctx, accountID, snap, change)
return nil
}
// UpdateGroup object of the peers
func (am *DefaultAccountManager) UpdateGroup(ctx context.Context, accountID, userID string, newGroup *types.Group) error {
- allowed, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Groups, operations.Update)
+ allowed, ctx, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Groups, operations.Update)
if err != nil {
return status.NewPermissionValidationError(err)
}
@@ -140,7 +140,8 @@ func (am *DefaultAccountManager) UpdateGroup(ctx context.Context, accountID, use
}
var eventsToStore []func()
- var updateAccountPeers bool
+ var snap *affectedpeers.Snapshot
+ change := affectedpeers.Change{ChangedGroupIDs: []string{newGroup.ID}}
err = am.Store.ExecuteInTransaction(ctx, func(transaction store.Store) error {
if err = validateNewGroup(ctx, transaction, accountID, newGroup); err != nil {
@@ -159,20 +160,7 @@ func (am *DefaultAccountManager) UpdateGroup(ctx context.Context, accountID, use
peersToAdd := util.Difference(newGroup.Peers, oldGroup.Peers)
peersToRemove := util.Difference(oldGroup.Peers, newGroup.Peers)
-
- for _, peerID := range peersToAdd {
- if err := transaction.AddPeerToGroup(ctx, accountID, peerID, newGroup.ID); err != nil {
- return status.Errorf(status.Internal, "failed to add peer %s to group %s: %v", peerID, newGroup.ID, err)
- }
- }
- for _, peerID := range peersToRemove {
- if err := transaction.RemovePeerFromGroup(ctx, peerID, newGroup.ID); err != nil {
- return status.Errorf(status.Internal, "failed to remove peer %s from group %s: %v", peerID, newGroup.ID, err)
- }
- }
-
- updateAccountPeers, err = areGroupChangesAffectPeers(ctx, transaction, accountID, []string{newGroup.ID})
- if err != nil {
+ if err = syncGroupMembership(ctx, transaction, accountID, newGroup.ID, peersToAdd, peersToRemove); err != nil {
return err
}
@@ -186,6 +174,17 @@ func (am *DefaultAccountManager) UpdateGroup(ctx context.Context, accountID, use
return err
}
+ // A membership change does not alter which entities reference the group, so
+ // the dependency walk runs once against the post-change snapshot. The new
+ // members are already in the snapshot's index; the removed members are
+ // carried separately and folded in only when the group is linked.
+ if len(peersToRemove) > 0 {
+ change.RemovedPeersByGroup = map[string][]string{newGroup.ID: peersToRemove}
+ }
+ if snap, err = affectedpeers.Load(ctx, transaction, accountID, change); err != nil {
+ return err
+ }
+
return transaction.IncrementNetworkSerial(ctx, accountID)
})
if err != nil {
@@ -196,19 +195,32 @@ func (am *DefaultAccountManager) UpdateGroup(ctx context.Context, accountID, use
storeEvent()
}
- if updateAccountPeers {
- am.UpdateAccountPeers(ctx, accountID, types.UpdateReason{Resource: types.UpdateResourceGroup, Operation: types.UpdateOperationUpdate})
- }
+ am.ExpandAndUpdateAffected(ctx, accountID, snap, change)
return nil
}
+// syncGroupMembership applies the peer membership delta for a group within a transaction.
+func syncGroupMembership(ctx context.Context, transaction store.Store, accountID, groupID string, peersToAdd, peersToRemove []string) error {
+ for _, peerID := range peersToAdd {
+ if err := transaction.AddPeerToGroup(ctx, accountID, peerID, groupID); err != nil {
+ return status.Errorf(status.Internal, "failed to add peer %s to group %s: %v", peerID, groupID, err)
+ }
+ }
+ for _, peerID := range peersToRemove {
+ if err := transaction.RemovePeerFromGroup(ctx, peerID, groupID); err != nil {
+ return status.Errorf(status.Internal, "failed to remove peer %s from group %s: %v", peerID, groupID, err)
+ }
+ }
+ return nil
+}
+
// CreateGroups adds new groups to the account.
// Note: This function does not acquire the global lock.
// It is the caller's responsibility to ensure proper locking is in place before invoking this method.
// This method will not create group peer membership relations. Use AddPeerToGroup or RemovePeerFromGroup methods for that.
func (am *DefaultAccountManager) CreateGroups(ctx context.Context, accountID, userID string, groups []*types.Group) error {
- allowed, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Groups, operations.Create)
+ allowed, ctx, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Groups, operations.Create)
if err != nil {
return status.NewPermissionValidationError(err)
}
@@ -217,11 +229,14 @@ func (am *DefaultAccountManager) CreateGroups(ctx context.Context, accountID, us
}
var eventsToStore []func()
- var updateAccountPeers bool
+ var snaps []*affectedpeers.Snapshot
+ var changes []affectedpeers.Change
var globalErr error
- groupIDs := make([]string, 0, len(groups))
+ createdCount := 0
for _, newGroup := range groups {
+ change := affectedpeers.Change{ChangedGroupIDs: []string{newGroup.ID}}
+ var snap *affectedpeers.Snapshot
err = am.Store.ExecuteInTransaction(ctx, func(transaction store.Store) error {
if err = validateNewGroup(ctx, transaction, accountID, newGroup); err != nil {
return err
@@ -244,35 +259,31 @@ func (am *DefaultAccountManager) CreateGroups(ctx context.Context, accountID, us
return err
}
- groupIDs = append(groupIDs, newGroup.ID)
-
events := am.prepareGroupEvents(ctx, transaction, accountID, userID, newGroup)
eventsToStore = append(eventsToStore, events...)
- return nil
+ snap, err = affectedpeers.Load(ctx, transaction, accountID, change)
+ return err
})
if err != nil {
log.WithContext(ctx).Errorf("failed to update group %s: %v", newGroup.ID, err)
- if len(groupIDs) == 1 {
+ if createdCount == 0 {
return err
}
globalErr = errors.Join(globalErr, err)
// continue updating other groups
+ continue
}
- }
-
- updateAccountPeers, err = areGroupChangesAffectPeers(ctx, am.Store, accountID, groupIDs)
- if err != nil {
- return err
+ createdCount++
+ snaps = append(snaps, snap)
+ changes = append(changes, change)
}
for _, storeEvent := range eventsToStore {
storeEvent()
}
- if updateAccountPeers {
- am.UpdateAccountPeers(ctx, accountID, types.UpdateReason{Resource: types.UpdateResourceGroup, Operation: types.UpdateOperationCreate})
- }
+ go am.dispatchAffected(ctx, accountID, snaps, changes)
return globalErr
}
@@ -282,7 +293,7 @@ func (am *DefaultAccountManager) CreateGroups(ctx context.Context, accountID, us
// It is the caller's responsibility to ensure proper locking is in place before invoking this method.
// This method will not create group peer membership relations. Use AddPeerToGroup or RemovePeerFromGroup methods for that.
func (am *DefaultAccountManager) UpdateGroups(ctx context.Context, accountID, userID string, groups []*types.Group) error {
- allowed, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Groups, operations.Update)
+ allowed, ctx, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Groups, operations.Update)
if err != nil {
return status.NewPermissionValidationError(err)
}
@@ -291,12 +302,13 @@ func (am *DefaultAccountManager) UpdateGroups(ctx context.Context, accountID, us
}
var eventsToStore []func()
- var updateAccountPeers bool
+ var snaps []*affectedpeers.Snapshot
+ var changes []affectedpeers.Change
var globalErr error
- groupIDs := make([]string, 0, len(groups))
for _, newGroup := range groups {
- events, err := am.updateSingleGroup(ctx, accountID, userID, newGroup)
+ change := affectedpeers.Change{ChangedGroupIDs: []string{newGroup.ID}}
+ events, snap, err := am.updateSingleGroup(ctx, accountID, userID, newGroup, change)
if err != nil {
log.WithContext(ctx).Errorf("failed to update group %s: %v", newGroup.ID, err)
if len(groups) == 1 {
@@ -306,27 +318,22 @@ func (am *DefaultAccountManager) UpdateGroups(ctx context.Context, accountID, us
continue
}
eventsToStore = append(eventsToStore, events...)
- groupIDs = append(groupIDs, newGroup.ID)
- }
-
- updateAccountPeers, err = areGroupChangesAffectPeers(ctx, am.Store, accountID, groupIDs)
- if err != nil {
- return err
+ snaps = append(snaps, snap)
+ changes = append(changes, change)
}
for _, storeEvent := range eventsToStore {
storeEvent()
}
- if updateAccountPeers {
- am.UpdateAccountPeers(ctx, accountID, types.UpdateReason{Resource: types.UpdateResourceGroup, Operation: types.UpdateOperationUpdate})
- }
+ go am.dispatchAffected(ctx, accountID, snaps, changes)
return globalErr
}
-func (am *DefaultAccountManager) updateSingleGroup(ctx context.Context, accountID, userID string, newGroup *types.Group) ([]func(), error) {
+func (am *DefaultAccountManager) updateSingleGroup(ctx context.Context, accountID, userID string, newGroup *types.Group, change affectedpeers.Change) ([]func(), *affectedpeers.Snapshot, error) {
var events []func()
+ var snap *affectedpeers.Snapshot
err := am.Store.ExecuteInTransaction(ctx, func(transaction store.Store) error {
if err := validateNewGroup(ctx, transaction, accountID, newGroup); err != nil {
return err
@@ -353,9 +360,11 @@ func (am *DefaultAccountManager) updateSingleGroup(ctx context.Context, accountI
}
events = am.prepareGroupEvents(ctx, transaction, accountID, userID, newGroup)
- return nil
+
+ snap, err = affectedpeers.Load(ctx, transaction, accountID, change)
+ return err
})
- return events, err
+ return events, snap, err
}
// prepareGroupEvents prepares a list of event functions to be stored.
@@ -447,7 +456,7 @@ func (am *DefaultAccountManager) DeleteGroup(ctx context.Context, accountID, use
// If an error occurs while deleting a group, the function skips it and continues deleting other groups.
// Errors are collected and returned at the end.
func (am *DefaultAccountManager) DeleteGroups(ctx context.Context, accountID, userID string, groupIDs []string) error {
- allowed, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Groups, operations.Delete)
+ allowed, ctx, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Groups, operations.Delete)
if err != nil {
return status.NewPermissionValidationError(err)
}
@@ -458,6 +467,8 @@ func (am *DefaultAccountManager) DeleteGroups(ctx context.Context, accountID, us
var allErrors error
var groupIDsToDelete []string
var deletedGroups []*types.Group
+ var snap *affectedpeers.Snapshot
+ var change affectedpeers.Change
extraSettings, err := am.settingsManager.GetExtraSettings(ctx, accountID)
if err != nil {
@@ -465,26 +476,23 @@ func (am *DefaultAccountManager) DeleteGroups(ctx context.Context, accountID, us
}
err = am.Store.ExecuteInTransaction(ctx, func(transaction store.Store) error {
- for _, groupID := range groupIDs {
- group, err := transaction.GetGroupByID(ctx, store.LockingStrengthNone, accountID, groupID)
- if err != nil {
- allErrors = errors.Join(allErrors, err)
- continue
- }
-
- if err = validateDeleteGroup(ctx, transaction, group, userID, extraSettings.FlowGroups); err != nil {
- allErrors = errors.Join(allErrors, err)
- continue
- }
-
- groupIDsToDelete = append(groupIDsToDelete, groupID)
- deletedGroups = append(deletedGroups, group)
+ deletedGroups, allErrors = collectDeletableGroups(ctx, transaction, accountID, userID, groupIDs, extraSettings.FlowGroups)
+ for _, group := range deletedGroups {
+ groupIDsToDelete = append(groupIDsToDelete, group.ID)
}
if len(groupIDsToDelete) == 0 {
return allErrors
}
+ // Delete: compute affected peers from the PRE-delete state. The groups,
+ // their members and the entities referencing them still exist, so a plain
+ // Load+Expand captures everyone — no removed-peer folding needed.
+ change = affectedpeers.Change{ChangedGroupIDs: groupIDsToDelete}
+ if snap, err = affectedpeers.Load(ctx, transaction, accountID, change); err != nil {
+ return err
+ }
+
if err = transaction.DeleteGroups(ctx, accountID, groupIDsToDelete); err != nil {
return err
}
@@ -503,25 +511,47 @@ func (am *DefaultAccountManager) DeleteGroups(ctx context.Context, accountID, us
am.StoreEvent(ctx, userID, group.ID, accountID, activity.GroupDeleted, group.EventMeta())
}
+ am.ExpandAndUpdateAffected(ctx, accountID, snap, change)
+
return allErrors
}
+// collectDeletableGroups loads and validates each group for deletion, returning
+// the groups that may be deleted and the joined validation errors for the rest.
+func collectDeletableGroups(ctx context.Context, transaction store.Store, accountID, userID string, groupIDs, flowGroups []string) ([]*types.Group, error) {
+ var deletable []*types.Group
+ var allErrors error
+ for _, groupID := range groupIDs {
+ group, err := transaction.GetGroupByID(ctx, store.LockingStrengthNone, accountID, groupID)
+ if err != nil {
+ allErrors = errors.Join(allErrors, err)
+ continue
+ }
+ if err = validateDeleteGroup(ctx, transaction, group, userID, flowGroups); err != nil {
+ allErrors = errors.Join(allErrors, err)
+ continue
+ }
+ deletable = append(deletable, group)
+ }
+ return deletable, allErrors
+}
+
// GroupAddPeer appends peer to the group
func (am *DefaultAccountManager) GroupAddPeer(ctx context.Context, accountID, groupID, peerID string) error {
- var updateAccountPeers bool
- var err error
+ var snap *affectedpeers.Snapshot
+ change := affectedpeers.Change{ChangedGroupIDs: []string{groupID}}
- err = am.Store.ExecuteInTransaction(ctx, func(transaction store.Store) error {
- updateAccountPeers, err = areGroupChangesAffectPeers(ctx, transaction, accountID, []string{groupID})
- if err != nil {
+ err := am.Store.ExecuteInTransaction(ctx, func(transaction store.Store) error {
+ if err := transaction.AddPeerToGroup(ctx, accountID, peerID, groupID); err != nil {
return err
}
- if err = transaction.AddPeerToGroup(ctx, accountID, peerID, groupID); err != nil {
+ if err := am.reconcileIPv6ForGroupChanges(ctx, transaction, accountID, []string{groupID}); err != nil {
return err
}
- if err = am.reconcileIPv6ForGroupChanges(ctx, transaction, accountID, []string{groupID}); err != nil {
+ var err error
+ if snap, err = affectedpeers.Load(ctx, transaction, accountID, change); err != nil {
return err
}
@@ -531,9 +561,7 @@ func (am *DefaultAccountManager) GroupAddPeer(ctx context.Context, accountID, gr
return err
}
- if updateAccountPeers {
- am.UpdateAccountPeers(ctx, accountID, types.UpdateReason{Resource: types.UpdateResourceGroup, Operation: types.UpdateOperationUpdate})
- }
+ am.ExpandAndUpdateAffected(ctx, accountID, snap, change)
return nil
}
@@ -541,8 +569,9 @@ func (am *DefaultAccountManager) GroupAddPeer(ctx context.Context, accountID, gr
// GroupAddResource appends resource to the group
func (am *DefaultAccountManager) GroupAddResource(ctx context.Context, accountID, groupID string, resource types.Resource) error {
var group *types.Group
- var updateAccountPeers bool
+ var snap *affectedpeers.Snapshot
var err error
+ change := affectedpeers.Change{ChangedGroupIDs: []string{groupID}}
err = am.Store.ExecuteInTransaction(ctx, func(transaction store.Store) error {
group, err = transaction.GetGroupByID(context.Background(), store.LockingStrengthUpdate, accountID, groupID)
@@ -554,12 +583,11 @@ func (am *DefaultAccountManager) GroupAddResource(ctx context.Context, accountID
return nil
}
- updateAccountPeers, err = areGroupChangesAffectPeers(ctx, transaction, accountID, []string{groupID})
- if err != nil {
+ if err = transaction.UpdateGroup(ctx, group); err != nil {
return err
}
- if err = transaction.UpdateGroup(ctx, group); err != nil {
+ if snap, err = affectedpeers.Load(ctx, transaction, accountID, change); err != nil {
return err
}
@@ -569,29 +597,32 @@ func (am *DefaultAccountManager) GroupAddResource(ctx context.Context, accountID
return err
}
- if updateAccountPeers {
- am.UpdateAccountPeers(ctx, accountID, types.UpdateReason{Resource: types.UpdateResourceGroup, Operation: types.UpdateOperationUpdate})
- }
+ am.ExpandAndUpdateAffected(ctx, accountID, snap, change)
return nil
}
// GroupDeletePeer removes peer from the group
func (am *DefaultAccountManager) GroupDeletePeer(ctx context.Context, accountID, groupID, peerID string) error {
- var updateAccountPeers bool
- var err error
+ var snap *affectedpeers.Snapshot
+ change := affectedpeers.Change{
+ ChangedGroupIDs: []string{groupID},
+ RemovedPeersByGroup: map[string][]string{groupID: {peerID}},
+ }
- err = am.Store.ExecuteInTransaction(ctx, func(transaction store.Store) error {
- updateAccountPeers, err = areGroupChangesAffectPeers(ctx, transaction, accountID, []string{groupID})
- if err != nil {
+ err := am.Store.ExecuteInTransaction(ctx, func(transaction store.Store) error {
+ if err := transaction.RemovePeerFromGroup(ctx, peerID, groupID); err != nil {
return err
}
- if err = transaction.RemovePeerFromGroup(ctx, peerID, groupID); err != nil {
+ if err := am.reconcileIPv6ForGroupChanges(ctx, transaction, accountID, []string{groupID}); err != nil {
return err
}
- if err = am.reconcileIPv6ForGroupChanges(ctx, transaction, accountID, []string{groupID}); err != nil {
+ // The removed peer is carried in change.RemovedPeersByGroup and folded in
+ // only when the group is linked, so loading post-removal is correct.
+ var err error
+ if snap, err = affectedpeers.Load(ctx, transaction, accountID, change); err != nil {
return err
}
@@ -601,9 +632,7 @@ func (am *DefaultAccountManager) GroupDeletePeer(ctx context.Context, accountID,
return err
}
- if updateAccountPeers {
- am.UpdateAccountPeers(ctx, accountID, types.UpdateReason{Resource: types.UpdateResourceGroup, Operation: types.UpdateOperationUpdate})
- }
+ am.ExpandAndUpdateAffected(ctx, accountID, snap, change)
return nil
}
@@ -611,8 +640,9 @@ func (am *DefaultAccountManager) GroupDeletePeer(ctx context.Context, accountID,
// GroupDeleteResource removes resource from the group
func (am *DefaultAccountManager) GroupDeleteResource(ctx context.Context, accountID, groupID string, resource types.Resource) error {
var group *types.Group
- var updateAccountPeers bool
+ var snap *affectedpeers.Snapshot
var err error
+ change := affectedpeers.Change{ChangedGroupIDs: []string{groupID}}
err = am.Store.ExecuteInTransaction(ctx, func(transaction store.Store) error {
group, err = transaction.GetGroupByID(context.Background(), store.LockingStrengthUpdate, accountID, groupID)
@@ -624,8 +654,9 @@ func (am *DefaultAccountManager) GroupDeleteResource(ctx context.Context, accoun
return nil
}
- updateAccountPeers, err = areGroupChangesAffectPeers(ctx, transaction, accountID, []string{groupID})
- if err != nil {
+ // Load before persisting the removal, so the snapshot still maps the group
+ // to the resource and the bridge can reach its routing peers.
+ if snap, err = affectedpeers.Load(ctx, transaction, accountID, change); err != nil {
return err
}
@@ -639,9 +670,7 @@ func (am *DefaultAccountManager) GroupDeleteResource(ctx context.Context, accoun
return err
}
- if updateAccountPeers {
- am.UpdateAccountPeers(ctx, accountID, types.UpdateReason{Resource: types.UpdateResourceGroup, Operation: types.UpdateOperationUpdate})
- }
+ am.ExpandAndUpdateAffected(ctx, accountID, snap, change)
return nil
}
@@ -852,49 +881,103 @@ func isGroupLinkedToNetworkRouter(ctx context.Context, transaction store.Store,
}
// areGroupChangesAffectPeers checks if any changes to the specified groups will affect peers.
+// It fetches each collection once and checks all groupIDs against them in memory.
func areGroupChangesAffectPeers(ctx context.Context, transaction store.Store, accountID string, groupIDs []string) (bool, error) {
if len(groupIDs) == 0 {
return false, nil
}
+ groupSet := make(map[string]struct{}, len(groupIDs))
+ for _, id := range groupIDs {
+ groupSet[id] = struct{}{}
+ }
+
+ if affected, err := dnsSettingsReferenceGroups(ctx, transaction, accountID, groupSet); affected || err != nil {
+ return affected, err
+ }
+ if affected, err := nameServersReferenceGroups(ctx, transaction, accountID, groupSet); affected || err != nil {
+ return affected, err
+ }
+ if affected, err := policiesReferenceGroups(ctx, transaction, accountID, groupSet); affected || err != nil {
+ return affected, err
+ }
+ if affected, err := routesReferenceGroups(ctx, transaction, accountID, groupSet); affected || err != nil {
+ return affected, err
+ }
+ if affected, err := networkRoutersReferenceGroups(ctx, transaction, accountID, groupSet); affected || err != nil {
+ return affected, err
+ }
+
+ return false, nil
+}
+
+func dnsSettingsReferenceGroups(ctx context.Context, transaction store.Store, accountID string, groupSet map[string]struct{}) (bool, error) {
dnsSettings, err := transaction.GetAccountDNSSettings(ctx, store.LockingStrengthNone, accountID)
if err != nil {
return false, err
}
-
- for _, groupID := range groupIDs {
- if slices.Contains(dnsSettings.DisabledManagementGroups, groupID) {
- return true, nil
- }
- if linked, _ := isGroupLinkedToDns(ctx, transaction, accountID, groupID); linked {
- return true, nil
- }
- if linked, _ := isGroupLinkedToPolicy(ctx, transaction, accountID, groupID); linked {
- return true, nil
- }
- if linked, _ := isGroupLinkedToRoute(ctx, transaction, accountID, groupID); linked {
- return true, nil
- }
- if linked, _ := isGroupLinkedToNetworkRouter(ctx, transaction, accountID, groupID); linked {
- return true, nil
- }
- }
-
- return false, nil
+ return anyInSet(dnsSettings.DisabledManagementGroups, groupSet), nil
}
-// anyGroupHasPeersOrResources checks if any of the given groups in the account have peers or resources.
-func anyGroupHasPeersOrResources(ctx context.Context, transaction store.Store, accountID string, groupIDs []string) (bool, error) {
- groups, err := transaction.GetGroupsByIDs(ctx, store.LockingStrengthNone, accountID, groupIDs)
+func nameServersReferenceGroups(ctx context.Context, transaction store.Store, accountID string, groupSet map[string]struct{}) (bool, error) {
+ nameServerGroups, err := transaction.GetAccountNameServerGroups(ctx, store.LockingStrengthNone, accountID)
if err != nil {
return false, err
}
-
- for _, group := range groups {
- if group.HasPeers() || group.HasResources() {
+ for _, ns := range nameServerGroups {
+ if anyInSet(ns.Groups, groupSet) {
return true, nil
}
}
-
return false, nil
}
+
+func policiesReferenceGroups(ctx context.Context, transaction store.Store, accountID string, groupSet map[string]struct{}) (bool, error) {
+ policies, err := transaction.GetAccountPolicies(ctx, store.LockingStrengthNone, accountID)
+ if err != nil {
+ return false, err
+ }
+ for _, policy := range policies {
+ for _, rule := range policy.Rules {
+ if anyInSet(rule.Sources, groupSet) || anyInSet(rule.Destinations, groupSet) {
+ return true, nil
+ }
+ }
+ }
+ return false, nil
+}
+
+func routesReferenceGroups(ctx context.Context, transaction store.Store, accountID string, groupSet map[string]struct{}) (bool, error) {
+ routes, err := transaction.GetAccountRoutes(ctx, store.LockingStrengthNone, accountID)
+ if err != nil {
+ return false, err
+ }
+ for _, r := range routes {
+ if anyInSet(r.Groups, groupSet) || anyInSet(r.PeerGroups, groupSet) || anyInSet(r.AccessControlGroups, groupSet) {
+ return true, nil
+ }
+ }
+ return false, nil
+}
+
+func networkRoutersReferenceGroups(ctx context.Context, transaction store.Store, accountID string, groupSet map[string]struct{}) (bool, error) {
+ routers, err := transaction.GetNetworkRoutersByAccountID(ctx, store.LockingStrengthNone, accountID)
+ if err != nil {
+ return false, err
+ }
+ for _, router := range routers {
+ if anyInSet(router.PeerGroups, groupSet) {
+ return true, nil
+ }
+ }
+ return false, nil
+}
+
+func anyInSet(ids []string, set map[string]struct{}) bool {
+ for _, id := range ids {
+ if _, ok := set[id]; ok {
+ return true
+ }
+ }
+ return false
+}
diff --git a/management/server/group_ipv6_test.go b/management/server/group_ipv6_test.go
index e4603c879..dfb436060 100644
--- a/management/server/group_ipv6_test.go
+++ b/management/server/group_ipv6_test.go
@@ -55,7 +55,7 @@ func TestGroupIPv6Assignment(t *testing.T) {
key, err := wgtypes.GeneratePrivateKey()
require.NoError(t, err)
- peer, _, _, err := am.AddPeer(ctx, "", setupKey.Key, "", &nbpeer.Peer{
+ peer, _, _, _, err := am.AddPeer(ctx, "", setupKey.Key, "", &nbpeer.Peer{
Key: key.PublicKey().String(),
Meta: nbpeer.PeerSystemMeta{Hostname: "ipv6-test-host"},
}, false)
diff --git a/management/server/groups/manager.go b/management/server/groups/manager.go
index d110ab564..c9a877d6f 100644
--- a/management/server/groups/manager.go
+++ b/management/server/groups/manager.go
@@ -42,7 +42,7 @@ func NewManager(store store.Store, permissionsManager permissions.Manager, accou
}
func (m *managerImpl) GetAllGroups(ctx context.Context, accountID, userID string) ([]*types.Group, error) {
- ok, err := m.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Groups, operations.Read)
+ ok, ctx, err := m.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Groups, operations.Read)
if err != nil {
return nil, err
}
@@ -73,7 +73,7 @@ func (m *managerImpl) GetAllGroupsMap(ctx context.Context, accountID, userID str
}
func (m *managerImpl) AddResourceToGroup(ctx context.Context, accountID, userID, groupID string, resource *types.Resource) error {
- ok, err := m.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Groups, operations.Update)
+ ok, ctx, err := m.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Groups, operations.Update)
if err != nil {
return err
}
diff --git a/management/server/http/handlers/peers/peers_handler.go b/management/server/http/handlers/peers/peers_handler.go
index 91026a374..310f90653 100644
--- a/management/server/http/handlers/peers/peers_handler.go
+++ b/management/server/http/handlers/peers/peers_handler.go
@@ -405,48 +405,48 @@ func (h *Handler) GetAccessiblePeers(w http.ResponseWriter, r *http.Request) {
return
}
- allowed, err := h.permissionsManager.ValidateUserPermissions(r.Context(), accountID, userID, modules.Peers, operations.Read)
+ allowed, ctx, err := h.permissionsManager.ValidateUserPermissions(r.Context(), accountID, userID, modules.Peers, operations.Read)
if err != nil {
- util.WriteError(r.Context(), status.NewPermissionValidationError(err), w)
+ util.WriteError(ctx, status.NewPermissionValidationError(err), w)
return
}
- account, err := h.accountManager.GetAccountByID(r.Context(), accountID, activity.SystemInitiator)
+ account, err := h.accountManager.GetAccountByID(ctx, accountID, activity.SystemInitiator)
if err != nil {
- util.WriteError(r.Context(), err, w)
+ util.WriteError(ctx, err, w)
return
}
if !allowed && !userAuth.IsChild {
if account.Settings.RegularUsersViewBlocked {
- util.WriteJSONObject(r.Context(), w, []api.AccessiblePeer{})
+ util.WriteJSONObject(ctx, w, []api.AccessiblePeer{})
return
}
peer, ok := account.Peers[peerID]
if !ok {
- util.WriteError(r.Context(), status.Errorf(status.NotFound, "peer not found"), w)
+ util.WriteError(ctx, status.Errorf(status.NotFound, "peer not found"), w)
return
}
if peer.UserID != user.Id {
- util.WriteJSONObject(r.Context(), w, []api.AccessiblePeer{})
+ util.WriteJSONObject(ctx, w, []api.AccessiblePeer{})
return
}
}
- validPeers, _, err := h.accountManager.GetValidatedPeers(r.Context(), accountID)
+ validPeers, _, err := h.accountManager.GetValidatedPeers(ctx, accountID)
if err != nil {
- log.WithContext(r.Context()).Errorf("failed to list approved peers: %v", err)
- util.WriteError(r.Context(), fmt.Errorf("internal error"), w)
+ log.WithContext(ctx).Errorf("failed to list approved peers: %v", err)
+ util.WriteError(ctx, fmt.Errorf("internal error"), w)
return
}
dnsDomain := h.networkMapController.GetDNSDomain(account.Settings)
- netMap := account.GetPeerNetworkMapFromComponents(r.Context(), peerID, dns.CustomZone{}, nil, validPeers, account.GetResourcePoliciesMap(), account.GetResourceRoutersMap(), nil, account.GetActiveGroupUsers())
+ netMap := account.GetPeerNetworkMapFromComponents(ctx, peerID, dns.CustomZone{}, nil, validPeers, account.GetResourcePoliciesMap(), account.GetResourceRoutersMap(), nil, account.GetActiveGroupUsers())
- util.WriteJSONObject(r.Context(), w, toAccessiblePeers(netMap, dnsDomain))
+ util.WriteJSONObject(ctx, w, toAccessiblePeers(netMap, dnsDomain))
}
func (h *Handler) CreateTemporaryAccess(w http.ResponseWriter, r *http.Request) {
@@ -479,7 +479,7 @@ func (h *Handler) CreateTemporaryAccess(w http.ResponseWriter, r *http.Request)
return
}
- peer, _, _, err := h.accountManager.AddPeer(r.Context(), userAuth.AccountId, "", userAuth.UserId, newPeer, true)
+ peer, _, _, _, err := h.accountManager.AddPeer(r.Context(), userAuth.AccountId, "", userAuth.UserId, newPeer, true)
if err != nil {
util.WriteError(r.Context(), err, w)
return
diff --git a/management/server/http/handlers/peers/peers_handler_test.go b/management/server/http/handlers/peers/peers_handler_test.go
index 9db095c8d..047213879 100644
--- a/management/server/http/handlers/peers/peers_handler_test.go
+++ b/management/server/http/handlers/peers/peers_handler_test.go
@@ -116,15 +116,15 @@ func initTestMetaData(t *testing.T, peers ...*nbpeer.Peer) *Handler {
ctrl2 := gomock.NewController(t)
permissionsManager := permissions.NewMockManager(ctrl2)
- permissionsManager.EXPECT().ValidateAccountAccess(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).AnyTimes()
+ permissionsManager.EXPECT().ValidateAccountAccess(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return(context.Background(), nil).AnyTimes()
permissionsManager.EXPECT().
ValidateUserPermissions(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Eq(modules.Peers), gomock.Eq(operations.Read)).
- DoAndReturn(func(ctx context.Context, accountID, userID string, module modules.Module, operation operations.Operation) (bool, error) {
+ DoAndReturn(func(ctx context.Context, accountID, userID string, module modules.Module, operation operations.Operation) (bool, context.Context, error) {
user, ok := account.Users[userID]
if !ok {
- return false, fmt.Errorf("user not found")
+ return false, ctx, fmt.Errorf("user not found")
}
- return user.HasAdminPower() || user.IsServiceUser, nil
+ return user.HasAdminPower() || user.IsServiceUser, ctx, nil
}).
AnyTimes()
diff --git a/management/server/http/handlers/policies/geolocation_handler_test.go b/management/server/http/handlers/policies/geolocation_handler_test.go
index 094a36e38..f5723b8fc 100644
--- a/management/server/http/handlers/policies/geolocation_handler_test.go
+++ b/management/server/http/handlers/policies/geolocation_handler_test.go
@@ -51,7 +51,7 @@ func initGeolocationTestData(t *testing.T) *geolocationsHandler {
permissionsManagerMock.
EXPECT().
ValidateUserPermissions(gomock.Any(), gomock.Any(), gomock.Any(), modules.Policies, operations.Read).
- Return(true, nil).
+ Return(true, context.Background(), nil).
AnyTimes()
return &geolocationsHandler{
diff --git a/management/server/identity_provider.go b/management/server/identity_provider.go
index f965f36b8..86bbcd893 100644
--- a/management/server/identity_provider.go
+++ b/management/server/identity_provider.go
@@ -88,7 +88,7 @@ func validateIdentityProviderConfig(ctx context.Context, idpConfig *types.Identi
// GetIdentityProviders returns all identity providers for an account
func (am *DefaultAccountManager) GetIdentityProviders(ctx context.Context, accountID, userID string) ([]*types.IdentityProvider, error) {
- ok, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.IdentityProviders, operations.Read)
+ ok, ctx, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.IdentityProviders, operations.Read)
if err != nil {
return nil, status.NewPermissionValidationError(err)
}
@@ -117,7 +117,7 @@ func (am *DefaultAccountManager) GetIdentityProviders(ctx context.Context, accou
// GetIdentityProvider returns a specific identity provider by ID
func (am *DefaultAccountManager) GetIdentityProvider(ctx context.Context, accountID, idpID, userID string) (*types.IdentityProvider, error) {
- ok, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.IdentityProviders, operations.Read)
+ ok, ctx, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.IdentityProviders, operations.Read)
if err != nil {
return nil, status.NewPermissionValidationError(err)
}
@@ -143,7 +143,7 @@ func (am *DefaultAccountManager) GetIdentityProvider(ctx context.Context, accoun
// CreateIdentityProvider creates a new identity provider
func (am *DefaultAccountManager) CreateIdentityProvider(ctx context.Context, accountID, userID string, idpConfig *types.IdentityProvider) (*types.IdentityProvider, error) {
- ok, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.IdentityProviders, operations.Create)
+ ok, ctx, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.IdentityProviders, operations.Create)
if err != nil {
return nil, status.NewPermissionValidationError(err)
}
@@ -180,7 +180,7 @@ func (am *DefaultAccountManager) CreateIdentityProvider(ctx context.Context, acc
// UpdateIdentityProvider updates an existing identity provider
func (am *DefaultAccountManager) UpdateIdentityProvider(ctx context.Context, accountID, idpID, userID string, idpConfig *types.IdentityProvider) (*types.IdentityProvider, error) {
- ok, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.IdentityProviders, operations.Update)
+ ok, ctx, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.IdentityProviders, operations.Update)
if err != nil {
return nil, status.NewPermissionValidationError(err)
}
@@ -213,7 +213,7 @@ func (am *DefaultAccountManager) UpdateIdentityProvider(ctx context.Context, acc
// DeleteIdentityProvider deletes an identity provider
func (am *DefaultAccountManager) DeleteIdentityProvider(ctx context.Context, accountID, idpID, userID string) error {
- ok, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.IdentityProviders, operations.Delete)
+ ok, ctx, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.IdentityProviders, operations.Delete)
if err != nil {
return status.NewPermissionValidationError(err)
}
diff --git a/management/server/management_proto_test.go b/management/server/management_proto_test.go
index 1b77ea335..45d4ab8c9 100644
--- a/management/server/management_proto_test.go
+++ b/management/server/management_proto_test.go
@@ -728,7 +728,7 @@ func Test_LoginPerformance(t *testing.T) {
}
login := func() error {
- _, _, _, err = am.LoginPeer(context.Background(), peerLogin)
+ _, _, _, _, err = am.LoginPeer(context.Background(), peerLogin)
if err != nil {
t.Logf("failed to login peer: %v", err)
return err
@@ -746,7 +746,7 @@ func Test_LoginPerformance(t *testing.T) {
go func(peerLogin types.PeerLogin, counterStart *int32) {
defer wgPeer.Done()
- _, _, _, err = am.LoginPeer(context.Background(), peerLogin)
+ _, _, _, _, err = am.LoginPeer(context.Background(), peerLogin)
if err != nil {
t.Logf("failed to login peer: %v", err)
return
diff --git a/management/server/mock_server/account_mock.go b/management/server/mock_server/account_mock.go
index 32549a521..f81139f24 100644
--- a/management/server/mock_server/account_mock.go
+++ b/management/server/mock_server/account_mock.go
@@ -15,6 +15,7 @@ import (
"github.com/netbirdio/netbird/management/internals/modules/reverseproxy/service"
"github.com/netbirdio/netbird/management/server/account"
"github.com/netbirdio/netbird/management/server/activity"
+ "github.com/netbirdio/netbird/management/server/affectedpeers"
"github.com/netbirdio/netbird/management/server/idp"
nbpeer "github.com/netbirdio/netbird/management/server/peer"
"github.com/netbirdio/netbird/management/server/posture"
@@ -38,13 +39,13 @@ type MockAccountManager struct {
GetUserFromUserAuthFunc func(ctx context.Context, userAuth auth.UserAuth) (*types.User, error)
ListUsersFunc func(ctx context.Context, accountID string) ([]*types.User, error)
GetPeersFunc func(ctx context.Context, accountID, userID, nameFilter, ipFilter string) ([]*nbpeer.Peer, error)
- MarkPeerConnectedFunc func(ctx context.Context, peerKey string, realIP net.IP, accountID string, sessionStartedAt int64) error
+ MarkPeerConnectedFunc func(ctx context.Context, peerKey string, realIP net.IP, accountID string, sessionStartedAt int64, nmap *types.NetworkMap) error
MarkPeerDisconnectedFunc func(ctx context.Context, peerKey string, accountID string, sessionStartedAt int64) error
SyncAndMarkPeerFunc func(ctx context.Context, accountID string, peerPubKey string, meta nbpeer.PeerSystemMeta, realIP net.IP, syncTime time.Time) (*nbpeer.Peer, *types.NetworkMap, []*posture.Checks, int64, error)
DeletePeerFunc func(ctx context.Context, accountID, peerKey, userID string) error
GetNetworkMapFunc func(ctx context.Context, peerKey string) (*types.NetworkMap, error)
GetPeerNetworkFunc func(ctx context.Context, peerKey string) (*types.Network, error)
- AddPeerFunc func(ctx context.Context, accountID string, setupKey string, userId string, peer *nbpeer.Peer, temporary bool) (*nbpeer.Peer, *types.NetworkMap, []*posture.Checks, error)
+ AddPeerFunc func(ctx context.Context, accountID string, setupKey string, userId string, peer *nbpeer.Peer, temporary bool) (*nbpeer.Peer, *types.Network, []*posture.Checks, bool, error)
GetGroupFunc func(ctx context.Context, accountID, groupID, userID string) (*types.Group, error)
GetAllGroupsFunc func(ctx context.Context, accountID, userID string) ([]*types.Group, error)
GetGroupByNameFunc func(ctx context.Context, groupName, accountID, userID string) (*types.Group, error)
@@ -97,7 +98,7 @@ type MockAccountManager struct {
SaveDNSSettingsFunc func(ctx context.Context, accountID, userID string, dnsSettingsToSave *types.DNSSettings) error
GetPeerFunc func(ctx context.Context, accountID, peerID, userID string) (*nbpeer.Peer, error)
UpdateAccountSettingsFunc func(ctx context.Context, accountID, userID string, newSettings *types.Settings) (*types.Settings, error)
- LoginPeerFunc func(ctx context.Context, login types.PeerLogin) (*nbpeer.Peer, *types.NetworkMap, []*posture.Checks, error)
+ LoginPeerFunc func(ctx context.Context, login types.PeerLogin) (*nbpeer.Peer, *types.Network, []*posture.Checks, bool, error)
ExtendPeerSessionFunc func(ctx context.Context, peerPubKey, userID string) (time.Time, error)
SyncPeerFunc func(ctx context.Context, sync types.PeerSync, accountID string) (*nbpeer.Peer, *types.NetworkMap, []*posture.Checks, int64, error)
InviteUserFunc func(ctx context.Context, accountID string, initiatorUserID string, targetUserEmail string) error
@@ -132,6 +133,7 @@ type MockAccountManager struct {
AllowSyncFunc func(string, uint64) bool
UpdateAccountPeersFunc func(ctx context.Context, accountID string, reason types.UpdateReason)
+ ExpandAndUpdateAffectedFunc func(ctx context.Context, accountID string, snap *affectedpeers.Snapshot, change affectedpeers.Change)
BufferUpdateAccountPeersFunc func(ctx context.Context, accountID string, reason types.UpdateReason)
RecalculateNetworkMapCacheFunc func(ctx context.Context, accountId string) error
@@ -209,6 +211,12 @@ func (am *MockAccountManager) UpdateAccountPeers(ctx context.Context, accountID
}
}
+func (am *MockAccountManager) ExpandAndUpdateAffected(ctx context.Context, accountID string, snap *affectedpeers.Snapshot, change affectedpeers.Change) {
+ if am.ExpandAndUpdateAffectedFunc != nil {
+ am.ExpandAndUpdateAffectedFunc(ctx, accountID, snap, change)
+ }
+}
+
func (am *MockAccountManager) BufferUpdateAccountPeers(ctx context.Context, accountID string, reason types.UpdateReason) {
if am.BufferUpdateAccountPeersFunc != nil {
am.BufferUpdateAccountPeersFunc(ctx, accountID, reason)
@@ -337,9 +345,9 @@ func (am *MockAccountManager) GetAccountIDByUserID(ctx context.Context, userAuth
}
// MarkPeerConnected mock implementation of MarkPeerConnected from server.AccountManager interface
-func (am *MockAccountManager) MarkPeerConnected(ctx context.Context, peerKey string, realIP net.IP, accountID string, sessionStartedAt int64) error {
+func (am *MockAccountManager) MarkPeerConnected(ctx context.Context, peerKey string, realIP net.IP, accountID string, sessionStartedAt int64, nmap *types.NetworkMap) error {
if am.MarkPeerConnectedFunc != nil {
- return am.MarkPeerConnectedFunc(ctx, peerKey, realIP, accountID, sessionStartedAt)
+ return am.MarkPeerConnectedFunc(ctx, peerKey, realIP, accountID, sessionStartedAt, nmap)
}
return status.Errorf(codes.Unimplemented, "method MarkPeerConnected is not implemented")
}
@@ -416,11 +424,11 @@ func (am *MockAccountManager) AddPeer(
userId string,
peer *nbpeer.Peer,
temporary bool,
-) (*nbpeer.Peer, *types.NetworkMap, []*posture.Checks, error) {
+) (*nbpeer.Peer, *types.Network, []*posture.Checks, bool, error) {
if am.AddPeerFunc != nil {
return am.AddPeerFunc(ctx, accountID, setupKey, userId, peer, temporary)
}
- return nil, nil, nil, status.Errorf(codes.Unimplemented, "method AddPeer is not implemented")
+ return nil, nil, nil, false, status.Errorf(codes.Unimplemented, "method AddPeer is not implemented")
}
// GetGroupByName mock implementation of GetGroupByName from server.AccountManager interface
@@ -854,11 +862,11 @@ func (am *MockAccountManager) UpdateAccountSettings(ctx context.Context, account
}
// LoginPeer mocks LoginPeer of the AccountManager interface
-func (am *MockAccountManager) LoginPeer(ctx context.Context, login types.PeerLogin) (*nbpeer.Peer, *types.NetworkMap, []*posture.Checks, error) {
+func (am *MockAccountManager) LoginPeer(ctx context.Context, login types.PeerLogin) (*nbpeer.Peer, *types.Network, []*posture.Checks, bool, error) {
if am.LoginPeerFunc != nil {
return am.LoginPeerFunc(ctx, login)
}
- return nil, nil, nil, status.Errorf(codes.Unimplemented, "method LoginPeer is not implemented")
+ return nil, nil, nil, false, status.Errorf(codes.Unimplemented, "method LoginPeer is not implemented")
}
// ExtendPeerSession mocks ExtendPeerSession of the AccountManager interface
diff --git a/management/server/nameserver.go b/management/server/nameserver.go
index 4277d0996..8f12facee 100644
--- a/management/server/nameserver.go
+++ b/management/server/nameserver.go
@@ -4,6 +4,7 @@ import (
"context"
"errors"
"fmt"
+ "slices"
"strings"
"unicode/utf8"
@@ -11,6 +12,7 @@ import (
nbdns "github.com/netbirdio/netbird/dns"
"github.com/netbirdio/netbird/management/server/activity"
+ "github.com/netbirdio/netbird/management/server/affectedpeers"
"github.com/netbirdio/netbird/management/server/permissions/modules"
"github.com/netbirdio/netbird/management/server/permissions/operations"
"github.com/netbirdio/netbird/management/server/store"
@@ -23,7 +25,7 @@ var errInvalidDomainName = errors.New("invalid domain name")
// GetNameServerGroup gets a nameserver group object from account and nameserver group IDs
func (am *DefaultAccountManager) GetNameServerGroup(ctx context.Context, accountID, userID, nsGroupID string) (*nbdns.NameServerGroup, error) {
- allowed, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Nameservers, operations.Read)
+ allowed, ctx, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Nameservers, operations.Read)
if err != nil {
return nil, status.NewPermissionValidationError(err)
}
@@ -36,7 +38,7 @@ func (am *DefaultAccountManager) GetNameServerGroup(ctx context.Context, account
// CreateNameServerGroup creates and saves a new nameserver group
func (am *DefaultAccountManager) CreateNameServerGroup(ctx context.Context, accountID string, name, description string, nameServerList []nbdns.NameServer, groups []string, primary bool, domains []string, enabled bool, userID string, searchDomainEnabled bool) (*nbdns.NameServerGroup, error) {
- allowed, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Nameservers, operations.Create)
+ allowed, ctx, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Nameservers, operations.Create)
if err != nil {
return nil, status.NewPermissionValidationError(err)
}
@@ -57,18 +59,14 @@ func (am *DefaultAccountManager) CreateNameServerGroup(ctx context.Context, acco
SearchDomainsEnabled: searchDomainEnabled,
}
- var updateAccountPeers bool
+ var snap *affectedpeers.Snapshot
+ change := affectedpeers.Change{DistributionGroupIDs: newNSGroup.Groups}
err = am.Store.ExecuteInTransaction(ctx, func(transaction store.Store) error {
if err = validateNameServerGroup(ctx, transaction, accountID, newNSGroup); err != nil {
return err
}
- updateAccountPeers, err = anyGroupHasPeersOrResources(ctx, transaction, accountID, newNSGroup.Groups)
- if err != nil {
- return err
- }
-
seq, err := transaction.AllocateAccountSeqID(ctx, accountID, types.AccountSeqEntityNameserverGroup)
if err != nil {
return err
@@ -79,6 +77,10 @@ func (am *DefaultAccountManager) CreateNameServerGroup(ctx context.Context, acco
return err
}
+ if snap, err = affectedpeers.Load(ctx, transaction, accountID, change); err != nil {
+ return err
+ }
+
return transaction.IncrementNetworkSerial(ctx, accountID)
})
if err != nil {
@@ -87,9 +89,7 @@ func (am *DefaultAccountManager) CreateNameServerGroup(ctx context.Context, acco
am.StoreEvent(ctx, userID, newNSGroup.ID, accountID, activity.NameserverGroupCreated, newNSGroup.EventMeta())
- if updateAccountPeers {
- am.UpdateAccountPeers(ctx, accountID, types.UpdateReason{Resource: types.UpdateResourceNameServerGroup, Operation: types.UpdateOperationCreate})
- }
+ am.ExpandAndUpdateAffected(ctx, accountID, snap, change)
return newNSGroup.Copy(), nil
}
@@ -100,7 +100,7 @@ func (am *DefaultAccountManager) SaveNameServerGroup(ctx context.Context, accoun
return status.Errorf(status.InvalidArgument, "nameserver group provided is nil")
}
- allowed, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Nameservers, operations.Update)
+ allowed, ctx, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Nameservers, operations.Update)
if err != nil {
return status.NewPermissionValidationError(err)
}
@@ -108,7 +108,8 @@ func (am *DefaultAccountManager) SaveNameServerGroup(ctx context.Context, accoun
return status.NewPermissionDeniedError()
}
- var updateAccountPeers bool
+ var snap *affectedpeers.Snapshot
+ var change affectedpeers.Change
err = am.Store.ExecuteInTransaction(ctx, func(transaction store.Store) error {
oldNSGroup, err := transaction.GetNameServerGroupByID(ctx, store.LockingStrengthNone, accountID, nsGroupToSave.ID)
@@ -121,17 +122,17 @@ func (am *DefaultAccountManager) SaveNameServerGroup(ctx context.Context, accoun
return err
}
- updateAccountPeers, err = areNameServerGroupChangesAffectPeers(ctx, transaction, nsGroupToSave, oldNSGroup)
- if err != nil {
- return err
- }
-
nsGroupToSave.AccountSeqID = oldNSGroup.AccountSeqID
if err = transaction.SaveNameServerGroup(ctx, nsGroupToSave); err != nil {
return err
}
+ change = affectedpeers.Change{DistributionGroupIDs: slices.Concat(nsGroupToSave.Groups, oldNSGroup.Groups)}
+ if snap, err = affectedpeers.Load(ctx, transaction, accountID, change); err != nil {
+ return err
+ }
+
return transaction.IncrementNetworkSerial(ctx, accountID)
})
if err != nil {
@@ -140,16 +141,14 @@ func (am *DefaultAccountManager) SaveNameServerGroup(ctx context.Context, accoun
am.StoreEvent(ctx, userID, nsGroupToSave.ID, accountID, activity.NameserverGroupUpdated, nsGroupToSave.EventMeta())
- if updateAccountPeers {
- am.UpdateAccountPeers(ctx, accountID, types.UpdateReason{Resource: types.UpdateResourceNameServerGroup, Operation: types.UpdateOperationUpdate})
- }
+ am.ExpandAndUpdateAffected(ctx, accountID, snap, change)
return nil
}
// DeleteNameServerGroup deletes nameserver group with nsGroupID
func (am *DefaultAccountManager) DeleteNameServerGroup(ctx context.Context, accountID, nsGroupID, userID string) error {
- allowed, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Nameservers, operations.Delete)
+ allowed, ctx, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Nameservers, operations.Delete)
if err != nil {
return status.NewPermissionValidationError(err)
}
@@ -158,7 +157,8 @@ func (am *DefaultAccountManager) DeleteNameServerGroup(ctx context.Context, acco
}
var nsGroup *nbdns.NameServerGroup
- var updateAccountPeers bool
+ var snap *affectedpeers.Snapshot
+ var change affectedpeers.Change
err = am.Store.ExecuteInTransaction(ctx, func(transaction store.Store) error {
nsGroup, err = transaction.GetNameServerGroupByID(ctx, store.LockingStrengthUpdate, accountID, nsGroupID)
@@ -166,8 +166,9 @@ func (am *DefaultAccountManager) DeleteNameServerGroup(ctx context.Context, acco
return err
}
- updateAccountPeers, err = anyGroupHasPeersOrResources(ctx, transaction, accountID, nsGroup.Groups)
- if err != nil {
+ // Load before delete: the post-delete state no longer references the groups.
+ change = affectedpeers.Change{DistributionGroupIDs: nsGroup.Groups}
+ if snap, err = affectedpeers.Load(ctx, transaction, accountID, change); err != nil {
return err
}
@@ -183,16 +184,14 @@ func (am *DefaultAccountManager) DeleteNameServerGroup(ctx context.Context, acco
am.StoreEvent(ctx, userID, nsGroup.ID, accountID, activity.NameserverGroupDeleted, nsGroup.EventMeta())
- if updateAccountPeers {
- am.UpdateAccountPeers(ctx, accountID, types.UpdateReason{Resource: types.UpdateResourceNameServerGroup, Operation: types.UpdateOperationDelete})
- }
+ am.ExpandAndUpdateAffected(ctx, accountID, snap, change)
return nil
}
// ListNameServerGroups returns a list of nameserver groups from account
func (am *DefaultAccountManager) ListNameServerGroups(ctx context.Context, accountID string, userID string) ([]*nbdns.NameServerGroup, error) {
- allowed, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Nameservers, operations.Read)
+ allowed, ctx, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Nameservers, operations.Read)
if err != nil {
return nil, status.NewPermissionValidationError(err)
}
@@ -232,24 +231,6 @@ func validateNameServerGroup(ctx context.Context, transaction store.Store, accou
return validateGroups(nameserverGroup.Groups, groups)
}
-// areNameServerGroupChangesAffectPeers checks if the changes in the nameserver group affect the peers.
-func areNameServerGroupChangesAffectPeers(ctx context.Context, transaction store.Store, newNSGroup, oldNSGroup *nbdns.NameServerGroup) (bool, error) {
- if !newNSGroup.Enabled && !oldNSGroup.Enabled {
- return false, nil
- }
-
- hasPeers, err := anyGroupHasPeersOrResources(ctx, transaction, newNSGroup.AccountID, newNSGroup.Groups)
- if err != nil {
- return false, err
- }
-
- if hasPeers {
- return true, nil
- }
-
- return anyGroupHasPeersOrResources(ctx, transaction, oldNSGroup.AccountID, oldNSGroup.Groups)
-}
-
func validateDomainInput(primary bool, domains []string, searchDomainsEnabled bool) error {
if !primary && len(domains) == 0 {
return status.Errorf(status.InvalidArgument, "nameserver group primary status is false and domains are empty,"+
diff --git a/management/server/nameserver_test.go b/management/server/nameserver_test.go
index b2c8300d6..e13b0bb19 100644
--- a/management/server/nameserver_test.go
+++ b/management/server/nameserver_test.go
@@ -896,11 +896,11 @@ func initTestNSAccount(t *testing.T, am *DefaultAccountManager) (*types.Account,
return nil, err
}
- _, _, _, err = am.AddPeer(context.Background(), "", "", userID, peer1, false)
+ _, _, _, _, err = am.AddPeer(context.Background(), "", "", userID, peer1, false)
if err != nil {
return nil, err
}
- _, _, _, err = am.AddPeer(context.Background(), "", "", userID, peer2, false)
+ _, _, _, _, err = am.AddPeer(context.Background(), "", "", userID, peer2, false)
if err != nil {
return nil, err
}
diff --git a/management/server/networks/manager.go b/management/server/networks/manager.go
index 8da0c594d..a94170f6e 100644
--- a/management/server/networks/manager.go
+++ b/management/server/networks/manager.go
@@ -8,6 +8,7 @@ import (
"github.com/netbirdio/netbird/management/server/account"
"github.com/netbirdio/netbird/management/server/activity"
+ "github.com/netbirdio/netbird/management/server/affectedpeers"
"github.com/netbirdio/netbird/management/server/networks/resources"
"github.com/netbirdio/netbird/management/server/networks/routers"
"github.com/netbirdio/netbird/management/server/networks/types"
@@ -49,7 +50,7 @@ func NewManager(store store.Store, permissionsManager permissions.Manager, resou
}
func (m *managerImpl) GetAllNetworks(ctx context.Context, accountID, userID string) ([]*types.Network, error) {
- ok, err := m.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Networks, operations.Read)
+ ok, ctx, err := m.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Networks, operations.Read)
if err != nil {
return nil, status.NewPermissionValidationError(err)
}
@@ -61,7 +62,7 @@ func (m *managerImpl) GetAllNetworks(ctx context.Context, accountID, userID stri
}
func (m *managerImpl) CreateNetwork(ctx context.Context, userID string, network *types.Network) (*types.Network, error) {
- ok, err := m.permissionsManager.ValidateUserPermissions(ctx, network.AccountID, userID, modules.Networks, operations.Create)
+ ok, ctx, err := m.permissionsManager.ValidateUserPermissions(ctx, network.AccountID, userID, modules.Networks, operations.Create)
if err != nil {
return nil, status.NewPermissionValidationError(err)
}
@@ -93,7 +94,7 @@ func (m *managerImpl) CreateNetwork(ctx context.Context, userID string, network
}
func (m *managerImpl) GetNetwork(ctx context.Context, accountID, userID, networkID string) (*types.Network, error) {
- ok, err := m.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Networks, operations.Read)
+ ok, ctx, err := m.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Networks, operations.Read)
if err != nil {
return nil, status.NewPermissionValidationError(err)
}
@@ -105,7 +106,7 @@ func (m *managerImpl) GetNetwork(ctx context.Context, accountID, userID, network
}
func (m *managerImpl) UpdateNetwork(ctx context.Context, userID string, network *types.Network) (*types.Network, error) {
- ok, err := m.permissionsManager.ValidateUserPermissions(ctx, network.AccountID, userID, modules.Networks, operations.Update)
+ ok, ctx, err := m.permissionsManager.ValidateUserPermissions(ctx, network.AccountID, userID, modules.Networks, operations.Update)
if err != nil {
return nil, status.NewPermissionValidationError(err)
}
@@ -135,7 +136,7 @@ func (m *managerImpl) UpdateNetwork(ctx context.Context, userID string, network
}
func (m *managerImpl) DeleteNetwork(ctx context.Context, accountID, userID, networkID string) error {
- ok, err := m.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Networks, operations.Delete)
+ ok, ctx, err := m.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Networks, operations.Delete)
if err != nil {
return status.NewPermissionValidationError(err)
}
@@ -149,30 +150,39 @@ func (m *managerImpl) DeleteNetwork(ctx context.Context, accountID, userID, netw
}
var eventsToStore []func()
+ var snap *affectedpeers.Snapshot
+ change := affectedpeers.Change{Networks: []*types.Network{network}}
err = m.store.ExecuteInTransaction(ctx, func(transaction store.Store) error {
resources, err := transaction.GetNetworkResourcesByNetID(ctx, store.LockingStrengthUpdate, accountID, networkID)
if err != nil {
return fmt.Errorf("failed to get resources in network: %w", err)
}
- for _, resource := range resources {
- event, err := m.resourcesManager.DeleteResourceInTransaction(ctx, transaction, accountID, userID, networkID, resource.ID)
- if err != nil {
- return fmt.Errorf("failed to delete resource: %w", err)
- }
- eventsToStore = append(eventsToStore, event...)
- }
-
- routers, err := transaction.GetNetworkRoutersByNetID(ctx, store.LockingStrengthUpdate, accountID, networkID)
+ netRouters, err := transaction.GetNetworkRoutersByNetID(ctx, store.LockingStrengthUpdate, accountID, networkID)
if err != nil {
return fmt.Errorf("failed to get routers in network: %w", err)
}
- for _, router := range routers {
- event, err := m.routersManager.DeleteRouterInTransaction(ctx, transaction, accountID, userID, networkID, router.ID)
+ var lerr error
+ if snap, lerr = affectedpeers.Load(ctx, transaction, accountID, change); lerr != nil {
+ return lerr
+ }
+
+ for _, resource := range resources {
+ deleted, event, err := m.resourcesManager.DeleteResourceInTransaction(ctx, transaction, accountID, userID, networkID, resource.ID)
+ if err != nil {
+ return fmt.Errorf("failed to delete resource: %w", err)
+ }
+ change.Resources = append(change.Resources, deleted)
+ eventsToStore = append(eventsToStore, event...)
+ }
+
+ for _, router := range netRouters {
+ deleted, event, err := m.routersManager.DeleteRouterInTransaction(ctx, transaction, accountID, userID, networkID, router.ID)
if err != nil {
return fmt.Errorf("failed to delete router: %w", err)
}
+ change.Routers = append(change.Routers, deleted)
eventsToStore = append(eventsToStore, event)
}
@@ -200,7 +210,7 @@ func (m *managerImpl) DeleteNetwork(ctx context.Context, accountID, userID, netw
event()
}
- go m.accountManager.UpdateAccountPeers(ctx, accountID, serverTypes.UpdateReason{Resource: serverTypes.UpdateResourceNetwork, Operation: serverTypes.UpdateOperationDelete})
+ m.accountManager.ExpandAndUpdateAffected(ctx, accountID, snap, change)
return nil
}
diff --git a/management/server/networks/resources/manager.go b/management/server/networks/resources/manager.go
index fb098a569..470bab10c 100644
--- a/management/server/networks/resources/manager.go
+++ b/management/server/networks/resources/manager.go
@@ -10,6 +10,7 @@ import (
"github.com/netbirdio/netbird/management/internals/modules/reverseproxy/service"
"github.com/netbirdio/netbird/management/server/account"
"github.com/netbirdio/netbird/management/server/activity"
+ "github.com/netbirdio/netbird/management/server/affectedpeers"
"github.com/netbirdio/netbird/management/server/groups"
"github.com/netbirdio/netbird/management/server/networks/resources/types"
"github.com/netbirdio/netbird/management/server/permissions"
@@ -29,7 +30,7 @@ type Manager interface {
GetResource(ctx context.Context, accountID, userID, networkID, resourceID string) (*types.NetworkResource, error)
UpdateResource(ctx context.Context, userID string, resource *types.NetworkResource) (*types.NetworkResource, error)
DeleteResource(ctx context.Context, accountID, userID, networkID, resourceID string) error
- DeleteResourceInTransaction(ctx context.Context, transaction store.Store, accountID, userID, networkID, resourceID string) ([]func(), error)
+ DeleteResourceInTransaction(ctx context.Context, transaction store.Store, accountID, userID, networkID, resourceID string) (*types.NetworkResource, []func(), error)
}
type managerImpl struct {
@@ -54,7 +55,7 @@ func NewManager(store store.Store, permissionsManager permissions.Manager, group
}
func (m *managerImpl) GetAllResourcesInNetwork(ctx context.Context, accountID, userID, networkID string) ([]*types.NetworkResource, error) {
- ok, err := m.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Networks, operations.Read)
+ ok, ctx, err := m.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Networks, operations.Read)
if err != nil {
return nil, status.NewPermissionValidationError(err)
}
@@ -66,7 +67,7 @@ func (m *managerImpl) GetAllResourcesInNetwork(ctx context.Context, accountID, u
}
func (m *managerImpl) GetAllResourcesInAccount(ctx context.Context, accountID, userID string) ([]*types.NetworkResource, error) {
- ok, err := m.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Networks, operations.Read)
+ ok, ctx, err := m.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Networks, operations.Read)
if err != nil {
return nil, status.NewPermissionValidationError(err)
}
@@ -78,7 +79,7 @@ func (m *managerImpl) GetAllResourcesInAccount(ctx context.Context, accountID, u
}
func (m *managerImpl) GetAllResourceIDsInAccount(ctx context.Context, accountID, userID string) (map[string][]string, error) {
- ok, err := m.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Networks, operations.Read)
+ ok, ctx, err := m.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Networks, operations.Read)
if err != nil {
return nil, status.NewPermissionValidationError(err)
}
@@ -100,7 +101,7 @@ func (m *managerImpl) GetAllResourceIDsInAccount(ctx context.Context, accountID,
}
func (m *managerImpl) CreateResource(ctx context.Context, userID string, resource *types.NetworkResource) (*types.NetworkResource, error) {
- ok, err := m.permissionsManager.ValidateUserPermissions(ctx, resource.AccountID, userID, modules.Networks, operations.Create)
+ ok, ctx, err := m.permissionsManager.ValidateUserPermissions(ctx, resource.AccountID, userID, modules.Networks, operations.Create)
if err != nil {
return nil, status.NewPermissionValidationError(err)
}
@@ -114,51 +115,12 @@ func (m *managerImpl) CreateResource(ctx context.Context, userID string, resourc
}
var eventsToStore []func()
+ var snap *affectedpeers.Snapshot
+ change := affectedpeers.Change{Resources: []*types.NetworkResource{resource}}
err = m.store.ExecuteInTransaction(ctx, func(transaction store.Store) error {
- _, err = transaction.GetNetworkResourceByName(ctx, store.LockingStrengthNone, resource.AccountID, resource.Name)
- if err == nil {
- return status.Errorf(status.InvalidArgument, "resource with name %s already exists", resource.Name)
- }
-
- network, err := transaction.GetNetworkByID(ctx, store.LockingStrengthUpdate, resource.AccountID, resource.NetworkID)
- if err != nil {
- return fmt.Errorf("failed to get network: %w", err)
- }
-
- seq, err := transaction.AllocateAccountSeqID(ctx, resource.AccountID, nbtypes.AccountSeqEntityNetworkResource)
- if err != nil {
- return fmt.Errorf("failed to allocate network resource seq id: %w", err)
- }
- resource.AccountSeqID = seq
-
- err = transaction.SaveNetworkResource(ctx, resource)
- if err != nil {
- return fmt.Errorf("failed to save network resource: %w", err)
- }
-
- event := func() {
- m.accountManager.StoreEvent(ctx, userID, resource.ID, resource.AccountID, activity.NetworkResourceCreated, resource.EventMeta(network))
- }
- eventsToStore = append(eventsToStore, event)
-
- res := nbtypes.Resource{
- ID: resource.ID,
- Type: nbtypes.ResourceType(resource.Type.String()),
- }
- for _, groupID := range resource.GroupIDs {
- event, err := m.groupsManager.AddResourceToGroupInTransaction(ctx, transaction, resource.AccountID, userID, groupID, &res)
- if err != nil {
- return fmt.Errorf("failed to add resource to group: %w", err)
- }
- eventsToStore = append(eventsToStore, event)
- }
-
- err = transaction.IncrementNetworkSerial(ctx, resource.AccountID)
- if err != nil {
- return fmt.Errorf("failed to increment network serial: %w", err)
- }
-
- return nil
+ var txErr error
+ eventsToStore, snap, txErr = m.createResourceInTransaction(ctx, transaction, userID, resource, change)
+ return txErr
})
if err != nil {
return nil, fmt.Errorf("failed to create network resource: %w", err)
@@ -168,13 +130,63 @@ func (m *managerImpl) CreateResource(ctx context.Context, userID string, resourc
event()
}
- go m.accountManager.UpdateAccountPeers(ctx, resource.AccountID, nbtypes.UpdateReason{Resource: nbtypes.UpdateResourceNetworkResource, Operation: nbtypes.UpdateOperationCreate})
+ m.accountManager.ExpandAndUpdateAffected(ctx, resource.AccountID, snap, change)
return resource, nil
}
+func (m *managerImpl) createResourceInTransaction(ctx context.Context, transaction store.Store, userID string, resource *types.NetworkResource, change affectedpeers.Change) ([]func(), *affectedpeers.Snapshot, error) {
+ _, err := transaction.GetNetworkResourceByName(ctx, store.LockingStrengthNone, resource.AccountID, resource.Name)
+ if err == nil {
+ return nil, nil, status.Errorf(status.InvalidArgument, "resource with name %s already exists", resource.Name)
+ }
+
+ network, err := transaction.GetNetworkByID(ctx, store.LockingStrengthUpdate, resource.AccountID, resource.NetworkID)
+ if err != nil {
+ return nil, nil, fmt.Errorf("failed to get network: %w", err)
+ }
+
+ seq, err := transaction.AllocateAccountSeqID(ctx, resource.AccountID, nbtypes.AccountSeqEntityNetworkResource)
+ if err != nil {
+ return nil, nil, fmt.Errorf("failed to allocate network resource seq id: %w", err)
+ }
+ resource.AccountSeqID = seq
+
+ if err = transaction.SaveNetworkResource(ctx, resource); err != nil {
+ return nil, nil, fmt.Errorf("failed to save network resource: %w", err)
+ }
+
+ var eventsToStore []func()
+ eventsToStore = append(eventsToStore, func() {
+ m.accountManager.StoreEvent(ctx, userID, resource.ID, resource.AccountID, activity.NetworkResourceCreated, resource.EventMeta(network))
+ })
+
+ res := nbtypes.Resource{
+ ID: resource.ID,
+ Type: nbtypes.ResourceType(resource.Type.String()),
+ }
+ for _, groupID := range resource.GroupIDs {
+ event, err := m.groupsManager.AddResourceToGroupInTransaction(ctx, transaction, resource.AccountID, userID, groupID, &res)
+ if err != nil {
+ return nil, nil, fmt.Errorf("failed to add resource to group: %w", err)
+ }
+ eventsToStore = append(eventsToStore, event)
+ }
+
+ if err = transaction.IncrementNetworkSerial(ctx, resource.AccountID); err != nil {
+ return nil, nil, fmt.Errorf("failed to increment network serial: %w", err)
+ }
+
+ snap, err := affectedpeers.Load(ctx, transaction, resource.AccountID, change)
+ if err != nil {
+ return nil, nil, err
+ }
+
+ return eventsToStore, snap, nil
+}
+
func (m *managerImpl) GetResource(ctx context.Context, accountID, userID, networkID, resourceID string) (*types.NetworkResource, error) {
- ok, err := m.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Networks, operations.Read)
+ ok, ctx, err := m.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Networks, operations.Read)
if err != nil {
return nil, status.NewPermissionValidationError(err)
}
@@ -195,7 +207,7 @@ func (m *managerImpl) GetResource(ctx context.Context, accountID, userID, networ
}
func (m *managerImpl) UpdateResource(ctx context.Context, userID string, resource *types.NetworkResource) (*types.NetworkResource, error) {
- ok, err := m.permissionsManager.ValidateUserPermissions(ctx, resource.AccountID, userID, modules.Networks, operations.Update)
+ ok, ctx, err := m.permissionsManager.ValidateUserPermissions(ctx, resource.AccountID, userID, modules.Networks, operations.Update)
if err != nil {
return nil, status.NewPermissionValidationError(err)
}
@@ -213,6 +225,8 @@ func (m *managerImpl) UpdateResource(ctx context.Context, userID string, resourc
resource.Prefix = prefix
var eventsToStore []func()
+ var snap *affectedpeers.Snapshot
+ var change affectedpeers.Change
err = m.store.ExecuteInTransaction(ctx, func(transaction store.Store) error {
network, err := transaction.GetNetworkByID(ctx, store.LockingStrengthUpdate, resource.AccountID, resource.NetworkID)
if err != nil {
@@ -239,6 +253,14 @@ func (m *managerImpl) UpdateResource(ctx context.Context, userID string, resourc
}
resource.AccountSeqID = oldResource.AccountSeqID
+ oldGroups, err := m.groupsManager.GetResourceGroupsInTransaction(ctx, transaction, store.LockingStrengthNone, resource.AccountID, resource.ID)
+ if err != nil {
+ return fmt.Errorf("failed to get old resource groups: %w", err)
+ }
+ for _, g := range oldGroups {
+ oldResource.GroupIDs = append(oldResource.GroupIDs, g.ID)
+ }
+
err = transaction.SaveNetworkResource(ctx, resource)
if err != nil {
return fmt.Errorf("failed to save network resource: %w", err)
@@ -254,6 +276,11 @@ func (m *managerImpl) UpdateResource(ctx context.Context, userID string, resourc
m.accountManager.StoreEvent(ctx, userID, resource.ID, resource.AccountID, activity.NetworkResourceUpdated, resource.EventMeta(network))
})
+ change = affectedpeers.Change{Resources: []*types.NetworkResource{oldResource, resource}}
+ if snap, err = affectedpeers.Load(ctx, transaction, resource.AccountID, change); err != nil {
+ return err
+ }
+
err = transaction.IncrementNetworkSerial(ctx, resource.AccountID)
if err != nil {
return fmt.Errorf("failed to increment network serial: %w", err)
@@ -277,7 +304,7 @@ func (m *managerImpl) UpdateResource(ctx context.Context, userID string, resourc
}
}()
- go m.accountManager.UpdateAccountPeers(ctx, resource.AccountID, nbtypes.UpdateReason{Resource: nbtypes.UpdateResourceNetworkResource, Operation: nbtypes.UpdateOperationUpdate})
+ m.accountManager.ExpandAndUpdateAffected(ctx, resource.AccountID, snap, change)
return resource, nil
}
@@ -321,7 +348,7 @@ func (m *managerImpl) updateResourceGroups(ctx context.Context, transaction stor
}
func (m *managerImpl) DeleteResource(ctx context.Context, accountID, userID, networkID, resourceID string) error {
- ok, err := m.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Networks, operations.Delete)
+ ok, ctx, err := m.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Networks, operations.Delete)
if err != nil {
return status.NewPermissionValidationError(err)
}
@@ -338,8 +365,26 @@ func (m *managerImpl) DeleteResource(ctx context.Context, accountID, userID, net
}
var events []func()
+ var snap *affectedpeers.Snapshot
+ var change affectedpeers.Change
err = m.store.ExecuteInTransaction(ctx, func(transaction store.Store) error {
- events, err = m.DeleteResourceInTransaction(ctx, transaction, accountID, userID, networkID, resourceID)
+ existing, err := transaction.GetNetworkResourceByID(ctx, store.LockingStrengthUpdate, accountID, resourceID)
+ if err != nil {
+ return fmt.Errorf("failed to get network resource: %w", err)
+ }
+ oldGroups, err := m.groupsManager.GetResourceGroupsInTransaction(ctx, transaction, store.LockingStrengthNone, accountID, resourceID)
+ if err != nil {
+ return fmt.Errorf("failed to get resource groups: %w", err)
+ }
+ for _, g := range oldGroups {
+ existing.GroupIDs = append(existing.GroupIDs, g.ID)
+ }
+ change = affectedpeers.Change{Resources: []*types.NetworkResource{existing}}
+ if snap, err = affectedpeers.Load(ctx, transaction, accountID, change); err != nil {
+ return err
+ }
+
+ _, events, err = m.DeleteResourceInTransaction(ctx, transaction, accountID, userID, networkID, resourceID)
if err != nil {
return fmt.Errorf("failed to delete resource: %w", err)
}
@@ -359,51 +404,53 @@ func (m *managerImpl) DeleteResource(ctx context.Context, accountID, userID, net
event()
}
- go m.accountManager.UpdateAccountPeers(ctx, accountID, nbtypes.UpdateReason{Resource: nbtypes.UpdateResourceNetworkResource, Operation: nbtypes.UpdateOperationDelete})
+ m.accountManager.ExpandAndUpdateAffected(ctx, accountID, snap, change)
return nil
}
-func (m *managerImpl) DeleteResourceInTransaction(ctx context.Context, transaction store.Store, accountID, userID, networkID, resourceID string) ([]func(), error) {
+func (m *managerImpl) DeleteResourceInTransaction(ctx context.Context, transaction store.Store, accountID, userID, networkID, resourceID string) (*types.NetworkResource, []func(), error) {
resource, err := transaction.GetNetworkResourceByID(ctx, store.LockingStrengthUpdate, accountID, resourceID)
if err != nil {
- return nil, fmt.Errorf("failed to get network resource: %w", err)
+ return nil, nil, fmt.Errorf("failed to get network resource: %w", err)
}
network, err := transaction.GetNetworkByID(ctx, store.LockingStrengthUpdate, accountID, networkID)
if err != nil {
- return nil, fmt.Errorf("failed to get network: %w", err)
+ return nil, nil, fmt.Errorf("failed to get network: %w", err)
}
if resource.NetworkID != networkID {
- return nil, errors.New("resource not part of network")
+ return nil, nil, errors.New("resource not part of network")
}
groups, err := m.groupsManager.GetResourceGroupsInTransaction(ctx, transaction, store.LockingStrengthUpdate, accountID, resourceID)
if err != nil {
- return nil, fmt.Errorf("failed to get resource groups: %w", err)
+ return nil, nil, fmt.Errorf("failed to get resource groups: %w", err)
}
var eventsToStore []func()
for _, group := range groups {
+ resource.GroupIDs = append(resource.GroupIDs, group.ID)
+
event, err := m.groupsManager.RemoveResourceFromGroupInTransaction(ctx, transaction, accountID, userID, group.ID, resourceID)
if err != nil {
- return nil, fmt.Errorf("failed to remove resource from group: %w", err)
+ return nil, nil, fmt.Errorf("failed to remove resource from group: %w", err)
}
eventsToStore = append(eventsToStore, event)
}
err = transaction.DeleteNetworkResource(ctx, accountID, resourceID)
if err != nil {
- return nil, fmt.Errorf("failed to delete network resource: %w", err)
+ return nil, nil, fmt.Errorf("failed to delete network resource: %w", err)
}
eventsToStore = append(eventsToStore, func() {
m.accountManager.StoreEvent(ctx, userID, resourceID, accountID, activity.NetworkResourceDeleted, resource.EventMeta(network))
})
- return eventsToStore, nil
+ return resource, eventsToStore, nil
}
func NewManagerMock() Manager {
@@ -438,6 +485,6 @@ func (m *mockManager) DeleteResource(ctx context.Context, accountID, userID, net
return nil
}
-func (m *mockManager) DeleteResourceInTransaction(ctx context.Context, transaction store.Store, accountID, userID, networkID, resourceID string) ([]func(), error) {
- return []func(){}, nil
+func (m *mockManager) DeleteResourceInTransaction(ctx context.Context, transaction store.Store, accountID, userID, networkID, resourceID string) (*types.NetworkResource, []func(), error) {
+ return nil, []func(){}, nil
}
diff --git a/management/server/networks/routers/manager.go b/management/server/networks/routers/manager.go
index 3e9f47235..24f989d5b 100644
--- a/management/server/networks/routers/manager.go
+++ b/management/server/networks/routers/manager.go
@@ -9,6 +9,7 @@ import (
"github.com/netbirdio/netbird/management/server/account"
"github.com/netbirdio/netbird/management/server/activity"
+ "github.com/netbirdio/netbird/management/server/affectedpeers"
"github.com/netbirdio/netbird/management/server/networks/routers/types"
networkTypes "github.com/netbirdio/netbird/management/server/networks/types"
"github.com/netbirdio/netbird/management/server/permissions"
@@ -26,7 +27,7 @@ type Manager interface {
GetRouter(ctx context.Context, accountID, userID, networkID, routerID string) (*types.NetworkRouter, error)
UpdateRouter(ctx context.Context, userID string, router *types.NetworkRouter) (*types.NetworkRouter, error)
DeleteRouter(ctx context.Context, accountID, userID, networkID, routerID string) error
- DeleteRouterInTransaction(ctx context.Context, transaction store.Store, accountID, userID, networkID, routerID string) (func(), error)
+ DeleteRouterInTransaction(ctx context.Context, transaction store.Store, accountID, userID, networkID, routerID string) (*types.NetworkRouter, func(), error)
}
type managerImpl struct {
@@ -47,7 +48,7 @@ func NewManager(store store.Store, permissionsManager permissions.Manager, accou
}
func (m *managerImpl) GetAllRoutersInNetwork(ctx context.Context, accountID, userID, networkID string) ([]*types.NetworkRouter, error) {
- ok, err := m.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Networks, operations.Read)
+ ok, ctx, err := m.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Networks, operations.Read)
if err != nil {
return nil, status.NewPermissionValidationError(err)
}
@@ -59,7 +60,7 @@ func (m *managerImpl) GetAllRoutersInNetwork(ctx context.Context, accountID, use
}
func (m *managerImpl) GetAllRoutersInAccount(ctx context.Context, accountID, userID string) (map[string][]*types.NetworkRouter, error) {
- ok, err := m.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Networks, operations.Read)
+ ok, ctx, err := m.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Networks, operations.Read)
if err != nil {
return nil, status.NewPermissionValidationError(err)
}
@@ -81,7 +82,7 @@ func (m *managerImpl) GetAllRoutersInAccount(ctx context.Context, accountID, use
}
func (m *managerImpl) CreateRouter(ctx context.Context, userID string, router *types.NetworkRouter) (*types.NetworkRouter, error) {
- ok, err := m.permissionsManager.ValidateUserPermissions(ctx, router.AccountID, userID, modules.Networks, operations.Create)
+ ok, ctx, err := m.permissionsManager.ValidateUserPermissions(ctx, router.AccountID, userID, modules.Networks, operations.Create)
if err != nil {
return nil, status.NewPermissionValidationError(err)
}
@@ -90,6 +91,8 @@ func (m *managerImpl) CreateRouter(ctx context.Context, userID string, router *t
}
var network *networkTypes.Network
+ var snap *affectedpeers.Snapshot
+ change := affectedpeers.Change{Routers: []*types.NetworkRouter{router}}
err = m.store.ExecuteInTransaction(ctx, func(transaction store.Store) error {
network, err = transaction.GetNetworkByID(ctx, store.LockingStrengthNone, router.AccountID, router.NetworkID)
if err != nil {
@@ -118,6 +121,10 @@ func (m *managerImpl) CreateRouter(ctx context.Context, userID string, router *t
return fmt.Errorf("failed to increment network serial: %w", err)
}
+ if snap, err = affectedpeers.Load(ctx, transaction, router.AccountID, change); err != nil {
+ return err
+ }
+
return nil
})
if err != nil {
@@ -126,13 +133,13 @@ func (m *managerImpl) CreateRouter(ctx context.Context, userID string, router *t
m.accountManager.StoreEvent(ctx, userID, router.ID, router.AccountID, activity.NetworkRouterCreated, router.EventMeta(network))
- go m.accountManager.UpdateAccountPeers(ctx, router.AccountID, serverTypes.UpdateReason{Resource: serverTypes.UpdateResourceNetworkRouter, Operation: serverTypes.UpdateOperationCreate})
+ m.accountManager.ExpandAndUpdateAffected(ctx, router.AccountID, snap, change)
return router, nil
}
func (m *managerImpl) GetRouter(ctx context.Context, accountID, userID, networkID, routerID string) (*types.NetworkRouter, error) {
- ok, err := m.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Networks, operations.Read)
+ ok, ctx, err := m.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Networks, operations.Read)
if err != nil {
return nil, status.NewPermissionValidationError(err)
}
@@ -153,7 +160,7 @@ func (m *managerImpl) GetRouter(ctx context.Context, accountID, userID, networkI
}
func (m *managerImpl) UpdateRouter(ctx context.Context, userID string, router *types.NetworkRouter) (*types.NetworkRouter, error) {
- ok, err := m.permissionsManager.ValidateUserPermissions(ctx, router.AccountID, userID, modules.Networks, operations.Update)
+ ok, ctx, err := m.permissionsManager.ValidateUserPermissions(ctx, router.AccountID, userID, modules.Networks, operations.Update)
if err != nil {
return nil, status.NewPermissionValidationError(err)
}
@@ -162,44 +169,12 @@ func (m *managerImpl) UpdateRouter(ctx context.Context, userID string, router *t
}
var network *networkTypes.Network
+ var snap *affectedpeers.Snapshot
+ var change affectedpeers.Change
err = m.store.ExecuteInTransaction(ctx, func(transaction store.Store) error {
- network, err = transaction.GetNetworkByID(ctx, store.LockingStrengthNone, router.AccountID, router.NetworkID)
- if err != nil {
- return fmt.Errorf("failed to get network: %w", err)
- }
-
- existing, err := transaction.GetNetworkRouterByID(ctx, store.LockingStrengthUpdate, router.AccountID, router.ID)
- if err != nil {
- return fmt.Errorf("failed to get network router: %w", err)
- }
-
- if existing.AccountID != router.AccountID {
- return status.NewNetworkRouterNotFoundError(router.ID)
- }
-
- if existing.NetworkID != router.NetworkID {
- return status.NewRouterNotPartOfNetworkError(router.ID, router.NetworkID)
- }
-
- // Preserve AccountSeqID from the existing router so the upstream
- // UpdateNetworkRouter (which does Updates(router) with Select("*"))
- // doesn't clobber it with the request's zero value. Main's split of
- // Save → Create/Update removed the PUT-as-upsert path that the
- // earlier branch carried, so callers must always pass a real router
- // id — UpdateNetworkRouter returns NotFound otherwise.
- router.AccountSeqID = existing.AccountSeqID
-
- err = transaction.UpdateNetworkRouter(ctx, router)
- if err != nil {
- return fmt.Errorf("failed to update network router: %w", err)
- }
-
- err = transaction.IncrementNetworkSerial(ctx, router.AccountID)
- if err != nil {
- return fmt.Errorf("failed to increment network serial: %w", err)
- }
-
- return nil
+ var txErr error
+ network, snap, change, txErr = m.updateRouterInTransaction(ctx, transaction, router)
+ return txErr
})
if err != nil {
return nil, err
@@ -207,13 +182,54 @@ func (m *managerImpl) UpdateRouter(ctx context.Context, userID string, router *t
m.accountManager.StoreEvent(ctx, userID, router.ID, router.AccountID, activity.NetworkRouterUpdated, router.EventMeta(network))
- go m.accountManager.UpdateAccountPeers(ctx, router.AccountID, serverTypes.UpdateReason{Resource: serverTypes.UpdateResourceNetworkRouter, Operation: serverTypes.UpdateOperationUpdate})
+ m.accountManager.ExpandAndUpdateAffected(ctx, router.AccountID, snap, change)
return router, nil
}
+func (m *managerImpl) updateRouterInTransaction(ctx context.Context, transaction store.Store, router *types.NetworkRouter) (*networkTypes.Network, *affectedpeers.Snapshot, affectedpeers.Change, error) {
+ network, err := transaction.GetNetworkByID(ctx, store.LockingStrengthNone, router.AccountID, router.NetworkID)
+ if err != nil {
+ return nil, nil, affectedpeers.Change{}, fmt.Errorf("failed to get network: %w", err)
+ }
+
+ existing, err := transaction.GetNetworkRouterByID(ctx, store.LockingStrengthUpdate, router.AccountID, router.ID)
+ if err != nil {
+ return nil, nil, affectedpeers.Change{}, fmt.Errorf("failed to get network router: %w", err)
+ }
+
+ if existing.AccountID != router.AccountID {
+ return nil, nil, affectedpeers.Change{}, status.NewNetworkRouterNotFoundError(router.ID)
+ }
+
+ if existing.NetworkID != router.NetworkID {
+ return nil, nil, affectedpeers.Change{}, status.NewRouterNotPartOfNetworkError(router.ID, router.NetworkID)
+ }
+
+ // Preserve AccountSeqID from the existing router so the upstream
+ // UpdateNetworkRouter (which does Updates(router) with Select("*"))
+ // doesn't clobber it with the request's zero value.
+ router.AccountSeqID = existing.AccountSeqID
+
+ if err = transaction.UpdateNetworkRouter(ctx, router); err != nil {
+ return nil, nil, affectedpeers.Change{}, fmt.Errorf("failed to update network router: %w", err)
+ }
+
+ if err = transaction.IncrementNetworkSerial(ctx, router.AccountID); err != nil {
+ return nil, nil, affectedpeers.Change{}, fmt.Errorf("failed to increment network serial: %w", err)
+ }
+
+ change := affectedpeers.Change{Routers: []*types.NetworkRouter{existing, router}}
+ snap, err := affectedpeers.Load(ctx, transaction, router.AccountID, change)
+ if err != nil {
+ return nil, nil, affectedpeers.Change{}, err
+ }
+
+ return network, snap, change, nil
+}
+
func (m *managerImpl) DeleteRouter(ctx context.Context, accountID, userID, networkID, routerID string) error {
- ok, err := m.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Networks, operations.Delete)
+ ok, ctx, err := m.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Networks, operations.Delete)
if err != nil {
return status.NewPermissionValidationError(err)
}
@@ -222,8 +238,19 @@ func (m *managerImpl) DeleteRouter(ctx context.Context, accountID, userID, netwo
}
var event func()
+ var snap *affectedpeers.Snapshot
+ var change affectedpeers.Change
err = m.store.ExecuteInTransaction(ctx, func(transaction store.Store) error {
- event, err = m.DeleteRouterInTransaction(ctx, transaction, accountID, userID, networkID, routerID)
+ existing, err := transaction.GetNetworkRouterByID(ctx, store.LockingStrengthUpdate, accountID, routerID)
+ if err != nil {
+ return fmt.Errorf("failed to get network router: %w", err)
+ }
+ change = affectedpeers.Change{Routers: []*types.NetworkRouter{existing}}
+ if snap, err = affectedpeers.Load(ctx, transaction, accountID, change); err != nil {
+ return err
+ }
+
+ _, event, err = m.DeleteRouterInTransaction(ctx, transaction, accountID, userID, networkID, routerID)
if err != nil {
return fmt.Errorf("failed to delete network router: %w", err)
}
@@ -241,36 +268,36 @@ func (m *managerImpl) DeleteRouter(ctx context.Context, accountID, userID, netwo
event()
- go m.accountManager.UpdateAccountPeers(ctx, accountID, serverTypes.UpdateReason{Resource: serverTypes.UpdateResourceNetworkRouter, Operation: serverTypes.UpdateOperationDelete})
+ m.accountManager.ExpandAndUpdateAffected(ctx, accountID, snap, change)
return nil
}
-func (m *managerImpl) DeleteRouterInTransaction(ctx context.Context, transaction store.Store, accountID, userID, networkID, routerID string) (func(), error) {
+func (m *managerImpl) DeleteRouterInTransaction(ctx context.Context, transaction store.Store, accountID, userID, networkID, routerID string) (*types.NetworkRouter, func(), error) {
network, err := transaction.GetNetworkByID(ctx, store.LockingStrengthNone, accountID, networkID)
if err != nil {
- return nil, fmt.Errorf("failed to get network: %w", err)
+ return nil, nil, fmt.Errorf("failed to get network: %w", err)
}
router, err := transaction.GetNetworkRouterByID(ctx, store.LockingStrengthUpdate, accountID, routerID)
if err != nil {
- return nil, fmt.Errorf("failed to get network router: %w", err)
+ return nil, nil, fmt.Errorf("failed to get network router: %w", err)
}
if router.NetworkID != networkID {
- return nil, status.NewRouterNotPartOfNetworkError(routerID, networkID)
+ return nil, nil, status.NewRouterNotPartOfNetworkError(routerID, networkID)
}
err = transaction.DeleteNetworkRouter(ctx, accountID, routerID)
if err != nil {
- return nil, fmt.Errorf("failed to delete network router: %w", err)
+ return nil, nil, fmt.Errorf("failed to delete network router: %w", err)
}
event := func() {
m.accountManager.StoreEvent(ctx, userID, routerID, accountID, activity.NetworkRouterDeleted, router.EventMeta(network))
}
- return event, nil
+ return router, event, nil
}
func NewManagerMock() Manager {
@@ -301,6 +328,9 @@ func (m *mockManager) DeleteRouter(ctx context.Context, accountID, userID, netwo
return nil
}
-func (m *mockManager) DeleteRouterInTransaction(ctx context.Context, transaction store.Store, accountID, userID, networkID, routerID string) (func(), error) {
- return func() {}, nil
+func (m *mockManager) DeleteRouterInTransaction(ctx context.Context, transaction store.Store, accountID, userID, networkID, routerID string) (*types.NetworkRouter, func(), error) {
+ return nil, func() {
+ // no-op mock: returns zero values so tests that don't exercise router deletion
+ // can satisfy the Manager interface without a real store.
+ }, nil
}
diff --git a/management/server/peer.go b/management/server/peer.go
index 7066bf307..9d78f597b 100644
--- a/management/server/peer.go
+++ b/management/server/peer.go
@@ -27,9 +27,11 @@ import (
"github.com/netbirdio/netbird/management/server/types"
"github.com/netbirdio/netbird/management/server/activity"
+ "github.com/netbirdio/netbird/management/server/affectedpeers"
nbpeer "github.com/netbirdio/netbird/management/server/peer"
"github.com/netbirdio/netbird/management/server/telemetry"
"github.com/netbirdio/netbird/shared/management/status"
+ "github.com/netbirdio/netbird/version"
)
const remoteJobsMinVer = "0.64.0"
@@ -42,7 +44,7 @@ func (am *DefaultAccountManager) GetPeers(ctx context.Context, accountID, userID
return nil, err
}
- allowed, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Peers, operations.Read)
+ allowed, ctx, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Peers, operations.Read)
if err != nil {
return nil, status.NewPermissionValidationError(err)
}
@@ -72,7 +74,7 @@ func (am *DefaultAccountManager) GetPeers(ctx context.Context, accountID, userID
//
// Disconnects use MarkPeerDisconnected and require the session to match
// exactly; see PeerStatus.SessionStartedAt for the protocol.
-func (am *DefaultAccountManager) MarkPeerConnected(ctx context.Context, peerPubKey string, realIP net.IP, accountID string, sessionStartedAt int64) error {
+func (am *DefaultAccountManager) MarkPeerConnected(ctx context.Context, peerPubKey string, realIP net.IP, accountID string, sessionStartedAt int64, nmap *types.NetworkMap) error {
start := time.Now()
defer func() {
am.metrics.AccountManagerMetrics().RecordPeerStatusUpdateDuration(telemetry.PeerStatusConnect, time.Since(start))
@@ -104,35 +106,22 @@ func (am *DefaultAccountManager) MarkPeerConnected(ctx context.Context, peerPubK
am.updatePeerLocationIfChanged(ctx, accountID, peer, realIP)
}
- expired := peer.Status != nil && peer.Status.LoginExpired
-
- if peer.AddedWithSSOLogin() {
- settings, err := am.Store.GetAccountSettings(ctx, store.LockingStrengthNone, accountID)
- if err != nil {
- return err
- }
- if peer.LoginExpirationEnabled && settings.PeerLoginExpirationEnabled {
- am.schedulePeerLoginExpiration(ctx, accountID)
- }
- if peer.InactivityExpirationEnabled && settings.PeerInactivityExpirationEnabled {
- am.checkAndSchedulePeerInactivityExpiration(ctx, accountID)
- }
+ if err = am.schedulePeerExpirations(ctx, accountID, peer); err != nil {
+ return err
}
- if expired {
- if err = am.networkMapController.OnPeersUpdated(ctx, accountID, []string{peer.ID}); err != nil {
+ // A login-expired peer reconnecting, or an embedded proxy peer flipping to
+ // connected (which triggers SynthesizePrivateServiceZones), must refresh the
+ // peers reachable from it. The embedded-proxy fan-out tolerates a dispatch error.
+ if peer.Status != nil && peer.Status.LoginExpired {
+ affectedPeerIDs := am.markConnectedAffectedPeers(ctx, accountID, peer.ID, nmap)
+ if err = am.networkMapController.OnPeersUpdated(ctx, accountID, []string{peer.ID}, affectedPeerIDs); err != nil {
return fmt.Errorf("notify network map controller of peer update: %w", err)
}
}
-
- // An embedded proxy peer flipping to connected is the trigger for
- // SynthesizePrivateServiceZones to emit DNS A records pointing at its
- // tunnel IP. Without an account-wide netmap recompute, user peers keep
- // the stale synth (or no synth at all on first connect) until some
- // other change pokes the controller. Fire OnPeersUpdated so the
- // buffered recompute fans the new state out to every peer.
if peer.ProxyMeta.Embedded {
- if err := am.networkMapController.OnPeersUpdated(ctx, accountID, []string{peer.ID}); err != nil {
+ affectedPeerIDs := am.markConnectedAffectedPeers(ctx, accountID, peer.ID, nmap)
+ if err := am.networkMapController.OnPeersUpdated(ctx, accountID, []string{peer.ID}, affectedPeerIDs); err != nil {
log.WithContext(ctx).Warnf("notify network map controller of embedded proxy %s connect: %v", peer.ID, err)
}
}
@@ -140,6 +129,25 @@ func (am *DefaultAccountManager) MarkPeerConnected(ctx context.Context, peerPubK
return nil
}
+// schedulePeerExpirations reschedules the account's login/inactivity expiration
+// timers for an SSO peer that just connected.
+func (am *DefaultAccountManager) schedulePeerExpirations(ctx context.Context, accountID string, peer *nbpeer.Peer) error {
+ if !peer.AddedWithSSOLogin() {
+ return nil
+ }
+ settings, err := am.Store.GetAccountSettings(ctx, store.LockingStrengthNone, accountID)
+ if err != nil {
+ return err
+ }
+ if peer.LoginExpirationEnabled && settings.PeerLoginExpirationEnabled {
+ am.schedulePeerLoginExpiration(ctx, accountID)
+ }
+ if peer.InactivityExpirationEnabled && settings.PeerInactivityExpirationEnabled {
+ am.checkAndSchedulePeerInactivityExpiration(ctx, accountID)
+ }
+ return nil
+}
+
// MarkPeerDisconnected marks a peer as disconnected, but only when the
// stored session token matches the one passed in. A mismatch means a
// newer stream has already taken ownership of the peer — disconnects from
@@ -174,11 +182,12 @@ func (am *DefaultAccountManager) MarkPeerDisconnected(ctx context.Context, peerP
am.metrics.AccountManagerMetrics().CountPeerStatusUpdate(telemetry.PeerStatusDisconnect, telemetry.PeerStatusApplied)
// Symmetric with MarkPeerConnected: when an embedded proxy peer goes
- // offline, drive an account-wide netmap recompute so the synthesized
- // DNS records that pointed at it are pulled. Without this the records
- // linger client-side at TTL until something else triggers a refresh.
+ // offline, refresh the peers that had synthesized records pointing at
+ // it so they pull the stale entries instead of waiting out TTL.
if peer.ProxyMeta.Embedded {
- if err := am.networkMapController.OnPeersUpdated(ctx, accountID, []string{peer.ID}); err != nil {
+ changedPeerIDs := []string{peer.ID}
+ affectedPeerIDs := am.resolveAffectedPeersForPeerChanges(ctx, am.Store, accountID, changedPeerIDs)
+ if err := am.networkMapController.OnPeersUpdated(ctx, accountID, changedPeerIDs, affectedPeerIDs); err != nil {
log.WithContext(ctx).Warnf("notify network map controller of embedded proxy %s disconnect: %v", peer.ID, err)
}
}
@@ -209,7 +218,7 @@ func (am *DefaultAccountManager) updatePeerLocationIfChanged(ctx context.Context
// UpdatePeer updates peer. Only Peer.Name, Peer.SSHEnabled, Peer.LoginExpirationEnabled and Peer.InactivityExpirationEnabled can be updated.
func (am *DefaultAccountManager) UpdatePeer(ctx context.Context, accountID, userID string, update *nbpeer.Peer) (*nbpeer.Peer, error) {
- allowed, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Peers, operations.Update)
+ allowed, ctx, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Peers, operations.Update)
if err != nil {
return nil, status.NewPermissionValidationError(err)
}
@@ -345,7 +354,10 @@ func (am *DefaultAccountManager) UpdatePeer(ctx context.Context, accountID, user
}
}
- err = am.networkMapController.OnPeersUpdated(ctx, accountID, []string{peer.ID})
+ changedPeerIDs := []string{peer.ID}
+ affectedPeerIDs := am.resolveAffectedPeersForPeerChanges(ctx, am.Store, accountID, changedPeerIDs)
+ affectedPeerIDs = append(affectedPeerIDs, peer.ID)
+ err = am.networkMapController.OnPeersUpdated(ctx, accountID, changedPeerIDs, affectedPeerIDs)
if err != nil {
return nil, fmt.Errorf("notify network map controller of peer update: %w", err)
}
@@ -354,7 +366,7 @@ func (am *DefaultAccountManager) UpdatePeer(ctx context.Context, accountID, user
}
func (am *DefaultAccountManager) CreatePeerJob(ctx context.Context, accountID, peerID, userID string, job *types.Job) error {
- allowed, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.RemoteJobs, operations.Create)
+ allowed, ctx, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.RemoteJobs, operations.Create)
if err != nil {
return status.NewPermissionValidationError(err)
}
@@ -372,7 +384,7 @@ func (am *DefaultAccountManager) CreatePeerJob(ctx context.Context, accountID, p
}
meetMinVer, err := posture.MeetsMinVersion(remoteJobsMinVer, p.Meta.WtVersion)
- if !strings.Contains(p.Meta.WtVersion, "dev") && (!meetMinVer || err != nil) {
+ if !version.IsDevelopmentVersion(p.Meta.WtVersion) && (!meetMinVer || err != nil) {
return status.Errorf(status.PreconditionFailed, "peer version %s does not meet the minimum required version %s for remote jobs", p.Meta.WtVersion, remoteJobsMinVer)
}
@@ -430,7 +442,7 @@ func (am *DefaultAccountManager) CreatePeerJob(ctx context.Context, accountID, p
func (am *DefaultAccountManager) GetAllPeerJobs(ctx context.Context, accountID, userID, peerID string) ([]*types.Job, error) {
// todo: Create permissions for job
- allowed, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.RemoteJobs, operations.Read)
+ allowed, ctx, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.RemoteJobs, operations.Read)
if err != nil {
return nil, status.NewPermissionValidationError(err)
}
@@ -456,7 +468,7 @@ func (am *DefaultAccountManager) GetAllPeerJobs(ctx context.Context, accountID,
}
func (am *DefaultAccountManager) GetPeerJobByID(ctx context.Context, accountID, userID, peerID, jobID string) (*types.Job, error) {
- allowed, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.RemoteJobs, operations.Read)
+ allowed, ctx, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.RemoteJobs, operations.Read)
if err != nil {
return nil, status.NewPermissionValidationError(err)
}
@@ -483,7 +495,7 @@ func (am *DefaultAccountManager) GetPeerJobByID(ctx context.Context, accountID,
// DeletePeer removes peer from the account by its IP
func (am *DefaultAccountManager) DeletePeer(ctx context.Context, accountID, peerID, userID string) error {
- allowed, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Peers, operations.Delete)
+ allowed, ctx, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Peers, operations.Delete)
if err != nil {
return status.NewPermissionValidationError(err)
}
@@ -500,10 +512,6 @@ func (am *DefaultAccountManager) DeletePeer(ctx context.Context, accountID, peer
return status.NewPeerNotPartOfAccountError()
}
- var peer *nbpeer.Peer
- var settings *types.Settings
- var eventsToStore []func()
-
serviceID, err := am.serviceManager.GetServiceIDByTargetID(ctx, accountID, peerID)
if err != nil {
return fmt.Errorf("failed to check if resource is used by service: %w", err)
@@ -512,8 +520,38 @@ func (am *DefaultAccountManager) DeletePeer(ctx context.Context, accountID, peer
return status.NewPeerInUseError(peerID, serviceID)
}
- err = am.Store.ExecuteInTransaction(ctx, func(transaction store.Store) error {
- peer, err = transaction.GetPeerByID(ctx, store.LockingStrengthNone, accountID, peerID)
+ change := affectedpeers.Change{ChangedPeerIDs: []string{peerID}}
+ settings, eventsToStore, snap, err := am.deletePeerInTransaction(ctx, accountID, userID, peerID, change)
+ if err != nil {
+ return err
+ }
+
+ for _, storeEvent := range eventsToStore {
+ storeEvent()
+ }
+
+ if err = am.integratedPeerValidator.PeerDeleted(ctx, accountID, peerID, settings.Extra); err != nil {
+ log.WithContext(ctx).Errorf("failed to delete peer %s from integrated validator: %v", peerID, err)
+ }
+
+ affectedPeerIDs := snap.Expand(ctx, accountID, change)
+ if err = am.networkMapController.OnPeersDeleted(ctx, accountID, []string{peerID}, affectedPeerIDs); err != nil {
+ log.WithContext(ctx).Errorf("failed to delete peer %s from network map: %v", peerID, err)
+ }
+
+ return nil
+}
+
+// deletePeerInTransaction loads the peer + settings, captures the affected-peers
+// snapshot (before the delete, while the peer's group memberships still exist),
+// then deletes the peer and bumps the network serial — all in one transaction.
+func (am *DefaultAccountManager) deletePeerInTransaction(ctx context.Context, accountID, userID, peerID string, change affectedpeers.Change) (*types.Settings, []func(), *affectedpeers.Snapshot, error) {
+ var settings *types.Settings
+ var eventsToStore []func()
+ var snap *affectedpeers.Snapshot
+
+ err := am.Store.ExecuteInTransaction(ctx, func(transaction store.Store) error {
+ peer, err := transaction.GetPeerByID(ctx, store.LockingStrengthNone, accountID, peerID)
if err != nil {
return err
}
@@ -527,8 +565,11 @@ func (am *DefaultAccountManager) DeletePeer(ctx context.Context, accountID, peer
return err
}
- eventsToStore, err = deletePeers(ctx, am, transaction, accountID, userID, []*nbpeer.Peer{peer}, settings)
- if err != nil {
+ if snap, err = affectedpeers.Load(ctx, transaction, accountID, change); err != nil {
+ return err
+ }
+
+ if eventsToStore, err = deletePeers(ctx, am, transaction, accountID, userID, []*nbpeer.Peer{peer}, settings); err != nil {
return fmt.Errorf("failed to delete peer: %w", err)
}
@@ -538,23 +579,7 @@ func (am *DefaultAccountManager) DeletePeer(ctx context.Context, accountID, peer
return nil
})
- if err != nil {
- return err
- }
-
- for _, storeEvent := range eventsToStore {
- storeEvent()
- }
-
- if err = am.integratedPeerValidator.PeerDeleted(ctx, accountID, peerID, settings.Extra); err != nil {
- log.WithContext(ctx).Errorf("failed to delete peer %s from integrated validator: %v", peerID, err)
- }
-
- if err = am.networkMapController.OnPeersDeleted(ctx, accountID, []string{peerID}); err != nil {
- log.WithContext(ctx).Errorf("failed to delete peer %s from network map: %v", peerID, err)
- }
-
- return nil
+ return settings, eventsToStore, snap, err
}
// GetNetworkMap returns Network map for a given peer (omits original peer from the Peers result)
@@ -643,7 +668,7 @@ func (am *DefaultAccountManager) handleUserAddedPeer(ctx context.Context, accoun
}
if temporary {
- allowed, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Peers, operations.Create)
+ allowed, _, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Peers, operations.Create)
if err != nil {
return status.NewPermissionValidationError(err)
}
@@ -693,10 +718,10 @@ func (am *DefaultAccountManager) handleSetupKeyAddedPeer(ctx context.Context, en
// to it. We also add the User ID to the peer metadata to identify registrant. If no userID provided, then fail with status.PermissionDenied
// Each new Peer will be assigned a new next net.IP from the Account.Network and Account.Network.LastIP will be updated (IP's are not reused).
// The peer property is just a placeholder for the Peer properties to pass further
-func (am *DefaultAccountManager) AddPeer(ctx context.Context, accountID, setupKey, userID string, peer *nbpeer.Peer, temporary bool) (*nbpeer.Peer, *types.NetworkMap, []*posture.Checks, error) {
+func (am *DefaultAccountManager) AddPeer(ctx context.Context, accountID, setupKey, userID string, peer *nbpeer.Peer, temporary bool) (*nbpeer.Peer, *types.Network, []*posture.Checks, bool, error) {
if setupKey == "" && userID == "" && !peer.ProxyMeta.Embedded {
// no auth method provided => reject access
- return nil, nil, nil, status.Errorf(status.Unauthenticated, "no peer auth method provided, please use a setup key or interactive SSO login")
+ return nil, nil, nil, false, status.Errorf(status.Unauthenticated, "no peer auth method provided, please use a setup key or interactive SSO login")
}
upperKey := strings.ToUpper(setupKey)
@@ -712,7 +737,7 @@ func (am *DefaultAccountManager) AddPeer(ctx context.Context, accountID, setupKe
// The connecting peer should be able to recover with a retry.
_, err := am.Store.GetPeerByPeerPubKey(ctx, store.LockingStrengthNone, peer.Key)
if err == nil {
- return nil, nil, nil, status.Errorf(status.PreconditionFailed, "peer has been already registered")
+ return nil, nil, nil, false, status.Errorf(status.PreconditionFailed, "peer has been already registered")
}
opEvent := &activity.Event{
@@ -723,7 +748,7 @@ func (am *DefaultAccountManager) AddPeer(ctx context.Context, accountID, setupKe
peerAddConfig, err := am.processPeerAddAuth(ctx, accountID, userID, encodedHashedKey, peer, temporary, addedByUser, addedBySetupKey, opEvent)
if err != nil {
- return nil, nil, nil, err
+ return nil, nil, nil, false, err
}
accountID = peerAddConfig.AccountID
ephemeral := peerAddConfig.Ephemeral
@@ -738,7 +763,7 @@ func (am *DefaultAccountManager) AddPeer(ctx context.Context, accountID, setupKe
}
if err := domain.ValidateDomainsList(peer.ExtraDNSLabels); err != nil {
- return nil, nil, nil, status.Errorf(status.InvalidArgument, "invalid extra DNS labels: %v", err)
+ return nil, nil, nil, false, status.Errorf(status.InvalidArgument, "invalid extra DNS labels: %v", err)
}
registrationTime := time.Now().UTC()
@@ -764,7 +789,7 @@ func (am *DefaultAccountManager) AddPeer(ctx context.Context, accountID, setupKe
}
settings, err := am.Store.GetAccountSettings(ctx, store.LockingStrengthNone, accountID)
if err != nil {
- return nil, nil, nil, fmt.Errorf("failed to get account settings: %w", err)
+ return nil, nil, nil, false, fmt.Errorf("failed to get account settings: %w", err)
}
if am.geo != nil && newPeer.Location.ConnectionIP != nil {
@@ -782,30 +807,30 @@ func (am *DefaultAccountManager) AddPeer(ctx context.Context, accountID, setupKe
network, err := am.Store.GetAccountNetwork(ctx, store.LockingStrengthNone, accountID)
if err != nil {
- return nil, nil, nil, fmt.Errorf("failed getting network: %w", err)
+ return nil, nil, nil, false, fmt.Errorf("failed getting network: %w", err)
}
maxAttempts := 10
for attempt := 1; attempt <= maxAttempts; attempt++ {
netPrefix, err := netip.ParsePrefix(network.Net.String())
if err != nil {
- return nil, nil, nil, fmt.Errorf("parse network prefix: %w", err)
+ return nil, nil, nil, false, fmt.Errorf("parse network prefix: %w", err)
}
freeIP, err := types.AllocateRandomPeerIP(netPrefix)
if err != nil {
- return nil, nil, nil, fmt.Errorf("failed to get free IP: %w", err)
+ return nil, nil, nil, false, fmt.Errorf("failed to get free IP: %w", err)
}
var freeLabel string
if ephemeral || attempt > 1 {
freeLabel, err = getPeerIPDNSLabel(freeIP, peer.Meta.Hostname)
if err != nil {
- return nil, nil, nil, fmt.Errorf("failed to get free DNS label: %w", err)
+ return nil, nil, nil, false, fmt.Errorf("failed to get free DNS label: %w", err)
}
} else {
freeLabel, err = nbdns.GetParsedDomainLabel(peer.Meta.Hostname)
if err != nil {
- return nil, nil, nil, fmt.Errorf("failed to get free DNS label: %w", err)
+ return nil, nil, nil, false, fmt.Errorf("failed to get free DNS label: %w", err)
}
}
newPeer.DNSLabel = freeLabel
@@ -827,11 +852,11 @@ func (am *DefaultAccountManager) AddPeer(ctx context.Context, accountID, setupKe
if allocate {
v6Prefix, err := netip.ParsePrefix(network.NetV6.String())
if err != nil {
- return nil, nil, nil, fmt.Errorf("parse IPv6 prefix: %w", err)
+ return nil, nil, nil, false, fmt.Errorf("parse IPv6 prefix: %w", err)
}
freeIPv6, err := types.AllocateRandomPeerIPv6(v6Prefix)
if err != nil {
- return nil, nil, nil, fmt.Errorf("allocate peer IPv6: %w", err)
+ return nil, nil, nil, false, fmt.Errorf("allocate peer IPv6: %w", err)
}
newPeer.IPv6 = freeIPv6
}
@@ -904,10 +929,10 @@ func (am *DefaultAccountManager) AddPeer(ctx context.Context, accountID, setupKe
continue
}
- return nil, nil, nil, fmt.Errorf("failed to add peer to database: %w", err)
+ return nil, nil, nil, false, fmt.Errorf("failed to add peer to database: %w", err)
}
if newPeer == nil {
- return nil, nil, nil, fmt.Errorf("new peer is nil")
+ return nil, nil, nil, false, fmt.Errorf("new peer is nil")
}
opEvent.TargetID = newPeer.ID
@@ -915,7 +940,8 @@ func (am *DefaultAccountManager) AddPeer(ctx context.Context, accountID, setupKe
if !addedByUser {
opEvent.Meta["setup_key_name"] = peerAddConfig.SetupKeyName
}
- if newPeer.Status != nil && newPeer.Status.RequiresApproval {
+ requiresApproval := newPeer.Status != nil && newPeer.Status.RequiresApproval
+ if requiresApproval {
opEvent.Meta["pending_approval"] = true
}
@@ -923,12 +949,18 @@ func (am *DefaultAccountManager) AddPeer(ctx context.Context, accountID, setupKe
am.StoreEvent(ctx, opEvent.InitiatorID, opEvent.TargetID, opEvent.AccountID, opEvent.Activity, opEvent.Meta)
}
- if err := am.networkMapController.OnPeersAdded(ctx, accountID, []string{newPeer.ID}); err != nil {
+ network, postureChecks, enableSSH, err := getPeerLoginInfo(ctx, am.Store, accountID, newPeer, !requiresApproval)
+ if err != nil {
+ return nil, nil, nil, false, err
+ }
+
+ changedPeerIDs := []string{newPeer.ID}
+ affectedPeerIDs := am.resolveAffectedPeersForPeerChanges(ctx, am.Store, accountID, changedPeerIDs)
+ if err := am.networkMapController.OnPeersAdded(ctx, accountID, changedPeerIDs, affectedPeerIDs); err != nil {
log.WithContext(ctx).Errorf("failed to update network map cache for peer %s: %v", newPeer.ID, err)
}
- p, nmap, pc, _, err := am.networkMapController.GetValidatedPeerWithMap(ctx, false, accountID, newPeer)
- return p, nmap, pc, err
+ return newPeer, network, postureChecks, enableSSH, nil
}
func getPeerIPDNSLabel(ip netip.Addr, peerHostName string) (string, error) {
@@ -1010,17 +1042,51 @@ func (am *DefaultAccountManager) SyncPeer(ctx context.Context, sync types.PeerSy
return nil, nil, nil, 0, err
}
+ nmap, resPostureChecks, dnsFwdPort, err := am.networkMapController.GetValidatedPeerWithMap(ctx, peerNotValid, accountID, peer.ID)
+ if err != nil {
+ return nil, nil, nil, 0, err
+ }
+
if isStatusChanged || sync.UpdateAccountPeers || ipv6CapabilityChanged || (updated && (len(postureChecks) > 0 || versionChanged)) {
- err = am.networkMapController.OnPeersUpdated(ctx, accountID, []string{peer.ID})
- if err != nil {
+ changedPeerIDs := []string{peer.ID}
+ affectedPeerIDs := am.syncPeerAffectedPeers(ctx, accountID, peer.ID, nmap, peerNotValid, updated, len(postureChecks) > 0)
+ if err = am.networkMapController.OnPeersUpdated(ctx, accountID, changedPeerIDs, affectedPeerIDs); err != nil {
return nil, nil, nil, 0, fmt.Errorf("notify network map controller of peer update: %w", err)
}
}
- return am.networkMapController.GetValidatedPeerWithMap(ctx, peerNotValid, accountID, peer)
+ return peer, nmap, resPostureChecks, dnsFwdPort, nil
}
-func (am *DefaultAccountManager) handlePeerLoginNotFound(ctx context.Context, login types.PeerLogin, err error) (*nbpeer.Peer, *types.NetworkMap, []*posture.Checks, error) {
+// syncPeerAffectedPeers resolves the peers affected by a SyncPeer change. The
+// peer's own validated network map is bidirectional for policy and routing
+// reachability, so when the peer stays valid and no source-posture gate is in
+// play it already lists every affected peer — reuse it and skip the full
+// dependency walk. Posture checks gate the source side of a policy only, so a
+// metadata change that flips a posture result removes this peer from others'
+// maps asymmetrically; that case (and an invalid peer, whose map is empty) falls
+// back to the resolver.
+func (am *DefaultAccountManager) syncPeerAffectedPeers(ctx context.Context, accountID, peerID string, nmap *types.NetworkMap, peerNotValid, metaUpdated, hasPostureChecks bool) []string {
+ if peerNotValid || (metaUpdated && hasPostureChecks) {
+ return am.resolveAffectedPeersForPeerChanges(ctx, am.Store, accountID, []string{peerID})
+ }
+ return affectedPeerIDsFromNetworkMap(nmap, peerID)
+}
+
+// markConnectedAffectedPeers resolves the peers affected when a peer connects
+// (login-expiry reconnect or embedded-proxy connect). The connecting peer's
+// network map already lists them bidirectionally — the synthesized
+// private-service policy puts proxy access-group members in the proxy peer's own
+// map, and these edges carry no source-posture gate. An invalid peer has an
+// empty map, so fall back to the resolver in that case.
+func (am *DefaultAccountManager) markConnectedAffectedPeers(ctx context.Context, accountID, peerID string, nmap *types.NetworkMap) []string {
+ if nmap == nil || len(nmap.Peers)+len(nmap.OfflinePeers) == 0 {
+ return am.resolveAffectedPeersForPeerChanges(ctx, am.Store, accountID, []string{peerID})
+ }
+ return affectedPeerIDsFromNetworkMap(nmap, peerID)
+}
+
+func (am *DefaultAccountManager) handlePeerLoginNotFound(ctx context.Context, login types.PeerLogin, err error) (*nbpeer.Peer, *types.Network, []*posture.Checks, bool, error) {
if errStatus, ok := status.FromError(err); ok && errStatus.Type() == status.NotFound {
// we couldn't find this peer by its public key which can mean that peer hasn't been registered yet.
// Try registering it.
@@ -1036,12 +1102,12 @@ func (am *DefaultAccountManager) handlePeerLoginNotFound(ctx context.Context, lo
}
log.WithContext(ctx).Errorf("failed while logging in peer %s: %v", login.WireGuardPubKey, err)
- return nil, nil, nil, status.Errorf(status.Internal, "failed while logging in peer")
+ return nil, nil, nil, false, status.Errorf(status.Internal, "failed while logging in peer")
}
// LoginPeer logs in or registers a peer.
// If peer doesn't exist the function checks whether a setup key or a user is present and registers a new peer if so.
-func (am *DefaultAccountManager) LoginPeer(ctx context.Context, login types.PeerLogin) (*nbpeer.Peer, *types.NetworkMap, []*posture.Checks, error) {
+func (am *DefaultAccountManager) LoginPeer(ctx context.Context, login types.PeerLogin) (*nbpeer.Peer, *types.Network, []*posture.Checks, bool, error) {
accountID, err := am.Store.GetAccountIDByPeerPubKey(ctx, login.WireGuardPubKey)
if err != nil {
return am.handlePeerLoginNotFound(ctx, login, err)
@@ -1053,20 +1119,17 @@ func (am *DefaultAccountManager) LoginPeer(ctx context.Context, login types.Peer
if login.UserID == "" {
err = am.checkIFPeerNeedsLoginWithoutLock(ctx, accountID, login)
if err != nil {
- return nil, nil, nil, err
+ return nil, nil, nil, false, err
}
}
var peer *nbpeer.Peer
- var updateRemotePeers bool
- var isPeerUpdated bool
- var ipv6CapabilityChanged bool
- var postureChecks []*posture.Checks
+ var shouldStorePeer bool
var peerGroupIDs []string
settings, err := am.Store.GetAccountSettings(ctx, store.LockingStrengthNone, accountID)
if err != nil {
- return nil, nil, nil, err
+ return nil, nil, nil, false, err
}
err = am.Store.ExecuteInTransaction(ctx, func(transaction store.Store) error {
@@ -1075,9 +1138,6 @@ func (am *DefaultAccountManager) LoginPeer(ctx context.Context, login types.Peer
return err
}
- // this flag prevents unnecessary calls to the persistent store.
- shouldStorePeer := false
-
if login.UserID != "" {
if peer.UserID != login.UserID {
log.Warnf("user mismatch when logging in peer %s: peer user %s, login user %s ", peer.ID, peer.UserID, login.UserID)
@@ -1091,7 +1151,6 @@ func (am *DefaultAccountManager) LoginPeer(ctx context.Context, login types.Peer
if changed {
shouldStorePeer = true
- updateRemotePeers = true
}
}
@@ -1100,23 +1159,9 @@ func (am *DefaultAccountManager) LoginPeer(ctx context.Context, login types.Peer
return err
}
- oldHasIPv6Cap := peer.HasCapability(nbpeer.PeerCapabilityIPv6Overlay)
- isPeerUpdated, _ = peer.UpdateMetaIfNew(login.Meta)
- ipv6CapabilityChanged = oldHasIPv6Cap != peer.HasCapability(nbpeer.PeerCapabilityIPv6Overlay)
- if isPeerUpdated {
- am.metrics.AccountManagerMetrics().CountPeerMetUpdate()
- shouldStorePeer = true
-
- postureChecks, err = getPeerPostureChecks(ctx, transaction, accountID, peer.ID)
- if err != nil {
- return err
- }
- }
-
if peer.SSHKey != login.SSHKey {
peer.SSHKey = login.SSHKey
shouldStorePeer = true
- updateRemotePeers = true
}
if !peer.AllowExtraDNSLabels && len(login.ExtraDNSLabels) > 0 {
@@ -1132,23 +1177,28 @@ func (am *DefaultAccountManager) LoginPeer(ctx context.Context, login types.Peer
return nil
})
if err != nil {
- return nil, nil, nil, err
+ return nil, nil, nil, false, err
}
isRequiresApproval, isStatusChanged, err := am.integratedPeerValidator.IsNotValidPeer(ctx, accountID, peer, peerGroupIDs, settings.Extra)
if err != nil {
- return nil, nil, nil, err
+ return nil, nil, nil, false, err
}
- if updateRemotePeers || isStatusChanged || ipv6CapabilityChanged || (isPeerUpdated && len(postureChecks) > 0) {
- err = am.networkMapController.OnPeersUpdated(ctx, accountID, []string{peer.ID})
- if err != nil {
- return nil, nil, nil, fmt.Errorf("notify network map controller of peer update: %w", err)
+ network, postureChecks, enableSSH, err := getPeerLoginInfo(ctx, am.Store, accountID, peer, !isRequiresApproval)
+ if err != nil {
+ return nil, nil, nil, false, err
+ }
+
+ if isStatusChanged || shouldStorePeer {
+ changedPeerIDs := []string{peer.ID}
+ affectedPeerIDs := am.resolveAffectedPeersForPeerChanges(ctx, am.Store, accountID, changedPeerIDs)
+ if err = am.networkMapController.OnPeersUpdated(ctx, accountID, changedPeerIDs, affectedPeerIDs); err != nil {
+ return nil, nil, nil, false, fmt.Errorf("notify network map controller of peer update: %w", err)
}
}
- p, nmap, pc, _, err := am.networkMapController.GetValidatedPeerWithMap(ctx, isRequiresApproval, accountID, peer)
- return p, nmap, pc, err
+ return peer, network, postureChecks, enableSSH, nil
}
// ExtendPeerSession refreshes the peer's SSO session deadline by updating
@@ -1224,6 +1274,50 @@ func (am *DefaultAccountManager) ExtendPeerSession(ctx context.Context, peerPubK
return refreshed.SessionExpiresAt(settings.PeerLoginExpirationEnabled, settings.PeerLoginExpiration), nil
}
+// getPeerLoginInfo computes the login/register response data (network, posture
+// checks, SSH) from the store without building the peer's full network map.
+func getPeerLoginInfo(ctx context.Context, transaction store.Store, accountID string, peer *nbpeer.Peer, isValid bool) (*types.Network, []*posture.Checks, bool, error) {
+ network, err := transaction.GetAccountNetwork(ctx, store.LockingStrengthNone, accountID)
+ if err != nil {
+ return nil, nil, false, fmt.Errorf("get account network: %w", err)
+ }
+
+ if !isValid {
+ return network, nil, false, nil
+ }
+
+ postureChecks, err := getPeerPostureChecks(ctx, transaction, accountID, peer.ID)
+ if err != nil {
+ return nil, nil, false, err
+ }
+
+ enableSSH, err := isPeerSSHEnabled(ctx, transaction, accountID, peer)
+ if err != nil {
+ return nil, nil, false, err
+ }
+
+ return network, postureChecks, enableSSH, nil
+}
+
+func isPeerSSHEnabled(ctx context.Context, transaction store.Store, accountID string, peer *nbpeer.Peer) (bool, error) {
+ policies, err := transaction.GetAccountPolicies(ctx, store.LockingStrengthNone, accountID)
+ if err != nil {
+ return false, err
+ }
+
+ peerGroups, err := transaction.GetPeerGroups(ctx, store.LockingStrengthNone, accountID, peer.ID)
+ if err != nil {
+ return false, err
+ }
+
+ peerGroupIDs := make(map[string]struct{}, len(peerGroups))
+ for _, g := range peerGroups {
+ peerGroupIDs[g.ID] = struct{}{}
+ }
+
+ return types.PeerSSHEnabledFromPolicies(policies, peer.ID, peerGroupIDs, peer.SSHEnabled), nil
+}
+
// getPeerPostureChecks returns the posture checks for the peer.
func getPeerPostureChecks(ctx context.Context, transaction store.Store, accountID, peerID string) ([]*posture.Checks, error) {
policies, err := transaction.GetAccountPolicies(ctx, store.LockingStrengthNone, accountID)
@@ -1379,7 +1473,7 @@ func (am *DefaultAccountManager) GetPeer(ctx context.Context, accountID, peerID,
return nil, err
}
- allowed, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Peers, operations.Read)
+ allowed, ctx, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Peers, operations.Read)
if err != nil {
return nil, status.NewPermissionValidationError(err)
}
@@ -1406,6 +1500,100 @@ func (am *DefaultAccountManager) UpdateAccountPeers(ctx context.Context, account
_ = am.networkMapController.UpdateAccountPeers(ctx, accountID, reason)
}
+// ExpandAndUpdateAffected expands a Snapshot (loaded INSIDE the now-committed
+// transaction) into the affected peers and dispatches the network-map refresh.
+// Pure in-memory work plus dispatch, so it runs AFTER commit — the fan-out walk
+// never holds the write lock, over the consistent in-tx snapshot. Exported so the
+// networks sub-package managers (which hold only account.Manager) share it.
+func (am *DefaultAccountManager) ExpandAndUpdateAffected(ctx context.Context, accountID string, snap *affectedpeers.Snapshot, change affectedpeers.Change) {
+ go am.dispatchAffected(ctx, accountID, []*affectedpeers.Snapshot{snap}, []affectedpeers.Change{change})
+}
+
+// dispatchAffected expands one or more (snapshot, change) pairs — collected across
+// one or several transactions — unions their affected peers, and dispatches a
+// single network-map refresh. Each snapshot must already be loaded inside its
+// transaction; this runs AFTER commit (pure in-memory + dispatch). It is spawned
+// in a goroutine that outlives the request, so it detaches from the request
+// context's cancellation up front.
+func (am *DefaultAccountManager) dispatchAffected(ctx context.Context, accountID string, snaps []*affectedpeers.Snapshot, changes []affectedpeers.Change) {
+ ctx = context.WithoutCancel(ctx)
+
+ var lists [][]string
+ for i, snap := range snaps {
+ if snap == nil {
+ continue
+ }
+ lists = append(lists, snap.Expand(ctx, accountID, changes[i]))
+ }
+
+ affectedPeerIDs := unionStrings(lists...)
+ if len(affectedPeerIDs) == 0 {
+ log.WithContext(ctx).Tracef("no affected peers for account %s", accountID)
+ return
+ }
+
+ log.WithContext(ctx).Debugf("updating %d affected peers for account %s: %v", len(affectedPeerIDs), accountID, affectedPeerIDs)
+ _ = am.networkMapController.UpdateAffectedPeers(ctx, accountID, affectedPeerIDs)
+}
+
+// unionStrings concatenates the given string lists into one deduplicated slice,
+// preserving first-occurrence order.
+func unionStrings(lists ...[]string) []string {
+ seen := make(map[string]struct{})
+ var out []string
+ for _, list := range lists {
+ for _, id := range list {
+ if _, ok := seen[id]; ok {
+ continue
+ }
+ seen[id] = struct{}{}
+ out = append(out, id)
+ }
+ }
+ return out
+}
+
+// affectedPeerIDsFromNetworkMap returns the peer IDs referenced by a peer's
+// network map (its connected and offline peers, which include routing and proxy
+// peers), excluding the peer itself. For a freshly added peer these are, by ACL
+// symmetry, exactly the peers its addition affects.
+func affectedPeerIDsFromNetworkMap(nmap *types.NetworkMap, selfPeerID string) []string {
+ if nmap == nil {
+ return nil
+ }
+ seen := make(map[string]struct{}, len(nmap.Peers)+len(nmap.OfflinePeers))
+ ids := make([]string, 0, len(nmap.Peers)+len(nmap.OfflinePeers))
+ add := func(peers []*nbpeer.Peer) {
+ for _, p := range peers {
+ if p == nil || p.ID == "" || p.ID == selfPeerID {
+ continue
+ }
+ if _, ok := seen[p.ID]; ok {
+ continue
+ }
+ seen[p.ID] = struct{}{}
+ ids = append(ids, p.ID)
+ }
+ }
+ add(nmap.Peers)
+ add(nmap.OfflinePeers)
+ return ids
+}
+
+// resolveAffectedPeersForPeerChanges loads a snapshot and expands it for a peer
+// change. The graph is unchanged by these paths, so it runs out of the mutating
+// transaction (after commit); the resolver derives the peers' group memberships
+// during the walk, so the caller passes only the changed peer IDs.
+func (am *DefaultAccountManager) resolveAffectedPeersForPeerChanges(ctx context.Context, s store.Store, accountID string, changedPeerIDs []string) []string {
+ change := affectedpeers.Change{ChangedPeerIDs: changedPeerIDs}
+ snap, err := affectedpeers.Load(ctx, s, accountID, change)
+ if err != nil {
+ log.WithContext(ctx).Errorf("failed to load snapshot for affected peers: %v", err)
+ return nil
+ }
+ return snap.Expand(ctx, accountID, change)
+}
+
func (am *DefaultAccountManager) BufferUpdateAccountPeers(ctx context.Context, accountID string, reason types.UpdateReason) {
_ = am.networkMapController.BufferUpdateAccountPeers(ctx, accountID, reason)
}
diff --git a/management/server/peer_test.go b/management/server/peer_test.go
index 9d6856740..98cf10acf 100644
--- a/management/server/peer_test.go
+++ b/management/server/peer_test.go
@@ -205,7 +205,7 @@ func testGetNetworkMapGeneral(t *testing.T) {
return
}
- peer1, _, _, err := manager.AddPeer(context.Background(), "", setupKey.Key, "", &nbpeer.Peer{
+ peer1, _, _, _, err := manager.AddPeer(context.Background(), "", setupKey.Key, "", &nbpeer.Peer{
Key: peerKey1.PublicKey().String(),
Meta: nbpeer.PeerSystemMeta{Hostname: "test-peer-1"},
}, false)
@@ -219,7 +219,7 @@ func testGetNetworkMapGeneral(t *testing.T) {
t.Fatal(err)
return
}
- _, _, _, err = manager.AddPeer(context.Background(), "", setupKey.Key, "", &nbpeer.Peer{
+ _, _, _, _, err = manager.AddPeer(context.Background(), "", setupKey.Key, "", &nbpeer.Peer{
Key: peerKey2.PublicKey().String(),
Meta: nbpeer.PeerSystemMeta{Hostname: "test-peer-2"},
}, false)
@@ -278,7 +278,7 @@ func TestAccountManager_GetNetworkMapWithPolicy(t *testing.T) {
return
}
- peer1, _, _, err := manager.AddPeer(context.Background(), "", setupKey.Key, "", &nbpeer.Peer{
+ peer1, _, _, _, err := manager.AddPeer(context.Background(), "", setupKey.Key, "", &nbpeer.Peer{
Key: peerKey1.PublicKey().String(),
Meta: nbpeer.PeerSystemMeta{Hostname: "test-peer-1"},
}, false)
@@ -292,7 +292,7 @@ func TestAccountManager_GetNetworkMapWithPolicy(t *testing.T) {
t.Fatal(err)
return
}
- peer2, _, _, err := manager.AddPeer(context.Background(), "", setupKey.Key, "", &nbpeer.Peer{
+ peer2, _, _, _, err := manager.AddPeer(context.Background(), "", setupKey.Key, "", &nbpeer.Peer{
Key: peerKey2.PublicKey().String(),
Meta: nbpeer.PeerSystemMeta{Hostname: "test-peer-2"},
}, false)
@@ -454,7 +454,7 @@ func TestAccountManager_GetPeerNetwork(t *testing.T) {
return
}
- peer1, _, _, err := manager.AddPeer(context.Background(), "", setupKey.Key, "", &nbpeer.Peer{
+ peer1, _, _, _, err := manager.AddPeer(context.Background(), "", setupKey.Key, "", &nbpeer.Peer{
Key: peerKey1.PublicKey().String(),
Meta: nbpeer.PeerSystemMeta{Hostname: "test-peer-1"},
}, false)
@@ -468,7 +468,7 @@ func TestAccountManager_GetPeerNetwork(t *testing.T) {
t.Fatal(err)
return
}
- _, _, _, err = manager.AddPeer(context.Background(), "", setupKey.Key, "", &nbpeer.Peer{
+ _, _, _, _, err = manager.AddPeer(context.Background(), "", setupKey.Key, "", &nbpeer.Peer{
Key: peerKey2.PublicKey().String(),
Meta: nbpeer.PeerSystemMeta{Hostname: "test-peer-2"},
}, false)
@@ -526,7 +526,7 @@ func TestDefaultAccountManager_GetPeer(t *testing.T) {
return
}
- peer1, _, _, err := manager.AddPeer(context.Background(), "", "", someUser, &nbpeer.Peer{
+ peer1, _, _, _, err := manager.AddPeer(context.Background(), "", "", someUser, &nbpeer.Peer{
Key: peerKey1.PublicKey().String(),
Meta: nbpeer.PeerSystemMeta{Hostname: "test-peer-2"},
}, false)
@@ -542,7 +542,7 @@ func TestDefaultAccountManager_GetPeer(t *testing.T) {
}
// the second peer added with a setup key
- peer2, _, _, err := manager.AddPeer(context.Background(), "", setupKey.Key, "", &nbpeer.Peer{
+ peer2, _, _, _, err := manager.AddPeer(context.Background(), "", setupKey.Key, "", &nbpeer.Peer{
Key: peerKey2.PublicKey().String(),
Meta: nbpeer.PeerSystemMeta{Hostname: "test-peer-2"},
}, false)
@@ -698,7 +698,7 @@ func TestDefaultAccountManager_GetPeers(t *testing.T) {
return
}
- _, _, _, err = manager.AddPeer(context.Background(), "", "", someUser, &nbpeer.Peer{
+ _, _, _, _, err = manager.AddPeer(context.Background(), "", "", someUser, &nbpeer.Peer{
Key: peerKey1.PublicKey().String(),
Meta: nbpeer.PeerSystemMeta{Hostname: "test-peer-1"},
}, false)
@@ -707,7 +707,7 @@ func TestDefaultAccountManager_GetPeers(t *testing.T) {
return
}
- _, _, _, err = manager.AddPeer(context.Background(), "", "", adminUser, &nbpeer.Peer{
+ _, _, _, _, err = manager.AddPeer(context.Background(), "", "", adminUser, &nbpeer.Peer{
Key: peerKey2.PublicKey().String(),
Meta: nbpeer.PeerSystemMeta{Hostname: "test-peer-2"},
}, false)
@@ -1332,7 +1332,7 @@ func Test_RegisterPeerByUser(t *testing.T) {
},
}
- addedPeer, _, _, err := am.AddPeer(context.Background(), "", "", existingUserID, newPeer, false)
+ addedPeer, _, _, _, err := am.AddPeer(context.Background(), "", "", existingUserID, newPeer, false)
require.NoError(t, err)
assert.Equal(t, newPeer.ExtraDNSLabels, addedPeer.ExtraDNSLabels)
@@ -1465,7 +1465,7 @@ func Test_RegisterPeerBySetupKey(t *testing.T) {
ExtraDNSLabels: newPeerTemplate.ExtraDNSLabels,
}
- addedPeer, _, _, err := am.AddPeer(context.Background(), "", tc.existingSetupKeyID, "", currentPeer, false)
+ addedPeer, _, _, _, err := am.AddPeer(context.Background(), "", tc.existingSetupKeyID, "", currentPeer, false)
if tc.expectAddPeerError {
require.Error(t, err, "Expected an error when adding peer with setup key: %s", tc.existingSetupKeyID)
@@ -1577,7 +1577,7 @@ func Test_RegisterPeerRollbackOnFailure(t *testing.T) {
SSHEnabled: false,
}
- _, _, _, err = am.AddPeer(context.Background(), "", faultyKey, "", newPeer, false)
+ _, _, _, _, err = am.AddPeer(context.Background(), "", faultyKey, "", newPeer, false)
require.Error(t, err)
_, err = s.GetPeerByPeerPubKey(context.Background(), store.LockingStrengthNone, newPeer.Key)
@@ -1723,7 +1723,7 @@ func Test_LoginPeer(t *testing.T) {
if sk.AllowExtraDNSLabels {
currentPeer.ExtraDNSLabels = newPeerTemplate.ExtraDNSLabels
}
- _, _, _, err = am.AddPeer(context.Background(), "", tc.setupKey, "", currentPeer, false)
+ _, _, _, _, err = am.AddPeer(context.Background(), "", tc.setupKey, "", currentPeer, false)
require.NoError(t, err, "Expected no error when adding peer with setup key: %s", tc.setupKey)
loginInput := types.PeerLogin{
@@ -1739,12 +1739,12 @@ func Test_LoginPeer(t *testing.T) {
loginInput.ExtraDNSLabels = tc.extraDNSLabels
}
- loggedinPeer, networkMap, postureChecks, loginErr := am.LoginPeer(context.Background(), loginInput)
+ loggedinPeer, network, postureChecks, _, loginErr := am.LoginPeer(context.Background(), loginInput)
if tc.expectLoginError {
require.Error(t, loginErr, "Expected an error during LoginPeer with setup key: %s", tc.setupKey)
assert.Contains(t, loginErr.Error(), tc.expectedErrorMsgSubstring, "Error message mismatch")
assert.Nil(t, loggedinPeer, "LoggedinPeer should be nil on error")
- assert.Nil(t, networkMap, "NetworkMap should be nil on error")
+ assert.Nil(t, network, "Network should be nil on error")
assert.Nil(t, postureChecks, "PostureChecks should be empty or nil on error")
return
}
@@ -1757,7 +1757,7 @@ func Test_LoginPeer(t *testing.T) {
} else {
assert.Equal(t, currentPeer.ExtraDNSLabels, loggedinPeer.ExtraDNSLabels, "ExtraDNSLabels mismatch on loggedinPeer")
}
- assert.NotNil(t, networkMap, "networkMap should not be nil on success")
+ assert.NotNil(t, network, "network should not be nil on success")
assert.Equal(t, existingAccountID, loggedinPeer.AccountID, "AccountID mismatch for logged peer")
@@ -1855,7 +1855,7 @@ func TestPeerAccountPeersUpdate(t *testing.T) {
t.Run("adding peer to unlinked group", func(t *testing.T) {
done := make(chan struct{})
go func() {
- peerShouldReceiveUpdate(t, updMsg) //
+ peerShouldNotReceiveUpdate(t, updMsg)
close(done)
}()
@@ -1863,7 +1863,7 @@ func TestPeerAccountPeersUpdate(t *testing.T) {
require.NoError(t, err)
expectedPeerKey := key.PublicKey().String()
- peer4, _, _, err = manager.AddPeer(context.Background(), "", "", "regularUser1", &nbpeer.Peer{
+ peer4, _, _, _, err = manager.AddPeer(context.Background(), "", "", "regularUser1", &nbpeer.Peer{
Key: expectedPeerKey,
Meta: nbpeer.PeerSystemMeta{Hostname: expectedPeerKey},
}, false)
@@ -1880,7 +1880,7 @@ func TestPeerAccountPeersUpdate(t *testing.T) {
t.Run("deleting peer with unlinked group", func(t *testing.T) {
done := make(chan struct{})
go func() {
- peerShouldReceiveUpdate(t, updMsg)
+ peerShouldNotReceiveUpdate(t, updMsg)
close(done)
}()
@@ -1986,7 +1986,7 @@ func TestPeerAccountPeersUpdate(t *testing.T) {
require.NoError(t, err)
expectedPeerKey := key.PublicKey().String()
- peer4, _, _, err = manager.AddPeer(context.Background(), "", "", "regularUser1", &nbpeer.Peer{
+ peer4, _, _, _, err = manager.AddPeer(context.Background(), "", "", "regularUser1", &nbpeer.Peer{
Key: expectedPeerKey,
LoginExpirationEnabled: true,
Meta: nbpeer.PeerSystemMeta{Hostname: expectedPeerKey},
@@ -2018,7 +2018,10 @@ func TestPeerAccountPeersUpdate(t *testing.T) {
}
})
- // Adding peer to group linked with route should update account peers and send peer update
+ // drain any buffered updates from previous subtests
+ drainPeerUpdates(updMsg)
+
+ // Adding peer to group linked with route should update peers in that group, not unrelated peers
t.Run("adding peer to group linked with route", func(t *testing.T) {
route := nbroute.Route{
ID: "testingRoute1",
@@ -2042,7 +2045,7 @@ func TestPeerAccountPeersUpdate(t *testing.T) {
done := make(chan struct{})
go func() {
- peerShouldReceiveUpdate(t, updMsg)
+ peerShouldNotReceiveUpdate(t, updMsg)
close(done)
}()
@@ -2050,7 +2053,7 @@ func TestPeerAccountPeersUpdate(t *testing.T) {
require.NoError(t, err)
expectedPeerKey := key.PublicKey().String()
- peer5, _, _, err = manager.AddPeer(context.Background(), "", "", "regularUser2", &nbpeer.Peer{
+ peer5, _, _, _, err = manager.AddPeer(context.Background(), "", "", "regularUser2", &nbpeer.Peer{
Key: expectedPeerKey,
LoginExpirationEnabled: true,
Meta: nbpeer.PeerSystemMeta{Hostname: expectedPeerKey},
@@ -2059,16 +2062,16 @@ func TestPeerAccountPeersUpdate(t *testing.T) {
select {
case <-done:
- case <-time.After(peerUpdateTimeout):
- t.Error("timeout waiting for peerShouldReceiveUpdate")
+ case <-time.After(time.Second):
+ t.Error("timeout waiting for peerShouldNotReceiveUpdate")
}
})
- // Deleting peer with linked group to route should update account peers and send peer update
+ // Deleting peer with linked group to route should update peers in that group, not unrelated peers
t.Run("deleting peer with linked group to route", func(t *testing.T) {
done := make(chan struct{})
go func() {
- peerShouldReceiveUpdate(t, updMsg)
+ peerShouldNotReceiveUpdate(t, updMsg)
close(done)
}()
@@ -2077,12 +2080,12 @@ func TestPeerAccountPeersUpdate(t *testing.T) {
select {
case <-done:
- case <-time.After(peerUpdateTimeout):
- t.Error("timeout waiting for peerShouldReceiveUpdate")
+ case <-time.After(time.Second):
+ t.Error("timeout waiting for peerShouldNotReceiveUpdate")
}
})
- // Adding peer to group linked with name server group should update account peers and send peer update
+ // Adding peer to group linked with name server group should update peers in that group, not unrelated peers
t.Run("adding peer to group linked with name server group", func(t *testing.T) {
_, err = manager.CreateNameServerGroup(
context.Background(), account.Id, "nsGroup", "nsGroup", []nbdns.NameServer{{
@@ -2097,7 +2100,7 @@ func TestPeerAccountPeersUpdate(t *testing.T) {
done := make(chan struct{})
go func() {
- peerShouldReceiveUpdate(t, updMsg)
+ peerShouldNotReceiveUpdate(t, updMsg)
close(done)
}()
@@ -2105,7 +2108,7 @@ func TestPeerAccountPeersUpdate(t *testing.T) {
require.NoError(t, err)
expectedPeerKey := key.PublicKey().String()
- peer6, _, _, err = manager.AddPeer(context.Background(), "", "", "regularUser3", &nbpeer.Peer{
+ peer6, _, _, _, err = manager.AddPeer(context.Background(), "", "", "regularUser3", &nbpeer.Peer{
Key: expectedPeerKey,
LoginExpirationEnabled: true,
Meta: nbpeer.PeerSystemMeta{Hostname: expectedPeerKey},
@@ -2114,16 +2117,16 @@ func TestPeerAccountPeersUpdate(t *testing.T) {
select {
case <-done:
- case <-time.After(peerUpdateTimeout):
- t.Error("timeout waiting for peerShouldReceiveUpdate")
+ case <-time.After(time.Second):
+ t.Error("timeout waiting for peerShouldNotReceiveUpdate")
}
})
- // Deleting peer with linked group to name server group should update account peers and send peer update
+ // Deleting peer with linked group to name server group should update peers in that group, not unrelated peers
t.Run("deleting peer with linked group to route", func(t *testing.T) {
done := make(chan struct{})
go func() {
- peerShouldReceiveUpdate(t, updMsg)
+ peerShouldNotReceiveUpdate(t, updMsg)
close(done)
}()
@@ -2132,8 +2135,8 @@ func TestPeerAccountPeersUpdate(t *testing.T) {
select {
case <-done:
- case <-time.After(peerUpdateTimeout):
- t.Error("timeout waiting for peerShouldReceiveUpdate")
+ case <-time.After(time.Second):
+ t.Error("timeout waiting for peerShouldNotReceiveUpdate")
}
})
}
@@ -2283,7 +2286,7 @@ func Test_AddPeer(t *testing.T) {
<-start
- _, _, _, err := manager.AddPeer(context.Background(), "", setupKey.Key, "", newPeer, false)
+ _, _, _, _, err := manager.AddPeer(context.Background(), "", setupKey.Key, "", newPeer, false)
if err != nil {
errs <- fmt.Errorf("AddPeer failed for peer %d: %w", i, err)
return
@@ -2363,7 +2366,7 @@ func TestAddPeer_UserPendingApprovalBlocked(t *testing.T) {
},
}
- _, _, _, err = manager.AddPeer(context.Background(), "", "", pendingUser.Id, peer, false)
+ _, _, _, _, err = manager.AddPeer(context.Background(), "", "", pendingUser.Id, peer, false)
require.Error(t, err)
assert.Contains(t, err.Error(), "user pending approval cannot add peers")
}
@@ -2398,7 +2401,7 @@ func TestAddPeer_ApprovedUserCanAddPeers(t *testing.T) {
},
}
- _, _, _, err = manager.AddPeer(context.Background(), "", "", regularUser.Id, peer, false)
+ _, _, _, _, err = manager.AddPeer(context.Background(), "", "", regularUser.Id, peer, false)
require.NoError(t, err, "Regular user should be able to add peers")
}
@@ -2441,7 +2444,7 @@ func TestLoginPeer_UserPendingApprovalBlocked(t *testing.T) {
WtVersion: "0.28.0",
},
}
- existingPeer, _, _, err := manager.AddPeer(context.Background(), "", "", pendingUser.Id, newPeer, false)
+ existingPeer, _, _, _, err := manager.AddPeer(context.Background(), "", "", pendingUser.Id, newPeer, false)
require.NoError(t, err)
// Now set the user back to pending approval after peer was created
@@ -2460,7 +2463,7 @@ func TestLoginPeer_UserPendingApprovalBlocked(t *testing.T) {
},
}
- _, _, _, err = manager.LoginPeer(context.Background(), login)
+ _, _, _, _, err = manager.LoginPeer(context.Background(), login)
require.Error(t, err)
e, ok := status.FromError(err)
require.True(t, ok, "error is not a gRPC status error")
@@ -2497,7 +2500,7 @@ func TestLoginPeer_ApprovedUserCanLogin(t *testing.T) {
WtVersion: "0.28.0",
},
}
- existingPeer, _, _, err := manager.AddPeer(context.Background(), "", "", regularUser.Id, newPeer, false)
+ existingPeer, _, _, _, err := manager.AddPeer(context.Background(), "", "", regularUser.Id, newPeer, false)
require.NoError(t, err)
// Try to login with regular user
@@ -2510,7 +2513,7 @@ func TestLoginPeer_ApprovedUserCanLogin(t *testing.T) {
},
}
- _, _, _, err = manager.LoginPeer(context.Background(), login)
+ _, _, _, _, err = manager.LoginPeer(context.Background(), login)
require.NoError(t, err, "Regular user should be able to login peers")
}
@@ -2834,7 +2837,7 @@ func TestUpdatePeer_DnsLabelCollisionWithFQDN(t *testing.T) {
// Add first peer with hostname that produces DNS label "netbird1"
key1, err := wgtypes.GenerateKey()
require.NoError(t, err)
- peer1, _, _, err := manager.AddPeer(context.Background(), "", "", userID, &nbpeer.Peer{
+ peer1, _, _, _, err := manager.AddPeer(context.Background(), "", "", userID, &nbpeer.Peer{
Key: key1.PublicKey().String(),
Meta: nbpeer.PeerSystemMeta{Hostname: "netbird1.netbird.cloud"},
}, false)
@@ -2844,7 +2847,7 @@ func TestUpdatePeer_DnsLabelCollisionWithFQDN(t *testing.T) {
// Add second peer with a different hostname
key2, err := wgtypes.GenerateKey()
require.NoError(t, err)
- peer2, _, _, err := manager.AddPeer(context.Background(), "", "", userID, &nbpeer.Peer{
+ peer2, _, _, _, err := manager.AddPeer(context.Background(), "", "", userID, &nbpeer.Peer{
Key: key2.PublicKey().String(),
Meta: nbpeer.PeerSystemMeta{Hostname: "ip-10-29-5-130"},
}, false)
@@ -2868,7 +2871,7 @@ func TestUpdatePeer_DnsLabelUniqueName(t *testing.T) {
key1, err := wgtypes.GenerateKey()
require.NoError(t, err)
- peer1, _, _, err := manager.AddPeer(context.Background(), "", "", userID, &nbpeer.Peer{
+ peer1, _, _, _, err := manager.AddPeer(context.Background(), "", "", userID, &nbpeer.Peer{
Key: key1.PublicKey().String(),
Meta: nbpeer.PeerSystemMeta{Hostname: "web-server"},
}, false)
@@ -2878,7 +2881,7 @@ func TestUpdatePeer_DnsLabelUniqueName(t *testing.T) {
// Add second peer and rename it to a unique FQDN whose first label doesn't collide
key2, err := wgtypes.GenerateKey()
require.NoError(t, err)
- peer2, _, _, err := manager.AddPeer(context.Background(), "", "", userID, &nbpeer.Peer{
+ peer2, _, _, _, err := manager.AddPeer(context.Background(), "", "", userID, &nbpeer.Peer{
Key: key2.PublicKey().String(),
Meta: nbpeer.PeerSystemMeta{Hostname: "old-name"},
}, false)
diff --git a/management/server/permissions/manager.go b/management/server/permissions/manager.go
index e6bdd2025..995f234d8 100644
--- a/management/server/permissions/manager.go
+++ b/management/server/permissions/manager.go
@@ -9,6 +9,7 @@ import (
"github.com/netbirdio/netbird/management/server/account"
"github.com/netbirdio/netbird/management/server/activity"
+ nbcontext "github.com/netbirdio/netbird/management/server/context"
"github.com/netbirdio/netbird/management/server/permissions/modules"
"github.com/netbirdio/netbird/management/server/permissions/operations"
"github.com/netbirdio/netbird/management/server/permissions/roles"
@@ -18,9 +19,9 @@ import (
)
type Manager interface {
- ValidateUserPermissions(ctx context.Context, accountID, userID string, module modules.Module, operation operations.Operation) (bool, error)
+ ValidateUserPermissions(ctx context.Context, accountID, userID string, module modules.Module, operation operations.Operation) (bool, context.Context, error)
ValidateRoleModuleAccess(ctx context.Context, accountID string, role roles.RolePermissions, module modules.Module, operation operations.Operation) bool
- ValidateAccountAccess(ctx context.Context, accountID string, user *types.User, allowOwnerAndAdmin bool) error
+ ValidateAccountAccess(ctx context.Context, accountID string, user *types.User, allowOwnerAndAdmin bool) (context.Context, error)
GetPermissionsByRole(ctx context.Context, role types.UserRole) (roles.Permissions, error)
SetAccountManager(accountManager account.Manager)
@@ -42,42 +43,43 @@ func (m *managerImpl) ValidateUserPermissions(
userID string,
module modules.Module,
operation operations.Operation,
-) (bool, error) {
+) (bool, context.Context, error) {
if userID == activity.SystemInitiator {
- return true, nil
+ return true, ctx, nil
}
user, err := m.store.GetUserByUserID(ctx, store.LockingStrengthNone, userID)
if err != nil {
- return false, err
+ return false, ctx, err
}
if user == nil {
- return false, status.NewUserNotFoundError(userID)
+ return false, ctx, status.NewUserNotFoundError(userID)
}
if user.IsBlocked() && !user.PendingApproval {
- return false, status.NewUserBlockedError()
+ return false, ctx, status.NewUserBlockedError()
}
if user.IsBlocked() && user.PendingApproval {
- return false, status.NewUserPendingApprovalError()
+ return false, ctx, status.NewUserPendingApprovalError()
}
- if err := m.ValidateAccountAccess(ctx, accountID, user, false); err != nil {
- return false, err
+ ctxEnriched, err := m.ValidateAccountAccess(ctx, accountID, user, false)
+ if err != nil {
+ return false, ctx, err
}
if operation == operations.Read && user.IsServiceUser {
- return true, nil // this should be replaced by proper granular access role
+ return true, ctxEnriched, nil // this should be replaced by proper granular access role
}
role, ok := roles.RolesMap[user.Role]
if !ok {
- return false, status.NewUserRoleNotFoundError(string(user.Role))
+ return false, ctxEnriched, status.NewUserRoleNotFoundError(string(user.Role))
}
- return m.ValidateRoleModuleAccess(ctx, accountID, role, module, operation), nil
+ return m.ValidateRoleModuleAccess(ctx, accountID, role, module, operation), ctxEnriched, nil
}
func (m *managerImpl) ValidateRoleModuleAccess(
@@ -98,11 +100,14 @@ func (m *managerImpl) ValidateRoleModuleAccess(
return role.AutoAllowNew[operation]
}
-func (m *managerImpl) ValidateAccountAccess(ctx context.Context, accountID string, user *types.User, allowOwnerAndAdmin bool) error {
+func (m *managerImpl) ValidateAccountAccess(ctx context.Context, accountID string, user *types.User, allowOwnerAndAdmin bool) (context.Context, error) {
if user.AccountID != accountID {
- return status.NewUserNotPartOfAccountError()
+ return ctx, status.NewUserNotPartOfAccountError()
}
- return nil
+
+ ctx = nbcontext.WithRole(ctx, string(user.Role))
+
+ return ctx, nil
}
func (m *managerImpl) GetPermissionsByRole(ctx context.Context, role types.UserRole) (roles.Permissions, error) {
diff --git a/management/server/permissions/manager_mock.go b/management/server/permissions/manager_mock.go
index ec9f263f9..934e33398 100644
--- a/management/server/permissions/manager_mock.go
+++ b/management/server/permissions/manager_mock.go
@@ -67,11 +67,12 @@ func (mr *MockManagerMockRecorder) SetAccountManager(accountManager interface{})
}
// ValidateAccountAccess mocks base method.
-func (m *MockManager) ValidateAccountAccess(ctx context.Context, accountID string, user *types.User, allowOwnerAndAdmin bool) error {
+func (m *MockManager) ValidateAccountAccess(ctx context.Context, accountID string, user *types.User, allowOwnerAndAdmin bool) (context.Context, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "ValidateAccountAccess", ctx, accountID, user, allowOwnerAndAdmin)
- ret0, _ := ret[0].(error)
- return ret0
+ ret0, _ := ret[0].(context.Context)
+ ret1, _ := ret[1].(error)
+ return ret0, ret1
}
// ValidateAccountAccess indicates an expected call of ValidateAccountAccess.
@@ -95,12 +96,13 @@ func (mr *MockManagerMockRecorder) ValidateRoleModuleAccess(ctx, accountID, role
}
// ValidateUserPermissions mocks base method.
-func (m *MockManager) ValidateUserPermissions(ctx context.Context, accountID, userID string, module modules.Module, operation operations.Operation) (bool, error) {
+func (m *MockManager) ValidateUserPermissions(ctx context.Context, accountID, userID string, module modules.Module, operation operations.Operation) (bool, context.Context, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "ValidateUserPermissions", ctx, accountID, userID, module, operation)
ret0, _ := ret[0].(bool)
- ret1, _ := ret[1].(error)
- return ret0, ret1
+ ret1, _ := ret[1].(context.Context)
+ ret2, _ := ret[2].(error)
+ return ret0, ret1, ret2
}
// ValidateUserPermissions indicates an expected call of ValidateUserPermissions.
diff --git a/management/server/policy.go b/management/server/policy.go
index e33a33a43..2516fbfa6 100644
--- a/management/server/policy.go
+++ b/management/server/policy.go
@@ -5,7 +5,7 @@ import (
_ "embed"
"github.com/rs/xid"
- "github.com/sirupsen/logrus"
+ log "github.com/sirupsen/logrus"
"github.com/netbirdio/netbird/management/server/permissions/modules"
"github.com/netbirdio/netbird/management/server/permissions/operations"
@@ -13,13 +13,14 @@ import (
"github.com/netbirdio/netbird/management/server/types"
"github.com/netbirdio/netbird/management/server/activity"
+ "github.com/netbirdio/netbird/management/server/affectedpeers"
"github.com/netbirdio/netbird/management/server/posture"
"github.com/netbirdio/netbird/shared/management/status"
)
// GetPolicy from the store
func (am *DefaultAccountManager) GetPolicy(ctx context.Context, accountID, policyID, userID string) (*types.Policy, error) {
- allowed, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Policies, operations.Read)
+ allowed, ctx, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Policies, operations.Read)
if err != nil {
return nil, status.NewPermissionValidationError(err)
}
@@ -36,7 +37,7 @@ func (am *DefaultAccountManager) SavePolicy(ctx context.Context, accountID, user
if !create {
operation = operations.Update
}
- allowed, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Policies, operation)
+ allowed, ctx, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Policies, operation)
if err != nil {
return nil, status.NewPermissionValidationError(err)
}
@@ -45,41 +46,33 @@ func (am *DefaultAccountManager) SavePolicy(ctx context.Context, accountID, user
}
var isUpdate = policy.ID != ""
- var updateAccountPeers bool
+ var existingPolicy *types.Policy
var action = activity.PolicyAdded
var unchanged bool
+ var snap *affectedpeers.Snapshot
+ var change affectedpeers.Change
err = am.Store.ExecuteInTransaction(ctx, func(transaction store.Store) error {
- existingPolicy, err := validatePolicy(ctx, transaction, accountID, policy)
+ existingPolicy, err = validatePolicy(ctx, transaction, accountID, policy)
if err != nil {
return err
}
if isUpdate {
if policy.Equal(existingPolicy) {
- logrus.WithContext(ctx).Tracef("policy update skipped because equal to stored one - policy id %s", policy.ID)
+ log.WithContext(ctx).Tracef("policy update skipped because equal to stored one - policy id %s", policy.ID)
unchanged = true
return nil
}
action = activity.PolicyUpdated
- updateAccountPeers, err = arePolicyChangesAffectPeersWithExisting(ctx, transaction, policy, existingPolicy)
- if err != nil {
- return err
- }
-
policy.AccountSeqID = existingPolicy.AccountSeqID
if err = transaction.SavePolicy(ctx, policy); err != nil {
return err
}
} else {
- updateAccountPeers, err = arePolicyChangesAffectPeers(ctx, transaction, policy)
- if err != nil {
- return err
- }
-
seq, err := transaction.AllocateAccountSeqID(ctx, accountID, types.AccountSeqEntityPolicy)
if err != nil {
return err
@@ -91,6 +84,17 @@ func (am *DefaultAccountManager) SavePolicy(ctx context.Context, accountID, user
}
}
+ // On update carry both the old and new policy so peers losing access via a
+ // removed rule still refresh; on create there is no prior policy.
+ if isUpdate {
+ change = affectedpeers.Change{Policies: []*types.Policy{existingPolicy, policy}}
+ } else {
+ change = affectedpeers.Change{Policies: []*types.Policy{policy}}
+ }
+ if snap, err = affectedpeers.Load(ctx, transaction, accountID, change); err != nil {
+ return err
+ }
+
return transaction.IncrementNetworkSerial(ctx, accountID)
})
if err != nil {
@@ -103,20 +107,14 @@ func (am *DefaultAccountManager) SavePolicy(ctx context.Context, accountID, user
am.StoreEvent(ctx, userID, policy.ID, accountID, action, policy.EventMeta())
- if updateAccountPeers {
- policyOp := types.UpdateOperationCreate
- if isUpdate {
- policyOp = types.UpdateOperationUpdate
- }
- am.UpdateAccountPeers(ctx, accountID, types.UpdateReason{Resource: types.UpdateResourcePolicy, Operation: policyOp})
- }
+ am.ExpandAndUpdateAffected(ctx, accountID, snap, change)
return policy, nil
}
// DeletePolicy from the store
func (am *DefaultAccountManager) DeletePolicy(ctx context.Context, accountID, policyID, userID string) error {
- allowed, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Policies, operations.Delete)
+ allowed, ctx, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Policies, operations.Delete)
if err != nil {
return status.NewPermissionValidationError(err)
}
@@ -125,7 +123,8 @@ func (am *DefaultAccountManager) DeletePolicy(ctx context.Context, accountID, po
}
var policy *types.Policy
- var updateAccountPeers bool
+ var snap *affectedpeers.Snapshot
+ change := affectedpeers.Change{}
err = am.Store.ExecuteInTransaction(ctx, func(transaction store.Store) error {
policy, err = transaction.GetPolicyByID(ctx, store.LockingStrengthUpdate, accountID, policyID)
@@ -133,8 +132,9 @@ func (am *DefaultAccountManager) DeletePolicy(ctx context.Context, accountID, po
return err
}
- updateAccountPeers, err = arePolicyChangesAffectPeers(ctx, transaction, policy)
- if err != nil {
+ // Load before delete: pre-state still references the policy.
+ change = affectedpeers.Change{Policies: []*types.Policy{policy}}
+ if snap, err = affectedpeers.Load(ctx, transaction, accountID, change); err != nil {
return err
}
@@ -150,16 +150,14 @@ func (am *DefaultAccountManager) DeletePolicy(ctx context.Context, accountID, po
am.StoreEvent(ctx, userID, policyID, accountID, activity.PolicyRemoved, policy.EventMeta())
- if updateAccountPeers {
- am.UpdateAccountPeers(ctx, accountID, types.UpdateReason{Resource: types.UpdateResourcePolicy, Operation: types.UpdateOperationDelete})
- }
+ am.ExpandAndUpdateAffected(ctx, accountID, snap, change)
return nil
}
// ListPolicies from the store.
func (am *DefaultAccountManager) ListPolicies(ctx context.Context, accountID, userID string) ([]*types.Policy, error) {
- allowed, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Policies, operations.Read)
+ allowed, ctx, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Policies, operations.Read)
if err != nil {
return nil, status.NewPermissionValidationError(err)
}
@@ -170,46 +168,6 @@ func (am *DefaultAccountManager) ListPolicies(ctx context.Context, accountID, us
return am.Store.GetAccountPolicies(ctx, store.LockingStrengthNone, accountID)
}
-// arePolicyChangesAffectPeers checks if a policy (being created or deleted) will affect any associated peers.
-func arePolicyChangesAffectPeers(ctx context.Context, transaction store.Store, policy *types.Policy) (bool, error) {
- for _, rule := range policy.Rules {
- if rule.SourceResource.Type != "" || rule.DestinationResource.Type != "" {
- return true, nil
- }
- }
-
- return anyGroupHasPeersOrResources(ctx, transaction, policy.AccountID, policy.RuleGroups())
-}
-
-func arePolicyChangesAffectPeersWithExisting(ctx context.Context, transaction store.Store, policy *types.Policy, existingPolicy *types.Policy) (bool, error) {
- if !policy.Enabled && !existingPolicy.Enabled {
- return false, nil
- }
-
- for _, rule := range existingPolicy.Rules {
- if rule.SourceResource.Type != "" || rule.DestinationResource.Type != "" {
- return true, nil
- }
- }
-
- hasPeers, err := anyGroupHasPeersOrResources(ctx, transaction, policy.AccountID, existingPolicy.RuleGroups())
- if err != nil {
- return false, err
- }
-
- if hasPeers {
- return true, nil
- }
-
- for _, rule := range policy.Rules {
- if rule.SourceResource.Type != "" || rule.DestinationResource.Type != "" {
- return true, nil
- }
- }
-
- return anyGroupHasPeersOrResources(ctx, transaction, policy.AccountID, policy.RuleGroups())
-}
-
// validatePolicy validates the policy and its rules. For updates it returns
// the existing policy loaded from the store so callers can avoid a second read.
func validatePolicy(ctx context.Context, transaction store.Store, accountID string, policy *types.Policy) (*types.Policy, error) {
diff --git a/management/server/policy_test.go b/management/server/policy_test.go
index 1eae07e79..6fb573b9e 100644
--- a/management/server/policy_test.go
+++ b/management/server/policy_test.go
@@ -1319,12 +1319,14 @@ func TestPolicyAccountPeersUpdate(t *testing.T) {
}
})
- // Updating disabled policy with destination and source groups containing peers should not update account's peers
- // or send peer update
+ // Updating disabled policy with destination and source groups containing peers should still update account's peers
+ // because affected peer resolution does not filter by policy enabled state
t.Run("updating disabled policy with source and destination groups with peers", func(t *testing.T) {
+ drainPeerUpdates(updMsg)
+
done := make(chan struct{})
go func() {
- peerShouldNotReceiveUpdate(t, updMsg)
+ peerShouldReceiveUpdate(t, updMsg)
close(done)
}()
@@ -1335,8 +1337,8 @@ func TestPolicyAccountPeersUpdate(t *testing.T) {
select {
case <-done:
- case <-time.After(time.Second):
- t.Error("timeout waiting for peerShouldNotReceiveUpdate")
+ case <-time.After(peerUpdateTimeout):
+ t.Error("timeout waiting for peerShouldReceiveUpdate")
}
})
diff --git a/management/server/posture_checks.go b/management/server/posture_checks.go
index 5f548f2de..c85139069 100644
--- a/management/server/posture_checks.go
+++ b/management/server/posture_checks.go
@@ -7,6 +7,7 @@ import (
"github.com/rs/xid"
"github.com/netbirdio/netbird/management/server/activity"
+ "github.com/netbirdio/netbird/management/server/affectedpeers"
"github.com/netbirdio/netbird/management/server/permissions/modules"
"github.com/netbirdio/netbird/management/server/permissions/operations"
"github.com/netbirdio/netbird/management/server/posture"
@@ -16,7 +17,7 @@ import (
)
func (am *DefaultAccountManager) GetPostureChecks(ctx context.Context, accountID, postureChecksID, userID string) (*posture.Checks, error) {
- allowed, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Policies, operations.Read)
+ allowed, ctx, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Policies, operations.Read)
if err != nil {
return nil, status.NewPermissionValidationError(err)
}
@@ -33,7 +34,7 @@ func (am *DefaultAccountManager) SavePostureChecks(ctx context.Context, accountI
if !create {
operation = operations.Update
}
- allowed, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Policies, operation)
+ allowed, ctx, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Policies, operation)
if err != nil {
return nil, status.NewPermissionValidationError(err)
}
@@ -41,9 +42,10 @@ func (am *DefaultAccountManager) SavePostureChecks(ctx context.Context, accountI
return nil, status.NewPermissionDeniedError()
}
- var updateAccountPeers bool
var isUpdate = postureChecks.ID != ""
var action = activity.PostureCheckCreated
+ var snap *affectedpeers.Snapshot
+ change := affectedpeers.Change{PostureCheckIDs: []string{postureChecks.ID}}
err = am.Store.ExecuteInTransaction(ctx, func(transaction store.Store) error {
if err = validatePostureChecks(ctx, transaction, accountID, postureChecks); err != nil {
@@ -57,11 +59,6 @@ func (am *DefaultAccountManager) SavePostureChecks(ctx context.Context, accountI
}
postureChecks.AccountSeqID = existing.AccountSeqID
- updateAccountPeers, err = arePostureCheckChangesAffectPeers(ctx, transaction, accountID, postureChecks.ID)
- if err != nil {
- return err
- }
-
action = activity.PostureCheckUpdated
} else {
seq, err := transaction.AllocateAccountSeqID(ctx, accountID, types.AccountSeqEntityPostureCheck)
@@ -77,6 +74,11 @@ func (am *DefaultAccountManager) SavePostureChecks(ctx context.Context, accountI
}
if isUpdate {
+ // Editing a posture check does not change which policies reference it,
+ // so loading after the save is fine.
+ if snap, err = affectedpeers.Load(ctx, transaction, accountID, change); err != nil {
+ return err
+ }
return transaction.IncrementNetworkSerial(ctx, accountID)
}
@@ -88,20 +90,14 @@ func (am *DefaultAccountManager) SavePostureChecks(ctx context.Context, accountI
am.StoreEvent(ctx, userID, postureChecks.ID, accountID, action, postureChecks.EventMeta())
- if updateAccountPeers {
- postureOp := types.UpdateOperationCreate
- if isUpdate {
- postureOp = types.UpdateOperationUpdate
- }
- am.UpdateAccountPeers(ctx, accountID, types.UpdateReason{Resource: types.UpdateResourcePostureCheck, Operation: postureOp})
- }
+ am.ExpandAndUpdateAffected(ctx, accountID, snap, change)
return postureChecks, nil
}
// DeletePostureChecks deletes a posture check by ID.
func (am *DefaultAccountManager) DeletePostureChecks(ctx context.Context, accountID, postureChecksID, userID string) error {
- allowed, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Policies, operations.Delete)
+ allowed, ctx, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Policies, operations.Delete)
if err != nil {
return status.NewPermissionValidationError(err)
}
@@ -138,7 +134,7 @@ func (am *DefaultAccountManager) DeletePostureChecks(ctx context.Context, accoun
// ListPostureChecks returns a list of posture checks.
func (am *DefaultAccountManager) ListPostureChecks(ctx context.Context, accountID, userID string) ([]*posture.Checks, error) {
- allowed, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Policies, operations.Read)
+ allowed, ctx, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Policies, operations.Read)
if err != nil {
return nil, status.NewPermissionValidationError(err)
}
@@ -149,29 +145,6 @@ func (am *DefaultAccountManager) ListPostureChecks(ctx context.Context, accountI
return am.Store.GetAccountPostureChecks(ctx, store.LockingStrengthNone, accountID)
}
-// arePostureCheckChangesAffectPeers checks if the changes in posture checks are affecting peers.
-func arePostureCheckChangesAffectPeers(ctx context.Context, transaction store.Store, accountID, postureCheckID string) (bool, error) {
- policies, err := transaction.GetAccountPolicies(ctx, store.LockingStrengthNone, accountID)
- if err != nil {
- return false, err
- }
-
- for _, policy := range policies {
- if slices.Contains(policy.SourcePostureChecks, postureCheckID) {
- hasPeers, err := anyGroupHasPeersOrResources(ctx, transaction, accountID, policy.RuleGroups())
- if err != nil {
- return false, err
- }
-
- if hasPeers {
- return true, nil
- }
- }
- }
-
- return false, nil
-}
-
// validatePostureChecks validates the posture checks.
func validatePostureChecks(ctx context.Context, transaction store.Store, accountID string, postureChecks *posture.Checks) error {
if err := postureChecks.Validate(); err != nil {
diff --git a/management/server/posture_checks_test.go b/management/server/posture_checks_test.go
index ca9bef389..19fa0139e 100644
--- a/management/server/posture_checks_test.go
+++ b/management/server/posture_checks_test.go
@@ -503,21 +503,20 @@ func TestArePostureCheckChangesAffectPeers(t *testing.T) {
require.NoError(t, err, "failed to save policy")
t.Run("posture check exists and is linked to policy with peers", func(t *testing.T) {
- result, err := arePostureCheckChangesAffectPeers(context.Background(), manager.Store, account.Id, postureCheckA.ID)
- require.NoError(t, err)
- assert.True(t, result)
+ groupIDs, _ := collectPostureCheckAffectedGroupsAndPeers(context.Background(), manager.Store, account.Id, postureCheckA.ID)
+ assert.NotEmpty(t, groupIDs)
})
t.Run("posture check exists but is not linked to any policy", func(t *testing.T) {
- result, err := arePostureCheckChangesAffectPeers(context.Background(), manager.Store, account.Id, postureCheckB.ID)
- require.NoError(t, err)
- assert.False(t, result)
+ groupIDs, directPeerIDs := collectPostureCheckAffectedGroupsAndPeers(context.Background(), manager.Store, account.Id, postureCheckB.ID)
+ assert.Empty(t, groupIDs)
+ assert.Empty(t, directPeerIDs)
})
t.Run("posture check does not exist", func(t *testing.T) {
- result, err := arePostureCheckChangesAffectPeers(context.Background(), manager.Store, account.Id, "unknown")
- require.NoError(t, err)
- assert.False(t, result)
+ groupIDs, directPeerIDs := collectPostureCheckAffectedGroupsAndPeers(context.Background(), manager.Store, account.Id, "unknown")
+ assert.Empty(t, groupIDs)
+ assert.Empty(t, directPeerIDs)
})
t.Run("posture check is linked to policy with no peers in source groups", func(t *testing.T) {
@@ -526,9 +525,8 @@ func TestArePostureCheckChangesAffectPeers(t *testing.T) {
_, err = manager.SavePolicy(context.Background(), account.Id, adminUserID, policy, true)
require.NoError(t, err, "failed to update policy")
- result, err := arePostureCheckChangesAffectPeers(context.Background(), manager.Store, account.Id, postureCheckA.ID)
- require.NoError(t, err)
- assert.True(t, result)
+ groupIDs, _ := collectPostureCheckAffectedGroupsAndPeers(context.Background(), manager.Store, account.Id, postureCheckA.ID)
+ assert.NotEmpty(t, groupIDs)
})
t.Run("posture check is linked to policy with no peers in destination groups", func(t *testing.T) {
@@ -537,9 +535,8 @@ func TestArePostureCheckChangesAffectPeers(t *testing.T) {
_, err = manager.SavePolicy(context.Background(), account.Id, adminUserID, policy, true)
require.NoError(t, err, "failed to update policy")
- result, err := arePostureCheckChangesAffectPeers(context.Background(), manager.Store, account.Id, postureCheckA.ID)
- require.NoError(t, err)
- assert.True(t, result)
+ groupIDs, _ := collectPostureCheckAffectedGroupsAndPeers(context.Background(), manager.Store, account.Id, postureCheckA.ID)
+ assert.NotEmpty(t, groupIDs)
})
t.Run("posture check is linked to policy but no peers in groups", func(t *testing.T) {
@@ -547,9 +544,9 @@ func TestArePostureCheckChangesAffectPeers(t *testing.T) {
err = manager.UpdateGroup(context.Background(), account.Id, adminUserID, groupA)
require.NoError(t, err, "failed to save groups")
- result, err := arePostureCheckChangesAffectPeers(context.Background(), manager.Store, account.Id, postureCheckA.ID)
- require.NoError(t, err)
- assert.False(t, result)
+ // The collector returns groups even if they have no peers — the groups are still referenced
+ groupIDs, _ := collectPostureCheckAffectedGroupsAndPeers(context.Background(), manager.Store, account.Id, postureCheckA.ID)
+ assert.NotEmpty(t, groupIDs)
})
t.Run("posture check is linked to policy with non-existent group", func(t *testing.T) {
@@ -558,9 +555,11 @@ func TestArePostureCheckChangesAffectPeers(t *testing.T) {
_, err = manager.SavePolicy(context.Background(), account.Id, adminUserID, policy, true)
require.NoError(t, err, "failed to update policy")
- result, err := arePostureCheckChangesAffectPeers(context.Background(), manager.Store, account.Id, postureCheckA.ID)
- require.NoError(t, err)
- assert.False(t, result)
+ // Non-existent groups are filtered out during SavePolicy validation,
+ // so the saved policy has empty Sources/Destinations
+ groupIDs, directPeerIDs := collectPostureCheckAffectedGroupsAndPeers(context.Background(), manager.Store, account.Id, postureCheckA.ID)
+ assert.Empty(t, groupIDs)
+ assert.Empty(t, directPeerIDs)
})
}
diff --git a/management/server/route.go b/management/server/route.go
index 4d87b68de..9141dffe8 100644
--- a/management/server/route.go
+++ b/management/server/route.go
@@ -10,6 +10,7 @@ import (
"github.com/rs/xid"
"github.com/netbirdio/netbird/management/server/activity"
+ "github.com/netbirdio/netbird/management/server/affectedpeers"
"github.com/netbirdio/netbird/management/server/permissions/modules"
"github.com/netbirdio/netbird/management/server/permissions/operations"
"github.com/netbirdio/netbird/management/server/store"
@@ -21,7 +22,7 @@ import (
// GetRoute gets a route object from account and route IDs
func (am *DefaultAccountManager) GetRoute(ctx context.Context, accountID string, routeID route.ID, userID string) (*route.Route, error) {
- allowed, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Routes, operations.Read)
+ allowed, ctx, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Routes, operations.Read)
if err != nil {
return nil, status.NewPermissionValidationError(err)
}
@@ -134,7 +135,7 @@ func getRouteDescriptor(prefix netip.Prefix, domains domain.List) string {
// CreateRoute creates and saves a new route
func (am *DefaultAccountManager) CreateRoute(ctx context.Context, accountID string, prefix netip.Prefix, networkType route.NetworkType, domains domain.List, peerID string, peerGroupIDs []string, description string, netID route.NetID, masquerade bool, metric int, groups, accessControlGroupIDs []string, enabled bool, userID string, keepRoute bool, skipAutoApply bool) (*route.Route, error) {
- allowed, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Routes, operations.Create)
+ allowed, ctx, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Routes, operations.Create)
if err != nil {
return nil, status.NewPermissionValidationError(err)
}
@@ -147,7 +148,8 @@ func (am *DefaultAccountManager) CreateRoute(ctx context.Context, accountID stri
}
var newRoute *route.Route
- var updateAccountPeers bool
+ var snap *affectedpeers.Snapshot
+ var change affectedpeers.Change
err = am.Store.ExecuteInTransaction(ctx, func(transaction store.Store) error {
newRoute = &route.Route{
@@ -173,11 +175,6 @@ func (am *DefaultAccountManager) CreateRoute(ctx context.Context, accountID stri
return err
}
- updateAccountPeers, err = areRouteChangesAffectPeers(ctx, transaction, newRoute)
- if err != nil {
- return err
- }
-
seq, err := transaction.AllocateAccountSeqID(ctx, accountID, types.AccountSeqEntityRoute)
if err != nil {
return err
@@ -188,6 +185,11 @@ func (am *DefaultAccountManager) CreateRoute(ctx context.Context, accountID stri
return err
}
+ change = affectedpeers.Change{Routes: []*route.Route{newRoute}}
+ if snap, err = affectedpeers.Load(ctx, transaction, accountID, change); err != nil {
+ return err
+ }
+
return transaction.IncrementNetworkSerial(ctx, accountID)
})
if err != nil {
@@ -196,16 +198,14 @@ func (am *DefaultAccountManager) CreateRoute(ctx context.Context, accountID stri
am.StoreEvent(ctx, userID, string(newRoute.ID), accountID, activity.RouteCreated, newRoute.EventMeta())
- if updateAccountPeers {
- am.UpdateAccountPeers(ctx, accountID, types.UpdateReason{Resource: types.UpdateResourceRoute, Operation: types.UpdateOperationCreate})
- }
+ am.ExpandAndUpdateAffected(ctx, accountID, snap, change)
return newRoute, nil
}
// SaveRoute saves route
func (am *DefaultAccountManager) SaveRoute(ctx context.Context, accountID, userID string, routeToSave *route.Route) error {
- allowed, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Routes, operations.Update)
+ allowed, ctx, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Routes, operations.Update)
if err != nil {
return status.NewPermissionValidationError(err)
}
@@ -214,8 +214,8 @@ func (am *DefaultAccountManager) SaveRoute(ctx context.Context, accountID, userI
}
var oldRoute *route.Route
- var oldRouteAffectsPeers bool
- var newRouteAffectsPeers bool
+ var snap *affectedpeers.Snapshot
+ var change affectedpeers.Change
err = am.Store.ExecuteInTransaction(ctx, func(transaction store.Store) error {
if err = validateRoute(ctx, transaction, accountID, routeToSave); err != nil {
@@ -227,15 +227,6 @@ func (am *DefaultAccountManager) SaveRoute(ctx context.Context, accountID, userI
return err
}
- oldRouteAffectsPeers, err = areRouteChangesAffectPeers(ctx, transaction, oldRoute)
- if err != nil {
- return err
- }
-
- newRouteAffectsPeers, err = areRouteChangesAffectPeers(ctx, transaction, routeToSave)
- if err != nil {
- return err
- }
routeToSave.AccountID = accountID
routeToSave.AccountSeqID = oldRoute.AccountSeqID
@@ -243,6 +234,11 @@ func (am *DefaultAccountManager) SaveRoute(ctx context.Context, accountID, userI
return err
}
+ change = affectedpeers.Change{Routes: []*route.Route{routeToSave, oldRoute}}
+ if snap, err = affectedpeers.Load(ctx, transaction, accountID, change); err != nil {
+ return err
+ }
+
return transaction.IncrementNetworkSerial(ctx, accountID)
})
if err != nil {
@@ -251,16 +247,14 @@ func (am *DefaultAccountManager) SaveRoute(ctx context.Context, accountID, userI
am.StoreEvent(ctx, userID, string(routeToSave.ID), accountID, activity.RouteUpdated, routeToSave.EventMeta())
- if oldRouteAffectsPeers || newRouteAffectsPeers {
- am.UpdateAccountPeers(ctx, accountID, types.UpdateReason{Resource: types.UpdateResourceRoute, Operation: types.UpdateOperationUpdate})
- }
+ am.ExpandAndUpdateAffected(ctx, accountID, snap, change)
return nil
}
// DeleteRoute deletes route with routeID
func (am *DefaultAccountManager) DeleteRoute(ctx context.Context, accountID string, routeID route.ID, userID string) error {
- allowed, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Routes, operations.Delete)
+ allowed, ctx, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Routes, operations.Delete)
if err != nil {
return status.NewPermissionValidationError(err)
}
@@ -268,17 +262,19 @@ func (am *DefaultAccountManager) DeleteRoute(ctx context.Context, accountID stri
return status.NewPermissionDeniedError()
}
- var route *route.Route
- var updateAccountPeers bool
+ var rt *route.Route
+ var snap *affectedpeers.Snapshot
+ var change affectedpeers.Change
err = am.Store.ExecuteInTransaction(ctx, func(transaction store.Store) error {
- route, err = transaction.GetRouteByID(ctx, store.LockingStrengthUpdate, accountID, string(routeID))
+ rt, err = transaction.GetRouteByID(ctx, store.LockingStrengthUpdate, accountID, string(routeID))
if err != nil {
return err
}
- updateAccountPeers, err = areRouteChangesAffectPeers(ctx, transaction, route)
- if err != nil {
+ // Load before delete: pre-state captures everyone referencing the route.
+ change = affectedpeers.Change{Routes: []*route.Route{rt}}
+ if snap, err = affectedpeers.Load(ctx, transaction, accountID, change); err != nil {
return err
}
@@ -292,18 +288,16 @@ func (am *DefaultAccountManager) DeleteRoute(ctx context.Context, accountID stri
return fmt.Errorf("failed to delete route %s: %w", routeID, err)
}
- am.StoreEvent(ctx, userID, string(route.ID), accountID, activity.RouteRemoved, route.EventMeta())
+ am.StoreEvent(ctx, userID, string(rt.ID), accountID, activity.RouteRemoved, rt.EventMeta())
- if updateAccountPeers {
- am.UpdateAccountPeers(ctx, accountID, types.UpdateReason{Resource: types.UpdateResourceRoute, Operation: types.UpdateOperationDelete})
- }
+ am.ExpandAndUpdateAffected(ctx, accountID, snap, change)
return nil
}
// ListRoutes returns a list of routes from account
func (am *DefaultAccountManager) ListRoutes(ctx context.Context, accountID, userID string) ([]*route.Route, error) {
- allowed, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Routes, operations.Read)
+ allowed, ctx, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Routes, operations.Read)
if err != nil {
return nil, status.NewPermissionValidationError(err)
}
@@ -384,25 +378,6 @@ func getPlaceholderIP() netip.Prefix {
return netip.PrefixFrom(netip.AddrFrom4([4]byte{192, 0, 2, 0}), 32)
}
-// areRouteChangesAffectPeers checks if a given route affects peers by determining
-// if it has a routing peer, distribution, or peer groups that include peers.
-func areRouteChangesAffectPeers(ctx context.Context, transaction store.Store, route *route.Route) (bool, error) {
- if route.Peer != "" {
- return true, nil
- }
-
- hasPeers, err := anyGroupHasPeersOrResources(ctx, transaction, route.AccountID, route.Groups)
- if err != nil {
- return false, err
- }
-
- if hasPeers {
- return true, nil
- }
-
- return anyGroupHasPeersOrResources(ctx, transaction, route.AccountID, route.PeerGroups)
-}
-
// GetRoutesByPrefixOrDomains return list of routes by account and route prefix
func getRoutesByPrefixOrDomains(ctx context.Context, transaction store.Store, accountID string, prefix netip.Prefix, domains domain.List) ([]*route.Route, error) {
accountRoutes, err := transaction.GetAccountRoutes(ctx, store.LockingStrengthNone, accountID)
diff --git a/management/server/route_test.go b/management/server/route_test.go
index 79014790f..5ae18c253 100644
--- a/management/server/route_test.go
+++ b/management/server/route_test.go
@@ -1962,8 +1962,10 @@ func TestRouteAccountPeersUpdate(t *testing.T) {
})
- // Creating a route with no routing peer and having peers in groups should update account peers and send peer update
+ // Creating a route with no routing peer and having peers in groups that don't include peer1 should not send peer1 an update
t.Run("creating a route with peers in PeerGroups and Groups", func(t *testing.T) {
+ drainPeerUpdates(updMsg)
+
route := route.Route{
ID: "testingRoute2",
Network: netip.MustParsePrefix("192.0.2.0/32"),
@@ -1979,7 +1981,7 @@ func TestRouteAccountPeersUpdate(t *testing.T) {
done := make(chan struct{})
go func() {
- peerShouldReceiveUpdate(t, updMsg)
+ peerShouldNotReceiveUpdate(t, updMsg)
close(done)
}()
@@ -1992,8 +1994,8 @@ func TestRouteAccountPeersUpdate(t *testing.T) {
select {
case <-done:
- case <-time.After(peerUpdateTimeout):
- t.Error("timeout waiting for peerShouldReceiveUpdate")
+ case <-time.After(time.Second):
+ t.Error("timeout waiting for peerShouldNotReceiveUpdate")
}
})
diff --git a/management/server/settings/manager.go b/management/server/settings/manager.go
index 345d857f9..f84739193 100644
--- a/management/server/settings/manager.go
+++ b/management/server/settings/manager.go
@@ -59,7 +59,7 @@ func (m *managerImpl) GetExtraSettingsManager() extra_settings.Manager {
func (m *managerImpl) GetSettings(ctx context.Context, accountID, userID string) (*types.Settings, error) {
if userID != activity.SystemInitiator {
- ok, err := m.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Settings, operations.Read)
+ ok, _, err := m.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Settings, operations.Read)
if err != nil {
return nil, status.NewPermissionValidationError(err)
}
diff --git a/management/server/setupkey.go b/management/server/setupkey.go
index 8d0509871..cfc44377d 100644
--- a/management/server/setupkey.go
+++ b/management/server/setupkey.go
@@ -56,7 +56,7 @@ type SetupKeyUpdateOperation struct {
func (am *DefaultAccountManager) CreateSetupKey(ctx context.Context, accountID string, keyName string, keyType types.SetupKeyType,
expiresIn time.Duration, autoGroups []string, usageLimit int, userID string, ephemeral bool, allowExtraDNSLabels bool) (*types.SetupKey, error) {
- allowed, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.SetupKeys, operations.Create)
+ allowed, ctx, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.SetupKeys, operations.Create)
if err != nil {
return nil, status.NewPermissionValidationError(err)
}
@@ -105,7 +105,7 @@ func (am *DefaultAccountManager) SaveSetupKey(ctx context.Context, accountID str
return nil, status.Errorf(status.InvalidArgument, "provided setup key to update is nil")
}
- allowed, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.SetupKeys, operations.Update)
+ allowed, ctx, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.SetupKeys, operations.Update)
if err != nil {
return nil, status.NewPermissionValidationError(err)
}
@@ -162,7 +162,7 @@ func (am *DefaultAccountManager) SaveSetupKey(ctx context.Context, accountID str
// ListSetupKeys returns a list of all setup keys of the account
func (am *DefaultAccountManager) ListSetupKeys(ctx context.Context, accountID, userID string) ([]*types.SetupKey, error) {
- allowed, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.SetupKeys, operations.Read)
+ allowed, ctx, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.SetupKeys, operations.Read)
if err != nil {
return nil, status.NewPermissionValidationError(err)
}
@@ -175,7 +175,7 @@ func (am *DefaultAccountManager) ListSetupKeys(ctx context.Context, accountID, u
// GetSetupKey looks up a SetupKey by KeyID, returns NotFound error if not found.
func (am *DefaultAccountManager) GetSetupKey(ctx context.Context, accountID, userID, keyID string) (*types.SetupKey, error) {
- allowed, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.SetupKeys, operations.Read)
+ allowed, ctx, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.SetupKeys, operations.Read)
if err != nil {
return nil, status.NewPermissionValidationError(err)
}
@@ -198,7 +198,7 @@ func (am *DefaultAccountManager) GetSetupKey(ctx context.Context, accountID, use
// DeleteSetupKey removes the setup key from the account
func (am *DefaultAccountManager) DeleteSetupKey(ctx context.Context, accountID, userID, keyID string) error {
- allowed, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.SetupKeys, operations.Delete)
+ allowed, ctx, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.SetupKeys, operations.Delete)
if err != nil {
return status.NewPermissionValidationError(err)
}
diff --git a/management/server/setupkey_test.go b/management/server/setupkey_test.go
index 6eca27efd..2d43ea28b 100644
--- a/management/server/setupkey_test.go
+++ b/management/server/setupkey_test.go
@@ -426,6 +426,10 @@ func TestSetupKeyAccountPeersUpdate(t *testing.T) {
updateManager.CloseChannel(context.Background(), peer1.ID)
})
+ // The setup policy above dispatches affected-peer updates asynchronously; drain
+ // any in-flight ones so the assertions only observe the setup-key operations.
+ settleAffectedUpdates(updMsg)
+
var setupKey *types.SetupKey
// Creating setup key should not update account peers and not send peer update
diff --git a/management/server/store/sql_store.go b/management/server/store/sql_store.go
index 9224206ae..51ca06332 100644
--- a/management/server/store/sql_store.go
+++ b/management/server/store/sql_store.go
@@ -266,7 +266,8 @@ func (s *SqlStore) AcquireGlobalLock(ctx context.Context) (unlock func()) {
return unlock
}
-// Deprecated: Full account operations are no longer supported
+// Deprecated: Full
+// account operations are no longer supported
func (s *SqlStore) SaveAccount(ctx context.Context, account *types.Account) error {
start := time.Now()
defer func() {
@@ -1239,6 +1240,7 @@ func (s *SqlStore) getAccountGorm(ctx context.Context, accountID string) (*types
Preload("NetworkResources").
Preload("Onboarding").
Preload("Services.Targets").
+ Preload("Domains").
Take(&account, idQueryCondition, accountID)
if result.Error != nil {
log.WithContext(ctx).Errorf("error when getting account %s from the store: %s", accountID, result.Error)
@@ -1325,7 +1327,7 @@ func (s *SqlStore) getAccountPgx(ctx context.Context, accountID string) (*types.
}
var wg sync.WaitGroup
- errChan := make(chan error, 12)
+ errChan := make(chan error, 16)
wg.Add(1)
go func() {
@@ -1426,6 +1428,17 @@ func (s *SqlStore) getAccountPgx(ctx context.Context, accountID string) (*types.
account.Services = services
}()
+ wg.Add(1)
+ go func() {
+ defer wg.Done()
+ domains, err := s.ListCustomDomains(ctx, accountID)
+ if err != nil {
+ errChan <- err
+ return
+ }
+ account.Domains = domains
+ }()
+
wg.Add(1)
go func() {
defer wg.Done()
@@ -5013,7 +5026,13 @@ func (s *SqlStore) GetPeerByIP(ctx context.Context, lockStrength LockingStrength
result := tx.
Take(&peer, fmt.Sprintf("account_id = ? AND %s = ?", column), accountID, jsonValue)
if result.Error != nil {
- // no logging here
+ // A tunnel-IP miss is an expected outcome (e.g. the proxy's
+ // ValidateTunnelPeer probing an address that isn't in the
+ // account roster); surface it as NotFound so callers can tell
+ // it apart from a real store failure.
+ if errors.Is(result.Error, gorm.ErrRecordNotFound) {
+ return nil, status.Errorf(status.NotFound, "peer with ip %s not found", ip.String())
+ }
return nil, status.Errorf(status.Internal, "failed to get peer from store")
}
@@ -5173,6 +5192,64 @@ func (s *SqlStore) GetPeersByGroupIDs(ctx context.Context, accountID string, gro
return peers, nil
}
+func (s *SqlStore) GetPeerIDsByGroups(ctx context.Context, accountID string, groupIDs []string) ([]string, error) {
+ if len(groupIDs) == 0 {
+ return nil, nil
+ }
+
+ var peerIDs []string
+ result := s.db.Model(&types.GroupPeer{}).
+ Select("DISTINCT peer_id").
+ Where("account_id = ? AND group_id IN ?", accountID, groupIDs).
+ Pluck("peer_id", &peerIDs)
+ if result.Error != nil {
+ return nil, status.Errorf(status.Internal, "failed to get peer IDs by groups: %s", result.Error)
+ }
+
+ return peerIDs, nil
+}
+
+func (s *SqlStore) GetGroupIDsByPeerIDs(ctx context.Context, accountID string, peerIDs []string) ([]string, error) {
+ if len(peerIDs) == 0 {
+ return nil, nil
+ }
+
+ var groupIDs []string
+ result := s.db.Model(&types.GroupPeer{}).
+ Select("DISTINCT group_id").
+ Where("account_id = ? AND peer_id IN ?", accountID, peerIDs).
+ Pluck("group_id", &groupIDs)
+ if result.Error != nil {
+ return nil, status.Errorf(status.Internal, "failed to get group IDs by peers: %s", result.Error)
+ }
+
+ return groupIDs, nil
+}
+
+// GetEmbeddedProxyPeerIDsByCluster returns peer IDs of all embedded proxy peers
+// in the account, grouped by their ProxyCluster. The map is nil when no embedded
+// proxy peers exist.
+func (s *SqlStore) GetEmbeddedProxyPeerIDsByCluster(ctx context.Context, accountID string) (map[string][]string, error) {
+ type row struct {
+ ID string
+ Cluster string
+ }
+ var rows []row
+ result := s.db.Model(&nbpeer.Peer{}).
+ Select("id, proxy_meta_cluster AS cluster").
+ Where("account_id = ? AND proxy_meta_embedded = ?", accountID, true).
+ Scan(&rows)
+ if result.Error != nil {
+ return nil, status.Errorf(status.Internal, "failed to get embedded proxy peers: %s", result.Error)
+ }
+
+ out := make(map[string][]string, len(rows))
+ for _, r := range rows {
+ out[r.Cluster] = append(out[r.Cluster], r.ID)
+ }
+ return out, nil
+}
+
func (s *SqlStore) GetUserIDByPeerKey(ctx context.Context, lockStrength LockingStrength, peerKey string) (string, error) {
tx := s.db
if lockStrength != LockingStrengthNone {
@@ -6241,6 +6318,7 @@ func (s *SqlStore) getClusterCapability(ctx context.Context, clusterAddr, column
}
err := s.db.
+ WithContext(ctx).
Model(&proxy.Proxy{}).
Select("COUNT(CASE WHEN "+column+" IS NOT NULL THEN 1 END) > 0 AS has_capability, "+
"COALESCE(MAX(CASE WHEN "+column+" = true THEN 1 ELSE 0 END), 0) = 1 AS any_true").
diff --git a/management/server/store/sql_store_get_account_test.go b/management/server/store/sql_store_get_account_test.go
index 9a9de8cdd..56f2a6c41 100644
--- a/management/server/store/sql_store_get_account_test.go
+++ b/management/server/store/sql_store_get_account_test.go
@@ -4,6 +4,8 @@ import (
"context"
"net"
"net/netip"
+ "os"
+ "runtime"
"testing"
"time"
@@ -21,6 +23,63 @@ import (
"github.com/netbirdio/netbird/route"
)
+// TestGetAccount_LoadsCustomDomains verifies GetAccount populates account.Domains.
+// SynthesizePrivateServiceZones depends on this relation to anchor a custom-domain
+// private service's DNS zone; without the preload the relation is empty and the
+// service is silently skipped, so a custom domain never resolves on clients.
+func TestGetAccount_LoadsCustomDomains(t *testing.T) {
+ if runtime.GOOS == "windows" {
+ t.Skip("The SQLite store is not properly supported by Windows yet")
+ }
+
+ store, cleanup, err := NewTestStoreFromSQL(context.Background(), "", t.TempDir())
+ require.NoError(t, err)
+ defer cleanup()
+
+ assertGetAccountLoadsCustomDomains(t, store)
+}
+
+func TestPostgresql_GetAccount_LoadsCustomDomains(t *testing.T) {
+ if (os.Getenv("CI") == "true" && runtime.GOOS == "darwin") || runtime.GOOS == "windows" {
+ t.Skip("skip CI tests on darwin and windows")
+ }
+
+ t.Setenv("NETBIRD_STORE_ENGINE", string(types.PostgresStoreEngine))
+ store, cleanup, err := NewTestStoreFromSQL(context.Background(), "", t.TempDir())
+ require.NoError(t, err)
+ t.Cleanup(cleanup)
+
+ assertGetAccountLoadsCustomDomains(t, store)
+}
+
+// assertGetAccountLoadsCustomDomains exercises both the gorm and pgx GetAccount
+// paths: it persists two custom domains and asserts the relation comes back
+// populated, which SynthesizePrivateServiceZones relies on.
+func assertGetAccountLoadsCustomDomains(t *testing.T, store Store) {
+ t.Helper()
+ ctx := context.Background()
+
+ accountID := "acct-custom-domains"
+ require.NoError(t, store.SaveAccount(ctx, newAccountWithId(ctx, accountID, "user-1", "")))
+
+ _, err := store.CreateCustomDomain(ctx, accountID, "example.com", "eu.proxy.netbird.io", true)
+ require.NoError(t, err, "creating the first custom domain must succeed")
+ _, err = store.CreateCustomDomain(ctx, accountID, "apps.acme.io", "us.proxy.netbird.io", false)
+ require.NoError(t, err, "creating the second custom domain must succeed")
+
+ account, err := store.GetAccount(ctx, accountID)
+ require.NoError(t, err)
+ require.Len(t, account.Domains, 2, "GetAccount must preload the account's custom domains")
+
+ byDomain := map[string]string{}
+ for _, d := range account.Domains {
+ require.NotNil(t, d)
+ byDomain[d.Domain] = d.TargetCluster
+ }
+ assert.Equal(t, "eu.proxy.netbird.io", byDomain["example.com"], "custom domain must carry its target cluster")
+ assert.Equal(t, "us.proxy.netbird.io", byDomain["apps.acme.io"], "custom domain must carry its target cluster")
+}
+
// TestGetAccount_ComprehensiveFieldValidation validates that GetAccount properly loads
// all fields and nested objects from the database, including deeply nested structures.
func TestGetAccount_ComprehensiveFieldValidation(t *testing.T) {
diff --git a/management/server/store/sql_store_service_test.go b/management/server/store/sql_store_service_test.go
index 0978440c6..34999da4b 100644
--- a/management/server/store/sql_store_service_test.go
+++ b/management/server/store/sql_store_service_test.go
@@ -13,7 +13,7 @@ import (
)
func TestSqlStore_GetAccount_PrivateServiceRoundtrip(t *testing.T) {
- if (os.Getenv("CI") == "true" && runtime.GOOS == "darwin") || runtime.GOOS == "windows" {
+ if os.Getenv("CI") == "true" && (runtime.GOOS == "darwin" || runtime.GOOS == "windows") {
t.Skip("skip CI tests on darwin and windows")
}
diff --git a/management/server/store/sql_store_test.go b/management/server/store/sql_store_test.go
index 41e3290b6..ac136987e 100644
--- a/management/server/store/sql_store_test.go
+++ b/management/server/store/sql_store_test.go
@@ -6,7 +6,6 @@ import (
b64 "encoding/base64"
"encoding/binary"
"fmt"
- "math/rand"
"net"
"net/netip"
"os"
@@ -92,7 +91,7 @@ func runLargeTest(t *testing.T, store Store) {
account.SetupKeys[setupKey.Key] = setupKey
const numPerAccount = 6000
for n := 0; n < numPerAccount; n++ {
- netIP := randomIPv4()
+ netIP := sequentialIPv4(n)
peerID := fmt.Sprintf("%s-peer-%d", account.Id, n)
addr, _ := netip.AddrFromSlice(netIP)
@@ -216,12 +215,12 @@ func runLargeTest(t *testing.T, store Store) {
}
}
-func randomIPv4() net.IP {
- rand.New(rand.NewSource(time.Now().UnixNano()))
+// sequentialIPv4 returns a unique IPv4 address for the given index, avoiding
+// the random collisions that would otherwise violate the unique (account_id, ip)
+// index when generating a large number of peers.
+func sequentialIPv4(n int) net.IP {
b := make([]byte, 4)
- for i := range b {
- b[i] = byte(rand.Intn(256))
- }
+ binary.BigEndian.PutUint32(b, 0x0A000000+uint32(n))
return net.IP(b)
}
@@ -491,6 +490,27 @@ func Test_GetAccount(t *testing.T) {
})
}
+// TestSqlStore_GetPeerByIP_NotFound pins the not-found semantics the
+// proxy's ValidateTunnelPeer relies on: a tunnel-IP that isn't in the
+// account roster must surface as a NotFound error (not a generic
+// Internal) so callers can distinguish an expected miss from a real
+// store failure. A known IP still resolves.
+func TestSqlStore_GetPeerByIP_NotFound(t *testing.T) {
+ runTestForAllEngines(t, "../testdata/store.sql", func(t *testing.T, store Store) {
+ const accountID = "bf1c8084-ba50-4ce7-9439-34653001fc3b"
+
+ peer, err := store.GetPeerByIP(context.Background(), LockingStrengthNone, accountID, net.ParseIP("192.168.0.0"))
+ require.NoError(t, err, "known tunnel IP must resolve")
+ require.NotNil(t, peer)
+
+ _, err = store.GetPeerByIP(context.Background(), LockingStrengthNone, accountID, net.ParseIP("100.65.0.99"))
+ require.Error(t, err, "unknown tunnel IP must error")
+ parsedErr, ok := status.FromError(err)
+ require.True(t, ok, "error must be a status error")
+ require.Equal(t, status.NotFound, parsedErr.Type(), "tunnel-IP miss must be NotFound, not Internal")
+ })
+}
+
func TestSqlStore_SavePeer(t *testing.T) {
store, cleanUp, err := NewTestStoreFromSQL(context.Background(), "../testdata/store.sql", t.TempDir())
t.Cleanup(cleanUp)
diff --git a/management/server/store/store.go b/management/server/store/store.go
index cc43904ee..156faaeb6 100644
--- a/management/server/store/store.go
+++ b/management/server/store/store.go
@@ -162,6 +162,9 @@ type Store interface {
GetPeerByID(ctx context.Context, lockStrength LockingStrength, accountID string, peerID string) (*nbpeer.Peer, error)
GetPeersByIDs(ctx context.Context, lockStrength LockingStrength, accountID string, peerIDs []string) (map[string]*nbpeer.Peer, error)
GetPeersByGroupIDs(ctx context.Context, accountID string, groupIDs []string) ([]*nbpeer.Peer, error)
+ GetPeerIDsByGroups(ctx context.Context, accountID string, groupIDs []string) ([]string, error)
+ GetGroupIDsByPeerIDs(ctx context.Context, accountID string, peerIDs []string) ([]string, error)
+ GetEmbeddedProxyPeerIDsByCluster(ctx context.Context, accountID string) (map[string][]string, error)
GetAccountPeersWithExpiration(ctx context.Context, lockStrength LockingStrength, accountID string) ([]*nbpeer.Peer, error)
GetAccountPeersWithInactivity(ctx context.Context, lockStrength LockingStrength, accountID string) ([]*nbpeer.Peer, error)
GetAllEphemeralPeers(ctx context.Context, lockStrength LockingStrength) ([]*nbpeer.Peer, error)
diff --git a/management/server/store/store_mock.go b/management/server/store/store_mock.go
index f372fe0d2..77a040ef3 100644
--- a/management/server/store/store_mock.go
+++ b/management/server/store/store_mock.go
@@ -1940,6 +1940,51 @@ func (mr *MockStoreMockRecorder) GetPeersByGroupIDs(ctx, accountID, groupIDs int
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetPeersByGroupIDs", reflect.TypeOf((*MockStore)(nil).GetPeersByGroupIDs), ctx, accountID, groupIDs)
}
+// GetPeerIDsByGroups mocks base method.
+func (m *MockStore) GetPeerIDsByGroups(ctx context.Context, accountID string, groupIDs []string) ([]string, error) {
+ m.ctrl.T.Helper()
+ ret := m.ctrl.Call(m, "GetPeerIDsByGroups", ctx, accountID, groupIDs)
+ ret0, _ := ret[0].([]string)
+ ret1, _ := ret[1].(error)
+ return ret0, ret1
+}
+
+// GetPeerIDsByGroups indicates an expected call of GetPeerIDsByGroups.
+func (mr *MockStoreMockRecorder) GetPeerIDsByGroups(ctx, accountID, groupIDs interface{}) *gomock.Call {
+ mr.mock.ctrl.T.Helper()
+ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetPeerIDsByGroups", reflect.TypeOf((*MockStore)(nil).GetPeerIDsByGroups), ctx, accountID, groupIDs)
+}
+
+// GetGroupIDsByPeerIDs mocks base method.
+func (m *MockStore) GetGroupIDsByPeerIDs(ctx context.Context, accountID string, peerIDs []string) ([]string, error) {
+ m.ctrl.T.Helper()
+ ret := m.ctrl.Call(m, "GetGroupIDsByPeerIDs", ctx, accountID, peerIDs)
+ ret0, _ := ret[0].([]string)
+ ret1, _ := ret[1].(error)
+ return ret0, ret1
+}
+
+// GetGroupIDsByPeerIDs indicates an expected call of GetGroupIDsByPeerIDs.
+func (mr *MockStoreMockRecorder) GetGroupIDsByPeerIDs(ctx, accountID, peerIDs interface{}) *gomock.Call {
+ mr.mock.ctrl.T.Helper()
+ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetGroupIDsByPeerIDs", reflect.TypeOf((*MockStore)(nil).GetGroupIDsByPeerIDs), ctx, accountID, peerIDs)
+}
+
+// GetEmbeddedProxyPeerIDsByCluster mocks base method.
+func (m *MockStore) GetEmbeddedProxyPeerIDsByCluster(ctx context.Context, accountID string) (map[string][]string, error) {
+ m.ctrl.T.Helper()
+ ret := m.ctrl.Call(m, "GetEmbeddedProxyPeerIDsByCluster", ctx, accountID)
+ ret0, _ := ret[0].(map[string][]string)
+ ret1, _ := ret[1].(error)
+ return ret0, ret1
+}
+
+// GetEmbeddedProxyPeerIDsByCluster indicates an expected call of GetEmbeddedProxyPeerIDsByCluster.
+func (mr *MockStoreMockRecorder) GetEmbeddedProxyPeerIDsByCluster(ctx, accountID interface{}) *gomock.Call {
+ mr.mock.ctrl.T.Helper()
+ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetEmbeddedProxyPeerIDsByCluster", reflect.TypeOf((*MockStore)(nil).GetEmbeddedProxyPeerIDsByCluster), ctx, accountID)
+}
+
// GetPeersByIDs mocks base method.
func (m *MockStore) GetPeersByIDs(ctx context.Context, lockStrength LockingStrength, accountID string, peerIDs []string) (map[string]*peer.Peer, error) {
m.ctrl.T.Helper()
diff --git a/management/server/telemetry/http_api_metrics.go b/management/server/telemetry/http_api_metrics.go
index e48e6d64a..360d36949 100644
--- a/management/server/telemetry/http_api_metrics.go
+++ b/management/server/telemetry/http_api_metrics.go
@@ -21,6 +21,8 @@ const (
httpRequestCounterPrefix = "management.http.request.counter"
httpResponseCounterPrefix = "management.http.response.counter"
httpRequestDurationPrefix = "management.http.request.duration.ms"
+
+ RequestIDHeader = "X-Request-Id"
)
// WrappedResponseWriter is a wrapper for http.ResponseWriter that allows the
@@ -172,6 +174,10 @@ func (m *HTTPMiddleware) Handler(h http.Handler) http.Handler {
reqID := xid.New().String()
//nolint
ctx = context.WithValue(ctx, nbContext.RequestIDKey, reqID)
+ //nolint
+ ctx = context.WithValue(ctx, nbContext.UserAgentKey, r.UserAgent())
+
+ rw.Header().Set(RequestIDHeader, reqID)
log.WithContext(ctx).Tracef("HTTP request %v: %v %v", reqID, r.Method, r.URL)
diff --git a/management/server/types/account.go b/management/server/types/account.go
index ed42a4872..120046de5 100644
--- a/management/server/types/account.go
+++ b/management/server/types/account.go
@@ -41,7 +41,6 @@ const (
PublicCategory = "public"
PrivateCategory = "private"
UnknownCategory = "unknown"
-
)
// AccountMeta is a struct that contains a stripped down version of the Account object.
@@ -254,7 +253,7 @@ func (a *Account) SynthesizePrivateServiceZones(peerID string) []nbdns.CustomZon
}
peerGroups := a.GetPeerGroups(peerID)
- zonesByCluster := map[string]*nbdns.CustomZone{}
+ zonesByApex := map[string]*nbdns.CustomZone{}
for _, svc := range a.Services {
if svc == nil || !svc.Enabled || !svc.Private {
@@ -271,19 +270,24 @@ func (a *Account) SynthesizePrivateServiceZones(peerID string) []nbdns.CustomZon
continue
}
- zone, exists := zonesByCluster[svc.ProxyCluster]
+ serviceDomainZone := a.privateServiceDomainZone(svc)
+ if serviceDomainZone == "" {
+ continue
+ }
+
+ zone, exists := zonesByApex[serviceDomainZone]
if !exists {
// NonAuthoritative makes this a match-only zone: queries for
// names without an explicit record fall through to the
// upstream resolver instead of returning NXDOMAIN. Without
// it, adding a single private service would black-hole every
- // other name under the cluster apex.
+ // other name under the zone apex.
zone = &nbdns.CustomZone{
- Domain: dns.Fqdn(svc.ProxyCluster),
+ Domain: dns.Fqdn(serviceDomainZone),
Records: []nbdns.SimpleRecord{},
NonAuthoritative: true,
}
- zonesByCluster[svc.ProxyCluster] = zone
+ zonesByApex[serviceDomainZone] = zone
}
emitted := 0
@@ -321,8 +325,8 @@ func (a *Account) SynthesizePrivateServiceZones(peerID string) []nbdns.CustomZon
}
}
- out := make([]nbdns.CustomZone, 0, len(zonesByCluster))
- for _, zone := range zonesByCluster {
+ out := make([]nbdns.CustomZone, 0, len(zonesByApex))
+ for _, zone := range zonesByApex {
if len(zone.Records) == 0 {
continue
}
@@ -338,6 +342,33 @@ func (a *Account) SynthesizePrivateServiceZones(peerID string) []nbdns.CustomZon
return out
}
+// privateServiceDomainZone returns the DNS zone name for the given private service domain by
+// looking at the proxy cluster domain then the custom domains.
+func (a *Account) privateServiceDomainZone(svc *service.Service) string {
+ if domainFromSuffix(svc.Domain, svc.ProxyCluster) {
+ return svc.ProxyCluster
+ }
+
+ // Longest matching custom domain wins
+ zoneName := ""
+ for _, d := range a.Domains {
+ if d == nil || d.TargetCluster != svc.ProxyCluster {
+ continue
+ }
+ if domainFromSuffix(svc.Domain, d.Domain) && len(d.Domain) > len(zoneName) {
+ zoneName = d.Domain
+ }
+ }
+ return zoneName
+}
+
+func domainFromSuffix(domain, suffix string) bool {
+ if suffix == "" {
+ return false
+ }
+ return domain == suffix || strings.HasSuffix(domain, "."+suffix)
+}
+
// peerInDistributionGroups reports whether any of the peer's groups
// matches the service's bearer-auth distribution_groups.
func peerInDistributionGroups(peerGroups LookupMap, distributionGroups []string) bool {
@@ -1101,6 +1132,47 @@ func (a *Account) connResourcesGenerator(ctx context.Context, targetPeer *nbpeer
}
}
+// PeerSSHEnabledFromPolicies is the network-map-free equivalent of the sshEnabled
+// determination in GetPeerConnectionResources / CalculateNetworkMapFromComponents.
+func PeerSSHEnabledFromPolicies(policies []*Policy, peerID string, peerGroupIDs map[string]struct{}, peerSSHEnabled bool) bool {
+ for _, policy := range policies {
+ if !policy.Enabled {
+ continue
+ }
+
+ for _, rule := range policy.Rules {
+ if !rule.Enabled {
+ continue
+ }
+
+ isSSHRule := rule.Protocol == PolicyRuleProtocolNetbirdSSH ||
+ (PolicyRuleImpliesLegacySSH(rule) && peerSSHEnabled)
+ if !isSSHRule {
+ continue
+ }
+
+ if ruleHasDestination(rule, peerID, peerGroupIDs) {
+ return true
+ }
+ }
+ }
+
+ return false
+}
+
+func ruleHasDestination(rule *PolicyRule, peerID string, peerGroupIDs map[string]struct{}) bool {
+ if rule.DestinationResource.Type == ResourceTypePeer && rule.DestinationResource.ID != "" {
+ return rule.DestinationResource.ID == peerID
+ }
+
+ for _, groupID := range rule.Destinations {
+ if _, ok := peerGroupIDs[groupID]; ok {
+ return true
+ }
+ }
+ return false
+}
+
// getAllPeersFromGroups for given peer ID and list of groups
//
// Returns a list of peers from specified groups that pass specified posture checks
@@ -1693,7 +1765,6 @@ func (a *Account) createProxyPolicy(svc *service.Service, target *service.Target
}
}
-
// filterZoneRecordsForPeers filters DNS records to only include peers to connect.
// AAAA records are excluded when the requesting peer lacks IPv6 capability.
func filterZoneRecordsForPeers(peer *nbpeer.Peer, customZone nbdns.CustomZone, peersToConnect, expiredPeers []*nbpeer.Peer) []nbdns.SimpleRecord {
diff --git a/management/server/types/account_private_zones_test.go b/management/server/types/account_private_zones_test.go
index 1d4f720b7..efbbbffaf 100644
--- a/management/server/types/account_private_zones_test.go
+++ b/management/server/types/account_private_zones_test.go
@@ -11,6 +11,7 @@ import (
"github.com/stretchr/testify/require"
nbdns "github.com/netbirdio/netbird/dns"
+ proxydomain "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/domain"
"github.com/netbirdio/netbird/management/internals/modules/reverseproxy/service"
nbpeer "github.com/netbirdio/netbird/management/server/peer"
)
@@ -234,6 +235,113 @@ func TestPrivateZone_GetPeerNetworkMap_PeerOutsideGroups_OmitsSynthZone(t *testi
assert.False(t, ok, "peer outside the distribution_groups must not see the synth zone")
}
+func TestSynthesizePrivateServiceZones_CustomDomain_ZoneApexIsRegisteredDomain(t *testing.T) {
+ account := privateZoneTestAccount(t)
+ // A custom-domain service: Domain is the custom FQDN, ProxyCluster
+ // is the cluster serving it, and account.Domains holds the registered
+ // custom domain. The synth zone apex must be the registered domain,
+ // not the cluster, or the client's match-only zone never intercepts
+ // the query.
+ account.Services[0].Domain = "app.example.com"
+ account.Domains = []*proxydomain.Domain{
+ {Domain: "example.com", AccountID: "acct-1", TargetCluster: "eu.proxy.netbird.io", Validated: true},
+ }
+
+ zones := account.SynthesizePrivateServiceZones("user-peer")
+ require.Len(t, zones, 1, "custom-domain service must still produce one zone")
+ zone := zones[0]
+ assert.Equal(t, "example.com.", zone.Domain, "zone apex must be the registered custom domain, not the cluster or the service FQDN")
+ assert.True(t, zone.NonAuthoritative, "synth zone must remain match-only")
+ require.Len(t, zone.Records, 1, "custom-domain service yields one A record")
+ rec := zone.Records[0]
+ assert.Equal(t, "app.example.com.", rec.Name, "record name is the custom service FQDN")
+ assert.Equal(t, "100.64.0.99", rec.RData, "record points at the embedded proxy peer's tunnel IP")
+}
+
+func TestSynthesizePrivateServiceZones_CustomAndFreeDomain_SeparateZones(t *testing.T) {
+ account := privateZoneTestAccount(t)
+ account.Domains = []*proxydomain.Domain{
+ {Domain: "example.com", AccountID: "acct-1", TargetCluster: "eu.proxy.netbird.io", Validated: true},
+ }
+ account.Services = append(account.Services, &service.Service{
+ ID: "svc-2",
+ AccountID: "acct-1",
+ Name: "custom",
+ Domain: "app.example.com",
+ ProxyCluster: "eu.proxy.netbird.io",
+ Enabled: true,
+ Private: true,
+ Mode: service.ModeHTTP,
+ AccessGroups: []string{"grp-admins"},
+ })
+
+ zones := account.SynthesizePrivateServiceZones("user-peer")
+ require.Len(t, zones, 2, "a free-domain and a custom-domain service must not collapse into one zone")
+
+ free, ok := findCustomZone(zones, "eu.proxy.netbird.io")
+ require.True(t, ok, "free-domain service keeps the shared cluster-apex zone")
+ require.Len(t, free.Records, 1, "cluster zone carries only the free-domain record")
+ assert.Equal(t, "myapp.eu.proxy.netbird.io.", free.Records[0].Name, "cluster zone record is the free-domain FQDN")
+
+ custom, ok := findCustomZone(zones, "example.com")
+ require.True(t, ok, "custom-domain service gets its own zone at the registered custom domain apex")
+ require.Len(t, custom.Records, 1, "custom zone carries only the custom-domain record")
+ assert.Equal(t, "app.example.com.", custom.Records[0].Name, "custom zone record is the custom-domain FQDN")
+}
+
+func TestSynthesizePrivateServiceZones_TwoServicesSameCustomDomain_OneZone(t *testing.T) {
+ account := privateZoneTestAccount(t)
+ account.Domains = []*proxydomain.Domain{
+ {Domain: "example.com", AccountID: "acct-1", TargetCluster: "eu.proxy.netbird.io", Validated: true},
+ }
+ account.Services[0].Domain = "a.example.com"
+ account.Services = append(account.Services, &service.Service{
+ ID: "svc-2",
+ AccountID: "acct-1",
+ Name: "bapp",
+ Domain: "b.example.com",
+ ProxyCluster: "eu.proxy.netbird.io",
+ Enabled: true,
+ Private: true,
+ Mode: service.ModeHTTP,
+ AccessGroups: []string{"grp-admins"},
+ })
+
+ zones := account.SynthesizePrivateServiceZones("user-peer")
+ require.Len(t, zones, 1, "two services under the same registered custom domain must share one zone")
+ assert.Equal(t, "example.com.", zones[0].Domain, "shared zone apex is the registered custom domain")
+ require.Len(t, zones[0].Records, 2, "both services surface as records in the shared custom-domain zone")
+ names := []string{zones[0].Records[0].Name, zones[0].Records[1].Name}
+ assert.ElementsMatch(t, []string{"a.example.com.", "b.example.com."}, names, "both custom-domain service FQDNs must surface")
+}
+
+func TestSynthesizePrivateServiceZones_CustomDomainNotRegistered_NoZone(t *testing.T) {
+ account := privateZoneTestAccount(t)
+ // Service domain is outside the cluster and no account.Domains entry
+ // covers it: there is no apex that would intercept the query, so the
+ // service must be skipped rather than emit an unmatchable record.
+ account.Services[0].Domain = "app.example.com"
+
+ zones := account.SynthesizePrivateServiceZones("user-peer")
+ assert.Empty(t, zones, "a custom-domain service with no registered domain apex must not produce a zone")
+}
+
+func TestSynthesizePrivateServiceZones_CustomDomainClusterMismatch_NoZone(t *testing.T) {
+ account := privateZoneTestAccount(t)
+ // The registered custom domain matches the service FQDN by suffix but
+ // targets a different cluster than the service's ProxyCluster. It must
+ // be ignored, leaving no apex to intercept the query — otherwise the
+ // zone would point at this cluster's proxy peers under a domain owned
+ // by a different cluster.
+ account.Services[0].Domain = "app.example.com"
+ account.Domains = []*proxydomain.Domain{
+ {Domain: "example.com", AccountID: "acct-1", TargetCluster: "us.proxy.netbird.io", Validated: true},
+ }
+
+ zones := account.SynthesizePrivateServiceZones("user-peer")
+ assert.Empty(t, zones, "a custom domain targeting a different cluster must not anchor the service zone")
+}
+
func TestSynthesizePrivateServiceZones_TwoServicesSameCluster_OneZone(t *testing.T) {
account := privateZoneTestAccount(t)
account.Services = append(account.Services, &service.Service{
@@ -254,3 +362,72 @@ func TestSynthesizePrivateServiceZones_TwoServicesSameCluster_OneZone(t *testing
names := []string{zones[0].Records[0].Name, zones[0].Records[1].Name}
assert.ElementsMatch(t, []string{"myapp.eu.proxy.netbird.io.", "anotherapp.eu.proxy.netbird.io."}, names, "both service domains must surface")
}
+
+func TestSynthesizePrivateServiceZones_MixedClusterCustomAndPublic(t *testing.T) {
+ account := privateZoneTestAccount(t)
+ account.Domains = []*proxydomain.Domain{
+ {Domain: "example.com", AccountID: "acct-1", TargetCluster: "eu.proxy.netbird.io", Validated: true},
+ }
+
+ privateService := func(id, domain string) *service.Service {
+ return &service.Service{
+ ID: id,
+ AccountID: "acct-1",
+ Name: id,
+ Domain: domain,
+ ProxyCluster: "eu.proxy.netbird.io",
+ Enabled: true,
+ Private: true,
+ Mode: service.ModeHTTP,
+ AccessGroups: []string{"grp-admins"},
+ }
+ }
+ publicService := func(id, domain string) *service.Service {
+ s := privateService(id, domain)
+ s.Private = false
+ return s
+ }
+
+ account.Services = []*service.Service{
+ // 3 private services under the cluster suffix.
+ privateService("cluster-1", "cluster1.eu.proxy.netbird.io"),
+ privateService("cluster-2", "cluster2.eu.proxy.netbird.io"),
+ privateService("cluster-3", "cluster3.eu.proxy.netbird.io"),
+ // 4 private services under the custom domain suffix.
+ privateService("custom-1", "custom1.example.com"),
+ privateService("custom-2", "custom2.example.com"),
+ privateService("custom-3", "custom3.example.com"),
+ privateService("custom-4", "custom4.example.com"),
+ // 2 public services, one per suffix, must not surface.
+ publicService("public-cluster", "public.eu.proxy.netbird.io"),
+ publicService("public-custom", "public.example.com"),
+ }
+
+ zones := account.SynthesizePrivateServiceZones("user-peer")
+ require.Len(t, zones, 2, "one zone per apex: the cluster apex and the custom domain apex")
+
+ cluster, ok := findCustomZone(zones, "eu.proxy.netbird.io")
+ require.True(t, ok, "cluster-suffix services collapse into the cluster-apex zone")
+ clusterNames := recordNames(cluster)
+ assert.ElementsMatch(t,
+ []string{"cluster1.eu.proxy.netbird.io.", "cluster2.eu.proxy.netbird.io.", "cluster3.eu.proxy.netbird.io."},
+ clusterNames,
+ "only the 3 private cluster services surface in the cluster zone (public one excluded)")
+
+ custom, ok := findCustomZone(zones, "example.com")
+ require.True(t, ok, "custom-suffix services collapse into the custom-domain-apex zone")
+ customNames := recordNames(custom)
+ assert.ElementsMatch(t,
+ []string{"custom1.example.com.", "custom2.example.com.", "custom3.example.com.", "custom4.example.com."},
+ customNames,
+ "only the 4 private custom services surface in the custom zone (public one excluded)")
+}
+
+// recordNames returns the record names of a zone for order-independent assertions.
+func recordNames(zone nbdns.CustomZone) []string {
+ names := make([]string, 0, len(zone.Records))
+ for _, r := range zone.Records {
+ names = append(names, r.Name)
+ }
+ return names
+}
diff --git a/management/server/types/account_test.go b/management/server/types/account_test.go
index 52fa3829e..e5b5708fa 100644
--- a/management/server/types/account_test.go
+++ b/management/server/types/account_test.go
@@ -646,41 +646,7 @@ func Test_ExpandPortsAndRanges_SSHRuleExpansion(t *testing.T) {
expectedPorts: []string{"20-25", "10-100", "22022"},
},
{
- name: "dev suffix version supports all features",
- peer: &nbpeer.Peer{
- ID: "peer1",
- SSHEnabled: true,
- Meta: nbpeer.PeerSystemMeta{
- WtVersion: "0.50.0-dev",
- Flags: nbpeer.Flags{ServerSSHAllowed: true},
- },
- },
- rule: &PolicyRule{
- Protocol: PolicyRuleProtocolTCP,
- Ports: []string{"22"},
- },
- base: FirewallRule{PeerIP: "10.0.0.1", Direction: 0, Action: "accept", Protocol: "tcp"},
- expectedPorts: []string{"22", "22022"},
- },
- {
- name: "dev suffix version supports all features",
- peer: &nbpeer.Peer{
- ID: "peer1",
- SSHEnabled: true,
- Meta: nbpeer.PeerSystemMeta{
- WtVersion: "dev",
- Flags: nbpeer.Flags{ServerSSHAllowed: true},
- },
- },
- rule: &PolicyRule{
- Protocol: PolicyRuleProtocolTCP,
- Ports: []string{"22"},
- },
- base: FirewallRule{PeerIP: "10.0.0.1", Direction: 0, Action: "accept", Protocol: "tcp"},
- expectedPorts: []string{"22", "22022"},
- },
- {
- name: "development suffix version supports all features",
+ name: "development version supports all features",
peer: &nbpeer.Peer{
ID: "peer1",
SSHEnabled: true,
diff --git a/management/server/types/networkmap_components_correctness_test.go b/management/server/types/networkmap_components_correctness_test.go
index bcfb6fdf9..1e3035300 100644
--- a/management/server/types/networkmap_components_correctness_test.go
+++ b/management/server/types/networkmap_components_correctness_test.go
@@ -5,6 +5,7 @@ import (
"fmt"
"net"
"net/netip"
+ "slices"
"testing"
"time"
@@ -1029,6 +1030,48 @@ func TestComponents_RouteDefaultPermit(t *testing.T) {
assert.True(t, hasDefaultPermit, "route without ACG should have default permit rule with 0.0.0.0/0 source")
}
+// TestComponents_ExitNodeDefaultPermitIPv6 verifies that a default exit node route
+// (0.0.0.0/0) without AccessControlGroups also emits an IPv6 default permit rule
+// (::/0 source and destination) for peers that support IPv6, mirroring the route
+// the client installs. Without it, IPv6 traffic is routed to the exit node but
+// dropped at the forward chain.
+func TestComponents_ExitNodeDefaultPermitIPv6(t *testing.T) {
+ account, validatedPeers := scalableTestAccount(20, 2)
+
+ routingPeerID := "peer-5"
+ routingPeer := account.Peers[routingPeerID]
+ routingPeer.IPv6 = netip.MustParseAddr("fd00::5")
+ routingPeer.Meta.Capabilities = append(routingPeer.Meta.Capabilities, nbpeer.PeerCapabilityIPv6Overlay)
+
+ account.Routes["route-exit"] = &route.Route{
+ ID: "route-exit", Network: netip.MustParsePrefix("0.0.0.0/0"),
+ PeerID: routingPeerID, Peer: routingPeer.Key,
+ Enabled: true, Groups: []string{"group-all"}, PeerGroups: []string{"group-0"},
+ AccessControlGroups: []string{},
+ AccountID: "test-account",
+ }
+
+ nm := componentsNetworkMap(account, routingPeerID, validatedPeers)
+ require.NotNil(t, nm)
+
+ hasV4 := false
+ hasV6 := false
+ for _, rfr := range nm.RoutesFirewallRules {
+ switch rfr.Destination {
+ case "0.0.0.0/0":
+ if slices.Contains(rfr.SourceRanges, "0.0.0.0/0") {
+ hasV4 = true
+ }
+ case "::/0":
+ if slices.Contains(rfr.SourceRanges, "::/0") {
+ hasV6 = true
+ }
+ }
+ }
+ assert.True(t, hasV4, "exit node route should have an IPv4 default permit rule (0.0.0.0/0)")
+ assert.True(t, hasV6, "exit node route should have an IPv6 default permit rule (::/0)")
+}
+
// ──────────────────────────────────────────────────────────────────────────────
// 15. MULTIPLE ROUTERS PER NETWORK
// ──────────────────────────────────────────────────────────────────────────────
@@ -1190,3 +1233,97 @@ func TestComponents_DisabledRuleInEnabledPolicy(t *testing.T) {
assert.True(t, has3000, "enabled rule should generate firewall rule for port 3000")
assert.False(t, has3001, "disabled rule should NOT generate firewall rule for port 3001")
}
+
+func peerGroupIDSet(account *types.Account, peerID string) map[string]struct{} {
+ return account.GetPeerGroups(peerID)
+}
+
+func assertSSHEquivalence(t *testing.T, account *types.Account, peerID string, validatedPeers map[string]struct{}) {
+ t.Helper()
+ nm := componentsNetworkMap(account, peerID, validatedPeers)
+ require.NotNil(t, nm)
+
+ got := types.PeerSSHEnabledFromPolicies(account.Policies, peerID, peerGroupIDSet(account, peerID), account.Peers[peerID].SSHEnabled)
+ assert.Equalf(t, nm.EnableSSH, got, "PeerSSHEnabledFromPolicies mismatch for %s", peerID)
+}
+
+func TestPeerSSHEnabledFromPolicies_MatchesMap_NetbirdSSHProtocol(t *testing.T) {
+ account, validatedPeers := scalableTestAccount(20, 2)
+ account.Groups["ssh-users"] = &types.Group{ID: "ssh-users", Name: "SSH Users", Peers: []string{}}
+ account.Policies = append(account.Policies, &types.Policy{
+ ID: "policy-ssh", Name: "SSH Access", Enabled: true, AccountID: "test-account",
+ Rules: []*types.PolicyRule{{
+ ID: "rule-ssh", Name: "Allow SSH", Enabled: true,
+ Action: types.PolicyTrafficActionAccept, Protocol: types.PolicyRuleProtocolNetbirdSSH,
+ Bidirectional: false,
+ Sources: []string{"group-0"}, Destinations: []string{"group-1"},
+ AuthorizedGroups: map[string][]string{"ssh-users": {"root"}},
+ }},
+ })
+
+ assertSSHEquivalence(t, account, "peer-10", validatedPeers)
+ assertSSHEquivalence(t, account, "peer-0", validatedPeers)
+}
+
+func TestPeerSSHEnabledFromPolicies_MatchesMap_NoSSHPolicy(t *testing.T) {
+ account, validatedPeers := scalableTestAccount(20, 2)
+ assertSSHEquivalence(t, account, "peer-0", validatedPeers)
+}
+
+func TestPeerSSHEnabledFromPolicies_MatchesMap_LegacyImpliedSSH(t *testing.T) {
+ account, validatedPeers := scalableTestAccount(20, 2)
+ account.Peers["peer-10"].SSHEnabled = true
+ assertSSHEquivalence(t, account, "peer-10", validatedPeers)
+ assertSSHEquivalence(t, account, "peer-11", validatedPeers)
+}
+
+func TestPeerSSHEnabledFromPolicies_MatchesMap_PeerAsDestinationResource(t *testing.T) {
+ account, validatedPeers := scalableTestAccountWithoutDefaultPolicy(20, 2)
+ account.Policies = append(account.Policies, &types.Policy{
+ ID: "policy-ssh-res", Name: "SSH to peer", Enabled: true, AccountID: "test-account",
+ Rules: []*types.PolicyRule{{
+ ID: "rule-ssh-res", Name: "SSH to peer-5", Enabled: true,
+ Action: types.PolicyTrafficActionAccept, Protocol: types.PolicyRuleProtocolNetbirdSSH,
+ Sources: []string{"group-0"},
+ DestinationResource: types.Resource{ID: "peer-5", Type: types.ResourceTypePeer},
+ }},
+ })
+
+ assertSSHEquivalence(t, account, "peer-5", validatedPeers)
+ assertSSHEquivalence(t, account, "peer-6", validatedPeers)
+}
+
+func TestPeerSSHEnabledFromPolicies_MatchesMap_DisabledSSHPolicy(t *testing.T) {
+ account, validatedPeers := scalableTestAccountWithoutDefaultPolicy(20, 2)
+ account.Policies = append(account.Policies, &types.Policy{
+ ID: "policy-ssh-off", Name: "SSH disabled", Enabled: false, AccountID: "test-account",
+ Rules: []*types.PolicyRule{{
+ ID: "rule-ssh-off", Name: "Allow SSH", Enabled: true,
+ Action: types.PolicyTrafficActionAccept, Protocol: types.PolicyRuleProtocolNetbirdSSH,
+ Sources: []string{"group-0"}, Destinations: []string{"group-1"},
+ }},
+ })
+ assertSSHEquivalence(t, account, "peer-10", validatedPeers)
+}
+
+func TestPeerSSHEnabledFromPolicies_MatchesMap_Sweep(t *testing.T) {
+ account, validatedPeers := scalableTestAccount(60, 6)
+ account.Policies = append(account.Policies, &types.Policy{
+ ID: "policy-ssh-sweep", Name: "SSH sweep", Enabled: true, AccountID: "test-account",
+ Rules: []*types.PolicyRule{{
+ ID: "rule-ssh-sweep", Name: "Allow SSH", Enabled: true,
+ Action: types.PolicyTrafficActionAccept, Protocol: types.PolicyRuleProtocolNetbirdSSH,
+ Sources: []string{"group-0"}, Destinations: []string{"group-2"},
+ }},
+ })
+ for peerID := range account.Peers {
+ account.Peers[peerID].SSHEnabled = len(peerID)%2 == 0
+ }
+
+ for peerID := range account.Peers {
+ if _, ok := validatedPeers[peerID]; !ok {
+ continue
+ }
+ assertSSHEquivalence(t, account, peerID, validatedPeers)
+ }
+}
diff --git a/management/server/user.go b/management/server/user.go
index 60571a702..412f15ce7 100644
--- a/management/server/user.go
+++ b/management/server/user.go
@@ -18,6 +18,7 @@ import (
"github.com/netbirdio/netbird/idp/dex"
"github.com/netbirdio/netbird/management/server/account"
"github.com/netbirdio/netbird/management/server/activity"
+ "github.com/netbirdio/netbird/management/server/affectedpeers"
"github.com/netbirdio/netbird/management/server/idp"
nbpeer "github.com/netbirdio/netbird/management/server/peer"
"github.com/netbirdio/netbird/management/server/permissions/modules"
@@ -31,7 +32,7 @@ import (
// createServiceUser creates a new service user under the given account.
func (am *DefaultAccountManager) createServiceUser(ctx context.Context, accountID string, initiatorUserID string, role types.UserRole, serviceUserName string, nonDeletable bool, autoGroups []string) (*types.UserInfo, error) {
- allowed, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, initiatorUserID, modules.Users, operations.Create)
+ allowed, ctx, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, initiatorUserID, modules.Users, operations.Create)
if err != nil {
return nil, status.NewPermissionValidationError(err)
}
@@ -86,7 +87,7 @@ func (am *DefaultAccountManager) inviteNewUser(ctx context.Context, accountID, u
return nil, err
}
- allowed, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Users, operations.Create)
+ allowed, ctx, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Users, operations.Create)
if err != nil {
return nil, status.NewPermissionValidationError(err)
}
@@ -307,7 +308,7 @@ func (am *DefaultAccountManager) DeleteUser(ctx context.Context, accountID, init
return err
}
- allowed, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, initiatorUserID, modules.Users, operations.Delete)
+ allowed, ctx, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, initiatorUserID, modules.Users, operations.Delete)
if err != nil {
return status.NewPermissionValidationError(err)
}
@@ -357,7 +358,7 @@ func (am *DefaultAccountManager) InviteUser(ctx context.Context, accountID strin
return status.Errorf(status.PreconditionFailed, "IdP manager must be enabled to send user invites")
}
- allowed, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, initiatorUserID, modules.Users, operations.Create)
+ allowed, ctx, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, initiatorUserID, modules.Users, operations.Create)
if err != nil {
return status.NewPermissionValidationError(err)
}
@@ -401,7 +402,7 @@ func (am *DefaultAccountManager) CreatePAT(ctx context.Context, accountID string
return nil, status.Errorf(status.InvalidArgument, "expiration has to be between %d and %d", account.PATMinExpireDays, account.PATMaxExpireDays)
}
- allowed, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, initiatorUserID, modules.Pats, operations.Create)
+ allowed, ctx, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, initiatorUserID, modules.Pats, operations.Create)
if err != nil {
return nil, status.NewPermissionValidationError(err)
}
@@ -445,7 +446,7 @@ func (am *DefaultAccountManager) CreatePAT(ctx context.Context, accountID string
// DeletePAT deletes a specific PAT from a user
func (am *DefaultAccountManager) DeletePAT(ctx context.Context, accountID string, initiatorUserID string, targetUserID string, tokenID string) error {
- allowed, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, initiatorUserID, modules.Pats, operations.Delete)
+ allowed, ctx, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, initiatorUserID, modules.Pats, operations.Delete)
if err != nil {
return status.NewPermissionValidationError(err)
}
@@ -488,7 +489,7 @@ func (am *DefaultAccountManager) DeletePAT(ctx context.Context, accountID string
// GetPAT returns a specific PAT from a user
func (am *DefaultAccountManager) GetPAT(ctx context.Context, accountID string, initiatorUserID string, targetUserID string, tokenID string) (*types.PersonalAccessToken, error) {
- allowed, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, initiatorUserID, modules.Pats, operations.Read)
+ allowed, ctx, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, initiatorUserID, modules.Pats, operations.Read)
if err != nil {
return nil, status.NewPermissionValidationError(err)
}
@@ -519,7 +520,7 @@ func (am *DefaultAccountManager) GetPAT(ctx context.Context, accountID string, i
// GetAllPATs returns all PATs for a user
func (am *DefaultAccountManager) GetAllPATs(ctx context.Context, accountID string, initiatorUserID string, targetUserID string) ([]*types.PersonalAccessToken, error) {
- allowed, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, initiatorUserID, modules.Pats, operations.Read)
+ allowed, ctx, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, initiatorUserID, modules.Pats, operations.Read)
if err != nil {
return nil, status.NewPermissionValidationError(err)
}
@@ -576,7 +577,7 @@ func (am *DefaultAccountManager) SaveOrAddUsers(ctx context.Context, accountID,
return nil, nil //nolint:nilnil
}
- allowed, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, initiatorUserID, modules.Users, operations.Create) // TODO: split by Create and Update
+ allowed, ctx, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, initiatorUserID, modules.Users, operations.Create) // TODO: split by Create and Update
if err != nil {
return nil, status.NewPermissionValidationError(err)
}
@@ -610,6 +611,11 @@ func (am *DefaultAccountManager) SaveOrAddUsers(ctx context.Context, accountID,
return nil, err
}
initiatorUser = result
+ role, ok := nbcontext.RoleFromContext(ctx)
+ if !ok {
+ return nil, status.Errorf(status.Internal, "failed to get user role from context")
+ }
+ initiatorUser.Role = types.UserRole(role)
}
var globalErr error
@@ -755,19 +761,6 @@ func (am *DefaultAccountManager) processUserUpdate(ctx context.Context, transact
return false, nil, nil, nil, status.Errorf(status.InvalidArgument, "provided user update is nil")
}
- if initiatorUserId != activity.SystemInitiator {
- freshInitiator, err := transaction.GetUserByUserID(ctx, store.LockingStrengthUpdate, initiatorUserId)
- if err != nil {
- return false, nil, nil, nil, fmt.Errorf("failed to re-read initiator user in transaction: %w", err)
- }
-
- // Ensure the initiator still has admin privileges
- if !freshInitiator.HasAdminPower() {
- return false, nil, nil, nil, status.Errorf(status.PermissionDenied, "initiator role was changed during request processing")
- }
- initiatorUser = freshInitiator
- }
-
oldUser, isNewUser, err := getUserOrCreateIfNotExists(ctx, transaction, accountID, update, addIfNotExists)
if err != nil {
return false, nil, nil, nil, err
@@ -988,7 +981,7 @@ func (am *DefaultAccountManager) GetOrCreateAccountByUser(ctx context.Context, u
// GetUsersFromAccount performs a batched request for users from IDP by account ID apply filter on what data to return
// based on provided user role.
func (am *DefaultAccountManager) GetUsersFromAccount(ctx context.Context, accountID, initiatorUserID string) (map[string]*types.UserInfo, error) {
- allowed, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, initiatorUserID, modules.Users, operations.Read)
+ allowed, ctx, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, initiatorUserID, modules.Users, operations.Read)
if err != nil {
return nil, status.NewPermissionValidationError(err)
}
@@ -1165,7 +1158,8 @@ func (am *DefaultAccountManager) expireAndUpdatePeers(ctx context.Context, accou
}
}
- err = am.networkMapController.OnPeersUpdated(ctx, accountID, peerIDs)
+ affectedPeerIDs := am.resolveAffectedPeersForPeerChanges(ctx, am.Store, accountID, peerIDs)
+ err = am.networkMapController.OnPeersUpdated(ctx, accountID, peerIDs, affectedPeerIDs)
if err != nil {
return fmt.Errorf("notify network map controller of peer update: %w", err)
}
@@ -1205,7 +1199,7 @@ func (am *DefaultAccountManager) deleteUserFromIDP(ctx context.Context, targetUs
// If an error occurs while deleting the user, the function skips it and continues deleting other users.
// Errors are collected and returned at the end.
func (am *DefaultAccountManager) DeleteRegularUsers(ctx context.Context, accountID, initiatorUserID string, targetUserIDs []string, userInfos map[string]*types.UserInfo) error {
- allowed, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, initiatorUserID, modules.Users, operations.Delete)
+ allowed, ctx, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, initiatorUserID, modules.Users, operations.Delete)
if err != nil {
return status.NewPermissionValidationError(err)
}
@@ -1281,6 +1275,8 @@ func (am *DefaultAccountManager) deleteRegularUser(ctx context.Context, accountI
var userPeers []*nbpeer.Peer
var targetUser *types.User
var settings *types.Settings
+ var snap *affectedpeers.Snapshot
+ var change affectedpeers.Change
var err error
err = am.Store.ExecuteInTransaction(ctx, func(transaction store.Store) error {
@@ -1301,6 +1297,18 @@ func (am *DefaultAccountManager) deleteRegularUser(ctx context.Context, accountI
if len(userPeers) > 0 {
updateAccountPeers = true
+
+ var peerIDs []string
+ for _, peer := range userPeers {
+ peerIDs = append(peerIDs, peer.ID)
+ }
+ // Load before delete so the snapshot still has the peers' group
+ // memberships; the resolver derives them from the peer IDs during the walk.
+ change = affectedpeers.Change{ChangedPeerIDs: peerIDs}
+ if snap, err = affectedpeers.Load(ctx, transaction, accountID, change); err != nil {
+ return err
+ }
+
addPeerRemovedEvents, err = deletePeers(ctx, am, transaction, accountID, targetUserInfo.ID, userPeers, settings)
if err != nil {
return fmt.Errorf("failed to delete user peers: %w", err)
@@ -1324,7 +1332,8 @@ func (am *DefaultAccountManager) deleteRegularUser(ctx context.Context, accountI
log.WithContext(ctx).Errorf("failed to delete peer %s from integrated validator: %v", peer.ID, err)
}
}
- if err := am.networkMapController.OnPeersDeleted(ctx, accountID, peerIDs); err != nil {
+ affectedPeerIDs := snap.Expand(ctx, accountID, change)
+ if err := am.networkMapController.OnPeersDeleted(ctx, accountID, peerIDs, affectedPeerIDs); err != nil {
log.WithContext(ctx).Errorf("failed to delete peers %s from network map: %v", peerIDs, err)
}
@@ -1403,7 +1412,8 @@ func (am *DefaultAccountManager) GetCurrentUserInfo(ctx context.Context, userAut
return nil, status.NewPermissionDeniedError()
}
- if err := am.permissionsManager.ValidateAccountAccess(ctx, accountID, user, false); err != nil {
+ ctx, err = am.permissionsManager.ValidateAccountAccess(ctx, accountID, user, false)
+ if err != nil {
return nil, err
}
@@ -1432,7 +1442,7 @@ func (am *DefaultAccountManager) GetCurrentUserInfo(ctx context.Context, userAut
// ApproveUser approves a user that is pending approval
func (am *DefaultAccountManager) ApproveUser(ctx context.Context, accountID, initiatorUserID, targetUserID string) (*types.UserInfo, error) {
- allowed, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, initiatorUserID, modules.Users, operations.Update)
+ allowed, ctx, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, initiatorUserID, modules.Users, operations.Update)
if err != nil {
return nil, status.NewPermissionValidationError(err)
}
@@ -1473,7 +1483,7 @@ func (am *DefaultAccountManager) ApproveUser(ctx context.Context, accountID, ini
// RejectUser rejects a user that is pending approval by deleting them
func (am *DefaultAccountManager) RejectUser(ctx context.Context, accountID, initiatorUserID, targetUserID string) error {
- allowed, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, initiatorUserID, modules.Users, operations.Delete)
+ allowed, ctx, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, initiatorUserID, modules.Users, operations.Delete)
if err != nil {
return status.NewPermissionValidationError(err)
}
@@ -1519,7 +1529,7 @@ func (am *DefaultAccountManager) CreateUserInvite(ctx context.Context, accountID
return nil, err
}
- allowed, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, initiatorUserID, modules.Users, operations.Create)
+ allowed, ctx, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, initiatorUserID, modules.Users, operations.Create)
if err != nil {
return nil, status.NewPermissionValidationError(err)
}
@@ -1637,7 +1647,7 @@ func (am *DefaultAccountManager) ListUserInvites(ctx context.Context, accountID,
return nil, status.Errorf(status.PreconditionFailed, "invite links are only available with embedded identity provider")
}
- allowed, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, initiatorUserID, modules.Users, operations.Read)
+ allowed, ctx, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, initiatorUserID, modules.Users, operations.Read)
if err != nil {
return nil, status.NewPermissionValidationError(err)
}
@@ -1751,7 +1761,7 @@ func (am *DefaultAccountManager) RegenerateUserInvite(ctx context.Context, accou
return nil, status.Errorf(status.PreconditionFailed, "invite links are only available with embedded identity provider")
}
- allowed, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, initiatorUserID, modules.Users, operations.Update)
+ allowed, ctx, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, initiatorUserID, modules.Users, operations.Update)
if err != nil {
return nil, status.NewPermissionValidationError(err)
}
@@ -1813,7 +1823,7 @@ func (am *DefaultAccountManager) DeleteUserInvite(ctx context.Context, accountID
return status.Errorf(status.PreconditionFailed, "invite links are only available with embedded identity provider")
}
- allowed, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, initiatorUserID, modules.Users, operations.Delete)
+ allowed, ctx, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, initiatorUserID, modules.Users, operations.Delete)
if err != nil {
return status.NewPermissionValidationError(err)
}
diff --git a/management/server/user_test.go b/management/server/user_test.go
index c77ea53d1..f32a6b3a1 100644
--- a/management/server/user_test.go
+++ b/management/server/user_test.go
@@ -846,7 +846,7 @@ func TestUser_DeleteUser_regularUser(t *testing.T) {
ctrl := gomock.NewController(t)
networkMapControllerMock := network_map.NewMockController(ctrl)
networkMapControllerMock.EXPECT().
- OnPeersDeleted(gomock.Any(), gomock.Any(), gomock.Any()).
+ OnPeersDeleted(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).
Return(nil)
permissionsManager := permissions.NewManager(store)
@@ -962,7 +962,7 @@ func TestUser_DeleteUser_RegularUsers(t *testing.T) {
ctrl := gomock.NewController(t)
networkMapControllerMock := network_map.NewMockController(ctrl)
networkMapControllerMock.EXPECT().
- OnPeersDeleted(gomock.Any(), gomock.Any(), gomock.Any()).
+ OnPeersDeleted(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).
Return(nil).
AnyTimes()
@@ -1531,11 +1531,14 @@ func TestUserAccountPeersUpdate(t *testing.T) {
}
})
+ // drain any buffered updates from previous subtests
+ drainPeerUpdates(updMsg)
+
// deleting user with no linked peers should not update account peers and not send peer update
t.Run("deleting user with no linked peers", func(t *testing.T) {
done := make(chan struct{})
go func() {
- peerShouldReceiveUpdate(t, updMsg)
+ peerShouldNotReceiveUpdate(t, updMsg)
close(done)
}()
@@ -1562,7 +1565,7 @@ func TestUserAccountPeersUpdate(t *testing.T) {
require.NoError(t, err)
expectedPeerKey := key.PublicKey().String()
- peer4, _, _, err := manager.AddPeer(context.Background(), "", "", "regularUser2", &nbpeer.Peer{
+ peer4, _, _, _, err := manager.AddPeer(context.Background(), "", "", "regularUser2", &nbpeer.Peer{
Key: expectedPeerKey,
Meta: nbpeer.PeerSystemMeta{Hostname: expectedPeerKey},
}, false)
@@ -2022,7 +2025,7 @@ func TestUser_Operations_WithEmbeddedIDP(t *testing.T) {
ctrl := gomock.NewController(t)
networkMapControllerMock := network_map.NewMockController(ctrl)
networkMapControllerMock.EXPECT().
- OnPeersDeleted(gomock.Any(), gomock.Any(), gomock.Any()).
+ OnPeersDeleted(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).
Return(nil).
AnyTimes()
@@ -2129,66 +2132,3 @@ func TestUser_Operations_WithEmbeddedIDP(t *testing.T) {
t.Logf("Duplicate email error: %v", err)
})
}
-
-func TestProcessUserUpdate_RejectsStaleInitiatorRole(t *testing.T) {
- s, cleanup, err := store.NewTestStoreFromSQL(context.Background(), "", t.TempDir())
- require.NoError(t, err)
- t.Cleanup(cleanup)
-
- account := newAccountWithId(context.Background(), "account1", "owner1", "", "", "", false)
-
- adminID := "admin1"
- account.Users[adminID] = types.NewAdminUser(adminID)
-
- targetID := "target1"
- account.Users[targetID] = types.NewRegularUser(targetID, "", "")
-
- require.NoError(t, s.SaveAccount(context.Background(), account))
-
- demotedAdmin, err := s.GetUserByUserID(context.Background(), store.LockingStrengthNone, adminID)
- require.NoError(t, err)
- demotedAdmin.Role = types.UserRoleUser
- require.NoError(t, s.SaveUser(context.Background(), demotedAdmin))
-
- staleInitiator := &types.User{
- Id: adminID,
- AccountID: account.Id,
- Role: types.UserRoleAdmin,
- }
-
- permissionsManager := permissions.NewManager(s)
- am := DefaultAccountManager{
- Store: s,
- eventStore: &activity.InMemoryEventStore{},
- permissionsManager: permissionsManager,
- }
-
- settings, err := s.GetAccountSettings(context.Background(), store.LockingStrengthNone, account.Id)
- require.NoError(t, err)
-
- groups, err := s.GetAccountGroups(context.Background(), store.LockingStrengthNone, account.Id)
- require.NoError(t, err)
- groupsMap := make(map[string]*types.Group, len(groups))
- for _, g := range groups {
- groupsMap[g.ID] = g
- }
-
- update := &types.User{
- Id: targetID,
- Role: types.UserRoleAdmin,
- }
-
- err = s.ExecuteInTransaction(context.Background(), func(tx store.Store) error {
- _, _, _, _, txErr := am.processUserUpdate(
- context.Background(), tx, groupsMap, account.Id, adminID, staleInitiator, update, false, settings,
- )
- return txErr
- })
-
- require.Error(t, err, "processUserUpdate should reject stale initiator whose role was demoted")
- assert.Contains(t, err.Error(), "initiator role was changed during request processing")
-
- targetUser, err := s.GetUserByUserID(context.Background(), store.LockingStrengthNone, targetID)
- require.NoError(t, err)
- assert.Equal(t, types.UserRoleUser, targetUser.Role)
-}
diff --git a/proxy/cmd/proxy/cmd/root.go b/proxy/cmd/proxy/cmd/root.go
index 405fa2789..ad8e1b7c0 100644
--- a/proxy/cmd/proxy/cmd/root.go
+++ b/proxy/cmd/proxy/cmd/root.go
@@ -214,7 +214,10 @@ func runServer(cmd *cobra.Command, args []string) error {
return fmt.Errorf("invalid --trusted-proxies: %w", err)
}
- srv := proxy.New(proxy.Config{
+ ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGTERM, syscall.SIGINT)
+ defer stop()
+
+ srv := proxy.New(ctx, proxy.Config{
ListenAddr: addr,
Logger: logger,
Version: Version,
@@ -246,14 +249,12 @@ func runServer(cmd *cobra.Command, args []string) error {
Private: private,
MaxDialTimeout: maxDialTimeout,
MaxSessionIdleTimeout: maxSessionIdleTimeout,
+ MappingBatchWatchdog: envDurationOrDefault("NB_PROXY_MAPPING_BATCH_WATCHDOG", 0),
GeoDataDir: geoDataDir,
CrowdSecAPIURL: crowdsecAPIURL,
CrowdSecAPIKey: crowdsecAPIKey,
})
- ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGTERM, syscall.SIGINT)
- defer stop()
-
return srv.ListenAndServe(ctx, addr)
}
diff --git a/proxy/inbound.go b/proxy/inbound.go
index 8165b331f..d729ba9ae 100644
--- a/proxy/inbound.go
+++ b/proxy/inbound.go
@@ -5,6 +5,7 @@ import (
"crypto/tls"
"errors"
"fmt"
+ "io"
stdlog "log"
"net"
"net/http"
@@ -42,7 +43,7 @@ const privateInboundPortHTTPS = 443
const privateInboundPortHTTP = 80
// inboundManager wires per-account inbound listeners into the proxy
-// pipeline when --private-inbound is enabled. When disabled the manager
+// pipeline when --private is enabled. When disabled the manager
// is nil and every method on *Server that touches it short-circuits.
type inboundManager struct {
logger *log.Logger
@@ -55,15 +56,18 @@ type inboundManager struct {
}
// inboundEntry owns the listeners, router and HTTP servers for a single
-// account's embedded netstack.
+// account's embedded netstack. errorLogWriters retain the logrus pipe
+// writers backing each http.Server's ErrorLog so tearDown can close
+// them — otherwise the pipe + its scanner goroutine leak per account.
type inboundEntry struct {
- router *nbtcp.Router
- tlsListener net.Listener
- plainListener net.Listener
- httpsServer *http.Server
- httpServer *http.Server
- cancel context.CancelFunc
- wg sync.WaitGroup
+ router *nbtcp.Router
+ tlsListener net.Listener
+ plainListener net.Listener
+ httpsServer *http.Server
+ httpServer *http.Server
+ errorLogWriters []*io.PipeWriter
+ cancel context.CancelFunc
+ wg sync.WaitGroup
}
// pendingInboundRoute holds a route that arrived before the account's
@@ -147,30 +151,34 @@ func (m *inboundManager) bringUp(ctx context.Context, accountID types.AccountID,
return types.WithOverlayOrigin(ctx)
}
+ httpsErrLog, httpsErrW := newInboundErrorLog(m.logger, "https", accountID)
+ httpErrLog, httpErrW := newInboundErrorLog(m.logger, "http", accountID)
+
httpsServer := &http.Server{
Handler: scopedHandler,
TLSConfig: m.tlsConfig,
ReadHeaderTimeout: httpInboundReadHeaderTimeout,
IdleTimeout: httpInboundIdleTimeout,
- ErrorLog: newInboundErrorLog(m.logger, "https", accountID),
+ ErrorLog: httpsErrLog,
ConnContext: markOverlayOrigin,
}
httpServer := &http.Server{
Handler: scopedHandler,
ReadHeaderTimeout: httpInboundReadHeaderTimeout,
IdleTimeout: httpInboundIdleTimeout,
- ErrorLog: newInboundErrorLog(m.logger, "http", accountID),
+ ErrorLog: httpErrLog,
ConnContext: markOverlayOrigin,
}
runCtx, cancel := context.WithCancel(ctx)
entry := &inboundEntry{
- router: router,
- tlsListener: tlsListener,
- plainListener: plainListener,
- httpsServer: httpsServer,
- httpServer: httpServer,
- cancel: cancel,
+ router: router,
+ tlsListener: tlsListener,
+ plainListener: plainListener,
+ httpsServer: httpsServer,
+ httpServer: httpServer,
+ errorLogWriters: []*io.PipeWriter{httpsErrW, httpErrW},
+ cancel: cancel,
}
entry.wg.Add(1)
@@ -237,6 +245,14 @@ func (m *inboundManager) tearDown(accountID types.AccountID, entry *inboundEntry
m.logger.Debugf("close per-account plain listener: %v", err)
}
entry.wg.Wait()
+ // Close the ErrorLog pipes only after the http.Servers have fully
+ // stopped so any straggling stdlib write doesn't race with the
+ // close. Each writer also tears down the logrus scanner goroutine.
+ for _, w := range entry.errorLogWriters {
+ if err := w.Close(); err != nil {
+ m.logger.Debugf("close per-account inbound error log writer: %v", err)
+ }
+ }
}
// AddRoute records an SNI/host route on the account's per-account router.
@@ -374,7 +390,7 @@ func (m *inboundManager) ListenerInfo(accountID types.AccountID) (InboundListene
}
// Snapshot returns the inbound listener state for every account that has
-// a live listener at call time. Empty when --private-inbound is off or
+// a live listener at call time. Empty when --private is off or
// no accounts have come up yet.
func (m *inboundManager) Snapshot() map[types.AccountID]InboundListenerInfo {
if m == nil {
@@ -497,7 +513,7 @@ func accountTunnelLookup(client *embed.Client) auth.TunnelLookupFunc {
// peerstore lookup to every request's context before delegating to next.
// Calling on the host-level listener is a no-op because that path never
// installs this wrapper, so the existing behaviour stays byte-for-byte
-// identical when --private-inbound is off or the request didn't arrive
+// identical when --private is off or the request didn't arrive
// on a per-account listener.
func withTunnelLookup(next http.Handler, lookup auth.TunnelLookupFunc) http.Handler {
if lookup == nil {
@@ -538,10 +554,14 @@ func (a inboundDebugAdapter) InboundListeners() map[types.AccountID]debug.Inboun
}
// newInboundErrorLog routes a per-account http.Server's stdlib error
-// stream through logrus at warn level.
-func newInboundErrorLog(logger *log.Logger, scheme string, accountID types.AccountID) *stdlog.Logger {
- return stdlog.New(logger.WithFields(log.Fields{
+// stream through logrus at warn level. The returned PipeWriter must be
+// closed by the caller (tearDown) once the http.Server has shut down —
+// otherwise the pipe and its scanner goroutine leak per account, see
+// logrus.Entry.WriterLevel.
+func newInboundErrorLog(logger *log.Logger, scheme string, accountID types.AccountID) (*stdlog.Logger, *io.PipeWriter) {
+ w := logger.WithFields(log.Fields{
"inbound-http": scheme,
"account_id": accountID,
- }).WriterLevel(log.WarnLevel), "", 0)
+ }).WriterLevel(log.WarnLevel)
+ return stdlog.New(w, "", 0), w
}
diff --git a/proxy/inbound_test.go b/proxy/inbound_test.go
index a868f1c12..584a04238 100644
--- a/proxy/inbound_test.go
+++ b/proxy/inbound_test.go
@@ -4,6 +4,7 @@ import (
"bufio"
"context"
"crypto/tls"
+ "io"
"net"
"net/http"
"net/http/httptest"
@@ -110,7 +111,7 @@ func TestServer_PrivateInbound_Enabled_WiresLifecycle(t *testing.T) {
// Construct a NetBird transport. We can't actually start the embedded
// client here (that needs a real management server), but we can
// confirm that the lifecycle callbacks are registered.
- s.netbird = roundtrip.NewNetBird("test", "test", roundtrip.ClientConfig{
+ s.netbird = roundtrip.NewNetBird(t.Context(), "test", "test", roundtrip.ClientConfig{
MgmtAddr: "http://invalid.test",
}, quietLogger(), nil, fakeMgmtClient{})
@@ -139,7 +140,7 @@ func TestInboundManager_AddRouteAfterReady_RegistersDirectly(t *testing.T) {
// TestPrivateCapability_DerivedFromPrivateOnly tests that the capability
// bit reported upstream tracks --private exclusively. The previous
-// --private-inbound flag has been folded into --private.
+// --private flag has been folded into --private.
func TestPrivateCapability_DerivedFromPrivateOnly(t *testing.T) {
tests := []struct {
name string
@@ -318,7 +319,7 @@ func TestInboundManager_ListenerInfo(t *testing.T) {
}
// TestInboundManager_NilManagerSafe ensures the observability accessors
-// are safe to call when --private-inbound is off (nil manager).
+// are safe to call when --private is off (nil manager).
func TestInboundManager_NilManagerSafe(t *testing.T) {
var mgr *inboundManager
_, ok := mgr.ListenerInfo("anything")
@@ -482,6 +483,38 @@ func selfSignedTLSConfig(t *testing.T) *tls.Config {
return &tls.Config{Certificates: []tls.Certificate{cert}, MinVersion: tls.VersionTLS12} //nolint:gosec
}
+// TestNewInboundErrorLog_WriterIsCloseable guards the close path on the
+// logrus PipeWriter that backs each per-account http.Server's ErrorLog.
+// logrus.Entry.WriterLevel returns an *io.PipeWriter that owns a pipe +
+// scanner goroutine; the caller must Close() it on teardown or the
+// resources leak per account. The contract is verified two ways:
+//
+// - the constructor returns a non-nil writer the caller can keep,
+// - writing to the writer after Close() fails with io.ErrClosedPipe,
+// which is the only externally observable sign that Close was wired.
+//
+// A leaking refactor (forgetting to thread the writer to tearDown, or
+// dropping the Close call) would still pass this test individually but
+// fail an integration goleak check; this unit test is the cheap first
+// line of defence.
+func TestNewInboundErrorLog_WriterIsCloseable(t *testing.T) {
+ logger := quietLogger()
+ stdLog, writer := newInboundErrorLog(logger, "https", types.AccountID("acct-1"))
+
+ require.NotNil(t, stdLog, "newInboundErrorLog must return a non-nil *log.Logger")
+ require.NotNil(t, writer, "newInboundErrorLog must return the underlying PipeWriter so tearDown can Close it")
+
+ // First Close succeeds.
+ require.NoError(t, writer.Close(), "PipeWriter.Close should succeed the first time")
+
+ // After Close, the writer must refuse new writes — that's the only
+ // behavioural signal that the pipe (and its scanner goroutine) has
+ // shut down.
+ _, err := writer.Write([]byte("post-close write\n"))
+ require.ErrorIs(t, err, io.ErrClosedPipe,
+ "writes after Close must surface io.ErrClosedPipe so callers know the writer is gone")
+}
+
// testCertPEM / testKeyPEM are a minimal RSA self-signed cert for
// 127.0.0.1 — only used by tests that need a working TLS handshake.
var testCertPEM = []byte(`-----BEGIN CERTIFICATE-----
diff --git a/proxy/internal/auth/middleware.go b/proxy/internal/auth/middleware.go
index a76427ca0..72630b085 100644
--- a/proxy/internal/auth/middleware.go
+++ b/proxy/internal/auth/middleware.go
@@ -346,13 +346,15 @@ func (mw *Middleware) forwardWithSessionCookie(w http.ResponseWriter, r *http.Re
// management unreachable, peer unknown, user not in group) returns false so
// the caller falls back to the existing OIDC scheme dispatch.
//
-// Phase 3 adds a local-first short-circuit: when the request arrived on a
-// per-account inbound listener the context carries a peerstore lookup
-// (TunnelLookupFromContext). If the lookup says the IP isn't in the account's
-// roster the proxy denies fast without calling management. If the lookup
-// confirms a known peer the RPC still runs for the user-identity tail
-// (UserID + group access), but its result is cached for tunnelCacheTTL so
-// repeat requests skip management entirely.
+// The fast-path is gated on TunnelLookupFromContext(r.Context()) being
+// present — that context value is attached only by the per-account
+// inbound (overlay) listener. The host listener never sets it, so a
+// public client whose source IP happens to fall inside an RFC1918 / ULA
+// / CGNAT range can't impersonate a mesh peer by colliding with a
+// tunnel-IP. Once we know the request arrived over WireGuard the
+// per-account peerstore lookup is consulted: a miss denies fast (no
+// management round-trip), a hit gates the cached ValidateTunnelPeer RPC
+// that mints the session JWT.
func (mw *Middleware) forwardWithTunnelPeer(w http.ResponseWriter, r *http.Request, host string, config DomainConfig, next http.Handler) bool {
if mw.sessionValidator == nil {
return false
@@ -361,18 +363,24 @@ func (mw *Middleware) forwardWithTunnelPeer(w http.ResponseWriter, r *http.Reque
if !clientIP.IsValid() {
return false
}
+
+ // Anti-spoof: only honour the tunnel-peer fast-path on requests that
+ // were stamped by an overlay listener. Without that marker an
+ // attacker could send a request from a colliding RFC1918 / CGNAT
+ // source on the public listener and bypass operator auth.
+ lookup := TunnelLookupFromContext(r.Context())
+ if lookup == nil {
+ return false
+ }
if !isTunnelSourceIP(clientIP) {
return false
}
-
- if lookup := TunnelLookupFromContext(r.Context()); lookup != nil {
- if _, ok := lookup(clientIP); !ok {
- mw.logger.WithFields(log.Fields{
- "host": host,
- "remote": clientIP,
- }).Debug("local peerstore: tunnel IP not in account roster; denying without RPC")
- return false
- }
+ if _, ok := lookup(clientIP); !ok {
+ mw.logger.WithFields(log.Fields{
+ "host": host,
+ "remote": clientIP,
+ }).Debug("local peerstore: tunnel IP not in account roster; denying without RPC")
+ return false
}
resp, _, err := mw.tunnelCache.fetch(r.Context(), tunnelCacheKey{
diff --git a/proxy/internal/auth/middleware_test.go b/proxy/internal/auth/middleware_test.go
index 84c319446..c0ec5c94c 100644
--- a/proxy/internal/auth/middleware_test.go
+++ b/proxy/internal/auth/middleware_test.go
@@ -1227,3 +1227,93 @@ func TestProtect_NonOIDCSchemes_PlainHTTP_NotBlocked(t *testing.T) {
assert.Equal(t, http.StatusUnauthorized, rec.Code, "PIN-only domain should serve the login page on plain HTTP")
}
+
+// stubTunnelValidator records ValidateTunnelPeer calls so a test can
+// assert whether the fast-path reached management.
+type stubTunnelValidator struct {
+ called bool
+ resp *proto.ValidateTunnelPeerResponse
+}
+
+func (s *stubTunnelValidator) ValidateSession(context.Context, *proto.ValidateSessionRequest, ...grpc.CallOption) (*proto.ValidateSessionResponse, error) {
+ return nil, errors.New("not used in this test")
+}
+
+func (s *stubTunnelValidator) ValidateTunnelPeer(context.Context, *proto.ValidateTunnelPeerRequest, ...grpc.CallOption) (*proto.ValidateTunnelPeerResponse, error) {
+ s.called = true
+ return s.resp, nil
+}
+
+// TestProtect_TunnelPeerFastPath_RequiresInboundMarker guards the
+// anti-spoof gate: a request with an RFC1918 source IP arriving on the
+// public listener (no TunnelLookupFromContext attached) must not be
+// allowed to take the tunnel-peer fast-path. Without this gate a public
+// client whose source IP happens to fall inside an RFC1918 range could
+// bypass the configured auth scheme by colliding with a known tunnel
+// IP.
+func TestProtect_TunnelPeerFastPath_RequiresInboundMarker(t *testing.T) {
+ validator := &stubTunnelValidator{
+ resp: &proto.ValidateTunnelPeerResponse{
+ Valid: true,
+ SessionToken: "should-not-be-used",
+ UserId: "user-1",
+ },
+ }
+ mw := NewMiddleware(log.StandardLogger(), validator, nil)
+ kp := generateTestKeyPair(t)
+
+ scheme := &stubScheme{method: auth.MethodPIN, promptID: "pin"}
+ require.NoError(t, mw.AddDomain("example.com", []Scheme{scheme}, kp.PublicKey, time.Hour, "", "", nil, false))
+
+ handler := mw.Protect(newPassthroughHandler())
+
+ // Request from an RFC1918 source IP on the public listener — no
+ // TunnelLookupFromContext attached. The fast-path must reject this
+ // and fall through to the PIN scheme (which renders 401 on plain
+ // HTTP for a non-authenticated request).
+ req := httptest.NewRequest(http.MethodGet, "http://example.com/", nil)
+ req.RemoteAddr = "100.64.0.5:5000"
+ rec := httptest.NewRecorder()
+ handler.ServeHTTP(rec, req)
+
+ assert.False(t, validator.called,
+ "ValidateTunnelPeer must not be invoked when the request lacks the inbound TunnelLookup marker")
+ assert.Equal(t, http.StatusUnauthorized, rec.Code,
+ "without the inbound marker the request must fall through to the operator auth scheme")
+}
+
+// TestProtect_TunnelPeerFastPath_TakesPathWithInboundMarker verifies
+// the positive side: a request marked as overlay-origin (carrying the
+// TunnelLookup context value) and matching a tunnel-IP range does take
+// the fast-path and reach management.
+func TestProtect_TunnelPeerFastPath_TakesPathWithInboundMarker(t *testing.T) {
+ validator := &stubTunnelValidator{
+ resp: &proto.ValidateTunnelPeerResponse{
+ Valid: true,
+ SessionToken: "tunnel-session-token",
+ UserId: "user-1",
+ },
+ }
+ mw := NewMiddleware(log.StandardLogger(), validator, nil)
+ kp := generateTestKeyPair(t)
+
+ scheme := &stubScheme{method: auth.MethodPIN, promptID: "pin"}
+ require.NoError(t, mw.AddDomain("example.com", []Scheme{scheme}, kp.PublicKey, time.Hour, "", "", nil, false))
+
+ handler := mw.Protect(newPassthroughHandler())
+
+ lookup := TunnelLookupFunc(func(_ netip.Addr) (PeerIdentity, bool) {
+ return PeerIdentity{}, true
+ })
+
+ req := httptest.NewRequest(http.MethodGet, "http://example.com/", nil)
+ req.RemoteAddr = "100.64.0.5:5000"
+ req = req.WithContext(WithTunnelLookup(req.Context(), lookup))
+ rec := httptest.NewRecorder()
+ handler.ServeHTTP(rec, req)
+
+ assert.True(t, validator.called,
+ "ValidateTunnelPeer must run when the request carries the inbound TunnelLookup marker")
+ assert.Equal(t, http.StatusOK, rec.Code,
+ "a successful tunnel-peer validation must forward to the next handler")
+}
diff --git a/proxy/internal/auth/tunnel_lookup_test.go b/proxy/internal/auth/tunnel_lookup_test.go
index cc8081af2..808aa8b41 100644
--- a/proxy/internal/auth/tunnel_lookup_test.go
+++ b/proxy/internal/auth/tunnel_lookup_test.go
@@ -101,7 +101,10 @@ func TestForwardWithTunnelPeer_GroupsPropagateToCapturedData(t *testing.T) {
w, r := newTunnelRequest("100.64.0.10:55555")
cd := proxy.NewCapturedData("")
- r = r.WithContext(proxy.WithCapturedData(r.Context(), cd))
+ lookup := TunnelLookupFunc(func(_ netip.Addr) (PeerIdentity, bool) {
+ return PeerIdentity{}, true
+ })
+ r = r.WithContext(proxy.WithCapturedData(WithTunnelLookup(r.Context(), lookup), cd))
called := false
next := http.HandlerFunc(func(http.ResponseWriter, *http.Request) { called = true })
@@ -148,9 +151,13 @@ func TestForwardWithTunnelPeer_LocalLookupKnownPeerStillRPCs(t *testing.T) {
assert.Equal(t, int32(1), validator.tunnelCalls.Load(), "RPC must run for the user-identity tail when local lookup confirms the peer")
}
-// TestForwardWithTunnelPeer_NoLookupKeepsLegacyPath ensures the existing
-// behaviour stays intact on the host-level listener (no lookup attached).
-func TestForwardWithTunnelPeer_NoLookupKeepsLegacyPath(t *testing.T) {
+// TestForwardWithTunnelPeer_NoLookupRefusesFastPath guards the
+// anti-spoof gate: requests that didn't arrive on the per-account
+// inbound listener (no TunnelLookup attached) must never reach
+// management's ValidateTunnelPeer, even when the source IP looks like
+// a tunnel address. A colliding RFC1918 / CGNAT source on the public
+// listener would otherwise impersonate a mesh peer.
+func TestForwardWithTunnelPeer_NoLookupRefusesFastPath(t *testing.T) {
validator := &stubSessionValidator{
respFn: func(_ *proto.ValidateTunnelPeerRequest) *proto.ValidateTunnelPeerResponse {
return &proto.ValidateTunnelPeerResponse{Valid: true, SessionToken: "tok", UserId: "user-1"}
@@ -165,9 +172,9 @@ func TestForwardWithTunnelPeer_NoLookupKeepsLegacyPath(t *testing.T) {
config, _ := mw.getDomainConfig("svc.example")
handled := mw.forwardWithTunnelPeer(w, r, "svc.example", config, next)
- assert.True(t, handled, "host-level path forwards on positive RPC result")
- assert.True(t, called, "next handler runs on host-level success")
- assert.Equal(t, int32(1), validator.tunnelCalls.Load(), "host-level path always RPCs (Phase 3 unchanged)")
+ assert.False(t, handled, "fast-path must refuse without the inbound marker")
+ assert.False(t, called, "next handler must not run")
+ assert.Equal(t, int32(0), validator.tunnelCalls.Load(), "ValidateTunnelPeer must not be invoked without the inbound marker")
}
// TestForwardWithTunnelPeer_RPCErrorFallsThrough validates that an RPC
@@ -201,8 +208,13 @@ func TestForwardWithTunnelPeer_CacheReusesPositiveResponse(t *testing.T) {
}
mw := newTunnelMiddleware(t, validator)
+ lookup := TunnelLookupFunc(func(_ netip.Addr) (PeerIdentity, bool) {
+ return PeerIdentity{}, true
+ })
+
for i := 0; i < 4; i++ {
w, r := newTunnelRequest("100.64.0.10:55555")
+ r = r.WithContext(WithTunnelLookup(r.Context(), lookup))
next := http.HandlerFunc(func(http.ResponseWriter, *http.Request) {})
config, _ := mw.getDomainConfig("svc.example")
handled := mw.forwardWithTunnelPeer(w, r, "svc.example", config, next)
@@ -226,11 +238,21 @@ func TestForwardWithTunnelPeer_RoutesAccountIDIntoCacheKey(t *testing.T) {
require.NoError(t, mw.AddDomain("svc-a.example", nil, "", 0, "acct-a", "svc-a", nil, false))
require.NoError(t, mw.AddDomain("svc-b.example", nil, "", 0, "acct-b", "svc-b", nil, false))
+ // The fast-path requires the inbound-listener marker on the context.
+ // The peerstore lookup itself is account-agnostic at this level
+ // (one TunnelLookupFunc per account is attached by inbound.go); a
+ // trivial "always hit" lookup is enough to exercise the cache-key
+ // branch this test covers.
+ lookup := TunnelLookupFunc(func(_ netip.Addr) (PeerIdentity, bool) {
+ return PeerIdentity{}, true
+ })
+
for _, host := range []string{"svc-a.example", "svc-b.example"} {
w := httptest.NewRecorder()
r := httptest.NewRequest(http.MethodGet, "https://"+host+"/", nil)
r.Host = host
r.RemoteAddr = "100.64.0.10:55555"
+ r = r.WithContext(WithTunnelLookup(r.Context(), lookup))
config, _ := mw.getDomainConfig(host)
handled := mw.forwardWithTunnelPeer(w, r, host, config, http.HandlerFunc(func(http.ResponseWriter, *http.Request) {}))
require.True(t, handled, "host %s should forward", host)
@@ -314,9 +336,17 @@ func TestPrivateService_ForwardsOnTunnelPeerSuccess(t *testing.T) {
w.WriteHeader(http.StatusOK)
}))
+ // Per-account inbound listener attaches WithTunnelLookup; without it
+ // forwardWithTunnelPeer refuses to take the fast-path. Mirror the
+ // real flow so this test exercises the post-gating success branch.
+ lookup := TunnelLookupFunc(func(_ netip.Addr) (PeerIdentity, bool) {
+ return PeerIdentity{}, true
+ })
+
req := httptest.NewRequest(http.MethodGet, "https://private.svc/", nil)
req.Host = "private.svc"
req.RemoteAddr = "100.64.0.10:55555"
+ req = req.WithContext(WithTunnelLookup(req.Context(), lookup))
w := httptest.NewRecorder()
handler.ServeHTTP(w, req)
diff --git a/proxy/internal/debug/handler.go b/proxy/internal/debug/handler.go
index 826c6817f..6300228d7 100644
--- a/proxy/internal/debug/handler.go
+++ b/proxy/internal/debug/handler.go
@@ -131,7 +131,7 @@ func (h *Handler) SetCertStatus(cs certStatus) {
// SetInboundProvider wires per-account inbound listener observability.
// Pass nil (or skip the call) to keep the inbound section out of debug
-// responses on proxies that don't run --private-inbound.
+// responses on proxies that don't run --private.
func (h *Handler) SetInboundProvider(p InboundProvider) {
h.inbound = p
}
diff --git a/proxy/internal/proxy/reverseproxy.go b/proxy/internal/proxy/reverseproxy.go
index e437e78a7..da0bf6552 100644
--- a/proxy/internal/proxy/reverseproxy.go
+++ b/proxy/internal/proxy/reverseproxy.go
@@ -66,6 +66,22 @@ func (p *ReverseProxy) ServeHTTP(w http.ResponseWriter, r *http.Request) {
return
}
+ // Loop guard for private services: a peer that hosts the target
+ // dialing its own service URL would round-trip its own traffic
+ // through the proxy and back over WG to itself. Refuse the request
+ // with 421 (Misdirected Request) so the caller sees an explicit
+ // error instead of silently doubling tunnel traffic.
+ if p.isSelfTargetLoop(r, result.target.URL) {
+ if cd := CapturedDataFromContext(r.Context()); cd != nil {
+ cd.SetOrigin(OriginNoRoute)
+ }
+ requestID := getRequestID(r)
+ web.ServeErrorPage(w, r, http.StatusMisdirectedRequest, "Loop Detected",
+ "This peer is the target of the requested service. Reach the backend directly instead of dialing the public service URL from the same machine.",
+ requestID, web.ErrorStatus{Proxy: true, Destination: false})
+ return
+ }
+
ctx := r.Context()
// Set the account ID in the context for the roundtripper to use.
ctx = roundtrip.WithAccountID(ctx, result.accountID)
@@ -107,6 +123,32 @@ func (p *ReverseProxy) ServeHTTP(w http.ResponseWriter, r *http.Request) {
rp.ServeHTTP(w, r.WithContext(ctx))
}
+// isSelfTargetLoop reports whether an overlay-origin request is about to
+// be forwarded back to the very peer that initiated it. The detection
+// is intentionally narrow: it only fires when the request arrived on
+// the per-account inbound (overlay) listener (so we're confident the
+// source address is the caller's tunnel IP), and only when the resolved
+// target host matches that tunnel IP. Catching this here returns 421 to
+// the caller instead of letting the proxy round-trip its own traffic
+// over WG twice.
+func (p *ReverseProxy) isSelfTargetLoop(r *http.Request, target *url.URL) bool {
+ if target == nil {
+ return false
+ }
+ if !types.IsOverlayOrigin(r.Context()) {
+ return false
+ }
+ srcIP := extractHostIP(r.RemoteAddr)
+ if !srcIP.IsValid() {
+ return false
+ }
+ targetIP, err := netip.ParseAddr(target.Hostname())
+ if err != nil {
+ return false
+ }
+ return srcIP.Unmap() == targetIP.Unmap()
+}
+
// rewriteFunc returns a Rewrite function for httputil.ReverseProxy that rewrites
// inbound requests to target the backend service while setting security-relevant
// forwarding headers and stripping proxy authentication credentials.
diff --git a/proxy/internal/proxy/reverseproxy_test.go b/proxy/internal/proxy/reverseproxy_test.go
index d5158a6cc..a8244fa56 100644
--- a/proxy/internal/proxy/reverseproxy_test.go
+++ b/proxy/internal/proxy/reverseproxy_test.go
@@ -20,6 +20,7 @@ import (
"github.com/netbirdio/netbird/proxy/auth"
"github.com/netbirdio/netbird/proxy/internal/roundtrip"
+ "github.com/netbirdio/netbird/proxy/internal/types"
"github.com/netbirdio/netbird/proxy/web"
)
@@ -1285,6 +1286,103 @@ func TestStampNetBirdIdentity_OmitsGroupsHeaderWhenAllInvalid(t *testing.T) {
"X-NetBird-Groups must not be set when every group label is rejected")
}
+// nopOKTransport returns 200 for every request without dialing — used
+// by the self-target-loop tests so the non-loop cases don't pay a real
+// TCP-dial timeout.
+type nopOKTransport struct{}
+
+func (nopOKTransport) RoundTrip(*http.Request) (*http.Response, error) {
+ return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody, Header: http.Header{}}, nil
+}
+
+// TestServeHTTP_SelfTargetLoopReturns421 covers the loop guard for
+// private services: when a peer dials a service whose only target is
+// the peer itself, the proxy must refuse with 421 (Misdirected
+// Request) rather than round-tripping the request back over WG to
+// the same peer.
+func TestServeHTTP_SelfTargetLoopReturns421(t *testing.T) {
+ rp := NewReverseProxy(nopOKTransport{}, "auto", nil, nil)
+ rp.AddMapping(Mapping{
+ ID: "svc-1",
+ AccountID: "acct-1",
+ Host: "private.svc",
+ Paths: map[string]*PathTarget{
+ "/": {
+ URL: &url.URL{Scheme: "http", Host: "100.64.0.5:8080"},
+ },
+ },
+ })
+
+ req := httptest.NewRequest(http.MethodGet, "http://private.svc/", nil)
+ req.Host = "private.svc"
+ req.RemoteAddr = "100.64.0.5:55555"
+ req = req.WithContext(types.WithOverlayOrigin(req.Context()))
+ rec := httptest.NewRecorder()
+
+ rp.ServeHTTP(rec, req)
+
+ assert.Equal(t, http.StatusMisdirectedRequest, rec.Code,
+ "a peer dialing a service whose target is itself must get 421")
+}
+
+// TestServeHTTP_SelfTargetLoop_NonOverlayRequestPassesThrough verifies
+// the guard is scoped to overlay-origin requests. A public-listener
+// request that happens to share a source IP with the target host must
+// not be misinterpreted as a loop — the gating relies on the inbound
+// marker being attached only by the per-account overlay listener.
+func TestServeHTTP_SelfTargetLoop_NonOverlayRequestPassesThrough(t *testing.T) {
+ rp := NewReverseProxy(nopOKTransport{}, "auto", nil, nil)
+ rp.AddMapping(Mapping{
+ ID: "svc-1",
+ AccountID: "acct-1",
+ Host: "public.svc",
+ Paths: map[string]*PathTarget{
+ "/": {
+ URL: &url.URL{Scheme: "http", Host: "100.64.0.5:8080"},
+ },
+ },
+ })
+
+ req := httptest.NewRequest(http.MethodGet, "http://public.svc/", nil)
+ req.Host = "public.svc"
+ req.RemoteAddr = "100.64.0.5:55555"
+ // No WithOverlayOrigin → the guard must not fire.
+ rec := httptest.NewRecorder()
+
+ rp.ServeHTTP(rec, req)
+
+ assert.NotEqual(t, http.StatusMisdirectedRequest, rec.Code,
+ "a non-overlay request with a colliding source IP must not be flagged as a loop")
+}
+
+// TestServeHTTP_SelfTargetLoop_OverlayDifferentIPPassesThrough confirms
+// that overlay-origin requests with a source IP that does *not* match
+// the target host are forwarded normally.
+func TestServeHTTP_SelfTargetLoop_OverlayDifferentIPPassesThrough(t *testing.T) {
+ rp := NewReverseProxy(nopOKTransport{}, "auto", nil, nil)
+ rp.AddMapping(Mapping{
+ ID: "svc-1",
+ AccountID: "acct-1",
+ Host: "private.svc",
+ Paths: map[string]*PathTarget{
+ "/": {
+ URL: &url.URL{Scheme: "http", Host: "100.64.0.5:8080"},
+ },
+ },
+ })
+
+ req := httptest.NewRequest(http.MethodGet, "http://private.svc/", nil)
+ req.Host = "private.svc"
+ req.RemoteAddr = "100.64.0.99:55555" // different from the target
+ req = req.WithContext(types.WithOverlayOrigin(req.Context()))
+ rec := httptest.NewRecorder()
+
+ rp.ServeHTTP(rec, req)
+
+ assert.NotEqual(t, http.StatusMisdirectedRequest, rec.Code,
+ "overlay request with a non-matching source IP must not be flagged as a loop")
+}
+
// TestStampNetBirdIdentity_CapturedDataPresentButEmpty covers requests
// that carry CapturedData with no identity fields populated (e.g. the
// auth middleware ran but the request didn't authenticate). Both
diff --git a/proxy/internal/roundtrip/netbird.go b/proxy/internal/roundtrip/netbird.go
index 11bca22e3..13d386da2 100644
--- a/proxy/internal/roundtrip/netbird.go
+++ b/proxy/internal/roundtrip/netbird.go
@@ -28,6 +28,10 @@ import (
const deviceNamePrefix = "ingress-proxy-"
+const clientStopTimeout = 30 * time.Second
+
+const createProxyPeerTimeout = 30 * time.Second
+
// backendKey identifies a backend by its host:port from the target URL.
type backendKey string
@@ -152,6 +156,7 @@ type managementClient interface {
// backed by underlying NetBird connections.
// Clients are keyed by AccountID, allowing multiple services to share the same connection.
type NetBird struct {
+ ctx context.Context
proxyID string
proxyAddr string
clientCfg ClientConfig
@@ -161,6 +166,7 @@ type NetBird struct {
clientsMux sync.RWMutex
clients map[types.AccountID]*clientEntry
+ lifecycleMu sync.Map
initLogOnce sync.Once
statusNotifier statusNotifier
// readyHandler runs after the embedded client for an account reports
@@ -176,6 +182,10 @@ type NetBird struct {
// (i.e. when a new client was actually created, not when an existing one
// was reused). The duration covers keygen + gRPC CreateProxyPeer + embed.New.
OnAddPeer func(d time.Duration, err error)
+
+ // startClient runs the post-create client startup. Nil uses runClientStartup;
+ // tests override it to avoid a real embed client.Start.
+ startClient func(accountID types.AccountID, client *embed.Client)
}
// ClientDebugInfo contains debug information about a client.
@@ -199,27 +209,20 @@ type skipTLSVerifyContextKey struct{}
func (n *NetBird) AddPeer(ctx context.Context, accountID types.AccountID, key ServiceKey, authToken string, serviceID types.ServiceID) error {
si := serviceInfo{serviceID: serviceID}
- n.clientsMux.Lock()
+ if n.registerExistingClient(accountID, key, si) {
+ return nil
+ }
- entry, exists := n.clients[accountID]
- if exists {
- entry.services[key] = si
- started := entry.started
- n.clientsMux.Unlock()
-
- n.logger.WithFields(log.Fields{
- "account_id": accountID,
- "service_key": key,
- }).Debug("registered service with existing client")
-
- if started && n.statusNotifier != nil {
- if err := n.statusNotifier.NotifyStatus(ctx, accountID, serviceID, true); err != nil {
- n.logger.WithFields(log.Fields{
- "account_id": accountID,
- "service_key": key,
- }).WithError(err).Warn("failed to notify status for existing client")
- }
+ lifecycle := n.accountLifecycle(accountID)
+ lifecycle.Lock()
+ transferred := false
+ defer func() {
+ if !transferred {
+ lifecycle.Unlock()
}
+ }()
+
+ if n.registerExistingClient(accountID, key, si) {
return nil
}
@@ -229,10 +232,10 @@ func (n *NetBird) AddPeer(ctx context.Context, accountID types.AccountID, key Se
n.OnAddPeer(time.Since(createStart), err)
}
if err != nil {
- n.clientsMux.Unlock()
return err
}
+ n.clientsMux.Lock()
n.clients[accountID] = entry
n.clientsMux.Unlock()
@@ -241,15 +244,64 @@ func (n *NetBird) AddPeer(ctx context.Context, accountID types.AccountID, key Se
"service_key": key,
}).Info("created new client for account")
- // Attempt to start the client in the background; if this fails we will
- // retry on the first request via RoundTrip.
- go n.runClientStartup(ctx, accountID, entry.client)
+ transferred = true
+ go func() {
+ defer lifecycle.Unlock()
+ n.startClientStartup(accountID, entry.client)
+ }()
return nil
}
+func (n *NetBird) startClientStartup(accountID types.AccountID, client *embed.Client) {
+ if n.startClient != nil {
+ n.startClient(accountID, client)
+ return
+ }
+ n.runClientStartup(accountID, client)
+}
+
+// registerExistingClient registers the service against an already-present
+// client for the account and returns true when it did. It notifies management
+// of the new service when the client is already started.
+func (n *NetBird) registerExistingClient(accountID types.AccountID, key ServiceKey, si serviceInfo) bool {
+ n.clientsMux.Lock()
+ entry, exists := n.clients[accountID]
+ if !exists {
+ n.clientsMux.Unlock()
+ return false
+ }
+ entry.services[key] = si
+ started := entry.started
+ n.clientsMux.Unlock()
+
+ n.logger.WithFields(log.Fields{
+ "account_id": accountID,
+ "service_key": key,
+ }).Debug("registered service with existing client")
+
+ if started && n.statusNotifier != nil {
+ if err := n.statusNotifier.NotifyStatus(context.Background(), accountID, si.serviceID, true); err != nil {
+ n.logger.WithFields(log.Fields{
+ "account_id": accountID,
+ "service_key": key,
+ }).WithError(err).Warn("failed to notify status for existing client")
+ }
+ }
+ return true
+}
+
+// accountLifecycle returns the per-account lifecycle mutex, serialising client
+// creation against teardown so a slow client.Stop cannot race a new
+// client.Start for the same account, without blocking clientsMux.
+func (n *NetBird) accountLifecycle(accountID types.AccountID) *sync.Mutex {
+ mu, _ := n.lifecycleMu.LoadOrStore(accountID, &sync.Mutex{})
+ return mu.(*sync.Mutex)
+}
+
// createClientEntry generates a WireGuard keypair, authenticates with management,
-// and creates an embedded NetBird client. Must be called with clientsMux held.
+// and creates an embedded NetBird client. Must be called with the account's
+// lifecycle mutex held.
func (n *NetBird) createClientEntry(ctx context.Context, accountID types.AccountID, key ServiceKey, authToken string, si serviceInfo) (*clientEntry, error) {
serviceID := si.serviceID
n.logger.WithFields(log.Fields{
@@ -269,7 +321,9 @@ func (n *NetBird) createClientEntry(ctx context.Context, accountID types.Account
"public_key": publicKey.String(),
}).Debug("authenticating new proxy peer with management")
- resp, err := n.mgmtClient.CreateProxyPeer(ctx, &proto.CreateProxyPeerRequest{
+ createCtx, cancel := context.WithTimeout(ctx, createProxyPeerTimeout)
+ defer cancel()
+ resp, err := n.mgmtClient.CreateProxyPeer(createCtx, &proto.CreateProxyPeerRequest{
ServiceId: string(serviceID),
AccountId: string(accountID),
Token: authToken,
@@ -307,7 +361,7 @@ func (n *NetBird) createClientEntry(ctx context.Context, accountID types.Account
ManagementURL: n.clientCfg.MgmtAddr,
PrivateKey: privateKey.String(),
LogLevel: log.WarnLevel.String(),
- BlockInbound: n.clientCfg.BlockInbound,
+ BlockInbound: n.clientCfg.BlockInbound,
// The embedded proxy peer must never be a stepping stone into
// the proxy host's LAN: it only exists to reach NetBird mesh
// targets or, when direct_upstream is set, the host network
@@ -355,8 +409,14 @@ func (n *NetBird) createClientEntry(ctx context.Context, accountID types.Account
}, nil
}
-// runClientStartup starts the client and notifies registered services on success.
-func (n *NetBird) runClientStartup(ctx context.Context, accountID types.AccountID, client *embed.Client) {
+// runClientStartup starts the client and notifies registered services on
+// success. This function runs in a goroutine launched from AddPeer, so it
+// must never inherit the caller's request-scoped context — a canceled
+// request must not abort the inbound listener bring-up or the management
+// status notification. The embedded client.Start gets its own bounded
+// startCtx; once Start succeeds, notifyClientReady takes over with a
+// fresh context.Background() (see that function for the contract).
+func (n *NetBird) runClientStartup(accountID types.AccountID, client *embed.Client) {
startCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
@@ -369,7 +429,17 @@ func (n *NetBird) runClientStartup(ctx context.Context, accountID types.AccountI
return
}
- // Mark client as started and collect services to notify outside the lock.
+ n.notifyClientReady(accountID, client)
+}
+
+// notifyClientReady marks the account's client as started, fires the
+// readyHandler hook, and notifies management of the new tunnel
+// connection for every registered service. It is split out of
+// runClientStartup so a regression test can drive the post-Start tail
+// without needing a live embedded client. The contract that the
+// hooks/notifier see context.Background() — never the AddPeer caller's
+// ctx — lives here.
+func (n *NetBird) notifyClientReady(accountID types.AccountID, client *embed.Client) {
n.clientsMux.Lock()
entry, exists := n.clients[accountID]
if exists {
@@ -385,7 +455,7 @@ func (n *NetBird) runClientStartup(ctx context.Context, accountID types.AccountI
n.clientsMux.Unlock()
if readyHandler != nil {
- state := readyHandler(ctx, accountID, client)
+ state := readyHandler(n.ctx, accountID, client)
n.clientsMux.Lock()
if e, ok := n.clients[accountID]; ok {
e.inbound = state
@@ -404,7 +474,7 @@ func (n *NetBird) runClientStartup(ctx context.Context, accountID types.AccountI
return
}
for _, sn := range toNotify {
- if err := n.statusNotifier.NotifyStatus(ctx, accountID, sn.serviceID, true); err != nil {
+ if err := n.statusNotifier.NotifyStatus(n.ctx, accountID, sn.serviceID, true); err != nil {
n.logger.WithFields(log.Fields{
"account_id": accountID,
"service_key": sn.key,
@@ -421,6 +491,15 @@ func (n *NetBird) runClientStartup(ctx context.Context, accountID types.AccountI
// RemovePeer unregisters a service from an account. The client is only stopped
// when no services are using it anymore.
func (n *NetBird) RemovePeer(ctx context.Context, accountID types.AccountID, key ServiceKey) error {
+ lifecycle := n.accountLifecycle(accountID)
+ lifecycle.Lock()
+ transferred := false
+ defer func() {
+ if !transferred {
+ lifecycle.Unlock()
+ }
+ }()
+
n.clientsMux.Lock()
entry, exists := n.clients[accountID]
@@ -443,17 +522,8 @@ func (n *NetBird) RemovePeer(ctx context.Context, accountID types.AccountID, key
delete(entry.services, key)
stopClient := len(entry.services) == 0
- var client *embed.Client
- var transport, insecureTransport *http.Transport
- var inbound any
- var stopHandler func(types.AccountID, any)
if stopClient {
n.logger.WithField("account_id", accountID).Info("stopping client, no more services")
- client = entry.client
- transport = entry.transport
- insecureTransport = entry.insecureTransport
- inbound = entry.inbound
- stopHandler = n.stopHandler
delete(n.clients, accountID)
} else {
n.logger.WithFields(log.Fields{
@@ -467,19 +537,40 @@ func (n *NetBird) RemovePeer(ctx context.Context, accountID types.AccountID, key
n.notifyDisconnect(ctx, accountID, key, si.serviceID)
if stopClient {
- if inbound != nil && stopHandler != nil {
- stopHandler(accountID, inbound)
- }
- transport.CloseIdleConnections()
- insecureTransport.CloseIdleConnections()
- if err := client.Stop(ctx); err != nil {
- n.logger.WithField("account_id", accountID).WithError(err).Warn("failed to stop netbird client")
- }
+ transferred = true
+ go n.stopClientLocked(accountID, lifecycle, entry)
}
return nil
}
+// stopClientLocked releases a client's resources off the caller's goroutine so a
+// slow client.Stop cannot wedge the mapping receive loop (which calls RemovePeer
+// synchronously). It unlocks lifecycle when done so a new client.Start for the
+// same account waits for this teardown.
+func (n *NetBird) stopClientLocked(accountID types.AccountID, lifecycle *sync.Mutex, entry *clientEntry) {
+ defer lifecycle.Unlock()
+
+ if entry.inbound != nil && n.stopHandler != nil {
+ n.stopHandler(accountID, entry.inbound)
+ }
+ if entry.transport != nil {
+ entry.transport.CloseIdleConnections()
+ }
+ if entry.insecureTransport != nil {
+ entry.insecureTransport.CloseIdleConnections()
+ }
+ if entry.client == nil {
+ return
+ }
+
+ ctx, cancel := context.WithTimeout(context.Background(), clientStopTimeout)
+ defer cancel()
+ if err := entry.client.Stop(ctx); err != nil {
+ n.logger.WithField("account_id", accountID).WithError(err).Warn("failed to stop netbird client")
+ }
+}
+
func (n *NetBird) notifyDisconnect(ctx context.Context, accountID types.AccountID, key ServiceKey, serviceID types.ServiceID) {
if n.statusNotifier == nil {
return
@@ -666,11 +757,12 @@ func (n *NetBird) ListClientsForStartup() map[types.AccountID]*embed.Client {
// NewNetBird creates a new NetBird transport. Set clientCfg.WGPort to 0 for a random
// OS-assigned port. A fixed port only works with single-account deployments;
// multiple accounts will fail to bind the same port.
-func NewNetBird(proxyID, proxyAddr string, clientCfg ClientConfig, logger *log.Logger, notifier statusNotifier, mgmtClient managementClient) *NetBird {
+func NewNetBird(ctx context.Context, proxyID, proxyAddr string, clientCfg ClientConfig, logger *log.Logger, notifier statusNotifier, mgmtClient managementClient) *NetBird {
if logger == nil {
logger = log.StandardLogger()
}
return &NetBird{
+ ctx: ctx,
proxyID: proxyID,
proxyAddr: proxyAddr,
clientCfg: clientCfg,
diff --git a/proxy/internal/roundtrip/netbird_test.go b/proxy/internal/roundtrip/netbird_test.go
index 3f3e4138a..700cca83e 100644
--- a/proxy/internal/roundtrip/netbird_test.go
+++ b/proxy/internal/roundtrip/netbird_test.go
@@ -6,11 +6,13 @@ import (
"net/netip"
"sync"
"testing"
+ "time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"google.golang.org/grpc"
+ "github.com/netbirdio/netbird/client/embed"
"github.com/netbirdio/netbird/proxy/internal/types"
"github.com/netbirdio/netbird/shared/management/proto"
)
@@ -21,6 +23,18 @@ func (m *mockMgmtClient) CreateProxyPeer(_ context.Context, _ *proto.CreateProxy
return &proto.CreateProxyPeerResponse{Success: true}, nil
}
+// signalMgmtClient closes entered the first time CreateProxyPeer is called, so
+// tests can detect AddPeer reaching client creation.
+type signalMgmtClient struct {
+ entered chan struct{}
+ once sync.Once
+}
+
+func (m *signalMgmtClient) CreateProxyPeer(_ context.Context, _ *proto.CreateProxyPeerRequest, _ ...grpc.CallOption) (*proto.CreateProxyPeerResponse, error) {
+ m.once.Do(func() { close(m.entered) })
+ return &proto.CreateProxyPeerResponse{Success: true}, nil
+}
+
type mockStatusNotifier struct {
mu sync.Mutex
statuses []statusCall
@@ -30,12 +44,15 @@ type statusCall struct {
accountID types.AccountID
serviceID types.ServiceID
connected bool
+ // ctx is captured so tests can assert the notifier received a
+ // fresh background context rather than an inherited request ctx.
+ ctx context.Context
}
-func (m *mockStatusNotifier) NotifyStatus(_ context.Context, accountID types.AccountID, serviceID types.ServiceID, connected bool) error {
+func (m *mockStatusNotifier) NotifyStatus(ctx context.Context, accountID types.AccountID, serviceID types.ServiceID, connected bool) error {
m.mu.Lock()
defer m.mu.Unlock()
- m.statuses = append(m.statuses, statusCall{accountID, serviceID, connected})
+ m.statuses = append(m.statuses, statusCall{accountID, serviceID, connected, ctx})
return nil
}
@@ -48,11 +65,15 @@ func (m *mockStatusNotifier) calls() []statusCall {
// mockNetBird creates a NetBird instance for testing without actually connecting.
// It uses an invalid management URL to prevent real connections.
func mockNetBird() *NetBird {
- return NewNetBird("test-proxy", "invalid.test", ClientConfig{
+ nb := NewNetBird(context.Background(), "test-proxy", "invalid.test", ClientConfig{
MgmtAddr: "http://invalid.test:9999",
WGPort: 0,
PreSharedKey: "",
}, nil, nil, &mockMgmtClient{})
+ // Skip the real embed client.Start, which would hang against the unreachable
+ // mgmt URL and (now that the lifecycle lock spans startup) serialise removes.
+ nb.startClient = func(types.AccountID, *embed.Client) {}
+ return nb
}
func TestNetBird_AddPeer_CreatesClientForNewAccount(t *testing.T) {
@@ -279,11 +300,12 @@ func TestNetBird_RoundTrip_RequiresExistingClient(t *testing.T) {
func TestNetBird_AddPeer_ExistingStartedClient_NotifiesStatus(t *testing.T) {
notifier := &mockStatusNotifier{}
- nb := NewNetBird("test-proxy", "invalid.test", ClientConfig{
+ nb := NewNetBird(context.Background(), "test-proxy", "invalid.test", ClientConfig{
MgmtAddr: "http://invalid.test:9999",
WGPort: 0,
PreSharedKey: "",
}, nil, notifier, &mockMgmtClient{})
+ nb.startClient = func(types.AccountID, *embed.Client) {}
accountID := types.AccountID("account-1")
// Add first service — creates a new client entry.
@@ -295,8 +317,12 @@ func TestNetBird_AddPeer_ExistingStartedClient_NotifiesStatus(t *testing.T) {
nb.clients[accountID].started = true
nb.clientsMux.Unlock()
- // Add second service — should notify immediately since client is already started.
- err = nb.AddPeer(context.Background(), accountID, "domain2.test", "key-1", types.ServiceID("svc-2"))
+ // Add second service with an already-cancelled caller context —
+ // should notify immediately (client is started) AND the notification
+ // must not inherit the cancelled ctx.
+ cancelledCtx, cancel := context.WithCancel(context.Background())
+ cancel()
+ err = nb.AddPeer(cancelledCtx, accountID, "domain2.test", "key-1", types.ServiceID("svc-2"))
require.NoError(t, err)
calls := notifier.calls()
@@ -304,6 +330,9 @@ func TestNetBird_AddPeer_ExistingStartedClient_NotifiesStatus(t *testing.T) {
assert.Equal(t, accountID, calls[0].accountID)
assert.Equal(t, types.ServiceID("svc-2"), calls[0].serviceID)
assert.True(t, calls[0].connected)
+ require.NotNil(t, calls[0].ctx, "NotifyStatus must receive a context")
+ require.NoError(t, calls[0].ctx.Err(),
+ "already-started NotifyStatus must use a background ctx, not the cancelled caller ctx")
}
// TestNetBird_IdentityForIP_UnknownAccountReturnsFalse confirms that the
@@ -338,7 +367,7 @@ func TestClientEntry_IdentityForIP_InvalidIPReturnsFalse(t *testing.T) {
func TestNetBird_RemovePeer_NotifiesDisconnection(t *testing.T) {
notifier := &mockStatusNotifier{}
- nb := NewNetBird("test-proxy", "invalid.test", ClientConfig{
+ nb := NewNetBird(context.Background(), "test-proxy", "invalid.test", ClientConfig{
MgmtAddr: "http://invalid.test:9999",
WGPort: 0,
PreSharedKey: "",
@@ -360,3 +389,164 @@ func TestNetBird_RemovePeer_NotifiesDisconnection(t *testing.T) {
assert.Equal(t, types.ServiceID("svc-1"), calls[0].serviceID)
assert.False(t, calls[0].connected)
}
+
+// TestNetBird_RemovePeer_TeardownIsAsync proves the fix for the receive-loop
+// stall: RemovePeer must return promptly even when the client teardown blocks,
+// because teardown runs off the caller's goroutine. The receive loop calls
+// RemovePeer synchronously, so a blocking teardown inline would wedge it.
+func TestNetBird_RemovePeer_TeardownIsAsync(t *testing.T) {
+ nb := NewNetBird(context.Background(), "test-proxy", "invalid.test", ClientConfig{
+ MgmtAddr: "http://invalid.test:9999",
+ }, nil, &mockStatusNotifier{}, &mockMgmtClient{})
+
+ accountID := types.AccountID("acct-async-teardown")
+ key := DomainServiceKey("svc.example")
+
+ teardownEntered := make(chan struct{})
+ releaseTeardown := make(chan struct{})
+ nb.SetClientLifecycle(nil, func(types.AccountID, any) {
+ close(teardownEntered)
+ <-releaseTeardown
+ })
+
+ nb.clientsMux.Lock()
+ nb.clients[accountID] = &clientEntry{
+ services: map[ServiceKey]serviceInfo{key: {serviceID: types.ServiceID("svc-1")}},
+ started: true,
+ inbound: struct{}{},
+ }
+ nb.clientsMux.Unlock()
+
+ done := make(chan error, 1)
+ go func() { done <- nb.RemovePeer(context.Background(), accountID, key) }()
+
+ select {
+ case err := <-done:
+ require.NoError(t, err)
+ case <-time.After(2 * time.Second):
+ t.Fatal("RemovePeer did not return while teardown was blocked — teardown is not async")
+ }
+
+ select {
+ case <-teardownEntered:
+ case <-time.After(2 * time.Second):
+ t.Fatal("teardown never ran")
+ }
+
+ close(releaseTeardown)
+}
+
+// TestNetBird_AddPeer_WaitsForTeardown proves the lifecycle lock serialises a
+// new client bringup behind an in-flight teardown for the same account, so a
+// slow client.Stop can never race a new client.Start for that account.
+//
+// It targets the handoff race specifically: AddPeer is launched immediately
+// after RemovePeer returns, WITHOUT waiting for the teardown goroutine to start.
+// This only passes if RemovePeer acquires the lifecycle lock synchronously
+// (before returning) and hands it to the teardown goroutine — if the goroutine
+// acquired the lock itself, AddPeer could win the lock in this window and start
+// a replacement client while the old teardown is still pending.
+func TestNetBird_AddPeer_WaitsForTeardown(t *testing.T) {
+ nb := NewNetBird(context.Background(), "test-proxy", "invalid.test", ClientConfig{
+ MgmtAddr: "http://invalid.test:9999",
+ }, nil, &mockStatusNotifier{}, &mockMgmtClient{})
+ nb.startClient = func(types.AccountID, *embed.Client) {}
+
+ accountID := types.AccountID("acct-serialize")
+ key := DomainServiceKey("svc.example")
+
+ addEntered := make(chan struct{})
+ releaseTeardown := make(chan struct{})
+ nb.SetClientLifecycle(nil, func(types.AccountID, any) {
+ // Block teardown until released. If AddPeer ever reaches createClientEntry
+ // (signalled via the mgmt client below) while we hold the lock, the lock
+ // failed to serialise and the test fails before we release.
+ <-releaseTeardown
+ })
+
+ nb.clientsMux.Lock()
+ nb.clients[accountID] = &clientEntry{
+ services: map[ServiceKey]serviceInfo{key: {serviceID: types.ServiceID("svc-1")}},
+ started: true,
+ inbound: struct{}{},
+ }
+ nb.clientsMux.Unlock()
+
+ // createClientEntry calls CreateProxyPeer; closing addEntered there tells us
+ // AddPeer got past the lifecycle lock and into client creation.
+ nb.mgmtClient = &signalMgmtClient{entered: addEntered}
+
+ require.NoError(t, nb.RemovePeer(context.Background(), accountID, key))
+
+ // Launch AddPeer with NO synchronisation against the teardown goroutine.
+ addReturned := make(chan struct{})
+ go func() {
+ _ = nb.AddPeer(context.Background(), accountID, DomainServiceKey("svc2.example"), "key-2", types.ServiceID("svc-2"))
+ close(addReturned)
+ }()
+
+ select {
+ case <-addEntered:
+ t.Fatal("AddPeer entered client creation while teardown held the lifecycle lock — handoff race not closed")
+ case <-addReturned:
+ t.Fatal("AddPeer completed while teardown held the lifecycle lock — not serialised")
+ case <-time.After(300 * time.Millisecond):
+ }
+
+ close(releaseTeardown)
+ select {
+ case <-addReturned:
+ case <-time.After(2 * time.Second):
+ t.Fatal("AddPeer never completed after teardown released the lifecycle lock")
+ }
+}
+
+// TestNotifyClientReady_UsesBackgroundCtx pins the contract that the
+// post-Start hooks (readyHandler + statusNotifier.NotifyStatus) run on
+// a fresh context.Background() rather than inheriting the AddPeer
+// caller's request- or stream-scoped ctx. Without this, a cancelled
+// caller ctx could abort the inbound listener bring-up or cause the
+// management status notification to fail spuriously and leave the
+// account in a half-connected state.
+func TestNotifyClientReady_UsesBackgroundCtx(t *testing.T) {
+ notifier := &mockStatusNotifier{}
+ nb := NewNetBird(context.Background(), "test-proxy", "invalid.test", ClientConfig{
+ MgmtAddr: "http://invalid.test:9999",
+ }, nil, notifier, &mockMgmtClient{})
+
+ accountID := types.AccountID("acct-async")
+ // Pre-populate a client entry so notifyClientReady has something
+ // to mark started + something to enumerate for NotifyStatus.
+ nb.clientsMux.Lock()
+ nb.clients[accountID] = &clientEntry{
+ services: map[ServiceKey]serviceInfo{
+ DomainServiceKey("svc.example"): {serviceID: types.ServiceID("svc-1")},
+ },
+ }
+ nb.clientsMux.Unlock()
+
+ var capturedReadyCtx context.Context
+ nb.SetClientLifecycle(
+ func(ctx context.Context, _ types.AccountID, _ *embed.Client) any {
+ capturedReadyCtx = ctx
+ return nil
+ },
+ nil,
+ )
+
+ // Drive the post-Start path directly; a real client.Start would
+ // need a working management URL.
+ nb.notifyClientReady(accountID, nil)
+
+ require.NotNil(t, capturedReadyCtx, "readyHandler must have been invoked")
+ require.NoError(t, capturedReadyCtx.Err(),
+ "readyHandler must receive a background context, not an inherited cancelled one")
+ deadline, ok := capturedReadyCtx.Deadline()
+ assert.False(t, ok, "readyHandler ctx must have no deadline (background); got %v", deadline)
+
+ calls := notifier.calls()
+ require.Len(t, calls, 1, "NotifyStatus must be invoked once per registered service")
+ require.NotNil(t, calls[0].ctx, "NotifyStatus must receive a context")
+ require.NoError(t, calls[0].ctx.Err(),
+ "NotifyStatus must receive a background context, not an inherited cancelled one")
+}
diff --git a/proxy/internal/tcp/router_test.go b/proxy/internal/tcp/router_test.go
index 2f96d142c..ea1b418f5 100644
--- a/proxy/internal/tcp/router_test.go
+++ b/proxy/internal/tcp/router_test.go
@@ -1781,11 +1781,14 @@ func TestRouter_PlainHTTP_RoutesToPlainChannel(t *testing.T) {
}
}()
+ tlsListener, ok := router.HTTPListener().(*chanListener)
+ require.True(t, ok, "router.HTTPListener() must be the test's chanListener; the test relies on observing its channel directly")
+
select {
case conn := <-acceptDone:
require.NotNil(t, conn)
_ = conn.Close()
- case <-router.HTTPListener().(*chanListener).ch:
+ case <-tlsListener.ch:
t.Fatal("plain HTTP request leaked into TLS channel")
case <-time.After(3 * time.Second):
t.Fatal("plain HTTP connection never reached plain channel")
diff --git a/proxy/lifecycle.go b/proxy/lifecycle.go
index 9787f237e..0d4aded9c 100644
--- a/proxy/lifecycle.go
+++ b/proxy/lifecycle.go
@@ -1,6 +1,7 @@
package proxy
import (
+ "context"
"net/netip"
"time"
@@ -20,14 +21,17 @@ import (
type Config struct {
// ListenAddr is the TCP address the main listener binds. Required.
ListenAddr string
- // ID identifies this proxy instance to management. Empty value lets
- // New generate a timestamped default.
+ // ID identifies this proxy instance to management. Empty values are
+ // replaced with a timestamped default at Server.Start time (see
+ // initDefaults), not in New.
ID string
- // Logger is the logrus logger used everywhere. Empty value falls back
- // to log.StandardLogger().
+ // Logger is the logrus logger used everywhere. Empty values fall
+ // back to log.StandardLogger() at Server.Start time (see
+ // initDefaults), not in New.
Logger *log.Logger
// Version is the build version string reported to management. Empty
- // becomes "dev".
+ // values are replaced with "dev" at Server.Start time (see
+ // initDefaults), not in New.
Version string
// ProxyURL is the public address operators use to reach this proxy.
ProxyURL string
@@ -110,6 +114,10 @@ type Config struct {
MaxDialTimeout time.Duration
// MaxSessionIdleTimeout caps the per-service session idle timeout.
MaxSessionIdleTimeout time.Duration
+ // MappingBatchWatchdog bounds how long a single mapping batch may spend
+ // being applied before the receive loop reconnects to resync. Zero falls
+ // back to the internal default.
+ MappingBatchWatchdog time.Duration
// GeoDataDir is the directory containing GeoLite2 MMDB files.
GeoDataDir string
@@ -125,8 +133,9 @@ type Config struct {
// bound — call Start to bring the proxy up. Returning a fully-formed
// Server keeps the standalone code path (which still constructs Server
// directly) byte-for-byte equivalent.
-func New(cfg Config) *Server {
+func New(ctx context.Context, cfg Config) *Server {
return &Server{
+ ctx: ctx,
ListenAddr: cfg.ListenAddr,
ID: cfg.ID,
Logger: cfg.Logger,
@@ -159,6 +168,7 @@ func New(cfg Config) *Server {
Private: cfg.Private,
MaxDialTimeout: cfg.MaxDialTimeout,
MaxSessionIdleTimeout: cfg.MaxSessionIdleTimeout,
+ MappingBatchWatchdog: cfg.MappingBatchWatchdog,
GeoDataDir: cfg.GeoDataDir,
CrowdSecAPIURL: cfg.CrowdSecAPIURL,
CrowdSecAPIKey: cfg.CrowdSecAPIKey,
diff --git a/proxy/mapping_stall_test.go b/proxy/mapping_stall_test.go
new file mode 100644
index 000000000..acf313d19
--- /dev/null
+++ b/proxy/mapping_stall_test.go
@@ -0,0 +1,282 @@
+package proxy
+
+import (
+ "context"
+ "sync"
+ "sync/atomic"
+ "testing"
+ "time"
+
+ log "github.com/sirupsen/logrus"
+ "github.com/stretchr/testify/assert"
+ "google.golang.org/grpc"
+ "google.golang.org/grpc/metadata"
+
+ "github.com/netbirdio/netbird/proxy/internal/roundtrip"
+ "github.com/netbirdio/netbird/proxy/internal/types"
+ "github.com/netbirdio/netbird/shared/management/proto"
+)
+
+// blockingMgmtClient implements roundtrip's managementClient interface.
+// CreateProxyPeer parks until release is closed, signalling entry on entered.
+// This reproduces the confirmed real-world stall: createClientEntry calls
+// CreateProxyPeer synchronously while holding clientsMux, and the proxy's
+// receive loop calls that path synchronously inside processMappings.
+type blockingMgmtClient struct {
+ entered chan struct{}
+ once sync.Once
+}
+
+func (b *blockingMgmtClient) CreateProxyPeer(ctx context.Context, _ *proto.CreateProxyPeerRequest, _ ...grpc.CallOption) (*proto.CreateProxyPeerResponse, error) {
+ b.once.Do(func() { close(b.entered) })
+ // Park until the caller's context is cancelled. In production this ctx is
+ // the gRPC mapping-stream context with no per-call timeout, so a slow or
+ // unresponsive CreateProxyPeer parks the receive loop here indefinitely.
+ <-ctx.Done()
+ return nil, ctx.Err()
+}
+
+// gatedMappingStream is a mock GetMappingUpdate client stream that hands out a
+// pre-seeded list of messages, then records how many times Recv advanced. It
+// lets the test observe whether the single-threaded receive loop ever gets
+// past the first (blocking) batch to pull the second message.
+type gatedMappingStream struct {
+ grpc.ClientStream
+ messages []*proto.GetMappingUpdateResponse
+ idx int32
+}
+
+func (g *gatedMappingStream) Recv() (*proto.GetMappingUpdateResponse, error) {
+ i := int(atomic.LoadInt32(&g.idx))
+ if i >= len(g.messages) {
+ // Block instead of returning EOF so the loop doesn't exit; we only
+ // care whether the loop ever reaches this second Recv at all.
+ select {}
+ }
+ msg := g.messages[i]
+ atomic.AddInt32(&g.idx, 1)
+ return msg, nil
+}
+
+func (g *gatedMappingStream) deliveredCount() int32 { return atomic.LoadInt32(&g.idx) }
+
+func (g *gatedMappingStream) Header() (metadata.MD, error) { return nil, nil } //nolint:nilnil
+func (g *gatedMappingStream) Trailer() metadata.MD { return nil }
+func (g *gatedMappingStream) CloseSend() error { return nil }
+func (g *gatedMappingStream) Context() context.Context { return context.Background() }
+func (g *gatedMappingStream) SendMsg(any) error { return nil }
+func (g *gatedMappingStream) RecvMsg(any) error { return nil }
+
+// noopNotifier satisfies roundtrip's statusNotifier interface.
+type noopNotifier struct{}
+
+func (noopNotifier) NotifyStatus(context.Context, types.AccountID, types.ServiceID, bool) error {
+ return nil
+}
+
+// noopProxyClient is a proto.ProxyServiceClient that no-ops the one method the
+// teardown unwind reaches (SendStatusUpdate, via notifyError when the parked
+// AddPeer is cancelled). The embedded nil interface satisfies the rest at
+// compile time; none of those methods are called by this test.
+type noopProxyClient struct {
+ proto.ProxyServiceClient
+}
+
+func (noopProxyClient) SendStatusUpdate(context.Context, *proto.SendStatusUpdateRequest, ...grpc.CallOption) (*proto.SendStatusUpdateResponse, error) {
+ return &proto.SendStatusUpdateResponse{}, nil
+}
+
+// TestMappingStream_StallsWhenApplyBlocks proves the deadlock: the proxy's
+// mapping receive loop processes batches strictly serially, so when applying
+// one batch blocks (here: createClientEntry parked on a synchronous
+// CreateProxyPeer call, exactly as observed in production), the loop never
+// advances to Recv the next batch. Management can keep sending updates onto
+// the stream with no error and no channel overflow, yet the proxy applies
+// nothing further — it is stuck.
+func TestMappingStream_StallsWhenApplyBlocks(t *testing.T) {
+ logger := log.New()
+ logger.SetLevel(log.PanicLevel)
+
+ mgmt := &blockingMgmtClient{
+ entered: make(chan struct{}),
+ }
+
+ nb := roundtrip.NewNetBird(
+ context.Background(),
+ "proxy-test",
+ "proxy.example.com",
+ roundtrip.ClientConfig{},
+ logger,
+ noopNotifier{},
+ mgmt,
+ )
+
+ s := &Server{
+ Logger: logger,
+ netbird: nb,
+ mgmtClient: noopProxyClient{},
+ routerReady: closedChan(),
+ lastMappings: make(map[types.ServiceID]*proto.ProxyMapping),
+ }
+
+ // First batch: a CREATED mapping for a brand-new account. addMapping ->
+ // netbird.AddPeer -> createClientEntry -> CreateProxyPeer, which blocks.
+ // Empty Path keeps setupHTTPMapping a no-op (it returns early), so the
+ // ONLY blocking point is the synchronous CreateProxyPeer in AddPeer —
+ // no routers/auth need wiring. The second batch exists only to detect
+ // whether the loop ever advances past the blocked first batch.
+ stream := &gatedMappingStream{
+ messages: []*proto.GetMappingUpdateResponse{
+ {
+ Mapping: []*proto.ProxyMapping{
+ {
+ Type: proto.ProxyMappingUpdateType_UPDATE_TYPE_CREATED,
+ Id: "svc-1",
+ AccountId: "acct-1",
+ AuthToken: "token-1",
+ },
+ },
+ },
+ {
+ Mapping: []*proto.ProxyMapping{
+ {
+ Type: proto.ProxyMappingUpdateType_UPDATE_TYPE_CREATED,
+ Id: "svc-2",
+ AccountId: "acct-2",
+ AuthToken: "token-2",
+ },
+ },
+ },
+ },
+ }
+
+ ctx, cancel := context.WithCancel(context.Background())
+ // Unblock the parked apply on teardown via ctx (CreateProxyPeer returns
+ // ctx.Err()), so the wedged loop goroutine unwinds before embed.New —
+ // avoiding any dependency on collaborators this test deliberately leaves
+ // nil. The deadlock is fully proven before this fires.
+ t.Cleanup(cancel)
+
+ loopDone := make(chan struct{})
+ syncDone := false
+ go func() {
+ defer close(loopDone)
+ _ = s.handleMappingStream(ctx, stream, &syncDone, time.Time{})
+ }()
+
+ // The loop must reach the blocking apply for the first batch.
+ select {
+ case <-mgmt.entered:
+ case <-time.After(2 * time.Second):
+ t.Fatal("receive loop never reached CreateProxyPeer for the first batch")
+ }
+
+ // THE DEADLOCK: while the first batch is parked in CreateProxyPeer, the
+ // single-threaded loop cannot advance. The second batch is never pulled,
+ // even though it is already available on the stream. Give it ample time.
+ // deliveredCount is atomic; syncDone is intentionally not read here because
+ // the loop goroutine owns it (reading it from the test would race).
+ time.Sleep(500 * time.Millisecond)
+ assert.Equal(t, int32(1), stream.deliveredCount(),
+ "loop must NOT consume the second batch while the first is blocked in apply — proxy is stuck")
+
+ select {
+ case <-loopDone:
+ t.Fatal("receive loop returned while it should be wedged in apply")
+ default:
+ // Still wedged, as expected.
+ }
+}
+
+// TestMappingStream_StallsWhenRemoveBlocks proves the deadlock for the REMOVE
+// path observed in production: a mapping remove tears down the account's last
+// embedded client via netbird.RemovePeer -> client.Stop -> Engine.Stop, whose
+// jobExecutorWG.Wait() is unbounded. Because the receive loop is single-
+// threaded, a blocked remove wedges the loop: no further mapping updates of any
+// kind (create/modify/remove) are applied, while management keeps sending them
+// successfully (no send error, no channel-full). Matches the reported symptom:
+// the last log line is a remove that stops a client, then silence.
+func TestMappingStream_StallsWhenRemoveBlocks(t *testing.T) {
+ logger := log.New()
+ logger.SetLevel(log.PanicLevel)
+
+ enteredRemove := make(chan struct{})
+ blockRemove := make(chan struct{})
+ var once sync.Once
+
+ s := &Server{
+ Logger: logger,
+ mgmtClient: noopProxyClient{},
+ routerReady: closedChan(),
+ lastMappings: make(map[types.ServiceID]*proto.ProxyMapping),
+ // Stand in for netbird.RemovePeer -> client.Stop hanging on
+ // Engine.Stop's unbounded jobExecutorWG.Wait(). Only the first remove
+ // blocks; later removes return immediately so the recovery assertion
+ // can observe the loop advancing.
+ removePeer: func(ctx context.Context, _ types.AccountID, _ roundtrip.ServiceKey) error {
+ first := false
+ once.Do(func() {
+ first = true
+ close(enteredRemove)
+ })
+ if !first {
+ return nil
+ }
+ select {
+ case <-blockRemove:
+ case <-ctx.Done():
+ }
+ return nil
+ },
+ }
+
+ // Batch 1 removes a service (blocks in teardown). Batch 2 is a later update
+ // that must never be applied while the remove is wedged.
+ stream := &gatedMappingStream{
+ messages: []*proto.GetMappingUpdateResponse{
+ {
+ Mapping: []*proto.ProxyMapping{
+ {Type: proto.ProxyMappingUpdateType_UPDATE_TYPE_REMOVED, Id: "svc-1", AccountId: "acct-1"},
+ },
+ },
+ {
+ Mapping: []*proto.ProxyMapping{
+ {Type: proto.ProxyMappingUpdateType_UPDATE_TYPE_REMOVED, Id: "svc-2", AccountId: "acct-1"},
+ },
+ },
+ },
+ }
+
+ loopDone := make(chan struct{})
+ syncDone := false
+ go func() {
+ defer close(loopDone)
+ _ = s.handleMappingStream(context.Background(), stream, &syncDone, time.Time{})
+ }()
+
+ select {
+ case <-enteredRemove:
+ case <-time.After(2 * time.Second):
+ t.Fatal("receive loop never reached the blocking remove for the first batch")
+ }
+
+ // THE DEADLOCK: the loop is parked in the blocked remove and cannot advance.
+ // syncDone is owned by the loop goroutine, so it is not read here.
+ time.Sleep(500 * time.Millisecond)
+ assert.Equal(t, int32(1), stream.deliveredCount(),
+ "loop must NOT consume the second batch while the first remove is blocked — proxy is stuck")
+
+ select {
+ case <-loopDone:
+ t.Fatal("receive loop returned while it should be wedged on the remove")
+ default:
+ }
+
+ // Unblock and confirm the wedge was solely the blocked remove: the loop
+ // then advances and consumes the next batch.
+ close(blockRemove)
+ assert.Eventually(t, func() bool {
+ return stream.deliveredCount() >= 2
+ }, 2*time.Second, 5*time.Millisecond,
+ "once the remove unblocks, the loop must advance and consume the next batch")
+}
diff --git a/proxy/process_mappings_bench_test.go b/proxy/process_mappings_bench_test.go
index ca0792590..919cab95c 100644
--- a/proxy/process_mappings_bench_test.go
+++ b/proxy/process_mappings_bench_test.go
@@ -73,7 +73,7 @@ func benchServerWithLatency(b *testing.B, createPeerDelay, statusDelay time.Dura
statusUpdateDelay: statusDelay,
}
- nb := roundtrip.NewNetBird("bench-proxy", "bench.test",
+ nb := roundtrip.NewNetBird(b.Context(), "bench-proxy", "bench.test",
roundtrip.ClientConfig{MgmtAddr: "http://bench.test:9999"},
logger, nil, mgmtClient)
diff --git a/proxy/server.go b/proxy/server.go
index 037da925c..1d8a2451b 100644
--- a/proxy/server.go
+++ b/proxy/server.go
@@ -24,6 +24,7 @@ import (
"time"
"github.com/cenkalti/backoff/v4"
+ "github.com/google/uuid"
"github.com/pires/go-proxyproto"
prometheus2 "github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/promhttp"
@@ -75,28 +76,30 @@ type portRouter struct {
}
type Server struct {
- mgmtClient proto.ProxyServiceClient
- proxy *proxy.ReverseProxy
- netbird *roundtrip.NetBird
- acme *acme.Manager
- auth *auth.Middleware
- http *http.Server
- https *http.Server
- debug *http.Server
- healthServer *health.Server
- healthChecker *health.Checker
- meter *proxymetrics.Metrics
- accessLog *accesslog.Logger
- mainRouter *nbtcp.Router
- mainPort uint16
- udpMu sync.Mutex
- udpRelays map[types.ServiceID]*udprelay.Relay
- udpRelayWg sync.WaitGroup
- portMu sync.RWMutex
- portRouters map[uint16]*portRouter
- svcPorts map[types.ServiceID][]uint16
- lastMappings map[types.ServiceID]*proto.ProxyMapping
- portRouterWg sync.WaitGroup
+ ctx context.Context
+ mgmtClient proto.ProxyServiceClient
+ proxy *proxy.ReverseProxy
+ netbird *roundtrip.NetBird
+ acme *acme.Manager
+ staticCertWatcher *certwatch.Watcher
+ auth *auth.Middleware
+ http *http.Server
+ https *http.Server
+ debug *http.Server
+ healthServer *health.Server
+ healthChecker *health.Checker
+ meter *proxymetrics.Metrics
+ accessLog *accesslog.Logger
+ mainRouter *nbtcp.Router
+ mainPort uint16
+ udpMu sync.Mutex
+ udpRelays map[types.ServiceID]*udprelay.Relay
+ udpRelayWg sync.WaitGroup
+ portMu sync.RWMutex
+ portRouters map[uint16]*portRouter
+ svcPorts map[types.ServiceID][]uint16
+ lastMappings map[types.ServiceID]*proto.ProxyMapping
+ portRouterWg sync.WaitGroup
// hijackTracker tracks hijacked connections (e.g. WebSocket upgrades)
// so they can be closed during graceful shutdown, since http.Server.Shutdown
@@ -117,6 +120,9 @@ type Server struct {
// The mapping worker waits on this before processing updates.
routerReady chan struct{}
+ // removePeer defaults to netbird.RemovePeer; overridable in tests.
+ removePeer func(ctx context.Context, accountID types.AccountID, key roundtrip.ServiceKey) error
+
// inbound, when non-nil, manages per-account inbound listeners. Set by
// initPrivateInbound only when Private is true so the standalone
// proxy keeps its zero-overhead default path.
@@ -226,6 +232,10 @@ type Server struct {
// Zero means no cap (the proxy honors whatever management sends).
// Set via NB_PROXY_MAX_SESSION_IDLE_TIMEOUT for shared deployments.
MaxSessionIdleTimeout time.Duration
+ // MappingBatchWatchdog bounds how long a single mapping batch may spend
+ // in processMappings before the receive loop reconnects to resync.
+ // Zero uses defaultMappingBatchWatchdog.
+ MappingBatchWatchdog time.Duration
}
// clampIdleTimeout returns d capped to MaxSessionIdleTimeout when configured.
@@ -281,7 +291,7 @@ func (s *Server) NotifyCertificateIssued(ctx context.Context, accountID types.Ac
}
// inboundListenerProto resolves the per-account inbound listener state for
-// the SendStatusUpdate payload. Returns nil when --private-inbound is off
+// the SendStatusUpdate payload. Returns nil when --private is off
// or the account has no live listener so management treats the field as
// absent.
func (s *Server) inboundListenerProto(accountID types.AccountID) *proto.ProxyInboundListener {
@@ -528,10 +538,10 @@ func (s *Server) initManagementClient() error {
}
// initNetBirdClient builds the multi-tenant embedded NetBird client used
-// for outbound RoundTripping and (when --private-inbound is on) per-account
+// for outbound RoundTripping and (when --private is on) per-account
// inbound listeners.
func (s *Server) initNetBirdClient() {
- s.netbird = roundtrip.NewNetBird(s.ID, s.ProxyURL, roundtrip.ClientConfig{
+ s.netbird = roundtrip.NewNetBird(s.ctx, s.ID, s.ProxyURL, roundtrip.ClientConfig{
MgmtAddr: s.ManagementAddress,
WGPort: s.WireguardPort,
PreSharedKey: s.PreSharedKey,
@@ -606,7 +616,7 @@ func (s *Server) initDefaults() {
// If no ID is set then one can be generated.
if s.ID == "" {
- s.ID = "netbird-proxy-" + s.startTime.Format("20060102150405")
+ s.ID = fmt.Sprintf("netbird-proxy-%s", uuid.NewString())
}
// Fallback version option in case it is not set.
if s.Version == "" {
@@ -784,6 +794,7 @@ func (s *Server) configureTLS(ctx context.Context) (*tls.Config, error) {
return nil, fmt.Errorf("initialize certificate watcher: %w", err)
}
go certWatcher.Watch(ctx)
+ s.staticCertWatcher = certWatcher
tlsConfig.GetCertificate = certWatcher.GetCertificate
return tlsConfig, nil
}
@@ -1094,7 +1105,7 @@ func (s *Server) getOrCreatePortRouter(ctx context.Context, port uint16) (*nbtcp
router := nbtcp.NewPortRouter(s.Logger, s.resolveDialFunc)
router.SetObserver(s.meter)
router.SetAccessLogger(s.accessLog)
- portCtx, cancel := context.WithCancel(ctx)
+ portCtx, cancel := context.WithCancel(s.portRouterContext(ctx))
s.portRouters[port] = &portRouter{
router: router,
@@ -1110,10 +1121,26 @@ func (s *Server) getOrCreatePortRouter(ctx context.Context, port uint16) (*nbtcp
}
}()
- s.Logger.Debugf("started per-port router on %s", listenAddr)
+ s.Logger.WithFields(log.Fields{
+ "port": port,
+ "listen_addr": listenAddr,
+ "bound_addr": ln.Addr().String(),
+ "proxy_protocol": s.ProxyProtocol,
+ }).Info("custom TCP listener started")
return router, nil
}
+// portRouterContext returns the server-lifetime context for custom TCP
+// listeners. Mapping-batch contexts are cancelled after a batch is applied; a
+// per-port listener must outlive that batch and only stop on service removal or
+// server shutdown.
+func (s *Server) portRouterContext(ctx context.Context) context.Context {
+ if s.ctx != nil {
+ return s.ctx
+ }
+ return ctx
+}
+
// cleanupPortIfEmpty tears down a per-port router if it has no remaining
// routes or fallback. The main port is never cleaned up. Active relay
// connections are drained before the listener is closed.
@@ -1171,24 +1198,30 @@ func (s *Server) newManagementMappingWorker(ctx context.Context, client proto.Pr
s.healthChecker.SetManagementConnected(false)
}
+ connected := false
+ onConnected := func() { connected = true }
+
var streamErr error
if syncSupported {
- streamErr = s.trySyncMappings(ctx, client, &initialSyncDone)
+ streamErr = s.trySyncMappings(ctx, client, &initialSyncDone, onConnected)
if isSyncUnimplemented(streamErr) {
syncSupported = false
s.Logger.Info("management does not support SyncMappings, falling back to GetMappingUpdate")
- streamErr = s.tryGetMappingUpdate(ctx, client, &initialSyncDone)
+ streamErr = s.tryGetMappingUpdate(ctx, client, &initialSyncDone, onConnected)
}
} else {
- streamErr = s.tryGetMappingUpdate(ctx, client, &initialSyncDone)
+ streamErr = s.tryGetMappingUpdate(ctx, client, &initialSyncDone, onConnected)
}
if s.healthChecker != nil {
s.healthChecker.SetManagementConnected(false)
}
- // Stream established — reset backoff so the next failure retries quickly.
- bo.Reset()
+ // Reset backoff only when a stream actually connected, so immediate
+ // connect failures still back off instead of spinning.
+ if connected {
+ bo.Reset()
+ }
if streamErr == nil {
return fmt.Errorf("stream closed by server")
@@ -1220,7 +1253,7 @@ func (s *Server) proxyCapabilities() *proto.ProxyCapabilities {
}
}
-func (s *Server) tryGetMappingUpdate(ctx context.Context, client proto.ProxyServiceClient, initialSyncDone *bool) error {
+func (s *Server) tryGetMappingUpdate(ctx context.Context, client proto.ProxyServiceClient, initialSyncDone *bool, onConnected func()) error {
connectTime := time.Now()
mappingClient, err := client.GetMappingUpdate(ctx, &proto.GetMappingUpdateRequest{
ProxyId: s.ID,
@@ -1233,6 +1266,7 @@ func (s *Server) tryGetMappingUpdate(ctx context.Context, client proto.ProxyServ
return fmt.Errorf("create mapping stream: %w", err)
}
+ onConnected()
if s.healthChecker != nil {
s.healthChecker.SetManagementConnected(true)
}
@@ -1241,7 +1275,7 @@ func (s *Server) tryGetMappingUpdate(ctx context.Context, client proto.ProxyServ
return s.handleMappingStream(ctx, mappingClient, initialSyncDone, connectTime)
}
-func (s *Server) trySyncMappings(ctx context.Context, client proto.ProxyServiceClient, initialSyncDone *bool) error {
+func (s *Server) trySyncMappings(ctx context.Context, client proto.ProxyServiceClient, initialSyncDone *bool, onConnected func()) error {
connectTime := time.Now()
stream, err := client.SyncMappings(ctx)
if err != nil {
@@ -1262,6 +1296,7 @@ func (s *Server) trySyncMappings(ctx context.Context, client proto.ProxyServiceC
return fmt.Errorf("send sync init: %w", err)
}
+ onConnected()
if s.healthChecker != nil {
s.healthChecker.SetManagementConnected(true)
}
@@ -1306,7 +1341,9 @@ func (s *Server) handleSyncMappingsStream(ctx context.Context, stream proto.Prox
batchStart := time.Now()
s.Logger.Debug("Received mapping update, starting processing")
- s.processMappings(ctx, msg.GetMapping())
+ if err := s.processMappingsGuarded(ctx, msg.GetMapping()); err != nil {
+ return err
+ }
s.Logger.Debug("Processing mapping update completed")
tracker.recordBatch(ctx, s, msg.GetMapping(), msg.GetInitialSyncComplete(), batchStart)
@@ -1390,7 +1427,9 @@ func (s *Server) handleMappingStream(ctx context.Context, mappingClient proto.Pr
batchStart := time.Now()
s.Logger.Debug("Received mapping update, starting processing")
- s.processMappings(ctx, msg.GetMapping())
+ if err := s.processMappingsGuarded(ctx, msg.GetMapping()); err != nil {
+ return err
+ }
s.Logger.Debug("Processing mapping update completed")
tracker.recordBatch(ctx, s, msg.GetMapping(), msg.GetInitialSyncComplete(), batchStart)
}
@@ -1455,6 +1494,44 @@ func redactMappingForLog(m *proto.ProxyMapping) *proto.ProxyMapping {
return c
}
+const defaultMappingBatchWatchdog = 2 * time.Minute
+
+// mappingBatchWatchdog returns the configured batch watchdog or the default.
+func (s *Server) mappingBatchWatchdog() time.Duration {
+ if s.MappingBatchWatchdog > 0 {
+ return s.MappingBatchWatchdog
+ }
+ return defaultMappingBatchWatchdog
+}
+
+// processMappingsGuarded applies a batch under a watchdog, returning an error
+// if processing exceeds the watchdog so the caller reconnects and resyncs
+// instead of wedging silently.
+func (s *Server) processMappingsGuarded(ctx context.Context, mappings []*proto.ProxyMapping) error {
+ batchCtx, cancel := context.WithCancel(ctx)
+ defer cancel()
+
+ done := make(chan struct{})
+ go func() {
+ defer close(done)
+ s.processMappings(batchCtx, mappings)
+ }()
+
+ watchdog := s.mappingBatchWatchdog()
+ timer := time.NewTimer(watchdog)
+ defer timer.Stop()
+
+ select {
+ case <-done:
+ return nil
+ case <-ctx.Done():
+ return ctx.Err()
+ case <-timer.C:
+ s.Logger.Errorf("processing mapping batch exceeded %s, cancelling and reconnecting to resync", watchdog)
+ return fmt.Errorf("mapping batch processing stalled after %s", watchdog)
+ }
+}
+
func (s *Server) processMappings(ctx context.Context, mappings []*proto.ProxyMapping) {
debug := s.Logger != nil && s.Logger.IsLevelEnabled(log.DebugLevel)
for _, mapping := range mappings {
@@ -1565,6 +1642,8 @@ func (s *Server) setupHTTPMapping(ctx context.Context, mapping *proto.ProxyMappi
var wildcardHit bool
if s.acme != nil {
wildcardHit = s.acme.AddDomain(d, accountID, svcID)
+ } else {
+ wildcardHit = s.staticCertCovers(d)
}
httpRoute := nbtcp.Route{
Type: nbtcp.RouteHTTP,
@@ -1589,6 +1668,26 @@ func (s *Server) setupHTTPMapping(ctx context.Context, mapping *proto.ProxyMappi
return nil
}
+// staticCertCovers reports whether the static certificate loaded when ACME is
+// disabled covers the given domain, making it certificate-ready immediately —
+// the equivalent of a wildcard hit in the ACME path. Domains the certificate
+// does not cover are logged: clients connecting to them will get TLS errors.
+func (s *Server) staticCertCovers(d domain.Domain) bool {
+ if s.staticCertWatcher == nil {
+ return false
+ }
+ leaf := s.staticCertWatcher.Leaf()
+ if leaf == nil {
+ return false
+ }
+ name := d.PunycodeString()
+ if err := leaf.VerifyHostname(name); err != nil {
+ s.Logger.Warnf("static certificate (SANs %v) does not cover domain %q: %v", leaf.DNSNames, name, err)
+ return false
+ }
+ return true
+}
+
// setupTCPMapping sets up a TCP port-forwarding fallback route on the listen port.
func (s *Server) setupTCPMapping(ctx context.Context, mapping *proto.ProxyMapping) error {
svcID := types.ServiceID(mapping.GetId())
@@ -1635,6 +1734,13 @@ func (s *Server) setupTCPMapping(ctx context.Context, mapping *proto.ProxyMappin
s.meter.L4ServiceAdded(types.ServiceModeTCP)
s.sendStatusUpdate(ctx, accountID, svcID, proto.ProxyStatus_PROXY_STATUS_ACTIVE, nil)
+
+ s.Logger.WithFields(log.Fields{
+ "domain": mapping.GetDomain(),
+ "target": targetAddr,
+ "port": port,
+ "service": svcID,
+ }).Info("TCP mapping added")
return nil
}
@@ -1883,7 +1989,7 @@ func (s *Server) addUDPRelay(ctx context.Context, mapping *proto.ProxyMapping, t
"service_id": svcID,
})
- relay := udprelay.New(ctx, udprelay.RelayConfig{
+ relay := udprelay.New(s.portRouterContext(ctx), udprelay.RelayConfig{
Logger: entry,
Listener: listener,
Target: targetAddress,
@@ -1950,7 +2056,11 @@ func (s *Server) updateMapping(ctx context.Context, mapping *proto.ProxyMapping)
func (s *Server) removeMapping(ctx context.Context, mapping *proto.ProxyMapping) {
accountID := types.AccountID(mapping.GetAccountId())
svcKey := s.serviceKeyForMapping(mapping)
- if err := s.netbird.RemovePeer(ctx, accountID, svcKey); err != nil {
+ removePeer := s.removePeer
+ if removePeer == nil {
+ removePeer = s.netbird.RemovePeer
+ }
+ if err := removePeer(ctx, accountID, svcKey); err != nil {
s.Logger.WithFields(log.Fields{
"account_id": accountID,
"service_id": mapping.GetId(),
diff --git a/proxy/server_test.go b/proxy/server_test.go
index 10d38f250..f0c4765db 100644
--- a/proxy/server_test.go
+++ b/proxy/server_test.go
@@ -3,14 +3,20 @@ package proxy
import (
"context"
"errors"
+ "fmt"
"io"
+ "net"
"testing"
"time"
log "github.com/sirupsen/logrus"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
+ "go.opentelemetry.io/otel/metric/noop"
+ "google.golang.org/grpc"
+ proxymetrics "github.com/netbirdio/netbird/proxy/internal/metrics"
+ "github.com/netbirdio/netbird/proxy/internal/types"
"github.com/netbirdio/netbird/shared/management/proto"
)
@@ -64,7 +70,7 @@ func quietLifecycleLogger() *log.Logger {
}
func TestStopBeforeStartIsNoOp(t *testing.T) {
- srv := New(Config{Logger: quietLifecycleLogger()})
+ srv := New(t.Context(), Config{Logger: quietLifecycleLogger()})
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
defer cancel()
@@ -77,7 +83,7 @@ func TestStopBeforeStartIsNoOp(t *testing.T) {
}
func TestStartFailsWithoutManagement(t *testing.T) {
- srv := New(Config{
+ srv := New(t.Context(), Config{
Logger: quietLifecycleLogger(),
ListenAddr: "127.0.0.1:0",
ManagementAddress: "://broken-url",
@@ -137,7 +143,7 @@ func TestRecordRunErrPreservesFirstFailure(t *testing.T) {
}
func TestStopSkipsShutdownWhenNeverStarted(t *testing.T) {
- srv := New(Config{Logger: quietLifecycleLogger()})
+ srv := New(t.Context(), Config{Logger: quietLifecycleLogger()})
ctx, cancel := context.WithCancel(context.Background())
cancel()
@@ -202,3 +208,117 @@ func TestRedactMappingForLog_HandlesEmptyOrNilFields(t *testing.T) {
assert.Nil(t, redacted.Auth, "nil Auth must remain nil")
assert.Empty(t, redacted.Path, "empty Path must remain empty")
}
+
+type statusUpdateOnlyClient struct {
+ proto.ProxyServiceClient
+}
+
+func (statusUpdateOnlyClient) SendStatusUpdate(context.Context, *proto.SendStatusUpdateRequest, ...grpc.CallOption) (*proto.SendStatusUpdateResponse, error) {
+ return &proto.SendStatusUpdateResponse{}, nil
+}
+
+func TestSetupTCPMappingBindsCustomListenPort(t *testing.T) {
+ ln, err := net.Listen("tcp", "127.0.0.1:0")
+ require.NoError(t, err)
+ port := uint16(ln.Addr().(*net.TCPAddr).Port) //nolint:gosec // test port allocated by the OS
+ require.NoError(t, ln.Close())
+
+ meter, err := proxymetrics.New(context.Background(), noop.Meter{})
+ require.NoError(t, err)
+
+ srv := &Server{
+ Logger: quietLifecycleLogger(),
+ mgmtClient: statusUpdateOnlyClient{},
+ meter: meter,
+ mainPort: 8443,
+ portRouters: make(map[uint16]*portRouter),
+ svcPorts: make(map[types.ServiceID][]uint16),
+ }
+ t.Cleanup(func() {
+ srv.portMu.Lock()
+ for p, pr := range srv.portRouters {
+ pr.cancel()
+ require.NoError(t, pr.listener.Close())
+ delete(srv.portRouters, p)
+ }
+ srv.portMu.Unlock()
+ srv.portRouterWg.Wait()
+ })
+
+ mapping := &proto.ProxyMapping{
+ Type: proto.ProxyMappingUpdateType_UPDATE_TYPE_CREATED,
+ Id: "svc-tcp",
+ AccountId: "acct-1",
+ Domain: "ssh.example.com",
+ Mode: "tcp",
+ ListenPort: int32(port),
+ Path: []*proto.PathMapping{
+ {Target: "10.0.0.5:22"},
+ },
+ }
+
+ require.NoError(t, srv.setupTCPMapping(context.Background(), mapping))
+
+ srv.portMu.RLock()
+ pr := srv.portRouters[port]
+ ports := append([]uint16(nil), srv.svcPorts[types.ServiceID("svc-tcp")]...)
+ srv.portMu.RUnlock()
+
+ require.NotNil(t, pr, "custom TCP mapping must create a per-port router")
+ assert.Equal(t, []uint16{port}, ports, "service must track the custom listen port for cleanup")
+
+ second, err := net.Listen("tcp", fmt.Sprintf(":%d", port))
+ if err == nil {
+ _ = second.Close()
+ }
+ require.Error(t, err, "custom TCP listen port must be bound after setup")
+}
+
+func TestCustomTCPPortRouterOutlivesMappingBatchContext(t *testing.T) {
+ ln, err := net.Listen("tcp", "127.0.0.1:0")
+ require.NoError(t, err)
+ port := uint16(ln.Addr().(*net.TCPAddr).Port) //nolint:gosec // test port allocated by the OS
+ require.NoError(t, ln.Close())
+
+ meter, err := proxymetrics.New(context.Background(), noop.Meter{})
+ require.NoError(t, err)
+
+ srvCtx, srvCancel := context.WithCancel(context.Background())
+ t.Cleanup(srvCancel)
+
+ srv := &Server{
+ ctx: srvCtx,
+ Logger: quietLifecycleLogger(),
+ meter: meter,
+ mainPort: 8443,
+ portRouters: make(map[uint16]*portRouter),
+ svcPorts: make(map[types.ServiceID][]uint16),
+ }
+ t.Cleanup(func() {
+ srv.portMu.Lock()
+ for p, pr := range srv.portRouters {
+ pr.cancel()
+ if err := pr.listener.Close(); err != nil && !errors.Is(err, net.ErrClosed) {
+ require.NoError(t, err)
+ }
+ delete(srv.portRouters, p)
+ }
+ srv.portMu.Unlock()
+ srv.portRouterWg.Wait()
+ })
+
+ batchCtx, cancelBatch := context.WithCancel(context.Background())
+ _, err = srv.getOrCreatePortRouter(batchCtx, port)
+ require.NoError(t, err)
+
+ cancelBatch()
+
+ assert.Never(t, func() bool {
+ second, err := net.Listen("tcp", fmt.Sprintf(":%d", port))
+ if err == nil {
+ _ = second.Close()
+ return true
+ }
+ return false
+ }, 200*time.Millisecond, 10*time.Millisecond, "custom TCP listener must outlive mapping-batch context cancellation")
+}
diff --git a/proxy/static_cert_test.go b/proxy/static_cert_test.go
new file mode 100644
index 000000000..54d2b6485
--- /dev/null
+++ b/proxy/static_cert_test.go
@@ -0,0 +1,89 @@
+package proxy
+
+import (
+ "crypto/ecdsa"
+ "crypto/elliptic"
+ "crypto/rand"
+ "crypto/x509"
+ "crypto/x509/pkix"
+ "encoding/pem"
+ "math/big"
+ "os"
+ "path/filepath"
+ "testing"
+ "time"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+
+ "github.com/netbirdio/netbird/proxy/internal/certwatch"
+ "github.com/netbirdio/netbird/shared/management/domain"
+)
+
+func generateCertWithSANs(t *testing.T, dnsNames []string) (certPEM, keyPEM []byte) {
+ t.Helper()
+
+ key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
+ require.NoError(t, err)
+
+ template := &x509.Certificate{
+ SerialNumber: big.NewInt(1),
+ Subject: pkix.Name{CommonName: dnsNames[0]},
+ DNSNames: dnsNames,
+ NotBefore: time.Now().Add(-time.Hour),
+ NotAfter: time.Now().Add(24 * time.Hour),
+ }
+
+ certDER, err := x509.CreateCertificate(rand.Reader, template, template, &key.PublicKey, key)
+ require.NoError(t, err)
+ certPEM = pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: certDER})
+
+ keyDER, err := x509.MarshalECPrivateKey(key)
+ require.NoError(t, err)
+ keyPEM = pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: keyDER})
+
+ return certPEM, keyPEM
+}
+
+func newStaticWatcher(t *testing.T, dnsNames []string) *certwatch.Watcher {
+ t.Helper()
+
+ dir := t.TempDir()
+ certPEM, keyPEM := generateCertWithSANs(t, dnsNames)
+ certPath := filepath.Join(dir, "tls.crt")
+ keyPath := filepath.Join(dir, "tls.key")
+ require.NoError(t, os.WriteFile(certPath, certPEM, 0o600))
+ require.NoError(t, os.WriteFile(keyPath, keyPEM, 0o600))
+
+ w, err := certwatch.NewWatcher(certPath, keyPath, quietLifecycleLogger())
+ require.NoError(t, err)
+ return w
+}
+
+func TestStaticCertCovers(t *testing.T) {
+ s := &Server{
+ Logger: quietLifecycleLogger(),
+ staticCertWatcher: newStaticWatcher(t, []string{"*.p.example.com", "exact.example.com"}),
+ }
+
+ cases := []struct {
+ domain string
+ covered bool
+ }{
+ {"svc.p.example.com", true},
+ {"exact.example.com", true},
+ {"a.b.p.example.com", false}, // wildcard does not span labels
+ {"p.example.com", false},
+ {"other.example.com", false},
+ }
+ for _, tc := range cases {
+ t.Run(tc.domain, func(t *testing.T) {
+ assert.Equal(t, tc.covered, s.staticCertCovers(domain.Domain(tc.domain)))
+ })
+ }
+}
+
+func TestStaticCertCoversNoWatcher(t *testing.T) {
+ s := &Server{Logger: quietLifecycleLogger()}
+ assert.False(t, s.staticCertCovers(domain.Domain("svc.p.example.com")))
+}
diff --git a/proxy/sync_mappings_test.go b/proxy/sync_mappings_test.go
index 801587e4c..c9c0dad03 100644
--- a/proxy/sync_mappings_test.go
+++ b/proxy/sync_mappings_test.go
@@ -81,6 +81,95 @@ func TestIntegration_SyncMappings_HappyPath(t *testing.T) {
assert.Equal(t, "app2.test.proxy.io", rp2.GetDomain())
}
+func TestIntegration_SyncMappings_CustomTCPMappingDeliveredWithCapabilities(t *testing.T) {
+ setup := setupIntegrationTest(t)
+ defer setup.cleanup()
+
+ ctx := context.Background()
+ tcpSvc := &service.Service{
+ ID: "tcp-custom",
+ AccountID: "test-account-1",
+ Name: "Custom TCP",
+ Domain: "ssh.test.proxy.io",
+ ProxyCluster: "test.proxy.io",
+ Mode: "tcp",
+ ListenPort: 10001,
+ Enabled: true,
+ Targets: []*service.Target{{
+ Host: "10.0.0.5",
+ Port: 22,
+ Protocol: "tcp",
+ TargetId: "peer-ssh",
+ TargetType: "peer",
+ Enabled: true,
+ }},
+ }
+ require.NoError(t, setup.store.CreateService(ctx, tcpSvc))
+
+ conn, err := grpc.NewClient(setup.grpcAddr, grpc.WithTransportCredentials(insecure.NewCredentials()))
+ require.NoError(t, err)
+ defer conn.Close()
+
+ client := proto.NewProxyServiceClient(conn)
+ receiveSnapshot := func(proxyID string, caps *proto.ProxyCapabilities) map[string]*proto.ProxyMapping {
+ t.Helper()
+
+ streamCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
+ defer cancel()
+
+ stream, err := client.SyncMappings(streamCtx)
+ require.NoError(t, err)
+
+ err = stream.Send(&proto.SyncMappingsRequest{
+ Msg: &proto.SyncMappingsRequest_Init{
+ Init: &proto.SyncMappingsInit{
+ ProxyId: proxyID,
+ Version: "test-v1",
+ Address: "test.proxy.io",
+ Capabilities: caps,
+ },
+ },
+ })
+ require.NoError(t, err)
+
+ mappingsByID := make(map[string]*proto.ProxyMapping)
+ for {
+ msg, err := stream.Recv()
+ require.NoError(t, err)
+ for _, m := range msg.GetMapping() {
+ mappingsByID[m.GetId()] = m
+ }
+
+ err = stream.Send(&proto.SyncMappingsRequest{
+ Msg: &proto.SyncMappingsRequest_Ack{Ack: &proto.SyncMappingsAck{}},
+ })
+ require.NoError(t, err)
+
+ if msg.GetInitialSyncComplete() {
+ break
+ }
+ }
+ return mappingsByID
+ }
+
+ legacyMappings := receiveSnapshot("sync-proxy-no-capabilities", nil)
+ assert.NotContains(t, legacyMappings, "tcp-custom",
+ "legacy proxies that do not report capabilities must not receive TCP custom-port mappings")
+
+ supportsCustomPorts := true
+ modernMappings := receiveSnapshot("sync-proxy-custom-ports", &proto.ProxyCapabilities{
+ SupportsCustomPorts: &supportsCustomPorts,
+ })
+
+ tcpMapping := modernMappings["tcp-custom"]
+ require.NotNil(t, tcpMapping, "capability-aware proxy must receive TCP custom-port mapping")
+ assert.Equal(t, "tcp", tcpMapping.GetMode())
+ assert.Equal(t, int32(10001), tcpMapping.GetListenPort())
+ require.Len(t, tcpMapping.GetPath(), 1)
+ assert.Equal(t, "10.0.0.5:22", tcpMapping.GetPath()[0].GetTarget())
+ assert.NotEmpty(t, tcpMapping.GetAuthToken(), "snapshot mapping must include per-proxy auth token")
+}
+
func TestIntegration_SyncMappings_BackPressure(t *testing.T) {
setup := setupIntegrationTest(t)
defer setup.cleanup()
diff --git a/relay/metrics/realy.go b/relay/metrics/realy.go
index efb597ff5..49a357557 100644
--- a/relay/metrics/realy.go
+++ b/relay/metrics/realy.go
@@ -6,6 +6,7 @@ import (
"time"
log "github.com/sirupsen/logrus"
+ "go.opentelemetry.io/otel/attribute"
"go.opentelemetry.io/otel/metric"
)
@@ -119,8 +120,8 @@ func NewMetrics(ctx context.Context, meter metric.Meter) (*Metrics, error) {
}
// PeerConnected increments the number of connected peers and increments number of idle connections
-func (m *Metrics) PeerConnected(id string) {
- m.peers.Add(m.ctx, 1)
+func (m *Metrics) PeerConnected(id, transport string) {
+ m.peers.Add(m.ctx, 1, metric.WithAttributes(attribute.String("transport", transport)))
m.mutexActivity.Lock()
defer m.mutexActivity.Unlock()
@@ -138,8 +139,8 @@ func (m *Metrics) RecordPeerStoreTime(duration time.Duration) {
}
// PeerDisconnected decrements the number of connected peers and decrements number of idle or active connections
-func (m *Metrics) PeerDisconnected(id string) {
- m.peers.Add(m.ctx, -1)
+func (m *Metrics) PeerDisconnected(id, transport string) {
+ m.peers.Add(m.ctx, -1, metric.WithAttributes(attribute.String("transport", transport)))
m.mutexActivity.Lock()
defer m.mutexActivity.Unlock()
diff --git a/relay/server/listener/conn.go b/relay/server/listener/conn.go
index ef0869594..d86f7f58b 100644
--- a/relay/server/listener/conn.go
+++ b/relay/server/listener/conn.go
@@ -11,4 +11,6 @@ type Conn interface {
Write(ctx context.Context, b []byte) (n int, err error)
RemoteAddr() net.Addr
Close() error
+ // Protocol returns the transport name.
+ Protocol() string
}
diff --git a/relay/server/listener/quic/conn.go b/relay/server/listener/quic/conn.go
index d8dafcd1f..da5e12d36 100644
--- a/relay/server/listener/quic/conn.go
+++ b/relay/server/listener/quic/conn.go
@@ -42,6 +42,11 @@ func (c *Conn) RemoteAddr() net.Addr {
return c.session.RemoteAddr()
}
+// Protocol returns the transport name for this connection.
+func (c *Conn) Protocol() string {
+ return "quic"
+}
+
func (c *Conn) Close() error {
c.closedMu.Lock()
if c.closed {
diff --git a/relay/server/listener/ws/conn.go b/relay/server/listener/ws/conn.go
index c22b5719d..b1b64fe8e 100644
--- a/relay/server/listener/ws/conn.go
+++ b/relay/server/listener/ws/conn.go
@@ -64,6 +64,11 @@ func (c *Conn) RemoteAddr() net.Addr {
return c.rAddr
}
+// Protocol returns the transport name for this connection.
+func (c *Conn) Protocol() string {
+ return "ws"
+}
+
func (c *Conn) Close() error {
c.closedMu.Lock()
c.closed = true
diff --git a/relay/server/relay.go b/relay/server/relay.go
index 56add8bea..84c424b8e 100644
--- a/relay/server/relay.go
+++ b/relay/server/relay.go
@@ -154,15 +154,16 @@ func (r *Relay) Accept(conn listener.Conn) {
}
r.notifier.PeerCameOnline(peer.ID())
+ transport := conn.Protocol()
r.metrics.RecordPeerStoreTime(time.Since(storeTime))
- r.metrics.PeerConnected(peer.String())
+ r.metrics.PeerConnected(peer.String(), transport)
go func() {
peer.Work()
if deleted := r.store.DeletePeer(peer); deleted {
r.notifier.PeerWentOffline(peer.ID())
}
peer.log.Debugf("relay connection closed")
- r.metrics.PeerDisconnected(peer.String())
+ r.metrics.PeerDisconnected(peer.String(), transport)
}()
if err := h.handshakeResponse(hsCtx); err != nil {
diff --git a/release_files/install.sh b/release_files/install.sh
index 1e71936f3..a002de472 100755
--- a/release_files/install.sh
+++ b/release_files/install.sh
@@ -417,15 +417,30 @@ if type uname >/dev/null 2>&1; then
# Check the availability of a compatible package manager
if check_use_bin_variable; then
PACKAGE_MANAGER="bin"
+ elif [ -e /run/ostree-booted ]; then
+ if [ -x "$(command -v rpm-ostree)" ]; then
+ PACKAGE_MANAGER="rpm-ostree"
+ echo "The installation will be performed using rpm-ostree package manager"
+ elif [ -x "$(command -v bootc)" ]; then
+ echo "Detected bootc system without rpm-ostree." >&2
+ echo "NetBird cannot be installed via package manager on this system." >&2
+ echo "Options:" >&2
+ echo " 1. Install via Distrobox (instructions in the installation docs)" >&2
+ echo " 2. Rebuild your base image with rpm-ostree included" >&2
+ echo " 3. Bake NetBird into your Containerfile" >&2
+ exit 1
+ else
+ echo "Detected ostree-booted system without rpm-ostree or bootc." >&2
+ echo "NetBird cannot be installed automatically on this atomic system." >&2
+ echo "Please install NetBird by rebuilding your base image or use a supported package manager." >&2
+ exit 1
+ fi
elif [ -x "$(command -v apt-get)" ]; then
PACKAGE_MANAGER="apt"
echo "The installation will be performed using apt package manager"
elif [ -x "$(command -v dnf)" ]; then
PACKAGE_MANAGER="dnf"
echo "The installation will be performed using dnf package manager"
- elif [ -x "$(command -v rpm-ostree)" ]; then
- PACKAGE_MANAGER="rpm-ostree"
- echo "The installation will be performed using rpm-ostree package manager"
elif [ -x "$(command -v yum)" ]; then
PACKAGE_MANAGER="yum"
echo "The installation will be performed using yum package manager"
diff --git a/shared/context/keys.go b/shared/context/keys.go
index c5b5da044..3287a6366 100644
--- a/shared/context/keys.go
+++ b/shared/context/keys.go
@@ -3,6 +3,8 @@ package context
const (
RequestIDKey = "requestID"
AccountIDKey = "accountID"
+ RoleKey = "role"
UserIDKey = "userID"
PeerIDKey = "peerID"
+ UserAgentKey = "userAgent"
)
diff --git a/shared/management/client/client_test.go b/shared/management/client/client_test.go
index 4d15c4de0..570de7631 100644
--- a/shared/management/client/client_test.go
+++ b/shared/management/client/client_test.go
@@ -17,8 +17,8 @@ import (
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
- "github.com/netbirdio/netbird/management/server/integrations/integrated_validator/validator"
ephemeral_manager "github.com/netbirdio/netbird/management/internals/modules/peers/ephemeral/manager"
+ "github.com/netbirdio/netbird/management/server/integrations/integrated_validator/validator"
"github.com/netbirdio/netbird/management/internals/controllers/network_map/controller"
"github.com/netbirdio/netbird/management/internals/controllers/network_map/update_channel"
@@ -89,7 +89,7 @@ func startManagement(t *testing.T) (*grpc.Server, net.Listener) {
gomock.Any(),
gomock.Any(),
).
- Return(true, nil).
+ Return(true, context.Background(), nil).
AnyTimes()
peersManger := peers.NewManager(store, permissionsManagerMock)
@@ -322,16 +322,22 @@ func TestClient_Sync(t *testing.T) {
if resp.GetNetbirdConfig() == nil {
t.Error("expecting non nil NetbirdConfig got nil")
}
+ // Top-level RemotePeers is deprecated and must stay empty for
+ // v0.29.3+ (and dev) clients — the field rides inside NetworkMap
+ // (legacy) or the NetworkMapEnvelope (components) instead.
+ if len(resp.GetRemotePeers()) != 0 {
+ t.Error("expecting top-level RemotePeers to be empty for v0.29.3+ clients")
+ }
// Component-capable clients receive a NetworkMapEnvelope; the
// remote-peers list is encoded inside it. Decode it and check the
- // envelope's peers slice. Legacy peers populate the top-level
- // RemotePeers; both shapes must surface exactly one remote peer.
+ // envelope's peers slice. Legacy peers populate NetworkMap.RemotePeers;
+ // both shapes must surface exactly one remote peer.
remotePeerKeys := remotePeerKeysFromSync(resp, testKey.PublicKey().String())
if len(remotePeerKeys) != 1 {
t.Errorf("expecting RemotePeers size %d got %d", 1, len(remotePeerKeys))
return
}
- if resp.GetNetworkMap() != nil && resp.GetRemotePeersIsEmpty() {
+ if resp.GetNetworkMap() != nil && resp.GetNetworkMap().GetRemotePeersIsEmpty() {
t.Error("expecting RemotePeers property to be false, got true")
}
if remotePeerKeys[0] != remoteKey.PublicKey().String() {
@@ -355,6 +361,13 @@ func remotePeerKeysFromSync(resp *mgmtProto.SyncResponse, localKey string) []str
}
return out
}
+ if rp := resp.GetNetworkMap().GetRemotePeers(); len(rp) > 0 {
+ out := make([]string, 0, len(rp))
+ for _, p := range rp {
+ out = append(out, p.GetWgPubKey())
+ }
+ return out
+ }
env := resp.GetNetworkMapEnvelope().GetFull()
if env == nil {
return nil
diff --git a/shared/management/client/rest/reverse_proxy_clusters.go b/shared/management/client/rest/reverse_proxy_clusters.go
index 249833b01..ca9714dc0 100644
--- a/shared/management/client/rest/reverse_proxy_clusters.go
+++ b/shared/management/client/rest/reverse_proxy_clusters.go
@@ -2,6 +2,7 @@ package rest
import (
"context"
+ "errors"
"net/url"
"github.com/netbirdio/netbird/shared/management/http/api"
@@ -33,6 +34,12 @@ func (a *ReverseProxyClustersAPI) List(ctx context.Context) ([]api.ProxyCluster,
// NetBird cannot be deleted via this endpoint; the server returns 404 / 400
// for cluster addresses the account does not own.
func (a *ReverseProxyClustersAPI) Delete(ctx context.Context, clusterAddress string) error {
+ // Guard against the empty input: url.PathEscape("") returns "" which
+ // would collapse the request URL onto the collection endpoint and
+ // silently delete nothing (or 405 depending on routing).
+ if clusterAddress == "" {
+ return errors.New("clusterAddress is required")
+ }
resp, err := a.c.NewRequest(ctx, "DELETE", "/api/reverse-proxies/clusters/"+url.PathEscape(clusterAddress), nil, nil)
if err != nil {
return err
diff --git a/shared/management/client/rest/reverse_proxy_clusters_test.go b/shared/management/client/rest/reverse_proxy_clusters_test.go
index 2d9f6f7bb..16f955d5a 100644
--- a/shared/management/client/rest/reverse_proxy_clusters_test.go
+++ b/shared/management/client/rest/reverse_proxy_clusters_test.go
@@ -88,3 +88,17 @@ func TestReverseProxyClusters_Delete_Err(t *testing.T) {
assert.Error(t, err)
})
}
+
+// TestReverseProxyClusters_Delete_EmptyAddress guards against an empty
+// clusterAddress reaching the wire — that would collapse the URL onto
+// the collection endpoint instead of a specific cluster. The client
+// must short-circuit with a typed error before any request is issued.
+func TestReverseProxyClusters_Delete_EmptyAddress(t *testing.T) {
+ withMockClient(func(c *rest.Client, mux *http.ServeMux) {
+ mux.HandleFunc("/api/reverse-proxies/clusters/", func(http.ResponseWriter, *http.Request) {
+ t.Fatal("empty clusterAddress must be rejected client-side; no request should reach the server")
+ })
+ err := c.ReverseProxyClusters.Delete(context.Background(), "")
+ assert.Error(t, err, "empty clusterAddress must surface as an error")
+ })
+}
diff --git a/shared/management/client/rest/reverse_proxy_tokens.go b/shared/management/client/rest/reverse_proxy_tokens.go
index de59f3176..caa240395 100644
--- a/shared/management/client/rest/reverse_proxy_tokens.go
+++ b/shared/management/client/rest/reverse_proxy_tokens.go
@@ -4,6 +4,7 @@ import (
"bytes"
"context"
"encoding/json"
+ "errors"
"net/url"
"github.com/netbirdio/netbird/shared/management/http/api"
@@ -61,6 +62,12 @@ func (a *ReverseProxyTokensAPI) Create(ctx context.Context, request api.ProxyTok
// credentials existed; the plain secret can no longer authenticate any
// new proxy registration.
func (a *ReverseProxyTokensAPI) Delete(ctx context.Context, tokenID string) error {
+ // Guard against the empty input: url.PathEscape("") returns "" which
+ // would collapse the request URL onto the collection endpoint and
+ // silently delete nothing (or 405 depending on routing).
+ if tokenID == "" {
+ return errors.New("tokenID is required")
+ }
resp, err := a.c.NewRequest(ctx, "DELETE", "/api/reverse-proxies/proxy-tokens/"+url.PathEscape(tokenID), nil, nil)
if err != nil {
return err
diff --git a/shared/management/client/rest/reverse_proxy_tokens_test.go b/shared/management/client/rest/reverse_proxy_tokens_test.go
index a3f5e014f..ecd80bd1a 100644
--- a/shared/management/client/rest/reverse_proxy_tokens_test.go
+++ b/shared/management/client/rest/reverse_proxy_tokens_test.go
@@ -129,3 +129,16 @@ func TestReverseProxyTokens_Delete_Err(t *testing.T) {
assert.Error(t, err)
})
}
+
+// TestReverseProxyTokens_Delete_EmptyID guards against an empty tokenID
+// reaching the wire — url.PathEscape("") would collapse the URL onto
+// the collection endpoint.
+func TestReverseProxyTokens_Delete_EmptyID(t *testing.T) {
+ withMockClient(func(c *rest.Client, mux *http.ServeMux) {
+ mux.HandleFunc("/api/reverse-proxies/proxy-tokens/", func(http.ResponseWriter, *http.Request) {
+ t.Fatal("empty tokenID must be rejected client-side; no request should reach the server")
+ })
+ err := c.ReverseProxyTokens.Delete(context.Background(), "")
+ assert.Error(t, err, "empty tokenID must surface as an error")
+ })
+}
diff --git a/shared/management/http/api/generate.sh b/shared/management/http/api/generate.sh
index 3770ea90f..ba29a6905 100755
--- a/shared/management/http/api/generate.sh
+++ b/shared/management/http/api/generate.sh
@@ -11,6 +11,6 @@ fi
old_pwd=$(pwd)
script_path=$(dirname $(realpath "$0"))
cd "$script_path"
-go install github.com/oapi-codegen/oapi-codegen/v2/cmd/oapi-codegen@latest
+go install github.com/oapi-codegen/oapi-codegen/v2/cmd/oapi-codegen@v2.7.1
oapi-codegen --config cfg.yaml openapi.yml
cd "$old_pwd"
diff --git a/shared/management/http/api/openapi.yml b/shared/management/http/api/openapi.yml
index 6b8939598..196a0c6b1 100644
--- a/shared/management/http/api/openapi.yml
+++ b/shared/management/http/api/openapi.yml
@@ -5072,31 +5072,63 @@ components:
responses:
not_found:
description: Resource not found
+ headers:
+ X-Request-Id:
+ $ref: '#/components/headers/X-Request-Id'
content: { }
validation_failed_simple:
description: Validation failed
+ headers:
+ X-Request-Id:
+ $ref: '#/components/headers/X-Request-Id'
content: { }
bad_request:
description: Bad Request
+ headers:
+ X-Request-Id:
+ $ref: '#/components/headers/X-Request-Id'
content: { }
internal_error:
description: Internal Server Error
+ headers:
+ X-Request-Id:
+ $ref: '#/components/headers/X-Request-Id'
content: { }
validation_failed:
description: Validation failed
+ headers:
+ X-Request-Id:
+ $ref: '#/components/headers/X-Request-Id'
content: { }
forbidden:
description: Forbidden
+ headers:
+ X-Request-Id:
+ $ref: '#/components/headers/X-Request-Id'
content: { }
requires_authentication:
description: Requires authentication
+ headers:
+ X-Request-Id:
+ $ref: '#/components/headers/X-Request-Id'
content: { }
conflict:
description: Conflict
+ headers:
+ X-Request-Id:
+ $ref: '#/components/headers/X-Request-Id'
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
+ headers:
+ X-Request-Id:
+ description: |
+ Unique identifier assigned to the request by the server and set on every
+ response. Useful for correlating client requests with server-side logs.
+ schema:
+ type: string
+ example: cot7r4n3l3vh3qj4qveg
securitySchemes:
BearerAuth:
type: http
diff --git a/shared/management/http/api/types.gen.go b/shared/management/http/api/types.gen.go
index d7945e448..ed5060a86 100644
--- a/shared/management/http/api/types.gen.go
+++ b/shared/management/http/api/types.gen.go
@@ -1,6 +1,6 @@
// Package api provides primitives to interact with the openapi HTTP API.
//
-// Code generated by github.com/oapi-codegen/oapi-codegen/v2 version v2.7.0 DO NOT EDIT.
+// Code generated by github.com/oapi-codegen/oapi-codegen/v2 version v2.7.1 DO NOT EDIT.
package api
import (
diff --git a/shared/management/proto/proxy_service.proto b/shared/management/proto/proxy_service.proto
index 71e18c721..14d188877 100644
--- a/shared/management/proto/proxy_service.proto
+++ b/shared/management/proto/proxy_service.proto
@@ -237,7 +237,7 @@ message SendStatusUpdateRequest {
bool certificate_issued = 4;
optional string error_message = 5;
// Per-account inbound listener state for the account that owns
- // service_id. Populated only when --private-inbound is enabled and the
+ // service_id. Populated only when --private is enabled and the
// embedded client for the account is up. Field numbers >=50 reserved
// for observability extensions.
optional ProxyInboundListener inbound_listener = 50;
diff --git a/shared/management/types/networkmap_components.go b/shared/management/types/networkmap_components.go
index f341e9071..a8772d005 100644
--- a/shared/management/types/networkmap_components.go
+++ b/shared/management/types/networkmap_components.go
@@ -568,7 +568,6 @@ func (c *NetworkMapComponents) getRoutingPeerRoutes(peerID string) (enabledRoute
return enabledRoutes, disabledRoutes
}
-
func (c *NetworkMapComponents) filterRoutesByGroups(routes []*route.Route, groupListMap LookupMap) []*route.Route {
var filteredRoutes []*route.Route
for _, r := range routes {
@@ -639,9 +638,14 @@ func (c *NetworkMapComponents) getDefaultPermit(r *route.Route, includeIPv6 bool
rules := []*RouteFirewallRule{&rule}
- if includeIPv6 && r.IsDynamic() {
+ isDefaultV4 := r.Network.Addr().Is4() && r.Network.Bits() == 0
+ if includeIPv6 && (r.IsDynamic() || isDefaultV4) {
ruleV6 := rule
ruleV6.SourceRanges = []string{"::/0"}
+ if isDefaultV4 {
+ ruleV6.Destination = "::/0"
+ ruleV6.RouteID = r.ID + "-v6-default"
+ }
rules = append(rules, &ruleV6)
}
diff --git a/shared/relay/client/client.go b/shared/relay/client/client.go
index 1800bddb2..8d4aa6020 100644
--- a/shared/relay/client/client.go
+++ b/shared/relay/client/client.go
@@ -9,12 +9,14 @@ import (
"net/url"
"strings"
"sync"
+ "sync/atomic"
"time"
log "github.com/sirupsen/logrus"
auth "github.com/netbirdio/netbird/shared/relay/auth/hmac"
"github.com/netbirdio/netbird/shared/relay/client/dialer"
+ netErr "github.com/netbirdio/netbird/shared/relay/client/dialer/net"
"github.com/netbirdio/netbird/shared/relay/healthcheck"
"github.com/netbirdio/netbird/shared/relay/messages"
)
@@ -143,6 +145,11 @@ func (cc *connContainer) close() {
}
}
+// transportConn is implemented by relay connections that know their transport.
+type transportConn interface {
+ Protocol() string
+}
+
// Client is a client for the relay server. It is responsible for establishing a connection to the relay server and
// managing connections to other peers. All exported functions are safe to call concurrently. After close the connection,
// the client can be reused by calling Connect again. When the client is closed, all connections are closed too.
@@ -172,6 +179,31 @@ type Client struct {
stateSubscription *PeersStateSubscription
mtu uint16
+
+ // transportFallback, when set, records datagram-too-large failures so a
+ // datagram-sized transport is avoided on subsequent connects. Shared via
+ // the manager.
+ transportFallback *transportFallback
+ // datagramFallbackTriggered guards a single fallback per connection so a
+ // burst of oversized datagrams triggers one reconnect, not many.
+ datagramFallbackTriggered atomic.Bool
+
+ // transport is the negotiated relay transport of the
+ // current connection, guarded by mu.
+ transport string
+}
+
+// Transport returns the negotiated relay transport of the current connection,
+// or an empty string when not connected.
+func (c *Client) Transport() string {
+ c.mu.Lock()
+ defer c.mu.Unlock()
+ return c.transport
+}
+
+// SetTransportFallback wires the shared datagram-transport fallback tracker.
+func (c *Client) SetTransportFallback(tf *transportFallback) {
+ c.transportFallback = tf
}
// NewClient creates a new client for the relay server. The client is not connected to the server until the Connect
@@ -361,12 +393,13 @@ func (c *Client) Close() error {
}
func (c *Client) connect(ctx context.Context) (*RelayAddr, error) {
- dialers := c.getDialers()
+ mode := transportModeFromEnv()
+ dialers := c.getDialers(mode)
var conn net.Conn
if c.serverIP.IsValid() {
var err error
- conn, err = c.dialRaceDirect(ctx, dialers)
+ conn, err = c.dialRaceDirect(ctx, mode, dialers)
if err != nil {
c.log.Infof("dial via server IP %s failed, falling back to FQDN: %v", c.serverIP, err)
conn = nil
@@ -375,6 +408,9 @@ func (c *Client) connect(ctx context.Context) (*RelayAddr, error) {
if conn == nil {
rd := dialer.NewRaceDial(c.log, dialer.DefaultConnectionTimeout, c.connectionURL, dialers...)
+ if mode.sequential() {
+ rd.WithSequential()
+ }
var err error
conn, err = rd.Dial(ctx)
if err != nil {
@@ -382,6 +418,10 @@ func (c *Client) connect(ctx context.Context) (*RelayAddr, error) {
}
}
c.relayConn = conn
+ c.datagramFallbackTriggered.Store(false)
+ if tc, ok := conn.(transportConn); ok {
+ c.transport = tc.Protocol()
+ }
instanceURL, err := c.handShake(ctx)
if err != nil {
@@ -396,7 +436,7 @@ func (c *Client) connect(ctx context.Context) (*RelayAddr, error) {
}
// dialRaceDirect dials c.serverIP, preserving the original FQDN as the TLS ServerName for SNI.
-func (c *Client) dialRaceDirect(ctx context.Context, dialers []dialer.DialeFn) (net.Conn, error) {
+func (c *Client) dialRaceDirect(ctx context.Context, mode TransportMode, dialers []dialer.DialeFn) (net.Conn, error) {
directURL, serverName, err := substituteHost(c.connectionURL, c.serverIP)
if err != nil {
return nil, fmt.Errorf("substitute host: %w", err)
@@ -406,6 +446,9 @@ func (c *Client) dialRaceDirect(ctx context.Context, dialers []dialer.DialeFn) (
rd := dialer.NewRaceDial(c.log, dialer.DefaultConnectionTimeout, directURL, dialers...).
WithServerName(serverName)
+ if mode.sequential() {
+ rd.WithSequential()
+ }
return rd.Dial(ctx)
}
@@ -631,13 +674,53 @@ func (c *Client) writeTo(containerRef *connContainer, dstID messages.PeerID, pay
}
// the write always return with 0 length because the underling does not support the size feedback.
- _, err = c.relayConn.Write(msg)
+ conn := c.relayConn
+ _, err = conn.Write(msg)
if err != nil {
- c.log.Errorf("failed to write transport message: %s", err)
+ if errors.Is(err, netErr.ErrDatagramTooLarge) {
+ c.onDatagramTooLarge(conn, err)
+ } else {
+ c.log.Errorf("failed to write transport message: %s", err)
+ }
}
return len(payload), err
}
+// onDatagramTooLarge reacts to a datagram rejected as too large for the path.
+// When a non-datagram transport is available, it records a fallback for this
+// server and closes the connection so the reconnect avoids datagram-sized
+// transports. A single fallback is triggered per connection regardless of how
+// many oversized datagrams arrive. cause carries the datagram size and budget.
+func (c *Client) onDatagramTooLarge(conn net.Conn, cause error) {
+ // Handle one oversized datagram per connection; a burst triggers a single
+ // fallback (and a single log line), not many.
+ if !c.datagramFallbackTriggered.CompareAndSwap(false, true) {
+ return
+ }
+
+ // If the selected mode offers no non-datagram transport (e.g. pinned to a
+ // datagram-sized transport), reconnecting would just re-fail, so leave the
+ // connection up rather than loop.
+ if len(nonDatagramSized(c.baseDialers(transportModeFromEnv()))) == 0 {
+ c.log.Warnf("%s, but no non-datagram transport is available, not falling back", cause)
+ return
+ }
+
+ // Without the shared tracker a reconnect would just select the same
+ // transport again and re-fail, so leave the connection up rather than loop.
+ if c.transportFallback == nil {
+ c.log.Debugf("%s, but no transport fallback configured, leaving connection up", cause)
+ return
+ }
+
+ window := c.transportFallback.recordFailure(c.connectionURL)
+ c.log.Warnf("%s, avoiding datagram-sized transport for %s", cause, window)
+
+ if err := conn.Close(); err != nil {
+ c.log.Debugf("close relay connection for transport fallback: %s", err)
+ }
+}
+
func (c *Client) listenForStopEvents(ctx context.Context, hc *healthcheck.Receiver, conn net.Conn, internalStopFlag *internalStopFlag) {
for {
select {
@@ -729,6 +812,7 @@ func (c *Client) close(gracefullyExit bool) error {
return nil
}
c.serviceIsRunning = false
+ c.transport = ""
c.muInstanceURL.Lock()
c.instanceURL = nil
diff --git a/shared/relay/client/dialer/capability.go b/shared/relay/client/dialer/capability.go
new file mode 100644
index 000000000..511cb2ac7
--- /dev/null
+++ b/shared/relay/client/dialer/capability.go
@@ -0,0 +1,18 @@
+package dialer
+
+// DatagramSized is implemented by dialers whose connections carry each write in
+// a single datagram, so a write can be rejected when it exceeds the path's
+// datagram budget (e.g. QUIC). Transports without this capability (e.g.
+// WebSocket over TCP) impose no per-write size limit, so the relay client can
+// fall back to them when a datagram-sized transport rejects a write as too
+// large. The capability is advertised per dialer rather than hardcoded, so a
+// new transport only needs to declare whether it is datagram-sized.
+type DatagramSized interface {
+ DatagramSized()
+}
+
+// IsDatagramSized reports whether d produces datagram-sized connections.
+func IsDatagramSized(d DialeFn) bool {
+ _, ok := d.(DatagramSized)
+ return ok
+}
diff --git a/shared/relay/client/dialer/net/err.go b/shared/relay/client/dialer/net/err.go
index fee844963..c622420dc 100644
--- a/shared/relay/client/dialer/net/err.go
+++ b/shared/relay/client/dialer/net/err.go
@@ -4,4 +4,9 @@ import "errors"
var (
ErrClosedByServer = errors.New("closed by server")
+
+ // ErrDatagramTooLarge is returned when a transport message exceeds the
+ // QUIC datagram size the path to the relay can carry. The relay client
+ // treats it as a signal to fall back to a non-datagram transport.
+ ErrDatagramTooLarge = errors.New("datagram frame too large")
)
diff --git a/shared/relay/client/dialer/quic/conn.go b/shared/relay/client/dialer/quic/conn.go
index 1d90d7139..e5ad77b29 100644
--- a/shared/relay/client/dialer/quic/conn.go
+++ b/shared/relay/client/dialer/quic/conn.go
@@ -8,7 +8,6 @@ import (
"time"
"github.com/quic-go/quic-go"
- log "github.com/sirupsen/logrus"
netErr "github.com/netbirdio/netbird/shared/relay/client/dialer/net"
)
@@ -52,15 +51,17 @@ func (c *Conn) Read(b []byte) (n int, err error) {
}
func (c *Conn) Write(b []byte) (int, error) {
- err := c.session.SendDatagram(b)
- if err != nil {
- err = c.remoteCloseErrHandling(err)
- log.Errorf("failed to write to QUIC stream: %v", err)
- return 0, err
+ if err := c.session.SendDatagram(b); err != nil {
+ return 0, c.writeErrHandling(err, len(b))
}
return len(b), nil
}
+// Protocol returns the transport name for this connection.
+func (c *Conn) Protocol() string {
+ return Network
+}
+
func (c *Conn) RemoteAddr() net.Addr {
return c.session.RemoteAddr()
}
@@ -95,3 +96,15 @@ func (c *Conn) remoteCloseErrHandling(err error) error {
}
return err
}
+
+// writeErrHandling normalizes SendDatagram errors. A datagram that exceeds the
+// path's QUIC packet budget is mapped to ErrDatagramTooLarge (annotated with the
+// datagram size and path budget) so the relay client can fall back to a
+// non-datagram transport.
+func (c *Conn) writeErrHandling(err error, size int) error {
+ var tooLarge *quic.DatagramTooLargeError
+ if errors.As(err, &tooLarge) {
+ return fmt.Errorf("%w: %d byte datagram over path budget %d", netErr.ErrDatagramTooLarge, size, tooLarge.MaxDatagramPayloadSize)
+ }
+ return c.remoteCloseErrHandling(err)
+}
diff --git a/shared/relay/client/dialer/quic/quic.go b/shared/relay/client/dialer/quic/quic.go
index 86f6f178d..2e8de8af3 100644
--- a/shared/relay/client/dialer/quic/quic.go
+++ b/shared/relay/client/dialer/quic/quic.go
@@ -9,6 +9,7 @@ import (
"time"
"github.com/quic-go/quic-go"
+ "github.com/quic-go/quic-go/logging"
log "github.com/sirupsen/logrus"
nbnet "github.com/netbirdio/netbird/client/net"
@@ -23,6 +24,12 @@ func (d Dialer) Protocol() string {
return Network
}
+// DatagramSized marks QUIC as a datagram-sized transport: relay traffic is
+// carried in QUIC DATAGRAM frames, which must fit a single packet.
+func (d Dialer) DatagramSized() {
+ // Intentional marker method; presence is the capability signal.
+}
+
func (d Dialer) Dial(ctx context.Context, address, serverName string) (net.Conn, error) {
quicURL, err := prepareURL(address)
if err != nil {
@@ -47,18 +54,17 @@ func (d Dialer) Dial(ctx context.Context, address, serverName string) (net.Conn,
MaxIdleTimeout: 4 * time.Minute,
EnableDatagrams: true,
InitialPacketSize: nbRelay.QUICInitialPacketSize,
+ Tracer: connectionTracer(quicURL),
}
udpConn, err := nbnet.ListenUDP("udp", &net.UDPAddr{Port: 0})
if err != nil {
- log.Errorf("failed to listen on UDP: %s", err)
- return nil, err
+ return nil, fmt.Errorf("listen udp: %w", err)
}
udpAddr, err := net.ResolveUDPAddr("udp", quicURL)
if err != nil {
- log.Errorf("failed to resolve UDP address: %s", err)
- return nil, err
+ return nil, fmt.Errorf("resolve %s: %w", quicURL, err)
}
session, err := quic.Dial(ctx, udpConn, udpAddr, tlsClientConfig, quicConfig)
@@ -66,7 +72,7 @@ func (d Dialer) Dial(ctx context.Context, address, serverName string) (net.Conn,
if errors.Is(err, context.Canceled) {
return nil, err
}
- log.Errorf("failed to dial to Relay server via QUIC '%s': %s", quicURL, err)
+ log.Debugf("failed to dial to Relay server via QUIC '%s': %s", quicURL, err)
return nil, err
}
@@ -74,6 +80,28 @@ func (d Dialer) Dial(ctx context.Context, address, serverName string) (net.Conn,
return conn, nil
}
+// connectionTracer returns a QUIC tracer that logs the DPLPMTUD result and the
+// reason a relay connection closed, so the path MTU settled on and teardown
+// cause are visible in logs. Lines carry the relay address as a structured
+// field, matching the rest of the relay client logging.
+func connectionTracer(addr string) func(context.Context, logging.Perspective, quic.ConnectionID) *logging.ConnectionTracer {
+ relayLog := log.WithField("relay", addr)
+ return func(context.Context, logging.Perspective, quic.ConnectionID) *logging.ConnectionTracer {
+ return &logging.ConnectionTracer{
+ UpdatedMTU: func(mtu logging.ByteCount, done bool) {
+ if done {
+ relayLog.Infof("QUIC path MTU settled at %d", mtu)
+ return
+ }
+ relayLog.Debugf("QUIC path MTU probing at %d", mtu)
+ },
+ ClosedConnection: func(err error) {
+ relayLog.Debugf("QUIC connection closed: %v", err)
+ },
+ }
+ }
+}
+
func prepareURL(address string) (string, error) {
var host string
var defaultPort string
diff --git a/shared/relay/client/dialer/race_dialer.go b/shared/relay/client/dialer/race_dialer.go
index 15208b858..d183802d0 100644
--- a/shared/relay/client/dialer/race_dialer.go
+++ b/shared/relay/client/dialer/race_dialer.go
@@ -3,6 +3,7 @@ package dialer
import (
"context"
"errors"
+ "fmt"
"net"
"time"
@@ -32,6 +33,7 @@ type RaceDial struct {
serverName string
dialerFns []DialeFn
connectionTimeout time.Duration
+ sequential bool
}
func NewRaceDial(log *log.Entry, connectionTimeout time.Duration, serverURL string, dialerFns ...DialeFn) *RaceDial {
@@ -53,9 +55,24 @@ func (r *RaceDial) WithServerName(serverName string) *RaceDial {
return r
}
+// WithSequential makes Dial try the dialers in order, falling back to the next
+// only when one fails to connect, instead of racing them concurrently.
+//
+// Mutates the receiver and is not safe for concurrent reconfiguration; a
+// RaceDial is intended to be constructed per dial and discarded.
+func (r *RaceDial) WithSequential() *RaceDial {
+ r.sequential = true
+ return r
+}
+
func (r *RaceDial) Dial(ctx context.Context) (net.Conn, error) {
+ if r.sequential {
+ return r.dialSequential(ctx)
+ }
+
connChan := make(chan dialResult, len(r.dialerFns))
winnerConn := make(chan net.Conn, 1)
+ errChan := make(chan error, 1)
abortCtx, abort := context.WithCancel(ctx)
defer abort()
@@ -63,15 +80,41 @@ func (r *RaceDial) Dial(ctx context.Context) (net.Conn, error) {
go r.dial(dfn, abortCtx, connChan)
}
- go r.processResults(connChan, winnerConn, abort)
+ go r.processResults(connChan, winnerConn, errChan, abort)
conn, ok := <-winnerConn
if !ok {
- return nil, errors.New("failed to dial to Relay server on any protocol")
+ return nil, <-errChan
}
return conn, nil
}
+// dialSequential tries each dialer in order, returning the first connection and
+// falling back to the next on failure.
+func (r *RaceDial) dialSequential(ctx context.Context) (net.Conn, error) {
+ var errs []error
+ for _, dfn := range r.dialerFns {
+ if err := ctx.Err(); err != nil {
+ return nil, err
+ }
+ attemptCtx, cancel := context.WithTimeout(ctx, r.connectionTimeout)
+ r.log.Infof("dialing Relay server via %s", dfn.Protocol())
+ conn, err := dfn.Dial(attemptCtx, r.serverURL, r.serverName)
+ cancel()
+ if err != nil {
+ if errors.Is(err, context.Canceled) {
+ return nil, err
+ }
+ r.log.Errorf("failed to dial via %s: %s", dfn.Protocol(), err)
+ errs = append(errs, fmt.Errorf("%s: %w", dfn.Protocol(), err))
+ continue
+ }
+ r.log.Infof("successfully dialed via: %s", dfn.Protocol())
+ return conn, nil
+ }
+ return nil, dialErr(errs)
+}
+
func (r *RaceDial) dial(dfn DialeFn, abortCtx context.Context, connChan chan dialResult) {
ctx, cancel := context.WithTimeout(abortCtx, r.connectionTimeout)
defer cancel()
@@ -81,8 +124,9 @@ func (r *RaceDial) dial(dfn DialeFn, abortCtx context.Context, connChan chan dia
connChan <- dialResult{Conn: conn, Protocol: dfn.Protocol(), Err: err}
}
-func (r *RaceDial) processResults(connChan chan dialResult, winnerConn chan net.Conn, abort context.CancelFunc) {
+func (r *RaceDial) processResults(connChan chan dialResult, winnerConn chan net.Conn, errChan chan error, abort context.CancelFunc) {
var hasWinner bool
+ errsByProtocol := make(map[string]error)
for i := 0; i < len(r.dialerFns); i++ {
dr := <-connChan
if dr.Err != nil {
@@ -90,6 +134,7 @@ func (r *RaceDial) processResults(connChan chan dialResult, winnerConn chan net.
r.log.Infof("connection attempt aborted via: %s", dr.Protocol)
} else {
r.log.Errorf("failed to dial via %s: %s", dr.Protocol, dr.Err)
+ errsByProtocol[dr.Protocol] = fmt.Errorf("%s: %w", dr.Protocol, dr.Err)
}
continue
}
@@ -107,5 +152,29 @@ func (r *RaceDial) processResults(connChan chan dialResult, winnerConn chan net.
hasWinner = true
winnerConn <- dr.Conn
}
+ if !hasWinner {
+ errChan <- dialErr(r.orderedErrs(errsByProtocol))
+ }
close(winnerConn)
}
+
+// orderedErrs returns the per-protocol errors in dialer order, so the combined
+// error is stable regardless of which attempt failed first.
+func (r *RaceDial) orderedErrs(byProtocol map[string]error) []error {
+ errs := make([]error, 0, len(byProtocol))
+ for _, dfn := range r.dialerFns {
+ if err, ok := byProtocol[dfn.Protocol()]; ok {
+ errs = append(errs, err)
+ }
+ }
+ return errs
+}
+
+// dialErr combines per-dialer failures, preserving the underlying reasons
+// (e.g. "connection refused") rather than a generic message.
+func dialErr(errs []error) error {
+ if len(errs) == 0 {
+ return errors.New("no relay transport available")
+ }
+ return errors.Join(errs...)
+}
diff --git a/shared/relay/client/dialer/race_dialer_test.go b/shared/relay/client/dialer/race_dialer_test.go
index a53edc00e..bd2f4bb85 100644
--- a/shared/relay/client/dialer/race_dialer_test.go
+++ b/shared/relay/client/dialer/race_dialer_test.go
@@ -250,3 +250,66 @@ func TestRaceDialFirstSuccessfulDialerWins(t *testing.T) {
}
}
}
+
+func TestRaceDialSequentialFallback(t *testing.T) {
+ logger := logrus.NewEntry(logrus.New())
+ serverURL := "test.server.com"
+
+ var firstDialed, secondDialed bool
+ preferred := &MockDialer{
+ protocolStr: "quic",
+ dialFunc: func(ctx context.Context, address string) (net.Conn, error) {
+ firstDialed = true
+ return nil, errors.New("quic unreachable")
+ },
+ }
+ fallbackConn := &MockConn{remoteAddr: &MockAddr{network: "ws"}}
+ fallback := &MockDialer{
+ protocolStr: "ws",
+ dialFunc: func(ctx context.Context, address string) (net.Conn, error) {
+ secondDialed = true
+ return fallbackConn, nil
+ },
+ }
+
+ rd := NewRaceDial(logger, DefaultConnectionTimeout, serverURL, preferred, fallback).WithSequential()
+ conn, err := rd.Dial(context.Background())
+ if err != nil {
+ t.Fatalf("expected fallback to succeed, got %v", err)
+ }
+ if conn != fallbackConn {
+ t.Errorf("expected fallback connection, got %v", conn)
+ }
+ if !firstDialed || !secondDialed {
+ t.Errorf("expected both dialers attempted in order, first=%v second=%v", firstDialed, secondDialed)
+ }
+}
+
+func TestRaceDialSequentialPreferredWins(t *testing.T) {
+ logger := logrus.NewEntry(logrus.New())
+ serverURL := "test.server.com"
+
+ preferredConn := &MockConn{remoteAddr: &MockAddr{network: "quic"}}
+ preferred := &MockDialer{
+ protocolStr: "quic",
+ dialFunc: func(ctx context.Context, address string) (net.Conn, error) {
+ return preferredConn, nil
+ },
+ }
+ fallback := &MockDialer{
+ protocolStr: "ws",
+ dialFunc: func(ctx context.Context, address string) (net.Conn, error) {
+ t.Errorf("fallback dialer must not be tried when preferred succeeds")
+ return nil, errors.New("should not happen")
+ },
+ }
+
+ rd := NewRaceDial(logger, DefaultConnectionTimeout, serverURL, preferred, fallback).WithSequential()
+ conn, err := rd.Dial(context.Background())
+ if err != nil {
+ t.Fatalf("expected preferred to succeed, got %v", err)
+ }
+ if conn != preferredConn {
+ t.Errorf("expected preferred connection, got %v", conn)
+ }
+}
diff --git a/shared/relay/client/dialer/ws/conn.go b/shared/relay/client/dialer/ws/conn.go
index 9497fab89..eec417c50 100644
--- a/shared/relay/client/dialer/ws/conn.go
+++ b/shared/relay/client/dialer/ws/conn.go
@@ -33,6 +33,11 @@ func NewConn(wsConn *websocket.Conn, serverAddress string, underlying net.Conn)
}
}
+// Protocol returns the transport name for this connection.
+func (c *Conn) Protocol() string {
+ return Network
+}
+
func (c *Conn) Read(b []byte) (n int, err error) {
t, ioReader, err := c.Conn.Reader(c.ctx)
if err != nil {
diff --git a/shared/relay/client/dialer/ws/ws.go b/shared/relay/client/dialer/ws/ws.go
index 8a13ba126..6b310b73d 100644
--- a/shared/relay/client/dialer/ws/ws.go
+++ b/shared/relay/client/dialer/ws/ws.go
@@ -22,7 +22,7 @@ type Dialer struct {
}
func (d Dialer) Protocol() string {
- return "WS"
+ return Network
}
func (d Dialer) Dial(ctx context.Context, address, serverName string) (net.Conn, error) {
@@ -39,7 +39,12 @@ func (d Dialer) Dial(ctx context.Context, address, serverName string) (net.Conn,
if errors.Is(err, context.Canceled) {
return nil, err
}
- log.Errorf("failed to dial to Relay server '%s': %s", wsURL, err)
+ // websocket.Dial wraps the cause in verbose layers; surface the
+ // underlying network error when present.
+ var opErr *net.OpError
+ if errors.As(err, &opErr) {
+ return nil, opErr
+ }
return nil, err
}
if resp.Body != nil {
diff --git a/shared/relay/client/dialers_generic.go b/shared/relay/client/dialers_generic.go
index a8ed79961..95e319338 100644
--- a/shared/relay/client/dialers_generic.go
+++ b/shared/relay/client/dialers_generic.go
@@ -9,11 +9,42 @@ import (
"github.com/netbirdio/netbird/shared/relay/client/dialer/ws"
)
-// getDialers returns the list of dialers to use for connecting to the relay server.
-func (c *Client) getDialers() []dialer.DialeFn {
- if c.mtu > 0 && c.mtu > iface.DefaultMTU {
- c.log.Infof("MTU %d exceeds default (%d), forcing WebSocket transport to avoid DATAGRAM frame size issues", c.mtu, iface.DefaultMTU)
- return []dialer.DialeFn{ws.Dialer{}}
+// getDialers returns the ordered dialers for connecting to the relay server. It
+// applies the datagram fallback generically: if this server recently rejected a
+// datagram-sized transport, those dialers are dropped, leaving the rest.
+func (c *Client) getDialers(mode TransportMode) []dialer.DialeFn {
+ dialers := c.baseDialers(mode)
+
+ if c.transportFallback != nil && c.transportFallback.avoidDatagramSized(c.connectionURL) {
+ if filtered := nonDatagramSized(dialers); len(filtered) > 0 {
+ c.log.Infof("relay recently rejected a datagram-sized transport, avoiding it")
+ return filtered
+ }
}
- return []dialer.DialeFn{quic.Dialer{}, ws.Dialer{}}
+ return dialers
+}
+
+// baseDialers returns the ordered dialers for the mode, before any datagram
+// fallback filtering. For racing modes (auto) the order is irrelevant; for
+// prefer modes the first entry is tried before falling back to the second.
+func (c *Client) baseDialers(mode TransportMode) []dialer.DialeFn {
+ switch mode {
+ case TransportModeWS:
+ c.log.Infof("%s=ws, using WebSocket transport", EnvRelayTransport)
+ return []dialer.DialeFn{ws.Dialer{}}
+ case TransportModeQUIC:
+ c.log.Infof("%s=quic, using QUIC transport", EnvRelayTransport)
+ return []dialer.DialeFn{quic.Dialer{}}
+ }
+
+ all := []dialer.DialeFn{quic.Dialer{}, ws.Dialer{}}
+ if mode == TransportModePreferWS {
+ all = []dialer.DialeFn{ws.Dialer{}, quic.Dialer{}}
+ }
+
+ if c.mtu > 0 && c.mtu > iface.DefaultMTU {
+ c.log.Infof("MTU %d exceeds default (%d), avoiding datagram-sized transports", c.mtu, iface.DefaultMTU)
+ return nonDatagramSized(all)
+ }
+ return all
}
diff --git a/shared/relay/client/dialers_generic_test.go b/shared/relay/client/dialers_generic_test.go
new file mode 100644
index 000000000..f6c885108
--- /dev/null
+++ b/shared/relay/client/dialers_generic_test.go
@@ -0,0 +1,101 @@
+//go:build !js
+
+package client
+
+import (
+ "os"
+ "testing"
+
+ log "github.com/sirupsen/logrus"
+ "github.com/stretchr/testify/assert"
+
+ "github.com/netbirdio/netbird/client/iface"
+ "github.com/netbirdio/netbird/shared/relay/client/dialer"
+ netErr "github.com/netbirdio/netbird/shared/relay/client/dialer/net"
+ "github.com/netbirdio/netbird/shared/relay/client/dialer/quic"
+ "github.com/netbirdio/netbird/shared/relay/client/dialer/ws"
+)
+
+// TestDatagramSizedCapability locks the capability the generic fallback relies
+// on: QUIC is datagram-sized, WebSocket is not.
+func TestDatagramSizedCapability(t *testing.T) {
+ assert.True(t, dialer.IsDatagramSized(quic.Dialer{}), "QUIC must advertise datagram-sized")
+ assert.False(t, dialer.IsDatagramSized(ws.Dialer{}), "WebSocket must not advertise datagram-sized")
+}
+
+func protocols(dialers []dialer.DialeFn) []string {
+ out := make([]string, len(dialers))
+ for i, d := range dialers {
+ out[i] = d.Protocol()
+ }
+ return out
+}
+
+func TestGetDialers(t *testing.T) {
+ const url = "rels://relay.example:443"
+
+ tests := []struct {
+ name string
+ mode string
+ mtu uint16
+ preferWS bool
+ want []string
+ }{
+ {name: "auto races quic and ws", mode: "auto", mtu: iface.DefaultMTU, want: []string{"quic", "ws"}},
+ {name: "ws pinned", mode: "ws", mtu: iface.DefaultMTU, want: []string{"ws"}},
+ {name: "quic pinned", mode: "quic", mtu: iface.DefaultMTU, want: []string{"quic"}},
+ {name: "prefer-quic orders quic first", mode: "prefer-quic", mtu: iface.DefaultMTU, want: []string{"quic", "ws"}},
+ {name: "prefer-ws orders ws first", mode: "prefer-ws", mtu: iface.DefaultMTU, want: []string{"ws", "quic"}},
+ {name: "mtu above default forces ws", mode: "auto", mtu: iface.DefaultMTU + 100, want: []string{"ws"}},
+ {name: "sticky fallback forces ws in auto", mode: "auto", mtu: iface.DefaultMTU, preferWS: true, want: []string{"ws"}},
+ {name: "sticky fallback forces ws in prefer-quic", mode: "prefer-quic", mtu: iface.DefaultMTU, preferWS: true, want: []string{"ws"}},
+ {name: "quic pin overrides sticky fallback", mode: "quic", mtu: iface.DefaultMTU, preferWS: true, want: []string{"quic"}},
+ }
+
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ t.Setenv(EnvRelayTransport, tc.mode)
+ if tc.mode == "" {
+ os.Unsetenv(EnvRelayTransport)
+ }
+
+ tf := newTransportFallback()
+ if tc.preferWS {
+ tf.recordFailure(url)
+ }
+
+ c := &Client{
+ log: log.WithField("test", t.Name()),
+ connectionURL: url,
+ mtu: tc.mtu,
+ transportFallback: tf,
+ }
+
+ assert.Equal(t, tc.want, protocols(c.getDialers(transportModeFromEnv())))
+ })
+ }
+}
+
+// TestStickyFallbackAfterDatagramTooLarge verifies the full chain: an oversized
+// datagram records a fallback that makes the next dial pick WebSocket, the way a
+// reconnect would after the connection is closed.
+func TestStickyFallbackAfterDatagramTooLarge(t *testing.T) {
+ const url = "rels://relay.example:443"
+ t.Setenv(EnvRelayTransport, string(TransportModeAuto))
+
+ c := &Client{
+ log: log.WithField("test", t.Name()),
+ connectionURL: url,
+ mtu: iface.DefaultMTU,
+ transportFallback: newTransportFallback(),
+ }
+
+ // First dial races both transports.
+ assert.Equal(t, []string{"quic", "ws"}, protocols(c.getDialers(transportModeFromEnv())))
+
+ // An oversized datagram records the fallback for this server.
+ c.onDatagramTooLarge(&closeTrackingConn{}, netErr.ErrDatagramTooLarge)
+
+ // The reconnect now sticks to WebSocket.
+ assert.Equal(t, []string{"ws"}, protocols(c.getDialers(transportModeFromEnv())))
+}
diff --git a/shared/relay/client/dialers_js.go b/shared/relay/client/dialers_js.go
index 6bd0e6696..c93787729 100644
--- a/shared/relay/client/dialers_js.go
+++ b/shared/relay/client/dialers_js.go
@@ -7,7 +7,11 @@ import (
"github.com/netbirdio/netbird/shared/relay/client/dialer/ws"
)
-func (c *Client) getDialers() []dialer.DialeFn {
+func (c *Client) getDialers(_ TransportMode) []dialer.DialeFn {
// JS/WASM build only uses WebSocket transport
return []dialer.DialeFn{ws.Dialer{}}
}
+
+func (c *Client) baseDialers(_ TransportMode) []dialer.DialeFn {
+ return []dialer.DialeFn{ws.Dialer{}}
+}
diff --git a/shared/relay/client/guard.go b/shared/relay/client/guard.go
index d7892d0ce..98b1b333e 100644
--- a/shared/relay/client/guard.go
+++ b/shared/relay/client/guard.go
@@ -2,6 +2,7 @@ package client
import (
"context"
+ "sync/atomic"
"time"
"github.com/cenkalti/backoff/v4"
@@ -20,6 +21,10 @@ type Guard struct {
// maxBackoffInterval caps the exponential backoff between reconnect
// attempts.
maxBackoffInterval time.Duration
+
+ // lastErr is the error from the most recent failed reconnect attempt,
+ // surfaced as the home relay status while disconnected.
+ lastErr atomic.Pointer[error]
}
// NewGuard creates a new guard for the relay client. A non-positive
@@ -37,6 +42,15 @@ func NewGuard(sp *ServerPicker, maxBackoffInterval time.Duration) *Guard {
return g
}
+// LastError returns the error from the most recent failed reconnect attempt, or
+// nil if reconnection last succeeded.
+func (g *Guard) LastError() error {
+ if p := g.lastErr.Load(); p != nil {
+ return *p
+ }
+ return nil
+}
+
// StartReconnectTrys is called when the relay client is disconnected from the relay server.
// It attempts to reconnect to the relay server. The function first tries a quick reconnect
// to the same server that was used before, if the server URL is still valid. If the quick
@@ -63,6 +77,7 @@ func (g *Guard) StartReconnectTrys(ctx context.Context, relayClient *Client) {
case <-ticker.C:
if err := g.retry(ctx); err != nil {
log.Errorf("failed to pick new Relay server: %s", err)
+ g.setLastError(err)
continue
}
return
@@ -72,6 +87,10 @@ func (g *Guard) StartReconnectTrys(ctx context.Context, relayClient *Client) {
}
}
+func (g *Guard) setLastError(err error) {
+ g.lastErr.Store(&err)
+}
+
func (g *Guard) tryToQuickReconnect(parentCtx context.Context, rc *Client) bool {
if rc == nil {
return false
@@ -89,6 +108,7 @@ func (g *Guard) tryToQuickReconnect(parentCtx context.Context, rc *Client) bool
if err := rc.Connect(parentCtx); err != nil {
log.Errorf("failed to reconnect to relay server: %s", err)
+ g.setLastError(err)
return false
}
return true
@@ -100,6 +120,7 @@ func (g *Guard) retry(ctx context.Context) error {
if err != nil {
return err
}
+ g.setLastError(nil)
// prevent to work with a deprecated Relay client instance
g.drainRelayClientChan()
@@ -125,6 +146,7 @@ func (g *Guard) isServerURLStillValid(rc *Client) bool {
}
func (g *Guard) notifyReconnected() {
+ g.setLastError(nil)
select {
case g.OnReconnected <- struct{}{}:
default:
diff --git a/shared/relay/client/manager.go b/shared/relay/client/manager.go
index 3858b3c83..e1515401e 100644
--- a/shared/relay/client/manager.go
+++ b/shared/relay/client/manager.go
@@ -43,6 +43,17 @@ type OnServerCloseListener func()
// ManagerOption configures a Manager at construction time.
type ManagerOption func(*Manager)
+// RelayConnState is the connection state of a single relay server.
+type RelayConnState struct {
+ // URL is the server's instance address when connected, otherwise the
+ // configured server URL.
+ URL string
+ // Transport is the negotiated transport, empty if not connected.
+ Transport string
+ // Err is set when the relay is not connected.
+ Err error
+}
+
// WithMaxBackoffInterval caps the exponential backoff between reconnect
// attempts to the home relay. A non-positive value keeps the default.
func WithMaxBackoffInterval(d time.Duration) ManagerOption {
@@ -79,23 +90,30 @@ type Manager struct {
cleanupInterval time.Duration
keepUnusedServerTime time.Duration
+
+ // transportFallback is shared across home and foreign relay clients so a
+ // datagram-too-large failure makes that server avoid datagram-sized transports across reconnects.
+ transportFallback *transportFallback
}
// NewManager creates a new manager instance.
// The serverURL address can be empty. In this case, the manager will not serve.
func NewManager(ctx context.Context, serverURLs []string, peerID string, mtu uint16, opts ...ManagerOption) *Manager {
tokenStore := &relayAuth.TokenStore{}
+ tf := newTransportFallback()
m := &Manager{
- ctx: ctx,
- peerID: peerID,
- tokenStore: tokenStore,
- mtu: mtu,
+ ctx: ctx,
+ peerID: peerID,
+ tokenStore: tokenStore,
+ mtu: mtu,
+ transportFallback: tf,
serverPicker: &ServerPicker{
TokenStore: tokenStore,
PeerID: peerID,
MTU: mtu,
ConnectionTimeout: defaultConnectionTimeout,
+ TransportFallback: tf,
},
relayClients: make(map[string]*RelayTrack),
onDisconnectedListeners: make(map[string]*list.List),
@@ -123,6 +141,9 @@ func (m *Manager) Serve() error {
client, err := m.serverPicker.PickServer(m.ctx)
if err != nil {
+ // record the initial failure so status shows the real reason before
+ // the guard's first retry tick
+ m.reconnectGuard.setLastError(err)
go m.reconnectGuard.StartReconnectTrys(m.ctx, nil)
} else {
m.storeClient(client)
@@ -235,6 +256,56 @@ func (m *Manager) ServerURLs() []string {
return m.serverPicker.ServerURLs.Load().([]string)
}
+// RelayConnectError returns the error from the most recent failed home relay
+// reconnect attempt, or nil if the relay last connected successfully.
+func (m *Manager) RelayConnectError() error {
+ return m.reconnectGuard.LastError()
+}
+
+// RelayStates returns the connection state of the home relay and every foreign
+// relay the manager currently tracks.
+func (m *Manager) RelayStates() []RelayConnState {
+ var states []RelayConnState
+
+ m.relayClientMu.RLock()
+ home := m.relayClient
+ m.relayClientMu.RUnlock()
+ if home != nil {
+ st := relayConnState(home)
+ // The home relay reconnects through the guard, so the real failure
+ // reason lives there rather than on the (stale) client.
+ if st.Err != nil {
+ if gErr := m.reconnectGuard.LastError(); gErr != nil {
+ st.Err = gErr
+ }
+ }
+ states = append(states, st)
+ }
+
+ // Snapshot the tracks, then query each outside the map lock: a track can be
+ // held by an in-progress Connect, and blocking on it must not stall other
+ // relay operations.
+ m.relayClientsMutex.RLock()
+ tracks := make([]*RelayTrack, 0, len(m.relayClients))
+ for _, rt := range m.relayClients {
+ tracks = append(tracks, rt)
+ }
+ m.relayClientsMutex.RUnlock()
+
+ // Only connected foreign relays carry state; a failed connect is evicted
+ // immediately (openConnVia), so there is no error state to surface.
+ for _, rt := range tracks {
+ rt.RLock()
+ rc := rt.relayClient
+ rt.RUnlock()
+ if rc != nil {
+ states = append(states, relayConnState(rc))
+ }
+ }
+
+ return states
+}
+
// HasRelayAddress returns true if the manager is serving. With this method can check if the peer can communicate with
// Relay service.
func (m *Manager) HasRelayAddress() bool {
@@ -287,6 +358,7 @@ func (m *Manager) openConnVia(ctx context.Context, serverAddress, peerKey string
m.relayClientsMutex.Unlock()
relayClient := NewClientWithServerIP(serverAddress, serverIP, m.tokenStore, m.peerID, m.mtu)
+ relayClient.SetTransportFallback(m.transportFallback)
err := relayClient.Connect(m.ctx)
if err != nil {
rt.err = err
@@ -452,3 +524,11 @@ func (m *Manager) notifyOnDisconnectListeners(serverAddress string) {
}
delete(m.onDisconnectedListeners, serverAddress)
}
+
+func relayConnState(c *Client) RelayConnState {
+ addr, err := c.ServerInstanceURL()
+ if err != nil {
+ return RelayConnState{URL: c.connectionURL, Err: err}
+ }
+ return RelayConnState{URL: addr, Transport: c.Transport()}
+}
diff --git a/shared/relay/client/picker.go b/shared/relay/client/picker.go
index 39d0ba072..bb721e4ad 100644
--- a/shared/relay/client/picker.go
+++ b/shared/relay/client/picker.go
@@ -29,6 +29,7 @@ type ServerPicker struct {
PeerID string
MTU uint16
ConnectionTimeout time.Duration
+ TransportFallback *transportFallback
}
func (sp *ServerPicker) PickServer(parentCtx context.Context) (*Client, error) {
@@ -39,6 +40,7 @@ func (sp *ServerPicker) PickServer(parentCtx context.Context) (*Client, error) {
connResultChan := make(chan connResult, totalServers)
successChan := make(chan connResult, 1)
+ errChan := make(chan error, 1)
concurrentLimiter := make(chan struct{}, maxConcurrentServers)
log.Debugf("pick server from list: %v", sp.ServerURLs.Load().([]string))
@@ -53,23 +55,24 @@ func (sp *ServerPicker) PickServer(parentCtx context.Context) (*Client, error) {
}(url)
}
- go sp.processConnResults(connResultChan, successChan)
+ go sp.processConnResults(connResultChan, successChan, errChan)
select {
case cr, ok := <-successChan:
if !ok {
- return nil, errors.New("failed to connect to any relay server: all attempts failed")
+ return nil, <-errChan
}
log.Infof("chosen home Relay server: %s", cr.Url)
return cr.RelayClient, nil
case <-ctx.Done():
- return nil, fmt.Errorf("failed to connect to any relay server: %w", ctx.Err())
+ return nil, fmt.Errorf("connect to relay server: %w", ctx.Err())
}
}
func (sp *ServerPicker) startConnection(ctx context.Context, resultChan chan connResult, url string) {
log.Infof("try to connecting to relay server: %s", url)
relayClient := NewClient(url, sp.TokenStore, sp.PeerID, sp.MTU)
+ relayClient.SetTransportFallback(sp.TransportFallback)
err := relayClient.Connect(ctx)
resultChan <- connResult{
RelayClient: relayClient,
@@ -78,12 +81,14 @@ func (sp *ServerPicker) startConnection(ctx context.Context, resultChan chan con
}
}
-func (sp *ServerPicker) processConnResults(resultChan chan connResult, successChan chan connResult) {
+func (sp *ServerPicker) processConnResults(resultChan chan connResult, successChan chan connResult, errChan chan error) {
var hasSuccess bool
+ var errs []error
for numOfResults := 0; numOfResults < cap(resultChan); numOfResults++ {
cr := <-resultChan
if cr.Err != nil {
log.Tracef("failed to connect to Relay server: %s: %v", cr.Url, cr.Err)
+ errs = append(errs, cr.Err)
continue
}
log.Infof("connected to Relay server: %s", cr.Url)
@@ -99,5 +104,16 @@ func (sp *ServerPicker) processConnResults(resultChan chan connResult, successCh
hasSuccess = true
successChan <- cr
}
+ if !hasSuccess {
+ errChan <- pickErr(errs)
+ }
close(successChan)
}
+
+// pickErr combines per-server connection failures into a single error.
+func pickErr(errs []error) error {
+ if len(errs) == 0 {
+ return errors.New("no relay server available")
+ }
+ return errors.Join(errs...)
+}
diff --git a/shared/relay/client/transport.go b/shared/relay/client/transport.go
new file mode 100644
index 000000000..002707401
--- /dev/null
+++ b/shared/relay/client/transport.go
@@ -0,0 +1,129 @@
+package client
+
+import (
+ "os"
+ "strings"
+ "sync"
+ "time"
+
+ log "github.com/sirupsen/logrus"
+
+ "github.com/netbirdio/netbird/shared/relay/client/dialer"
+)
+
+// EnvRelayTransport pins the relay transport. Valid values: "auto" (default,
+// race QUIC and WebSocket), "quic" (QUIC only), "ws" (WebSocket only),
+// "prefer-quic" / "prefer-ws" (try the preferred transport first, fall back to
+// the other only if it fails to connect; no race). The prefer modes trade a
+// slower connect when the preferred transport is blackholed for deterministic
+// transport selection.
+const EnvRelayTransport = "NB_RELAY_TRANSPORT"
+
+const (
+ // transportFallbackBase is the initial window a relay server avoids
+ // datagram-sized transports after a datagram is rejected as too large.
+ transportFallbackBase = 10 * time.Minute
+ // transportFallbackMax caps the pinned window when failures repeat.
+ transportFallbackMax = 60 * time.Minute
+)
+
+// TransportMode selects which relay dialers are used.
+type TransportMode string
+
+const (
+ TransportModeAuto TransportMode = "auto"
+ TransportModeQUIC TransportMode = "quic"
+ TransportModeWS TransportMode = "ws"
+ TransportModePreferQUIC TransportMode = "prefer-quic"
+ TransportModePreferWS TransportMode = "prefer-ws"
+)
+
+// transportModeFromEnv reads EnvRelayTransport, defaulting to auto for an empty
+// or unrecognized value.
+func transportModeFromEnv() TransportMode {
+ switch TransportMode(strings.ToLower(strings.TrimSpace(os.Getenv(EnvRelayTransport)))) {
+ case "", TransportModeAuto:
+ return TransportModeAuto
+ case TransportModeQUIC:
+ return TransportModeQUIC
+ case TransportModeWS:
+ return TransportModeWS
+ case TransportModePreferQUIC:
+ return TransportModePreferQUIC
+ case TransportModePreferWS:
+ return TransportModePreferWS
+ default:
+ log.Warnf("invalid %s value %q, using %q", EnvRelayTransport, os.Getenv(EnvRelayTransport), TransportModeAuto)
+ return TransportModeAuto
+ }
+}
+
+// sequential reports whether the mode tries dialers in order with fallback
+// instead of racing them concurrently.
+func (m TransportMode) sequential() bool {
+ return m == TransportModePreferQUIC || m == TransportModePreferWS
+}
+
+// transportFallback tracks relay servers that have rejected a datagram-sized
+// transport (a write too large for the path) and should temporarily avoid such
+// transports. It is shared across the relay manager so the preference survives
+// client recreation (foreign relay clients are evicted and rebuilt on
+// disconnect). Entries are keyed by server URL and expire after a window that
+// grows on repeated failures.
+type transportFallback struct {
+ mu sync.Mutex
+ entries map[string]*fallbackEntry
+}
+
+type fallbackEntry struct {
+ until time.Time
+ duration time.Duration
+}
+
+func newTransportFallback() *transportFallback {
+ return &transportFallback{entries: make(map[string]*fallbackEntry)}
+}
+
+// avoidDatagramSized reports whether serverURL is currently within a window
+// where datagram-sized transports should be avoided.
+func (f *transportFallback) avoidDatagramSized(serverURL string) bool {
+ f.mu.Lock()
+ defer f.mu.Unlock()
+ e := f.entries[serverURL]
+ return e != nil && time.Now().Before(e.until)
+}
+
+// recordFailure makes serverURL avoid datagram-sized transports for a window:
+// transportFallbackBase on the first failure, doubling up to transportFallbackMax
+// when a datagram transport fails again after a previous window expired. It
+// returns the active window duration.
+func (f *transportFallback) recordFailure(serverURL string) time.Duration {
+ f.mu.Lock()
+ defer f.mu.Unlock()
+
+ now := time.Now()
+ e := f.entries[serverURL]
+ switch {
+ case e == nil:
+ e = &fallbackEntry{duration: transportFallbackBase}
+ f.entries[serverURL] = e
+ case now.Before(e.until):
+ return time.Until(e.until)
+ default:
+ e.duration = min(e.duration*2, transportFallbackMax)
+ }
+ e.until = now.Add(e.duration)
+ return e.duration
+}
+
+// nonDatagramSized returns the dialers from in that are not datagram-sized,
+// preserving order.
+func nonDatagramSized(in []dialer.DialeFn) []dialer.DialeFn {
+ out := make([]dialer.DialeFn, 0, len(in))
+ for _, d := range in {
+ if !dialer.IsDatagramSized(d) {
+ out = append(out, d)
+ }
+ }
+ return out
+}
diff --git a/shared/relay/client/transport_test.go b/shared/relay/client/transport_test.go
new file mode 100644
index 000000000..8e10c8d42
--- /dev/null
+++ b/shared/relay/client/transport_test.go
@@ -0,0 +1,140 @@
+package client
+
+import (
+ "net"
+ "os"
+ "testing"
+ "time"
+
+ log "github.com/sirupsen/logrus"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+
+ netErr "github.com/netbirdio/netbird/shared/relay/client/dialer/net"
+)
+
+// closeTrackingConn records whether Close was called; only Close is exercised.
+type closeTrackingConn struct {
+ net.Conn
+ closed bool
+}
+
+func (c *closeTrackingConn) Close() error {
+ c.closed = true
+ return nil
+}
+
+func TestTransportModeFromEnv(t *testing.T) {
+ tests := []struct {
+ value string
+ want TransportMode
+ }{
+ {"", TransportModeAuto},
+ {"auto", TransportModeAuto},
+ {"quic", TransportModeQUIC},
+ {"QUIC", TransportModeQUIC},
+ {"ws", TransportModeWS},
+ {" Ws ", TransportModeWS},
+ {"prefer-quic", TransportModePreferQUIC},
+ {"prefer-ws", TransportModePreferWS},
+ {"garbage", TransportModeAuto},
+ }
+
+ for _, tc := range tests {
+ t.Run(tc.value, func(t *testing.T) {
+ t.Setenv(EnvRelayTransport, tc.value)
+ if tc.value == "" {
+ os.Unsetenv(EnvRelayTransport)
+ }
+ assert.Equal(t, tc.want, transportModeFromEnv())
+ })
+ }
+}
+
+func TestTransportFallbackRecordAndExpiry(t *testing.T) {
+ const url = "rels://relay.example:443"
+ f := newTransportFallback()
+
+ assert.False(t, f.avoidDatagramSized(url), "no fallback recorded yet")
+
+ d := f.recordFailure(url)
+ assert.Equal(t, transportFallbackBase, d, "first failure pins for the base window")
+ assert.True(t, f.avoidDatagramSized(url), "datagram-sized transport avoided within the window")
+
+ // A second failure while still inside the window must not grow the window.
+ d = f.recordFailure(url)
+ assert.LessOrEqual(t, d, transportFallbackBase, "still within the active window")
+ require.NotNil(t, f.entries[url])
+ assert.Equal(t, transportFallbackBase, f.entries[url].duration, "duration unchanged inside window")
+
+ // Expire the window: datagram-sized transport allowed again.
+ f.entries[url].until = time.Now().Add(-time.Second)
+ assert.False(t, f.avoidDatagramSized(url), "window expired, datagram-sized transport allowed")
+}
+
+func TestTransportFallbackGrowsOnRepeat(t *testing.T) {
+ const url = "rels://relay.example:443"
+ f := newTransportFallback()
+
+ want := transportFallbackBase
+ for i := range 6 {
+ d := f.recordFailure(url)
+ assert.Equal(t, want, d, "window after %d expiries", i)
+
+ // expire the window so the next failure is treated as a repeat
+ f.entries[url].until = time.Now().Add(-time.Second)
+
+ want = min(want*2, transportFallbackMax)
+ }
+
+ assert.Equal(t, transportFallbackMax, f.entries[url].duration, "window caps at the max")
+}
+
+func TestOnDatagramTooLargeAuto(t *testing.T) {
+ const url = "rels://relay.example:443"
+ t.Setenv(EnvRelayTransport, string(TransportModeAuto))
+
+ tf := newTransportFallback()
+ c := &Client{
+ log: log.WithField("test", t.Name()),
+ connectionURL: url,
+ transportFallback: tf,
+ }
+ conn := &closeTrackingConn{}
+
+ c.onDatagramTooLarge(conn, netErr.ErrDatagramTooLarge)
+
+ assert.True(t, conn.closed, "connection closed to force reconnect")
+ assert.True(t, tf.avoidDatagramSized(url), "fallback recorded for the server")
+
+ // A second oversized datagram on the same connection must not re-close.
+ conn.closed = false
+ c.onDatagramTooLarge(conn, netErr.ErrDatagramTooLarge)
+ assert.False(t, conn.closed, "single fallback per connection")
+}
+
+func TestOnDatagramTooLargeQUICPinned(t *testing.T) {
+ const url = "rels://relay.example:443"
+ t.Setenv(EnvRelayTransport, string(TransportModeQUIC))
+
+ tf := newTransportFallback()
+ c := &Client{
+ log: log.WithField("test", t.Name()),
+ connectionURL: url,
+ transportFallback: tf,
+ }
+ conn := &closeTrackingConn{}
+
+ c.onDatagramTooLarge(conn, netErr.ErrDatagramTooLarge)
+
+ assert.False(t, conn.closed, "QUIC pin keeps the connection, no fallback redial")
+ assert.False(t, tf.avoidDatagramSized(url), "QUIC pin records no fallback")
+}
+
+func TestTransportFallbackPerServer(t *testing.T) {
+ f := newTransportFallback()
+ f.recordFailure("rels://a.example:443")
+
+ assert.True(t, f.avoidDatagramSized("rels://a.example:443"))
+ assert.False(t, f.avoidDatagramSized("rels://b.example:443"), "fallback is scoped to one server")
+}
diff --git a/util/capture/text.go b/util/capture/text.go
index a6a6dd28b..32229894e 100644
--- a/util/capture/text.go
+++ b/util/capture/text.go
@@ -13,6 +13,8 @@ import (
"github.com/google/gopacket"
"github.com/google/gopacket/layers"
"github.com/miekg/dns"
+
+ nbdns "github.com/netbirdio/netbird/dns"
)
// TextWriter writes human-readable one-line-per-packet summaries.
@@ -150,7 +152,7 @@ func (tw *TextWriter) writeUDP(timeStr string, dir Direction, info *packetInfo,
plen := len(udp.Payload)
// DNS replaces the entire line format
- if plen > 0 && isDNSPort(info.srcPort, info.dstPort) {
+ if plen > 0 && (isDNSPort(info.srcPort) || isDNSPort(info.dstPort)) {
if s := formatDNSPayload(udp.Payload); s != "" {
var verbose string
if tw.verbose {
@@ -561,8 +563,12 @@ func findSNIExtension(body []byte, pos int) string {
return ""
}
-func isDNSPort(src, dst uint16) bool {
- return src == 53 || dst == 53 || src == 5353 || dst == 5353
+func isDNSPort(p uint16) bool {
+ switch p {
+ case nbdns.DefaultDNSPort, nbdns.ForwarderClientPort, nbdns.ForwarderServerPort:
+ return true
+ }
+ return false
}
// formatDNSPayload parses DNS and returns a tcpdump-style summary.
diff --git a/util/log.go b/util/log.go
index b1de2d999..3896ff6bc 100644
--- a/util/log.go
+++ b/util/log.go
@@ -1,15 +1,16 @@
package util
import (
+ "fmt"
"io"
"os"
"path/filepath"
"slices"
"strconv"
+ "github.com/DeRuina/timberjack"
log "github.com/sirupsen/logrus"
"google.golang.org/grpc/grpclog"
- "gopkg.in/natefinch/lumberjack.v2"
"github.com/netbirdio/netbird/formatter"
)
@@ -37,8 +38,7 @@ func InitLog(logLevel string, logs ...string) error {
func InitLogger(logger *log.Logger, logLevel string, logs ...string) error {
level, err := log.ParseLevel(logLevel)
if err != nil {
- logger.Errorf("Failed parsing log-level %s: %s", logLevel, err)
- return err
+ return fmt.Errorf("failed parsing log-level %s: %w", logLevel, err)
}
var writers []io.Writer
logFmt := os.Getenv("NB_LOG_FORMAT")
@@ -59,7 +59,11 @@ func InitLogger(logger *log.Logger, logLevel string, logs ...string) error {
case "":
logger.Warnf("empty log path received: %#v", logPath)
default:
- writers = append(writers, newRotatedOutput(logPath))
+ writer, err := setupLogFile(logPath, isRotationDisabled(logger))
+ if err != nil {
+ return fmt.Errorf("failed setting up log file: %s, %w", logPath, err)
+ }
+ writers = append(writers, writer)
}
}
@@ -94,17 +98,43 @@ func FindFirstLogPath(logs []string) string {
return ""
}
+func isRotationDisabled(logger *log.Logger) bool {
+ v, _ := os.LookupEnv("NB_LOG_DISABLE_ROTATION")
+ disabled, _ := strconv.ParseBool(v)
+ if disabled {
+ logger.Warnf("log rotation is disabled by env flag")
+ return true
+ }
+ conflict, configPath := FindFirstLogrotateConflict()
+ if conflict {
+ logger.Warnf("log rotation conflict detected in: %#v, rotation is disabled", configPath)
+ return true
+ }
+ return false
+}
+
+func setupLogFile(logPath string, disableRotation bool) (io.Writer, error) {
+ if disableRotation {
+ file, err := os.OpenFile(logPath, os.O_WRONLY|os.O_APPEND|os.O_CREATE, 0600)
+ if err != nil {
+ return nil, err
+ }
+ return file, nil
+ }
+ return newRotatedOutput(logPath), nil
+}
+
func newRotatedOutput(logPath string) io.Writer {
maxLogSize := getLogMaxSize()
- lumberjackLogger := &lumberjack.Logger{
+ timberjackLogger := &timberjack.Logger{
// Log file absolute path, os agnostic
- Filename: filepath.ToSlash(logPath),
- MaxSize: maxLogSize, // MB
- MaxBackups: 10,
- MaxAge: 30, // days
- Compress: true,
+ Filename: filepath.ToSlash(logPath),
+ MaxSize: maxLogSize, // MB
+ MaxBackups: 10,
+ MaxAge: 30, // days
+ Compression: "gzip",
}
- return lumberjackLogger
+ return timberjackLogger
}
func setGRPCLibLogger(logger *log.Logger) {
@@ -127,7 +157,7 @@ func getLogMaxSize() int {
if sizeVar, ok := os.LookupEnv("NB_LOG_MAX_SIZE_MB"); ok {
size, err := strconv.ParseInt(sizeVar, 10, 64)
if err != nil {
- log.Errorf("Failed parsing log-size %s: %s. Should be just an integer", sizeVar, err)
+ log.Errorf("failed parsing log-size %s: %s. Should be just an integer", sizeVar, err)
return defaultLogSize
}
diff --git a/util/log_test.go b/util/log_test.go
new file mode 100644
index 000000000..e9933e479
--- /dev/null
+++ b/util/log_test.go
@@ -0,0 +1,96 @@
+package util
+
+import (
+ "io"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+ "time"
+
+ log "github.com/sirupsen/logrus"
+ "github.com/stretchr/testify/require"
+)
+
+// TestSetupLogFile_RotatesOnSize drives >MaxSize bytes through the writer
+// returned by setupLogFile and asserts a backup file appears.
+func TestSetupLogFile_RotatesOnSize(t *testing.T) {
+ t.Setenv("NB_LOG_MAX_SIZE_MB", "1")
+
+ dir := t.TempDir()
+ logPath := filepath.Join(dir, "netbird.log")
+
+ w, err := setupLogFile(logPath, false)
+ require.NoError(t, err)
+ t.Cleanup(func() {
+ if c, ok := w.(io.Closer); ok {
+ _ = c.Close()
+ }
+ })
+
+ chunk := []byte(strings.Repeat("x", 64*1024) + "\n")
+ for range 20 {
+ _, err := w.Write(chunk)
+ require.NoError(t, err)
+ }
+
+ info, err := os.Stat(logPath)
+ require.NoError(t, err)
+ require.Less(t, info.Size(), int64(1<<20),
+ "active log should be < 1 MB after rotation, got %d", info.Size())
+
+ require.Eventually(t, func() bool {
+ entries, _ := os.ReadDir(dir)
+ for _, e := range entries {
+ name := e.Name()
+ if name == filepath.Base(logPath) {
+ continue
+ }
+ if strings.HasPrefix(name, "netbird-") && strings.HasSuffix(name, ".log.gz") {
+ return true
+ }
+ }
+ return false
+ }, 5*time.Second, 50*time.Millisecond, "expected a rotated backup file in %s", dir)
+}
+
+// TestSetupLogFile_RotationDisabled verifies that with rotation off, the file
+// grows past MaxSize and no backups are created.
+func TestSetupLogFile_RotationDisabled(t *testing.T) {
+ t.Setenv("NB_LOG_MAX_SIZE_MB", "1")
+
+ dir := t.TempDir()
+ logPath := filepath.Join(dir, "netbird.log")
+
+ w, err := setupLogFile(logPath, true)
+ require.NoError(t, err)
+
+ f, ok := w.(*os.File)
+ require.True(t, ok, "expected plain *os.File when rotation is disabled, got %T", w)
+ t.Cleanup(func() { _ = f.Close() })
+
+ chunk := []byte(strings.Repeat("y", 64*1024) + "\n")
+ for range 20 {
+ _, err := w.Write(chunk)
+ require.NoError(t, err)
+ }
+
+ info, err := os.Stat(logPath)
+ require.NoError(t, err)
+ require.GreaterOrEqual(t, info.Size(), int64(1<<20),
+ "file should exceed MaxSize when rotation is disabled, got %d", info.Size())
+
+ entries, err := os.ReadDir(dir)
+ require.NoError(t, err)
+ require.Len(t, entries, 1, "no backup files should exist when rotation is disabled, got %v", entries)
+}
+
+// TestIsRotationDisabled_EnvFlag covers the NB_LOG_DISABLE_ROTATION env path.
+// The logrotate-conflict branch is exercised separately on linux.
+func TestIsRotationDisabled_EnvFlag(t *testing.T) {
+ logger := log.New()
+ logger.SetOutput(io.Discard)
+
+ t.Setenv("NB_LOG_DISABLE_ROTATION", "true")
+ require.True(t, isRotationDisabled(logger))
+}
diff --git a/util/logrotate_linux.go b/util/logrotate_linux.go
new file mode 100644
index 000000000..7d2173ea8
--- /dev/null
+++ b/util/logrotate_linux.go
@@ -0,0 +1,93 @@
+//go:build linux
+
+package util
+
+import (
+ "bufio"
+ "errors"
+ "io/fs"
+ "os"
+ "path/filepath"
+ "strings"
+
+ log "github.com/sirupsen/logrus"
+)
+
+const (
+ defaultLogrotateConfPath = "/etc/logrotate.conf"
+ defaultLogrotateConfDir = "/etc/logrotate.d"
+ netbirdString = "netbird"
+)
+
+// FindLogrotateConflicts scans the standard logrotate locations for
+// indications of conflict with netbird. It returns true and the config file
+// path if a conflict was found.
+func FindFirstLogrotateConflict() (bool, string) {
+ return findFirstLogrotateConflictIn(defaultLogrotateConfPath, defaultLogrotateConfDir)
+}
+
+func findFirstLogrotateConflictIn(confPath, confDir string) (bool, string) {
+ for _, f := range listLogrotateConfigs(confPath, confDir) {
+ present, err := scanLogrotateFile(f, netbirdString)
+ if err != nil {
+ if !errors.Is(err, fs.ErrNotExist) {
+ log.Debugf("scan %s: %v", f, err)
+ }
+ continue
+ }
+ if present {
+ return present, f
+ }
+ }
+ return false, ""
+}
+
+// listLogrotateConfigs returns all config files for logrotate.
+func listLogrotateConfigs(confPath, confDir string) []string {
+ files := []string{confPath}
+ entries, err := os.ReadDir(confDir)
+ if err != nil {
+ return files
+ }
+ for _, e := range entries {
+ if e.IsDir() {
+ continue
+ }
+ files = append(files, filepath.Join(confDir, e.Name()))
+ }
+ return files
+}
+
+// scanLogrotateFile reads a config and reports if a non-comment line
+// contains the given substring.
+func scanLogrotateFile(path string, substring string) (bool, error) {
+ f, err := os.Open(path)
+ if err != nil {
+ return false, err
+ }
+ defer func() {
+ if err := f.Close(); err != nil {
+ log.Debugf("close %s: %v", path, err)
+ }
+ }()
+
+ scanner := bufio.NewScanner(f)
+ for scanner.Scan() {
+ line := strings.TrimSpace(stripLogrotateComment(scanner.Text()))
+ if line == "" {
+ continue
+ }
+ if strings.Contains(line, substring) {
+ return true, nil
+ }
+ }
+ if err := scanner.Err(); err != nil {
+ return false, err
+ }
+ return false, nil
+}
+
+func stripLogrotateComment(line string) string {
+ before, _, _ := strings.Cut(line, "#")
+ return before
+}
diff --git a/util/logrotate_linux_test.go b/util/logrotate_linux_test.go
new file mode 100644
index 000000000..92d7e410b
--- /dev/null
+++ b/util/logrotate_linux_test.go
@@ -0,0 +1,95 @@
+//go:build linux
+
+package util
+
+import (
+ "os"
+ "path/filepath"
+ "testing"
+
+ "github.com/stretchr/testify/require"
+)
+
+func TestFindFirstLogrotateConflict(t *testing.T) {
+ t.Run("conflict in confDir", func(t *testing.T) {
+ confPath, confDir := newLogrotateLayout(t)
+ conflictPath := filepath.Join(confDir, "netbird")
+ writeLogrotateConfig(t, conflictPath, `/var/log/netbird/*.log {
+ daily
+ rotate 7
+}`)
+ writeLogrotateConfig(t, filepath.Join(confDir, "nginx"), `/var/log/nginx/*.log { daily }`)
+
+ got, path := findFirstLogrotateConflictIn(confPath, confDir)
+ require.True(t, got)
+ require.Equal(t, conflictPath, path)
+ })
+
+ t.Run("conflict in main conf file", func(t *testing.T) {
+ confPath, confDir := newLogrotateLayout(t)
+ writeLogrotateConfig(t, confPath, `weekly
+rotate 4
+include /etc/logrotate.d
+/var/log/netbird/client.log { rotate 5 }`)
+
+ got, path := findFirstLogrotateConflictIn(confPath, confDir)
+ require.True(t, got)
+ require.Equal(t, confPath, path)
+ })
+
+ t.Run("no conflict when netbird is absent", func(t *testing.T) {
+ confPath, confDir := newLogrotateLayout(t)
+ writeLogrotateConfig(t, filepath.Join(confDir, "nginx"), `/var/log/nginx/*.log { daily }`)
+ writeLogrotateConfig(t, filepath.Join(confDir, "syslog"), `/var/log/syslog { weekly }`)
+
+ got, path := findFirstLogrotateConflictIn(confPath, confDir)
+ require.False(t, got)
+ require.Empty(t, path)
+ })
+
+ t.Run("commented-out netbird line is ignored", func(t *testing.T) {
+ confPath, confDir := newLogrotateLayout(t)
+ writeLogrotateConfig(t, filepath.Join(confDir, "misc"), `# /var/log/netbird/*.log { daily }
+/var/log/other.log { weekly }`)
+
+ got, path := findFirstLogrotateConflictIn(confPath, confDir)
+ require.False(t, got)
+ require.Empty(t, path)
+ })
+
+ t.Run("subdirectories in confDir are ignored", func(t *testing.T) {
+ confPath, confDir := newLogrotateLayout(t)
+ sub := filepath.Join(confDir, "nested")
+ require.NoError(t, os.MkdirAll(sub, 0o755))
+ writeLogrotateConfig(t, filepath.Join(sub, "netbird"), `/var/log/netbird/*.log { daily }`)
+
+ got, path := findFirstLogrotateConflictIn(confPath, confDir)
+ require.False(t, got)
+ require.Empty(t, path)
+ })
+
+ t.Run("missing paths return no conflict", func(t *testing.T) {
+ dir := t.TempDir()
+ got, path := findFirstLogrotateConflictIn(
+ filepath.Join(dir, "does-not-exist.conf"),
+ filepath.Join(dir, "does-not-exist.d"),
+ )
+ require.False(t, got)
+ require.Empty(t, path)
+ })
+}
+
+// newLogrotateLayout creates a temp logrotate.conf path and logrotate.d dir,
+// returning their paths. The conf file itself is not created.
+func newLogrotateLayout(t *testing.T) (confPath, confDir string) {
+ t.Helper()
+ root := t.TempDir()
+ confDir = filepath.Join(root, "logrotate.d")
+ require.NoError(t, os.MkdirAll(confDir, 0o755))
+ return filepath.Join(root, "logrotate.conf"), confDir
+}
+
+func writeLogrotateConfig(t *testing.T, path, body string) {
+ t.Helper()
+ require.NoError(t, os.WriteFile(path, []byte(body), 0o644))
+}
diff --git a/util/logrotate_nonlinux.go b/util/logrotate_nonlinux.go
new file mode 100644
index 000000000..0a188b864
--- /dev/null
+++ b/util/logrotate_nonlinux.go
@@ -0,0 +1,10 @@
+//go:build !linux
+
+package util
+
+// FindLogrotateConflicts scans the standard logrotate locations for
+// indications of conflict with netbird. It will always return false for
+// non-linux devices.
+func FindFirstLogrotateConflict() (bool, string) {
+ return false, ""
+}
diff --git a/version/version.go b/version/version.go
index d70a5effa..074305bd6 100644
--- a/version/version.go
+++ b/version/version.go
@@ -2,19 +2,86 @@ package version
import (
"regexp"
+ "runtime/debug"
+ "strings"
v "github.com/hashicorp/go-version"
)
+// DevelopmentVersion is the value of NetbirdVersion() for non-release builds.
+// Wire-format consumers (management server, dashboard) match against this
+// string, so it must not change without coordinating those consumers.
+const DevelopmentVersion = "development"
+
+// CIVersionPrefix marks CI snapshot builds (e.g. "ci-7470fbdd"). Such builds
+// are treated as development versions by IsDevelopmentVersion.
+const CIVersionPrefix = "ci-"
+
+// DevVersionPrefix marks dev snapshot builds (e.g. "dev-7470fbdd"). Such builds
+// are treated as development versions by IsDevelopmentVersion.
+const DevVersionPrefix = "dev-"
+
// will be replaced with the release version when using goreleaser
-var version = "development"
+var version = DevelopmentVersion
var (
VersionRegexp = regexp.MustCompile("^" + v.VersionRegexpRaw + "$")
SemverRegexp = regexp.MustCompile("^" + v.SemverRegexpRaw + "$")
)
-// NetbirdVersion returns the Netbird version
+// NetbirdVersion returns the Netbird version. For non-release builds the
+// value is the literal DevelopmentVersion constant; the VCS revision is
+// exposed separately via NetbirdCommit so the wire format stays stable.
func NetbirdVersion() string {
return version
}
+
+// NetbirdCommit returns the VCS revision (truncated to 12 chars) of the
+// build, with a "-dirty" suffix when the working tree was modified.
+// Returns an empty string when no build info is embedded (e.g. release
+// builds compiled by goreleaser without -buildvcs).
+func NetbirdCommit() string {
+ info, ok := debug.ReadBuildInfo()
+ if !ok {
+ return ""
+ }
+
+ var revision string
+ var modified bool
+ for _, s := range info.Settings {
+ switch s.Key {
+ case "vcs.revision":
+ revision = s.Value
+ case "vcs.modified":
+ modified = s.Value == "true"
+ }
+ }
+
+ if revision == "" {
+ return ""
+ }
+
+ if len(revision) > 12 {
+ revision = revision[:12]
+ }
+
+ if modified {
+ revision += "-dirty"
+ }
+ return revision
+}
+
+// IsDevelopmentVersion reports whether the given version string identifies
+// a non-release / development build. It is the single source of truth for
+// "is this a dev build" checks across the codebase; use it instead of
+// comparing against the "development" literal or ad-hoc substring checks.
+//
+// Matches the bare DevelopmentVersion constant as well as any future
+// extension such as "development-" or "development--dirty", and
+// CI/dev snapshot builds prefixed with "ci-" or "dev-", while excluding
+// tagged prereleases like "v0.31.1-dev".
+func IsDevelopmentVersion(v string) bool {
+ return strings.HasPrefix(v, DevelopmentVersion) ||
+ strings.HasPrefix(v, CIVersionPrefix) ||
+ strings.HasPrefix(v, DevVersionPrefix)
+}
diff --git a/version/version_test.go b/version/version_test.go
new file mode 100644
index 000000000..cdba6b804
--- /dev/null
+++ b/version/version_test.go
@@ -0,0 +1,28 @@
+package version
+
+import "testing"
+
+func TestIsDevelopmentVersion(t *testing.T) {
+ tests := []struct {
+ version string
+ want bool
+ }{
+ {"development", true},
+ {"development-0823f3ff9ab1", true},
+ {"development-0823f3ff9ab1-dirty", true},
+ {"ci-7470fbdd", true},
+ {"dev-7470fbdd", true},
+ {"0.50.0", false},
+ {"v0.31.1-dev", false},
+ {"1.0.0-dev", false},
+ {"dev", false},
+ {"", false},
+ }
+ for _, tt := range tests {
+ t.Run(tt.version, func(t *testing.T) {
+ if got := IsDevelopmentVersion(tt.version); got != tt.want {
+ t.Errorf("IsDevelopmentVersion(%q) = %v, want %v", tt.version, got, tt.want)
+ }
+ })
+ }
+}