diff --git a/.github/workflows/check-license-dependencies.yml b/.github/workflows/check-license-dependencies.yml
index 8acd645e2..17c9fdc8d 100644
--- a/.github/workflows/check-license-dependencies.yml
+++ b/.github/workflows/check-license-dependencies.yml
@@ -20,7 +20,7 @@ jobs:
steps:
- name: Checkout code
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
@@ -59,12 +59,12 @@ jobs:
runs-on: ubuntu-latest
steps:
- - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
- name: Set up Go
- uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0
+ uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0
with:
go-version-file: "go.mod"
cache: true
diff --git a/.github/workflows/git-town.yml b/.github/workflows/git-town.yml
index 3f145020f..160c2ea38 100644
--- a/.github/workflows/git-town.yml
+++ b/.github/workflows/git-town.yml
@@ -15,7 +15,7 @@ jobs:
pull-requests: write
steps:
- - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
- uses: git-town/action@3d8b878379abb1ee393fb49865a28b4a6c2cd3b0 # v1.2.1
diff --git a/.github/workflows/golang-test-darwin.yml b/.github/workflows/golang-test-darwin.yml
index ad84840a2..748e3f996 100644
--- a/.github/workflows/golang-test-darwin.yml
+++ b/.github/workflows/golang-test-darwin.yml
@@ -16,18 +16,18 @@ jobs:
runs-on: macos-latest
steps:
- name: Checkout code
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
- name: Install Go
- uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0
+ uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0
with:
go-version-file: "go.mod"
cache: false
- name: Cache Go modules
- uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
+ uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0
with:
path: ~/go/pkg/mod
key: macos-gotest-${{ hashFiles('**/go.sum') }}
@@ -45,10 +45,10 @@ jobs:
run: git --no-pager diff --exit-code
- name: Test
- 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)
+ run: NETBIRD_STORE_ENGINE=${{ matrix.store }} CI=true go test -coverprofile=coverage.txt -tags 'devcert privileged' -exec 'sudo --preserve-env=CI,NETBIRD_STORE_ENGINE' -timeout 5m -p 1 $(go list ./... | grep -v -e /management -e /signal -e /relay -e /proxy -e /combined -e /client/testutil/privileged)
- name: Upload coverage reports to Codecov
- uses: codecov/codecov-action@e79a6962e0d4c0c17b229090214935d2e33f8354 #v6.0.1
+ uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f #v7.0.0
with:
token: ${{ secrets.CODECOV_TOKEN }}
slug: netbirdio/netbird
diff --git a/.github/workflows/golang-test-freebsd.yml b/.github/workflows/golang-test-freebsd.yml
index 9a81d3e4c..9c795e783 100644
--- a/.github/workflows/golang-test-freebsd.yml
+++ b/.github/workflows/golang-test-freebsd.yml
@@ -16,7 +16,7 @@ jobs:
runs-on: ubuntu-22.04
steps:
- name: Checkout code
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
@@ -28,7 +28,7 @@ jobs:
id: test
env:
GO_VERSION: ${{ steps.goversion.outputs.version }}
- uses: vmactions/freebsd-vm@d1e65811565151536c0c894fff74f06351ed26e6 # v1.4.5
+ uses: vmactions/freebsd-vm@b84ab5559b5a1bb4b8ee2737d2506a16e1737636 # v1.4.8
with:
usesh: true
copyback: false
@@ -48,14 +48,14 @@ jobs:
export PATH=$PATH:/usr/local/go/bin:$HOME/go/bin
time go build -o netbird client/main.go
# check all component except management, since we do not support management server on freebsd
- time go test -timeout 1m -failfast ./base62/...
+ time go test -tags privileged -timeout 1m -failfast ./base62/...
# NOTE: without -p1 `client/internal/dns` will fail because of `listen udp4 :33100: bind: address already in use`
- time go test -timeout 8m -failfast -v -p 1 ./client/...
- time go test -timeout 1m -failfast ./dns/...
- time go test -timeout 1m -failfast ./encryption/...
- time go test -timeout 1m -failfast ./formatter/...
- time go test -timeout 1m -failfast ./client/iface/...
- time go test -timeout 1m -failfast ./route/...
- time go test -timeout 1m -failfast ./sharedsock/...
- time go test -timeout 1m -failfast ./util/...
- time go test -timeout 1m -failfast ./version/...
+ time go test -tags privileged -timeout 8m -failfast -v -p 1 ./client/...
+ time go test -tags privileged -timeout 1m -failfast ./dns/...
+ time go test -tags privileged -timeout 1m -failfast ./encryption/...
+ time go test -tags privileged -timeout 1m -failfast ./formatter/...
+ time go test -tags privileged -timeout 1m -failfast ./client/iface/...
+ time go test -tags privileged -timeout 1m -failfast ./route/...
+ time go test -tags privileged -timeout 1m -failfast ./sharedsock/...
+ time go test -tags privileged -timeout 1m -failfast ./util/...
+ time go test -tags privileged -timeout 1m -failfast ./version/...
diff --git a/.github/workflows/golang-test-linux.yml b/.github/workflows/golang-test-linux.yml
index 43f77bcda..37c780a33 100644
--- a/.github/workflows/golang-test-linux.yml
+++ b/.github/workflows/golang-test-linux.yml
@@ -18,7 +18,7 @@ jobs:
management: ${{ steps.filter.outputs.management }}
steps:
- name: Checkout code
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
@@ -30,7 +30,7 @@ jobs:
- 'management/**'
- name: Install Go
- uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0
+ uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0
with:
go-version-file: "go.mod"
cache: false
@@ -41,7 +41,7 @@ jobs:
echo "modcache=$(go env GOMODCACHE)" >> $GITHUB_ENV
- name: Cache Go modules
- uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
+ uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0
id: cache
with:
path: |
@@ -119,12 +119,12 @@ jobs:
runs-on: ubuntu-22.04
steps:
- name: Checkout code
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
- name: Install Go
- uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0
+ uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0
with:
go-version-file: "go.mod"
cache: false
@@ -135,7 +135,7 @@ jobs:
echo "modcache=$(go env GOMODCACHE)" >> $GITHUB_ENV
- name: Cache Go modules
- uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
+ uses: actions/cache/restore@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0
with:
path: |
${{ env.cache }}
@@ -158,11 +158,11 @@ jobs:
run: git --no-pager diff --exit-code
- name: Test
- run: CGO_ENABLED=1 GOARCH=${{ matrix.arch }} CI=true go test -coverprofile=coverage.txt -tags "devcert integration" -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 -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
+ uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f #v7.0.0
with:
token: ${{ secrets.CODECOV_TOKEN }}
slug: netbirdio/netbird
@@ -174,12 +174,12 @@ jobs:
runs-on: ubuntu-22.04
steps:
- name: Checkout code
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
- name: Install Go
- uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0
+ uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0
with:
go-version-file: "go.mod"
cache: false
@@ -191,7 +191,7 @@ jobs:
echo "modcache_dir=$(go env GOMODCACHE)" >> $GITHUB_OUTPUT
- name: Cache Go modules
- uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
+ uses: actions/cache/restore@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0
id: cache-restore
with:
path: |
@@ -228,7 +228,7 @@ jobs:
sh -c ' \
apk update; apk add --no-cache \
ca-certificates iptables ip6tables dbus dbus-dev libpcap-dev build-base; \
- go test -buildvcs=false -tags "devcert integration" -v -timeout 10m -p 1 $(go list -buildvcs=false ./... | grep -v -e /management -e /signal -e /relay -e /proxy -e /combined -e /client/ui -e /upload-server)
+ go test -buildvcs=false -tags "devcert privileged" -v -timeout 10m -p 1 $(go list -buildvcs=false ./... | grep -v -e /management -e /signal -e /relay -e /proxy -e /combined -e /client/ui -e /upload-server -e /client/testutil/privileged)
'
test_relay:
@@ -245,12 +245,12 @@ jobs:
runs-on: ubuntu-22.04
steps:
- name: Checkout code
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
- name: Install Go
- uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0
+ uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0
with:
go-version-file: "go.mod"
cache: false
@@ -265,7 +265,7 @@ jobs:
echo "modcache=$(go env GOMODCACHE)" >> $GITHUB_ENV
- name: Cache Go modules
- uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
+ uses: actions/cache/restore@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0
with:
path: |
${{ env.cache }}
@@ -289,7 +289,7 @@ jobs:
- name: Upload coverage reports to Codecov
if: matrix.arch == 'amd64'
- uses: codecov/codecov-action@e79a6962e0d4c0c17b229090214935d2e33f8354 #v6.0.1
+ uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f #v7.0.0
with:
token: ${{ secrets.CODECOV_TOKEN }}
slug: netbirdio/netbird
@@ -305,12 +305,12 @@ jobs:
runs-on: ubuntu-22.04
steps:
- name: Checkout code
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
- name: Install Go
- uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0
+ uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0
with:
go-version-file: "go.mod"
cache: false
@@ -324,7 +324,7 @@ jobs:
echo "modcache=$(go env GOMODCACHE)" >> $GITHUB_ENV
- name: Cache Go modules
- uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
+ uses: actions/cache/restore@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0
with:
path: |
${{ env.cache }}
@@ -346,7 +346,7 @@ jobs:
- name: Upload coverage reports to Codecov
if: matrix.arch == 'amd64'
- uses: codecov/codecov-action@e79a6962e0d4c0c17b229090214935d2e33f8354 #v6.0.1
+ uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f #v7.0.0
with:
token: ${{ secrets.CODECOV_TOKEN }}
slug: netbirdio/netbird
@@ -362,12 +362,12 @@ jobs:
runs-on: ubuntu-22.04
steps:
- name: Checkout code
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
- name: Install Go
- uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0
+ uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0
with:
go-version-file: "go.mod"
cache: false
@@ -382,7 +382,7 @@ jobs:
echo "modcache=$(go env GOMODCACHE)" >> $GITHUB_ENV
- name: Cache Go modules
- uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
+ uses: actions/cache/restore@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0
with:
path: |
${{ env.cache }}
@@ -406,7 +406,7 @@ jobs:
- name: Upload coverage reports to Codecov
if: matrix.arch == 'amd64'
- uses: codecov/codecov-action@e79a6962e0d4c0c17b229090214935d2e33f8354 #v6.0.1
+ uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f #v7.0.0
with:
token: ${{ secrets.CODECOV_TOKEN }}
slug: netbirdio/netbird
@@ -423,12 +423,12 @@ jobs:
runs-on: ubuntu-22.04
steps:
- name: Checkout code
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
- name: Install Go
- uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0
+ uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0
with:
go-version-file: "go.mod"
cache: false
@@ -439,7 +439,7 @@ jobs:
echo "modcache=$(go env GOMODCACHE)" >> $GITHUB_ENV
- name: Cache Go modules
- uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
+ uses: actions/cache/restore@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0
with:
path: |
${{ env.cache }}
@@ -483,7 +483,7 @@ jobs:
- name: Upload coverage reports to Codecov
if: matrix.arch == 'amd64'
- uses: codecov/codecov-action@e79a6962e0d4c0c17b229090214935d2e33f8354 #v6.0.1
+ uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f #v7.0.0
with:
token: ${{ secrets.CODECOV_TOKEN }}
slug: netbirdio/netbird
@@ -528,12 +528,12 @@ jobs:
prom/prometheus
- name: Checkout code
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
- name: Install Go
- uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0
+ uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0
with:
go-version-file: "go.mod"
cache: false
@@ -544,7 +544,7 @@ jobs:
echo "modcache=$(go env GOMODCACHE)" >> $GITHUB_ENV
- name: Cache Go modules
- uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
+ uses: actions/cache/restore@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0
with:
path: |
${{ env.cache }}
@@ -578,10 +578,11 @@ jobs:
CGO_ENABLED=1 GOARCH=${{ matrix.arch }} \
NETBIRD_STORE_ENGINE=${{ matrix.store }} \
CI=true \
- GIT_BRANCH=${{ github.ref_name }} \
go test -tags devcert -run=^$ -bench=. \
-exec 'sudo --preserve-env=CI,NETBIRD_STORE_ENGINE,GIT_BRANCH,GITHUB_RUN_ID' \
-timeout 20m ./management/... ./shared/management/... $(go list ./management/... ./shared/management/... | grep -v -e /management/server/http)
+ env:
+ GIT_BRANCH: ${{ github.ref_name }}
api_benchmark:
name: "Management / Benchmark (API)"
@@ -622,12 +623,12 @@ jobs:
prom/prometheus
- name: Checkout code
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
- name: Install Go
- uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0
+ uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0
with:
go-version-file: "go.mod"
cache: false
@@ -638,7 +639,7 @@ jobs:
echo "modcache=$(go env GOMODCACHE)" >> $GITHUB_ENV
- name: Cache Go modules
- uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
+ uses: actions/cache/restore@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0
with:
path: |
${{ env.cache }}
@@ -672,12 +673,13 @@ jobs:
CGO_ENABLED=1 GOARCH=${{ matrix.arch }} \
NETBIRD_STORE_ENGINE=${{ matrix.store }} \
CI=true \
- GIT_BRANCH=${{ github.ref_name }} \
go test -tags=benchmark \
-run=^$ \
-bench=. \
-exec 'sudo --preserve-env=CI,NETBIRD_STORE_ENGINE,GIT_BRANCH,GITHUB_RUN_ID' \
-timeout 20m ./management/server/http/...
+ env:
+ GIT_BRANCH: ${{ github.ref_name }}
api_integration_test:
name: "Management / Integration"
@@ -691,12 +693,12 @@ jobs:
runs-on: ubuntu-22.04
steps:
- name: Checkout code
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
- name: Install Go
- uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0
+ uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0
with:
go-version-file: "go.mod"
cache: false
@@ -707,7 +709,7 @@ jobs:
echo "modcache=$(go env GOMODCACHE)" >> $GITHUB_ENV
- name: Cache Go modules
- uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
+ uses: actions/cache/restore@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0
with:
path: |
${{ env.cache }}
@@ -733,7 +735,7 @@ jobs:
- name: Upload coverage reports to Codecov
if: matrix.arch == 'amd64'
- uses: codecov/codecov-action@e79a6962e0d4c0c17b229090214935d2e33f8354 #v6.0.1
+ uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f #v7.0.0
with:
token: ${{ secrets.CODECOV_TOKEN }}
slug: netbirdio/netbird
diff --git a/.github/workflows/golang-test-windows.yml b/.github/workflows/golang-test-windows.yml
index 8712cc879..b61c87cf6 100644
--- a/.github/workflows/golang-test-windows.yml
+++ b/.github/workflows/golang-test-windows.yml
@@ -18,12 +18,12 @@ jobs:
runs-on: windows-latest
steps:
- name: Checkout code
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
- name: Install Go
- uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0
+ uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0
id: go
with:
go-version-file: "go.mod"
@@ -35,7 +35,7 @@ jobs:
echo "modcache=$(go env GOMODCACHE)" >> $env:GITHUB_ENV
- name: Cache Go modules
- uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
+ uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0
with:
path: |
${{ env.cache }}
@@ -68,7 +68,7 @@ jobs:
run: |
$packages = go list ./... | Where-Object { $_ -notmatch '/management' } | Where-Object { $_ -notmatch '/relay' } | Where-Object { $_ -notmatch '/signal' } | Where-Object { $_ -notmatch '/proxy' } | Where-Object { $_ -notmatch '/combined' }
$goExe = "C:\hostedtoolcache\windows\go\${{ steps.go.outputs.go-version }}\x64\bin\go.exe"
- $cmd = "$goExe test -tags=devcert -timeout 10m -p 1 $($packages -join ' ') > test-out.txt 2>&1"
+ $cmd = "$goExe test -tags `"devcert privileged`" -timeout 10m -p 1 $($packages -join ' ') > test-out.txt 2>&1"
Set-Content -Path "${{ github.workspace }}\run-tests.cmd" -Value $cmd
- name: test
diff --git a/.github/workflows/golangci-lint.yml b/.github/workflows/golangci-lint.yml
index 8f6d1ddb0..5d26d678d 100644
--- a/.github/workflows/golangci-lint.yml
+++ b/.github/workflows/golangci-lint.yml
@@ -15,7 +15,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout code
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
- name: codespell
@@ -40,7 +40,7 @@ jobs:
timeout-minutes: 15
steps:
- name: Checkout code
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
- name: Check for duplicate constants
@@ -48,7 +48,7 @@ jobs:
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@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0
+ uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0
with:
go-version-file: "go.mod"
cache: false
diff --git a/.github/workflows/install-script-test.yml b/.github/workflows/install-script-test.yml
index aec9f6300..1514caedc 100644
--- a/.github/workflows/install-script-test.yml
+++ b/.github/workflows/install-script-test.yml
@@ -22,7 +22,7 @@ jobs:
runs-on: ${{ matrix.os }}
steps:
- name: Checkout code
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
diff --git a/.github/workflows/mobile-build-validation.yml b/.github/workflows/mobile-build-validation.yml
index 8e0538104..44e912c73 100644
--- a/.github/workflows/mobile-build-validation.yml
+++ b/.github/workflows/mobile-build-validation.yml
@@ -16,11 +16,11 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
- name: Install Go
- uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0
+ uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0
with:
go-version-file: "go.mod"
- name: Setup Android SDK
@@ -28,13 +28,13 @@ jobs:
with:
cmdline-tools-version: 8512546
- name: Setup Java
- uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654
+ uses: actions/setup-java@1bcf9fb12cf4aa7d266a90ae39939e61372fe520
with:
java-version: "11"
distribution: "adopt"
- name: NDK Cache
id: ndk-cache
- uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
+ uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0
with:
path: /usr/local/lib/android/sdk/ndk
key: ndk-cache-23.1.7779620
@@ -54,11 +54,11 @@ jobs:
runs-on: macos-latest
steps:
- name: Checkout repository
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
- name: Install Go
- uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0
+ uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0
with:
go-version-file: "go.mod"
- name: install gomobile
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index b15185198..16eae31fb 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -9,10 +9,13 @@ on:
pull_request:
env:
- SIGN_PIPE_VER: "v0.1.5"
- GORELEASER_VER: "v2.14.3"
+ SIGN_PIPE_VER: "v0.1.6"
+ GORELEASER_VER: "v2.16.0"
PRODUCT_NAME: "NetBird"
COPYRIGHT: "NetBird GmbH"
+ flags: ""
+ SKIP_PUBLISH: "true"
+ SKIP_DOCKER_PUSH: "false"
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}-${{ github.head_ref || github.actor_id }}
@@ -24,7 +27,7 @@ jobs:
runs-on: ubuntu-22.04
steps:
- name: Checkout
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
@@ -61,7 +64,7 @@ jobs:
if: steps.check_diff.outputs.diff_exists == 'true'
env:
GO_VERSION: ${{ steps.goversion.outputs.version }}
- uses: vmactions/freebsd-vm@d1e65811565151536c0c894fff74f06351ed26e6 # v1.4.5
+ uses: vmactions/freebsd-vm@b84ab5559b5a1bb4b8ee2737d2506a16e1737636 # v1.4.8
with:
usesh: true
copyback: false
@@ -130,11 +133,9 @@ jobs:
windows_packages_artifact_url: ${{ steps.upload_windows_packages.outputs.artifact-url }}
macos_packages_artifact_url: ${{ steps.upload_macos_packages.outputs.artifact-url }}
ghcr_images: ${{ steps.tag_and_push_images.outputs.images_markdown }}
- env:
- flags: ""
steps:
- name: Checkout
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
fetch-depth: 0 # It is required for GoReleaser to work properly
persist-credentials: false
@@ -143,15 +144,34 @@ jobs:
id: semver_parser
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: Set snapshot flag
+ if: ${{ !startsWith(github.ref, 'refs/tags/v') }}
+ run: |
+ echo "flags=--snapshot" >> $GITHUB_ENV
+
+ - name: Set build vars
+ if: ${{ startsWith(github.ref, 'refs/tags/v') }}
+ run: |
+ if [[ "x-${{ steps.semver_parser.outputs.prerelease }}" == "x-" && "x-${{ github.repository }}" == "x-netbirdio/netbird" ]]; then
+ echo "x-${{ github.repository }}"
+ echo "x-${{ steps.semver_parser.outputs.prerelease }}"
+ echo "SKIP_PUBLISH=false" >> $GITHUB_ENV
+ else
+ echo "x-${{ github.repository }}"
+ echo "x-${{ steps.semver_parser.outputs.prerelease }}"
+ fi
+
+ if [[ "x-${{ github.repository }}" != "x-netbirdio/netbird" ]]; then
+ echo "SKIP_DOCKER_PUSH=true" >> $GITHUB_ENV
+ fi
+
- name: Set up Go
- uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0
+ uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0
with:
go-version-file: "go.mod"
cache: false
- name: Cache Go modules
- uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
+ uses: actions/cache/restore@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0
with:
path: |
~/go/pkg/mod
@@ -161,12 +181,14 @@ 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@ce360397dd3f832beb865e1373c09c0e9f86d70a #v4.0.0
+ uses: docker/setup-qemu-action@06116385d9baf250c9f4dcb4858b16962ea869c3 #v4.1.0
- name: Set up Docker Buildx
- uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd #v4.0.0
+ uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 #v4.1.0
- name: Login to Docker hub
if: github.event_name != 'pull_request'
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
@@ -199,7 +221,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@4c6ab561adb47e50c45ef534e2155934e91c40c1 # v7.2.0
+ uses: goreleaser/goreleaser-action@5daf1e915a5f0af01ddbcd89a43b8061ff4f1a89 # v7.2.2
with:
version: ${{ env.GORELEASER_VER }}
args: release --clean ${{ env.flags }}
@@ -210,6 +232,8 @@ jobs:
UPLOAD_YUM_SECRET: ${{ secrets.PKG_UPLOAD_SECRET }}
GPG_RPM_KEY_FILE: ${{ env.GPG_RPM_KEY_FILE }}
NFPM_NETBIRD_RPM_PASSPHRASE: ${{ secrets.GPG_RPM_PASSPHRASE }}
+ SKIP_PUBLISH: ${{ env.SKIP_PUBLISH }}
+ SKIP_DOCKER_PUSH: ${{ env.SKIP_DOCKER_PUSH }}
- name: Verify RPM signatures
run: |
docker run --rm -v $(pwd)/dist:/dist fedora:41 bash -c '
@@ -323,7 +347,7 @@ jobs:
release_ui_artifact_url: ${{ steps.upload_release_ui.outputs.artifact-url }}
steps:
- name: Checkout
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
fetch-depth: 0 # It is required for GoReleaser to work properly
persist-credentials: false
@@ -332,16 +356,30 @@ jobs:
id: semver_parser
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: Set snapshot flag
+ if: ${{ !startsWith(github.ref, 'refs/tags/v') }}
+ run: |
+ echo "flags=--snapshot" >> $GITHUB_ENV
+
+ - name: Set build vars
+ if: ${{ startsWith(github.ref, 'refs/tags/v') }}
+ run: |
+ if [[ "x-${{ steps.semver_parser.outputs.prerelease }}" == "x-" && "x-${{ github.repository }}" == "x-netbirdio/netbird" ]]; then
+ echo "x-${{ github.repository }}"
+ echo "x-${{ steps.semver_parser.outputs.prerelease }}"
+ echo "SKIP_PUBLISH=false" >> $GITHUB_ENV
+ else
+ echo "x-${{ github.repository }}"
+ echo "x-${{ steps.semver_parser.outputs.prerelease }}"
+ fi
- name: Set up Go
- uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0
+ uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0
with:
go-version-file: "go.mod"
cache: false
- name: Cache Go modules
- uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
+ uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0
with:
path: |
~/go/pkg/mod
@@ -382,7 +420,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@4c6ab561adb47e50c45ef534e2155934e91c40c1 # v7.2.0
+ uses: goreleaser/goreleaser-action@5daf1e915a5f0af01ddbcd89a43b8061ff4f1a89 # v7.2.2
with:
version: ${{ env.GORELEASER_VER }}
args: release --config .goreleaser_ui.yaml --clean ${{ env.flags }}
@@ -393,6 +431,7 @@ jobs:
UPLOAD_YUM_SECRET: ${{ secrets.PKG_UPLOAD_SECRET }}
GPG_RPM_KEY_FILE: ${{ env.GPG_RPM_KEY_FILE }}
NFPM_NETBIRD_UI_RPM_PASSPHRASE: ${{ secrets.GPG_RPM_PASSPHRASE }}
+ SKIP_PUBLISH: ${{ env.SKIP_PUBLISH }}
- name: Verify RPM signatures
run: |
docker run --rm -v $(pwd)/dist:/dist fedora:41 bash -c '
@@ -425,17 +464,17 @@ jobs:
- if: ${{ !startsWith(github.ref, 'refs/tags/v') }}
run: echo "flags=--snapshot" >> $GITHUB_ENV
- name: Checkout
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
fetch-depth: 0 # It is required for GoReleaser to work properly
persist-credentials: false
- name: Set up Go
- uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0
+ uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0
with:
go-version-file: "go.mod"
cache: false
- name: Cache Go modules
- uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
+ uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0
with:
path: |
~/go/pkg/mod
@@ -449,7 +488,7 @@ jobs:
run: git --no-pager diff --exit-code
- name: Run GoReleaser
id: goreleaser
- uses: goreleaser/goreleaser-action@4c6ab561adb47e50c45ef534e2155934e91c40c1 # v7.2.0
+ uses: goreleaser/goreleaser-action@5daf1e915a5f0af01ddbcd89a43b8061ff4f1a89 # v7.2.2
with:
version: ${{ env.GORELEASER_VER }}
args: release --config .goreleaser_ui_darwin.yaml --clean ${{ env.flags }}
@@ -483,7 +522,7 @@ jobs:
downloadPath: '${{ github.workspace }}\temp'
steps:
- name: Checkout
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
@@ -495,13 +534,13 @@ jobs:
run: echo "C:\Program Files\7-Zip" | Out-File -FilePath $env:GITHUB_PATH -Encoding utf8 -Append
- name: Download release artifacts
- uses: actions/download-artifact@70fc10c6e5e1ce46ad2ea6f2b72d43f7d47b13c3 # v8.0.1
+ uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: release
path: release
- name: Download UI release artifacts
- uses: actions/download-artifact@70fc10c6e5e1ce46ad2ea6f2b72d43f7d47b13c3 # v8.0.1
+ uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: release-ui
path: release-ui
diff --git a/.github/workflows/test-infrastructure-files.yml b/.github/workflows/test-infrastructure-files.yml
index 9ad1f2f67..0a4f2e371 100644
--- a/.github/workflows/test-infrastructure-files.yml
+++ b/.github/workflows/test-infrastructure-files.yml
@@ -68,17 +68,17 @@ jobs:
run: sudo apt-get install -y curl
- name: Checkout code
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
- name: Install Go
- uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0
+ uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0
with:
go-version-file: "go.mod"
- name: Cache Go modules
- uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
+ uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0
with:
path: ~/go/pkg/mod
key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }}
@@ -207,7 +207,7 @@ jobs:
- name: Build management docker image
working-directory: management
run: |
- docker build -t netbirdio/management:latest .
+ docker build -t netbirdio/management:latest --build-arg TARGETPLATFORM=. .
- name: Build signal binary
working-directory: signal
@@ -216,7 +216,7 @@ jobs:
- name: Build signal docker image
working-directory: signal
run: |
- docker build -t netbirdio/signal:latest .
+ docker build -t netbirdio/signal:latest --build-arg TARGETPLATFORM=. .
- name: Build relay binary
working-directory: relay
@@ -225,7 +225,7 @@ jobs:
- name: Build relay docker image
working-directory: relay
run: |
- docker build -t netbirdio/relay:latest .
+ docker build -t netbirdio/relay:latest --build-arg TARGETPLATFORM=. .
- name: run docker compose up
working-directory: infrastructure_files/artifacts
@@ -256,7 +256,7 @@ jobs:
run: sudo apt-get install -y jq
- name: Checkout code
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
diff --git a/.github/workflows/wasm-build-validation.yml b/.github/workflows/wasm-build-validation.yml
index 318a127dd..35855918d 100644
--- a/.github/workflows/wasm-build-validation.yml
+++ b/.github/workflows/wasm-build-validation.yml
@@ -19,11 +19,11 @@ jobs:
GOARCH: wasm
steps:
- name: Checkout repository
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
- name: Install Go
- uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0
+ uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0
with:
go-version-file: "go.mod"
- name: Install dependencies
@@ -44,11 +44,11 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
- name: Install Go
- uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0
+ uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0
with:
go-version-file: "go.mod"
- name: Build Wasm client
diff --git a/.goreleaser.yaml b/.goreleaser.yaml
index 5ea479148..a2640dc8e 100644
--- a/.goreleaser.yaml
+++ b/.goreleaser.yaml
@@ -1,5 +1,7 @@
version: 2
-
+env:
+ - SKIP_PUBLISH={{ if index .Env "SKIP_PUBLISH" }}{{ .Env.SKIP_PUBLISH }}{{ else }}true{{ end }}
+ - SKIP_DOCKER_PUSH={{ if index .Env "SKIP_DOCKER_PUSH" }}{{ .Env.SKIP_DOCKER_PUSH }}{{ else }}false{{ end }}
project_name: netbird
builds:
- id: netbird-wasm
@@ -74,6 +76,8 @@ builds:
- amd64
- arm64
- arm
+ goarm:
+ - 7
ldflags:
- -s -w -X github.com/netbirdio/netbird/version.version={{.Version}} -X main.commit={{.Commit}} -X main.date={{.CommitDate}} -X main.builtBy=goreleaser
mod_timestamp: "{{ .CommitTimestamp }}"
@@ -88,6 +92,8 @@ builds:
- amd64
- arm64
- arm
+ goarm:
+ - 7
ldflags:
- -s -w -X github.com/netbirdio/netbird/version.version={{.Version}} -X main.commit={{.Commit}} -X main.date={{.CommitDate}} -X main.builtBy=goreleaser
mod_timestamp: "{{ .CommitTimestamp }}"
@@ -102,6 +108,8 @@ builds:
- amd64
- arm64
- arm
+ goarm:
+ - 7
ldflags:
- -s -w -X github.com/netbirdio/netbird/version.version={{.Version}} -X main.commit={{.Commit}} -X main.date={{.CommitDate}} -X main.builtBy=goreleaser
mod_timestamp: "{{ .CommitTimestamp }}"
@@ -122,6 +130,8 @@ builds:
- amd64
- arm64
- arm
+ goarm:
+ - 7
ldflags:
- -s -w -X github.com/netbirdio/netbird/version.version={{.Version}} -X main.commit={{.Commit}} -X main.date={{.CommitDate}} -X main.builtBy=goreleaser
mod_timestamp: "{{ .CommitTimestamp }}"
@@ -136,6 +146,8 @@ builds:
- amd64
- arm64
- arm
+ goarm:
+ - 7
ldflags:
- -s -w -X github.com/netbirdio/netbird/version.version={{.Version}} -X main.commit={{.Commit}} -X main.date={{.CommitDate}} -X main.builtBy=goreleaser
mod_timestamp: "{{ .CommitTimestamp }}"
@@ -150,6 +162,8 @@ builds:
- amd64
- arm64
- arm
+ goarm:
+ - 7
ldflags:
- -s -w -X main.Version={{.Version}} -X main.Commit={{.Commit}} -X main.BuildDate={{.CommitDate}}
mod_timestamp: "{{ .CommitTimestamp }}"
@@ -170,6 +184,8 @@ builds:
- amd64
- arm64
- arm
+ goarm:
+ - 7
ldflags:
- -s -w -X github.com/netbirdio/netbird/version.version={{.Version}} -X main.commit={{.Commit}} -X main.date={{.CommitDate}} -X main.builtBy=goreleaser
mod_timestamp: "{{ .CommitTimestamp }}"
@@ -222,670 +238,192 @@ nfpms:
rpm:
signature:
key_file: '{{ if index .Env "GPG_RPM_KEY_FILE" }}{{ .Env.GPG_RPM_KEY_FILE }}{{ end }}'
-dockers:
- - image_templates:
- - netbirdio/netbird:{{ .Version }}-amd64
- - ghcr.io/netbirdio/netbird:{{ .Version }}-amd64
- ids:
- - netbird
- goarch: amd64
- use: buildx
- dockerfile: client/Dockerfile
- extra_files:
- - client/netbird-entrypoint.sh
- build_flag_templates:
- - "--platform=linux/amd64"
- - "--label=org.opencontainers.image.created={{.Date}}"
- - "--label=org.opencontainers.image.title={{.ProjectName}}"
- - "--label=org.opencontainers.image.version={{.Version}}"
- - "--label=org.opencontainers.image.revision={{.FullCommit}}"
- - "--label=org.opencontainers.image.source=https://github.com/netbirdio/{{.ProjectName}}"
- - "--label=maintainer=dev@netbird.io"
- - image_templates:
- - netbirdio/netbird:{{ .Version }}-arm64v8
- - ghcr.io/netbirdio/netbird:{{ .Version }}-arm64v8
- ids:
- - netbird
- goarch: arm64
- use: buildx
- dockerfile: client/Dockerfile
- extra_files:
- - client/netbird-entrypoint.sh
- build_flag_templates:
- - "--platform=linux/arm64"
- - "--label=org.opencontainers.image.created={{.Date}}"
- - "--label=org.opencontainers.image.title={{.ProjectName}}"
- - "--label=org.opencontainers.image.version={{.Version}}"
- - "--label=org.opencontainers.image.revision={{.FullCommit}}"
- - "--label=org.opencontainers.image.source=https://github.com/netbirdio/{{.ProjectName}}"
- - "--label=maintainer=dev@netbird.io"
- - image_templates:
- - netbirdio/netbird:{{ .Version }}-arm
- - ghcr.io/netbirdio/netbird:{{ .Version }}-arm
- ids:
- - netbird
- goarch: arm
- goarm: 6
- use: buildx
- dockerfile: client/Dockerfile
- extra_files:
- - client/netbird-entrypoint.sh
- build_flag_templates:
- - "--platform=linux/arm"
- - "--label=org.opencontainers.image.created={{.Date}}"
- - "--label=org.opencontainers.image.title={{.ProjectName}}"
- - "--label=org.opencontainers.image.version={{.Version}}"
- - "--label=org.opencontainers.image.revision={{.FullCommit}}"
- - "--label=org.opencontainers.image.source=https://github.com/netbirdio/{{.ProjectName}}"
- - "--label=maintainer=dev@netbird.io"
-
- - image_templates:
- - netbirdio/netbird:{{ .Version }}-rootless-amd64
- - ghcr.io/netbirdio/netbird:{{ .Version }}-rootless-amd64
- ids:
- - netbird
- goarch: amd64
- use: buildx
- dockerfile: client/Dockerfile-rootless
- extra_files:
- - client/netbird-entrypoint.sh
- build_flag_templates:
- - "--platform=linux/amd64"
- - "--label=org.opencontainers.image.created={{.Date}}"
- - "--label=org.opencontainers.image.title={{.ProjectName}}"
- - "--label=org.opencontainers.image.version={{.Version}}"
- - "--label=org.opencontainers.image.revision={{.FullCommit}}"
- - "--label=org.opencontainers.image.source=https://github.com/netbirdio/{{.ProjectName}}"
- - "--label=maintainer=dev@netbird.io"
- - image_templates:
- - netbirdio/netbird:{{ .Version }}-rootless-arm64v8
- - ghcr.io/netbirdio/netbird:{{ .Version }}-rootless-arm64v8
- ids:
- - netbird
- goarch: arm64
- use: buildx
- dockerfile: client/Dockerfile-rootless
- extra_files:
- - client/netbird-entrypoint.sh
- build_flag_templates:
- - "--platform=linux/arm64"
- - "--label=org.opencontainers.image.created={{.Date}}"
- - "--label=org.opencontainers.image.title={{.ProjectName}}"
- - "--label=org.opencontainers.image.version={{.Version}}"
- - "--label=org.opencontainers.image.revision={{.FullCommit}}"
- - "--label=org.opencontainers.image.source=https://github.com/netbirdio/{{.ProjectName}}"
- - "--label=maintainer=dev@netbird.io"
- - image_templates:
- - netbirdio/netbird:{{ .Version }}-rootless-arm
- - ghcr.io/netbirdio/netbird:{{ .Version }}-rootless-arm
- ids:
- - netbird
- goarch: arm
- goarm: 6
- use: buildx
- dockerfile: client/Dockerfile-rootless
- extra_files:
- - client/netbird-entrypoint.sh
- build_flag_templates:
- - "--platform=linux/arm"
- - "--label=org.opencontainers.image.created={{.Date}}"
- - "--label=org.opencontainers.image.title={{.ProjectName}}"
- - "--label=org.opencontainers.image.version={{.Version}}"
- - "--label=org.opencontainers.image.revision={{.FullCommit}}"
- - "--label=org.opencontainers.image.source=https://github.com/netbirdio/{{.ProjectName}}"
- - "--label=maintainer=dev@netbird.io"
-
- - image_templates:
- - netbirdio/relay:{{ .Version }}-amd64
- - ghcr.io/netbirdio/relay:{{ .Version }}-amd64
- ids:
- - netbird-relay
- goarch: amd64
- use: buildx
- dockerfile: relay/Dockerfile
- build_flag_templates:
- - "--platform=linux/amd64"
- - "--label=org.opencontainers.image.created={{.Date}}"
- - "--label=org.opencontainers.image.title={{.ProjectName}}"
- - "--label=org.opencontainers.image.version={{.Version}}"
- - "--label=org.opencontainers.image.revision={{.FullCommit}}"
- - "--label=org.opencontainers.image.source=https://github.com/netbirdio/{{.ProjectName}}"
- - "--label=maintainer=dev@netbird.io"
- - image_templates:
- - netbirdio/relay:{{ .Version }}-arm64v8
- - ghcr.io/netbirdio/relay:{{ .Version }}-arm64v8
- ids:
- - netbird-relay
- goarch: arm64
- use: buildx
- dockerfile: relay/Dockerfile
- build_flag_templates:
- - "--platform=linux/arm64"
- - "--label=org.opencontainers.image.created={{.Date}}"
- - "--label=org.opencontainers.image.title={{.ProjectName}}"
- - "--label=org.opencontainers.image.version={{.Version}}"
- - "--label=org.opencontainers.image.revision={{.FullCommit}}"
- - "--label=org.opencontainers.image.source=https://github.com/netbirdio/{{.ProjectName}}"
- - "--label=maintainer=dev@netbird.io"
- - image_templates:
- - netbirdio/relay:{{ .Version }}-arm
- - ghcr.io/netbirdio/relay:{{ .Version }}-arm
- ids:
- - netbird-relay
- goarch: arm
- goarm: 6
- use: buildx
- dockerfile: relay/Dockerfile
- build_flag_templates:
- - "--platform=linux/arm"
- - "--label=org.opencontainers.image.created={{.Date}}"
- - "--label=org.opencontainers.image.title={{.ProjectName}}"
- - "--label=org.opencontainers.image.version={{.Version}}"
- - "--label=org.opencontainers.image.revision={{.FullCommit}}"
- - "--label=org.opencontainers.image.source=https://github.com/netbirdio/{{.ProjectName}}"
- - "--label=maintainer=dev@netbird.io"
- - image_templates:
- - netbirdio/signal:{{ .Version }}-amd64
- - ghcr.io/netbirdio/signal:{{ .Version }}-amd64
- ids:
- - netbird-signal
- goarch: amd64
- use: buildx
- dockerfile: signal/Dockerfile
- build_flag_templates:
- - "--platform=linux/amd64"
- - "--label=org.opencontainers.image.created={{.Date}}"
- - "--label=org.opencontainers.image.title={{.ProjectName}}"
- - "--label=org.opencontainers.image.version={{.Version}}"
- - "--label=org.opencontainers.image.revision={{.FullCommit}}"
- - "--label=org.opencontainers.image.source=https://github.com/netbirdio/{{.ProjectName}}"
- - "--label=maintainer=dev@netbird.io"
- - image_templates:
- - netbirdio/signal:{{ .Version }}-arm64v8
- - ghcr.io/netbirdio/signal:{{ .Version }}-arm64v8
- ids:
- - netbird-signal
- goarch: arm64
- use: buildx
- dockerfile: signal/Dockerfile
- build_flag_templates:
- - "--platform=linux/arm64"
- - "--label=org.opencontainers.image.created={{.Date}}"
- - "--label=org.opencontainers.image.title={{.ProjectName}}"
- - "--label=org.opencontainers.image.version={{.Version}}"
- - "--label=org.opencontainers.image.revision={{.FullCommit}}"
- - "--label=org.opencontainers.image.source=https://github.com/netbirdio/{{.ProjectName}}"
- - "--label=maintainer=dev@netbird.io"
- - image_templates:
- - netbirdio/signal:{{ .Version }}-arm
- - ghcr.io/netbirdio/signal:{{ .Version }}-arm
- ids:
- - netbird-signal
- goarch: arm
- goarm: 6
- use: buildx
- dockerfile: signal/Dockerfile
- build_flag_templates:
- - "--platform=linux/arm"
- - "--label=org.opencontainers.image.created={{.Date}}"
- - "--label=org.opencontainers.image.title={{.ProjectName}}"
- - "--label=org.opencontainers.image.version={{.Version}}"
- - "--label=org.opencontainers.image.revision={{.FullCommit}}"
- - "--label=org.opencontainers.image.source=https://github.com/netbirdio/{{.ProjectName}}"
- - "--label=maintainer=dev@netbird.io"
- - image_templates:
- - netbirdio/management:{{ .Version }}-amd64
- - ghcr.io/netbirdio/management:{{ .Version }}-amd64
- ids:
- - netbird-mgmt
- goarch: amd64
- use: buildx
- dockerfile: management/Dockerfile
- build_flag_templates:
- - "--platform=linux/amd64"
- - "--label=org.opencontainers.image.created={{.Date}}"
- - "--label=org.opencontainers.image.title={{.ProjectName}}"
- - "--label=org.opencontainers.image.version={{.Version}}"
- - "--label=org.opencontainers.image.revision={{.FullCommit}}"
- - "--label=org.opencontainers.image.source=https://github.com/netbirdio/{{.ProjectName}}"
- - "--label=maintainer=dev@netbird.io"
- - image_templates:
- - netbirdio/management:{{ .Version }}-arm64v8
- - ghcr.io/netbirdio/management:{{ .Version }}-arm64v8
- ids:
- - netbird-mgmt
- goarch: arm64
- use: buildx
- dockerfile: management/Dockerfile
- build_flag_templates:
- - "--platform=linux/arm64"
- - "--label=org.opencontainers.image.created={{.Date}}"
- - "--label=org.opencontainers.image.title={{.ProjectName}}"
- - "--label=org.opencontainers.image.version={{.Version}}"
- - "--label=org.opencontainers.image.revision={{.FullCommit}}"
- - "--label=org.opencontainers.image.source=https://github.com/netbirdio/{{.ProjectName}}"
- - "--label=maintainer=dev@netbird.io"
- - image_templates:
- - netbirdio/management:{{ .Version }}-arm
- - ghcr.io/netbirdio/management:{{ .Version }}-arm
- ids:
- - netbird-mgmt
- goarch: arm
- goarm: 6
- use: buildx
- dockerfile: management/Dockerfile
- build_flag_templates:
- - "--platform=linux/arm"
- - "--label=org.opencontainers.image.created={{.Date}}"
- - "--label=org.opencontainers.image.title={{.ProjectName}}"
- - "--label=org.opencontainers.image.version={{.Version}}"
- - "--label=org.opencontainers.image.revision={{.FullCommit}}"
- - "--label=org.opencontainers.image.source=https://github.com/netbirdio/{{.ProjectName}}"
- - "--label=maintainer=dev@netbird.io"
- - image_templates:
- - netbirdio/management:{{ .Version }}-debug-amd64
- - ghcr.io/netbirdio/management:{{ .Version }}-debug-amd64
- ids:
- - netbird-mgmt
- goarch: amd64
- use: buildx
- dockerfile: management/Dockerfile.debug
- build_flag_templates:
- - "--platform=linux/amd64"
- - "--label=org.opencontainers.image.created={{.Date}}"
- - "--label=org.opencontainers.image.title={{.ProjectName}}"
- - "--label=org.opencontainers.image.version={{.Version}}"
- - "--label=org.opencontainers.image.revision={{.FullCommit}}"
- - "--label=org.opencontainers.image.source=https://github.com/netbirdio/{{.ProjectName}}"
- - "--label=maintainer=dev@netbird.io"
- - image_templates:
- - netbirdio/management:{{ .Version }}-debug-arm64v8
- - ghcr.io/netbirdio/management:{{ .Version }}-debug-arm64v8
- ids:
- - netbird-mgmt
- goarch: arm64
- use: buildx
- dockerfile: management/Dockerfile.debug
- build_flag_templates:
- - "--platform=linux/arm64"
- - "--label=org.opencontainers.image.created={{.Date}}"
- - "--label=org.opencontainers.image.title={{.ProjectName}}"
- - "--label=org.opencontainers.image.version={{.Version}}"
- - "--label=org.opencontainers.image.revision={{.FullCommit}}"
- - "--label=org.opencontainers.image.source=https://github.com/netbirdio/{{.ProjectName}}"
- - "--label=maintainer=dev@netbird.io"
-
- - image_templates:
- - netbirdio/management:{{ .Version }}-debug-arm
- - ghcr.io/netbirdio/management:{{ .Version }}-debug-arm
- ids:
- - netbird-mgmt
- goarch: arm
- goarm: 6
- use: buildx
- dockerfile: management/Dockerfile.debug
- build_flag_templates:
- - "--platform=linux/arm"
- - "--label=org.opencontainers.image.created={{.Date}}"
- - "--label=org.opencontainers.image.title={{.ProjectName}}"
- - "--label=org.opencontainers.image.version={{.Version}}"
- - "--label=org.opencontainers.image.revision={{.FullCommit}}"
- - "--label=org.opencontainers.image.source=https://github.com/netbirdio/{{.ProjectName}}"
- - "--label=maintainer=dev@netbird.io"
- - image_templates:
- - netbirdio/upload:{{ .Version }}-amd64
- - ghcr.io/netbirdio/upload:{{ .Version }}-amd64
- ids:
- - netbird-upload
- goarch: amd64
- use: buildx
- dockerfile: upload-server/Dockerfile
- build_flag_templates:
- - "--platform=linux/amd64"
- - "--label=org.opencontainers.image.created={{.Date}}"
- - "--label=org.opencontainers.image.title={{.ProjectName}}"
- - "--label=org.opencontainers.image.version={{.Version}}"
- - "--label=org.opencontainers.image.revision={{.FullCommit}}"
- - "--label=org.opencontainers.image.source=https://github.com/netbirdio/{{.ProjectName}}"
- - "--label=maintainer=dev@netbird.io"
- - image_templates:
- - netbirdio/upload:{{ .Version }}-arm64v8
- - ghcr.io/netbirdio/upload:{{ .Version }}-arm64v8
- ids:
- - netbird-upload
- goarch: arm64
- use: buildx
- dockerfile: upload-server/Dockerfile
- build_flag_templates:
- - "--platform=linux/arm64"
- - "--label=org.opencontainers.image.created={{.Date}}"
- - "--label=org.opencontainers.image.title={{.ProjectName}}"
- - "--label=org.opencontainers.image.version={{.Version}}"
- - "--label=org.opencontainers.image.revision={{.FullCommit}}"
- - "--label=org.opencontainers.image.source=https://github.com/netbirdio/{{.ProjectName}}"
- - "--label=maintainer=dev@netbird.io"
- - image_templates:
- - netbirdio/upload:{{ .Version }}-arm
- - ghcr.io/netbirdio/upload:{{ .Version }}-arm
- ids:
- - netbird-upload
- goarch: arm
- goarm: 6
- use: buildx
- dockerfile: upload-server/Dockerfile
- build_flag_templates:
- - "--platform=linux/arm"
- - "--label=org.opencontainers.image.created={{.Date}}"
- - "--label=org.opencontainers.image.title={{.ProjectName}}"
- - "--label=org.opencontainers.image.version={{.Version}}"
- - "--label=org.opencontainers.image.revision={{.FullCommit}}"
- - "--label=org.opencontainers.image.source=https://github.com/netbirdio/{{.ProjectName}}"
- - "--label=maintainer=dev@netbird.io"
- - image_templates:
- - netbirdio/netbird-server:{{ .Version }}-amd64
- - ghcr.io/netbirdio/netbird-server:{{ .Version }}-amd64
- ids:
- - netbird-server
- goarch: amd64
- use: buildx
- dockerfile: combined/Dockerfile
- build_flag_templates:
- - "--platform=linux/amd64"
- - "--label=org.opencontainers.image.created={{.Date}}"
- - "--label=org.opencontainers.image.title={{.ProjectName}}"
- - "--label=org.opencontainers.image.version={{.Version}}"
- - "--label=org.opencontainers.image.revision={{.FullCommit}}"
- - "--label=org.opencontainers.image.source=https://github.com/netbirdio/{{.ProjectName}}"
- - "--label=maintainer=dev@netbird.io"
- - image_templates:
- - netbirdio/netbird-server:{{ .Version }}-arm64v8
- - ghcr.io/netbirdio/netbird-server:{{ .Version }}-arm64v8
- ids:
- - netbird-server
- goarch: arm64
- use: buildx
- dockerfile: combined/Dockerfile
- build_flag_templates:
- - "--platform=linux/arm64"
- - "--label=org.opencontainers.image.created={{.Date}}"
- - "--label=org.opencontainers.image.title={{.ProjectName}}"
- - "--label=org.opencontainers.image.version={{.Version}}"
- - "--label=org.opencontainers.image.revision={{.FullCommit}}"
- - "--label=org.opencontainers.image.source=https://github.com/netbirdio/{{.ProjectName}}"
- - "--label=maintainer=dev@netbird.io"
- - image_templates:
- - netbirdio/netbird-server:{{ .Version }}-arm
- - ghcr.io/netbirdio/netbird-server:{{ .Version }}-arm
- ids:
- - netbird-server
- goarch: arm
- goarm: 6
- use: buildx
- dockerfile: combined/Dockerfile
- build_flag_templates:
- - "--platform=linux/arm"
- - "--label=org.opencontainers.image.created={{.Date}}"
- - "--label=org.opencontainers.image.title={{.ProjectName}}"
- - "--label=org.opencontainers.image.version={{.Version}}"
- - "--label=org.opencontainers.image.revision={{.FullCommit}}"
- - "--label=org.opencontainers.image.source=https://github.com/netbirdio/{{.ProjectName}}"
- - "--label=maintainer=dev@netbird.io"
- - image_templates:
- - netbirdio/reverse-proxy:{{ .Version }}-amd64
- - ghcr.io/netbirdio/reverse-proxy:{{ .Version }}-amd64
- ids:
- - netbird-proxy
- goarch: amd64
- use: buildx
- dockerfile: proxy/Dockerfile
- build_flag_templates:
- - "--platform=linux/amd64"
- - "--label=org.opencontainers.image.created={{.Date}}"
- - "--label=org.opencontainers.image.title={{.ProjectName}}"
- - "--label=org.opencontainers.image.version={{.Version}}"
- - "--label=org.opencontainers.image.revision={{.FullCommit}}"
- - "--label=org.opencontainers.image.source=https://github.com/netbirdio/{{.ProjectName}}"
- - "--label=maintainer=dev@netbird.io"
- - image_templates:
- - netbirdio/reverse-proxy:{{ .Version }}-arm64v8
- - ghcr.io/netbirdio/reverse-proxy:{{ .Version }}-arm64v8
- ids:
- - netbird-proxy
- goarch: arm64
- use: buildx
- dockerfile: proxy/Dockerfile
- build_flag_templates:
- - "--platform=linux/arm64"
- - "--label=org.opencontainers.image.created={{.Date}}"
- - "--label=org.opencontainers.image.title={{.ProjectName}}"
- - "--label=org.opencontainers.image.version={{.Version}}"
- - "--label=org.opencontainers.image.revision={{.FullCommit}}"
- - "--label=org.opencontainers.image.source=https://github.com/netbirdio/{{.ProjectName}}"
- - "--label=maintainer=dev@netbird.io"
- - image_templates:
- - netbirdio/reverse-proxy:{{ .Version }}-arm
- - ghcr.io/netbirdio/reverse-proxy:{{ .Version }}-arm
- ids:
- - netbird-proxy
- goarch: arm
- goarm: 6
- use: buildx
- dockerfile: proxy/Dockerfile
- build_flag_templates:
- - "--platform=linux/arm"
- - "--label=org.opencontainers.image.created={{.Date}}"
- - "--label=org.opencontainers.image.title={{.ProjectName}}"
- - "--label=org.opencontainers.image.version={{.Version}}"
- - "--label=org.opencontainers.image.revision={{.FullCommit}}"
- - "--label=org.opencontainers.image.source=https://github.com/netbirdio/{{.ProjectName}}"
- - "--label=maintainer=dev@netbird.io"
-docker_manifests:
- - name_template: netbirdio/netbird:{{ .Version }}
- image_templates:
- - netbirdio/netbird:{{ .Version }}-arm64v8
- - netbirdio/netbird:{{ .Version }}-arm
- - netbirdio/netbird:{{ .Version }}-amd64
-
- - name_template: netbirdio/netbird:latest
- image_templates:
- - netbirdio/netbird:{{ .Version }}-arm64v8
- - netbirdio/netbird:{{ .Version }}-arm
- - netbirdio/netbird:{{ .Version }}-amd64
-
- - name_template: netbirdio/netbird:{{ .Version }}-rootless
- image_templates:
- - netbirdio/netbird:{{ .Version }}-rootless-arm64v8
- - netbirdio/netbird:{{ .Version }}-rootless-arm
- - netbirdio/netbird:{{ .Version }}-rootless-amd64
-
- - name_template: netbirdio/netbird:rootless-latest
- image_templates:
- - netbirdio/netbird:{{ .Version }}-rootless-arm64v8
- - netbirdio/netbird:{{ .Version }}-rootless-arm
- - netbirdio/netbird:{{ .Version }}-rootless-amd64
-
- - name_template: netbirdio/relay:{{ .Version }}
- image_templates:
- - netbirdio/relay:{{ .Version }}-arm64v8
- - netbirdio/relay:{{ .Version }}-arm
- - netbirdio/relay:{{ .Version }}-amd64
-
- - name_template: netbirdio/relay:latest
- image_templates:
- - netbirdio/relay:{{ .Version }}-arm64v8
- - netbirdio/relay:{{ .Version }}-arm
- - netbirdio/relay:{{ .Version }}-amd64
-
- - name_template: netbirdio/signal:{{ .Version }}
- image_templates:
- - netbirdio/signal:{{ .Version }}-arm64v8
- - netbirdio/signal:{{ .Version }}-arm
- - netbirdio/signal:{{ .Version }}-amd64
-
- - name_template: netbirdio/signal:latest
- image_templates:
- - netbirdio/signal:{{ .Version }}-arm64v8
- - netbirdio/signal:{{ .Version }}-arm
- - netbirdio/signal:{{ .Version }}-amd64
-
- - name_template: netbirdio/management:{{ .Version }}
- image_templates:
- - netbirdio/management:{{ .Version }}-arm64v8
- - netbirdio/management:{{ .Version }}-arm
- - netbirdio/management:{{ .Version }}-amd64
-
- - name_template: netbirdio/management:latest
- image_templates:
- - netbirdio/management:{{ .Version }}-arm64v8
- - netbirdio/management:{{ .Version }}-arm
- - netbirdio/management:{{ .Version }}-amd64
-
- - name_template: netbirdio/management:debug-latest
- image_templates:
- - netbirdio/management:{{ .Version }}-debug-arm64v8
- - netbirdio/management:{{ .Version }}-debug-arm
- - netbirdio/management:{{ .Version }}-debug-amd64
- - name_template: netbirdio/upload:{{ .Version }}
- image_templates:
- - netbirdio/upload:{{ .Version }}-arm64v8
- - netbirdio/upload:{{ .Version }}-arm
- - netbirdio/upload:{{ .Version }}-amd64
-
- - name_template: netbirdio/upload:latest
- image_templates:
- - netbirdio/upload:{{ .Version }}-arm64v8
- - netbirdio/upload:{{ .Version }}-arm
- - netbirdio/upload:{{ .Version }}-amd64
-
- - name_template: netbirdio/netbird-server:{{ .Version }}
- image_templates:
- - netbirdio/netbird-server:{{ .Version }}-arm64v8
- - netbirdio/netbird-server:{{ .Version }}-arm
- - netbirdio/netbird-server:{{ .Version }}-amd64
-
- - name_template: netbirdio/netbird-server:latest
- image_templates:
- - netbirdio/netbird-server:{{ .Version }}-arm64v8
- - netbirdio/netbird-server:{{ .Version }}-arm
- - netbirdio/netbird-server:{{ .Version }}-amd64
-
- - name_template: ghcr.io/netbirdio/netbird:{{ .Version }}
- image_templates:
- - ghcr.io/netbirdio/netbird:{{ .Version }}-arm64v8
- - ghcr.io/netbirdio/netbird:{{ .Version }}-arm
- - ghcr.io/netbirdio/netbird:{{ .Version }}-amd64
-
- - name_template: ghcr.io/netbirdio/netbird:latest
- image_templates:
- - ghcr.io/netbirdio/netbird:{{ .Version }}-arm64v8
- - ghcr.io/netbirdio/netbird:{{ .Version }}-arm
- - ghcr.io/netbirdio/netbird:{{ .Version }}-amd64
-
- - name_template: ghcr.io/netbirdio/netbird:{{ .Version }}-rootless
- image_templates:
- - ghcr.io/netbirdio/netbird:{{ .Version }}-rootless-arm64v8
- - ghcr.io/netbirdio/netbird:{{ .Version }}-rootless-arm
- - ghcr.io/netbirdio/netbird:{{ .Version }}-rootless-amd64
-
- - name_template: ghcr.io/netbirdio/netbird:rootless-latest
- image_templates:
- - ghcr.io/netbirdio/netbird:{{ .Version }}-rootless-arm64v8
- - ghcr.io/netbirdio/netbird:{{ .Version }}-rootless-arm
- - ghcr.io/netbirdio/netbird:{{ .Version }}-rootless-amd64
-
- - name_template: ghcr.io/netbirdio/relay:{{ .Version }}
- image_templates:
- - ghcr.io/netbirdio/relay:{{ .Version }}-arm64v8
- - ghcr.io/netbirdio/relay:{{ .Version }}-arm
- - ghcr.io/netbirdio/relay:{{ .Version }}-amd64
-
- - name_template: ghcr.io/netbirdio/relay:latest
- image_templates:
- - ghcr.io/netbirdio/relay:{{ .Version }}-arm64v8
- - ghcr.io/netbirdio/relay:{{ .Version }}-arm
- - ghcr.io/netbirdio/relay:{{ .Version }}-amd64
-
- - name_template: ghcr.io/netbirdio/signal:{{ .Version }}
- image_templates:
- - ghcr.io/netbirdio/signal:{{ .Version }}-arm64v8
- - ghcr.io/netbirdio/signal:{{ .Version }}-arm
- - ghcr.io/netbirdio/signal:{{ .Version }}-amd64
-
- - name_template: ghcr.io/netbirdio/signal:latest
- image_templates:
- - ghcr.io/netbirdio/signal:{{ .Version }}-arm64v8
- - ghcr.io/netbirdio/signal:{{ .Version }}-arm
- - ghcr.io/netbirdio/signal:{{ .Version }}-amd64
-
- - name_template: ghcr.io/netbirdio/management:{{ .Version }}
- image_templates:
- - ghcr.io/netbirdio/management:{{ .Version }}-arm64v8
- - ghcr.io/netbirdio/management:{{ .Version }}-arm
- - ghcr.io/netbirdio/management:{{ .Version }}-amd64
-
- - name_template: ghcr.io/netbirdio/management:latest
- image_templates:
- - ghcr.io/netbirdio/management:{{ .Version }}-arm64v8
- - ghcr.io/netbirdio/management:{{ .Version }}-arm
- - ghcr.io/netbirdio/management:{{ .Version }}-amd64
-
- - name_template: ghcr.io/netbirdio/management:debug-latest
- image_templates:
- - ghcr.io/netbirdio/management:{{ .Version }}-debug-arm64v8
- - ghcr.io/netbirdio/management:{{ .Version }}-debug-arm
- - ghcr.io/netbirdio/management:{{ .Version }}-debug-amd64
-
- - name_template: ghcr.io/netbirdio/upload:{{ .Version }}
- image_templates:
- - ghcr.io/netbirdio/upload:{{ .Version }}-arm64v8
- - ghcr.io/netbirdio/upload:{{ .Version }}-arm
- - ghcr.io/netbirdio/upload:{{ .Version }}-amd64
-
- - name_template: ghcr.io/netbirdio/upload:latest
- image_templates:
- - ghcr.io/netbirdio/upload:{{ .Version }}-arm64v8
- - ghcr.io/netbirdio/upload:{{ .Version }}-arm
- - ghcr.io/netbirdio/upload:{{ .Version }}-amd64
-
- - name_template: ghcr.io/netbirdio/netbird-server:{{ .Version }}
- image_templates:
- - ghcr.io/netbirdio/netbird-server:{{ .Version }}-arm64v8
- - ghcr.io/netbirdio/netbird-server:{{ .Version }}-arm
- - ghcr.io/netbirdio/netbird-server:{{ .Version }}-amd64
-
- - name_template: ghcr.io/netbirdio/netbird-server:latest
- image_templates:
- - ghcr.io/netbirdio/netbird-server:{{ .Version }}-arm64v8
- - ghcr.io/netbirdio/netbird-server:{{ .Version }}-arm
- - ghcr.io/netbirdio/netbird-server:{{ .Version }}-amd64
-
- - name_template: netbirdio/reverse-proxy:{{ .Version }}
- image_templates:
- - netbirdio/reverse-proxy:{{ .Version }}-arm64v8
- - netbirdio/reverse-proxy:{{ .Version }}-arm
- - netbirdio/reverse-proxy:{{ .Version }}-amd64
-
- - name_template: netbirdio/reverse-proxy:latest
- image_templates:
- - netbirdio/reverse-proxy:{{ .Version }}-arm64v8
- - netbirdio/reverse-proxy:{{ .Version }}-arm
- - netbirdio/reverse-proxy:{{ .Version }}-amd64
-
- - name_template: ghcr.io/netbirdio/reverse-proxy:{{ .Version }}
- image_templates:
- - ghcr.io/netbirdio/reverse-proxy:{{ .Version }}-arm64v8
- - ghcr.io/netbirdio/reverse-proxy:{{ .Version }}-arm
- - ghcr.io/netbirdio/reverse-proxy:{{ .Version }}-amd64
-
- - name_template: ghcr.io/netbirdio/reverse-proxy:latest
- image_templates:
- - ghcr.io/netbirdio/reverse-proxy:{{ .Version }}-arm64v8
- - ghcr.io/netbirdio/reverse-proxy:{{ .Version }}-arm
- - ghcr.io/netbirdio/reverse-proxy:{{ .Version }}-amd64
+dockers_v2:
+ - id: netbird
+ disable: "{{ .Env.SKIP_DOCKER_PUSH }}"
+ ids:
+ - netbird
+ images:
+ - netbirdio/netbird
+ - ghcr.io/netbirdio/netbird
+ tags:
+ - "{{ .Version }}"
+ - "{{ if eq .Env.SKIP_PUBLISH \"false\" }}latest{{ end }}"
+ dockerfile: client/Dockerfile
+ extra_files:
+ - client/netbird-entrypoint.sh
+ platforms:
+ - linux/amd64
+ - linux/arm64
+ - linux/arm/6
+ annotations:
+ "org.opencontainers.image.created": "{{.Date}}"
+ "org.opencontainers.image.title": "{{.ProjectName}}"
+ "org.opencontainers.image.version": "{{.Version}}"
+ "org.opencontainers.image.revision": "{{.FullCommit}}"
+ "org.opencontainers.image.source": "{{.GitURL}}"
+ "maintainer": "dev@netbird.io"
+ - id: netbird-rootless
+ disable: "{{ .Env.SKIP_DOCKER_PUSH }}"
+ ids:
+ - netbird
+ images:
+ - netbirdio/netbird
+ - ghcr.io/netbirdio/netbird
+ tags:
+ - "v{{ .Version }}-rootless"
+ - "{{ if eq .Env.SKIP_PUBLISH \"false\" }}latest{{ end }}"
+ dockerfile: client/Dockerfile-rootless
+ extra_files:
+ - client/netbird-entrypoint.sh
+ platforms:
+ - linux/amd64
+ - linux/arm64
+ - linux/arm/6
+ annotations:
+ "org.opencontainers.image.created": "{{.Date}}"
+ "org.opencontainers.image.title": "{{.ProjectName}}"
+ "org.opencontainers.image.version": "{{.Version}}"
+ "org.opencontainers.image.revision": "{{.FullCommit}}"
+ "org.opencontainers.image.source": "{{.GitURL}}"
+ "maintainer": "dev@netbird.io"
+ - id: relay
+ disable: "{{ .Env.SKIP_DOCKER_PUSH }}"
+ ids:
+ - netbird-relay
+ images:
+ - netbirdio/relay
+ - ghcr.io/netbirdio/relay
+ tags:
+ - "{{ .Version }}"
+ - "{{ if eq .Env.SKIP_PUBLISH \"false\" }}latest{{ end }}"
+ dockerfile: relay/Dockerfile
+ platforms:
+ - linux/amd64
+ - linux/arm64
+ - linux/arm
+ annotations:
+ "org.opencontainers.image.created": "{{.Date}}"
+ "org.opencontainers.image.title": "{{.ProjectName}}"
+ "org.opencontainers.image.version": "{{.Version}}"
+ "org.opencontainers.image.revision": "{{.FullCommit}}"
+ "org.opencontainers.image.source": "{{.GitURL}}"
+ "maintainer": "dev@netbird.io"
+ - id: signal
+ disable: "{{ .Env.SKIP_DOCKER_PUSH }}"
+ ids:
+ - netbird-signal
+ images:
+ - netbirdio/signal
+ - ghcr.io/netbirdio/signal
+ tags:
+ - "{{ .Version }}"
+ - "{{ if eq .Env.SKIP_PUBLISH \"false\" }}latest{{ end }}"
+ dockerfile: signal/Dockerfile
+ platforms:
+ - linux/amd64
+ - linux/arm64
+ - linux/arm
+ annotations:
+ "org.opencontainers.image.created": "{{.Date}}"
+ "org.opencontainers.image.title": "{{.ProjectName}}"
+ "org.opencontainers.image.version": "{{.Version}}"
+ "org.opencontainers.image.revision": "{{.FullCommit}}"
+ "org.opencontainers.image.source": "{{.GitURL}}"
+ "maintainer": "dev@netbird.io"
+ - id: management
+ disable: "{{ .Env.SKIP_DOCKER_PUSH }}"
+ ids:
+ - netbird-mgmt
+ images:
+ - netbirdio/management
+ - ghcr.io/netbirdio/management
+ tags:
+ - "{{ .Version }}"
+ - "{{ if eq .Env.SKIP_PUBLISH \"false\" }}latest{{ end }}"
+ dockerfile: management/Dockerfile
+ platforms:
+ - linux/amd64
+ - linux/arm64
+ - linux/arm
+ annotations:
+ "org.opencontainers.image.created": "{{.Date}}"
+ "org.opencontainers.image.title": "{{.ProjectName}}"
+ "org.opencontainers.image.version": "{{.Version}}"
+ "org.opencontainers.image.revision": "{{.FullCommit}}"
+ "org.opencontainers.image.source": "{{.GitURL}}"
+ "maintainer": "dev@netbird.io"
+ - id: upload
+ disable: "{{ .Env.SKIP_DOCKER_PUSH }}"
+ ids:
+ - netbird-upload
+ images:
+ - netbirdio/upload
+ - ghcr.io/netbirdio/upload
+ tags:
+ - "{{ .Version }}"
+ - "{{ if eq .Env.SKIP_PUBLISH \"false\" }}latest{{ end }}"
+ dockerfile: upload-server/Dockerfile
+ platforms:
+ - linux/amd64
+ - linux/arm64
+ - linux/arm
+ annotations:
+ "org.opencontainers.image.created": "{{.Date}}"
+ "org.opencontainers.image.title": "{{.ProjectName}}"
+ "org.opencontainers.image.version": "{{.Version}}"
+ "org.opencontainers.image.revision": "{{.FullCommit}}"
+ "org.opencontainers.image.source": "{{.GitURL}}"
+ "maintainer": "dev@netbird.io"
+ - id: netbird-server
+ disable: "{{ .Env.SKIP_DOCKER_PUSH }}"
+ ids:
+ - netbird-server
+ images:
+ - netbirdio/netbird-server
+ - ghcr.io/netbirdio/netbird-server
+ tags:
+ - "{{ .Version }}"
+ - "{{ if eq .Env.SKIP_PUBLISH \"false\" }}latest{{ end }}"
+ dockerfile: combined/Dockerfile
+ platforms:
+ - linux/amd64
+ - linux/arm64
+ - linux/arm
+ annotations:
+ "org.opencontainers.image.created": "{{.Date}}"
+ "org.opencontainers.image.title": "{{.ProjectName}}"
+ "org.opencontainers.image.version": "{{.Version}}"
+ "org.opencontainers.image.revision": "{{.FullCommit}}"
+ "org.opencontainers.image.source": "{{.GitURL}}"
+ "maintainer": "dev@netbird.io"
+ - id: netbird-proxy
+ disable: "{{ .Env.SKIP_DOCKER_PUSH }}"
+ ids:
+ - netbird-proxy
+ images:
+ - netbirdio/reverse-proxy
+ - ghcr.io/netbirdio/reverse-proxy
+ tags:
+ - "{{ .Version }}"
+ - "{{ if eq .Env.SKIP_PUBLISH \"false\" }}latest{{ end }}"
+ dockerfile: proxy/Dockerfile
+ platforms:
+ - linux/amd64
+ - linux/arm64
+ - linux/arm
+ annotations:
+ "org.opencontainers.image.created": "{{.Date}}"
+ "org.opencontainers.image.title": "{{.ProjectName}}"
+ "org.opencontainers.image.version": "{{.Version}}"
+ "org.opencontainers.image.revision": "{{.FullCommit}}"
+ "org.opencontainers.image.source": "{{.GitURL}}"
+ "maintainer": "dev@netbird.io"
brews:
- ids:
- default
+ skip_upload: "{{ .Env.SKIP_PUBLISH }}"
repository:
owner: netbirdio
name: homebrew-tap
@@ -902,6 +440,7 @@ brews:
uploads:
- name: debian
+ skip: "{{ .Env.SKIP_PUBLISH }}"
ids:
- netbird_deb
mode: archive
@@ -910,6 +449,7 @@ uploads:
method: PUT
- name: yum
+ skip: "{{ .Env.SKIP_PUBLISH }}"
ids:
- netbird_rpm
mode: archive
@@ -922,9 +462,13 @@ checksum:
- glob: ./infrastructure_files/getting-started-with-zitadel.sh
- glob: ./release_files/install.sh
- glob: ./infrastructure_files/getting-started.sh
+ - glob: ./infrastructure_files/getting-started-enterprise.sh
+ - glob: ./infrastructure_files/migrate-to-enterprise.sh
release:
extra_files:
- glob: ./infrastructure_files/getting-started-with-zitadel.sh
- glob: ./release_files/install.sh
- glob: ./infrastructure_files/getting-started.sh
+ - glob: ./infrastructure_files/getting-started-enterprise.sh
+ - glob: ./infrastructure_files/migrate-to-enterprise.sh
diff --git a/.goreleaser_ui.yaml b/.goreleaser_ui.yaml
index 470f1deaa..6f9b7c059 100644
--- a/.goreleaser_ui.yaml
+++ b/.goreleaser_ui.yaml
@@ -1,5 +1,6 @@
version: 2
-
+env:
+ - SKIP_PUBLISH={{ if index .Env "SKIP_PUBLISH" }}{{ .Env.SKIP_PUBLISH }}{{ else }}true{{ end }}
project_name: netbird-ui
builds:
- id: netbird-ui
@@ -101,6 +102,7 @@ nfpms:
uploads:
- name: debian
+ skip: "{{ .Env.SKIP_PUBLISH }}"
ids:
- netbird_ui_deb
mode: archive
@@ -109,6 +111,7 @@ uploads:
method: PUT
- name: yum
+ skip: "{{ .Env.SKIP_PUBLISH }}"
ids:
- netbird_ui_rpm
mode: archive
diff --git a/Makefile b/Makefile
index 5d52b94fa..0a4fad2f2 100644
--- a/Makefile
+++ b/Makefile
@@ -1,4 +1,4 @@
-.PHONY: lint lint-all lint-install setup-hooks
+.PHONY: lint lint-all lint-install setup-hooks test-unit test-privileged
GOLANGCI_LINT := $(shell pwd)/bin/golangci-lint
# Install golangci-lint locally if needed
@@ -25,3 +25,15 @@ setup-hooks:
@git config core.hooksPath .githooks
@chmod +x .githooks/pre-push
@echo "✅ Git hooks configured! Pre-push will now run 'make lint'"
+
+# Host-safe unit tests: excludes the privileged-tagged tests (root / system-mutating).
+# Runs as a normal user with no sudo and leaves host networking untouched.
+test-unit:
+ @go test -tags devcert -timeout 10m ./...
+
+# Privileged suite: runs the `privileged`-tagged tests inside a --privileged
+# --cap-add=NET_ADMIN container via the ory/dockertest harness. Requires Docker.
+# Narrow the run with env vars, e.g.:
+# PRIV_RUN=TestNftablesManager PRIV_PKGS=./client/firewall/nftables/... make test-privileged
+test-privileged:
+ @go test -tags 'devcert privileged' -timeout 30m -run TestRunPrivilegedSuiteInDocker -v ./client/testutil/privileged/...
diff --git a/README.md b/README.md
index cc27e2d28..c9a51b6f1 100644
--- a/README.md
+++ b/README.md
@@ -37,6 +37,11 @@
+> ### 🤖 NetBird Agent Network (Beta)
+> Identity-aware access control for AI agents — keyless access to LLM APIs and private
+> resources over the encrypted NetBird tunnel. See [`agent-network/`](agent-network/) or
+> read the docs at **[netbird.ai](https://netbird.ai)**.
+
**NetBird combines a configuration-free peer-to-peer private network and a centralized access control system in a single platform, making it easy to create secure private networks for your organization or home.**
**Connect.** NetBird creates a WireGuard-based overlay network that automatically connects your machines over an encrypted tunnel, leaving behind the hassle of opening ports, complex firewall rules, VPN gateways, and so forth.
diff --git a/agent-network/README.md b/agent-network/README.md
new file mode 100644
index 000000000..a09d3979e
--- /dev/null
+++ b/agent-network/README.md
@@ -0,0 +1,39 @@
+# NetBird Agent Network
+
+Agent Network is NetBird's access control layer for AI agents and the people who run
+them. It gives every agent a real identity, tied to your identity provider (IdP), and
+governs what it can reach — the LLM APIs and AI gateways it can call, and the internal
+resources it can access. Traffic flows only over the encrypted NetBird tunnel, scoped by
+policy, with no API keys to leak.
+
+> **Beta.** Agent Network is open source and can be self-hosted on your own
+> infrastructure.
+
+## How it works
+
+Agent Network is built on two existing NetBird capabilities:
+
+- **Overlay network** — the encrypted WireGuard mesh between peers.
+- **Reverse proxy** — a NetBird peer that terminates LLM requests, establishes the
+ caller's identity, evaluates policies/limits/guardrails, injects the upstream provider
+ key server-side, forwards to the API or gateway, and records usage.
+
+LLM traffic is routed through the proxy's identity-aware pipeline, while internal
+resources (databases, internal APIs, self-hosted models) are reached directly over
+peer-to-peer WireGuard tunnels, governed by the same identities and access policies.
+
+## Where the code lives
+
+There is no separate "agent-network" service — it reuses the reverse-proxy and management
+components:
+
+- [`proxy/`](../proxy) — the NetBird reverse proxy that serves the agent network endpoint
+ and runs the per-request middleware pipeline.
+- [`management/internals/modules/reverseproxy/`](../management/internals/modules/reverseproxy)
+ — the management-side control plane: providers, policies, guardrails, limits, routing,
+ and usage/access logs.
+
+## Documentation
+
+Full documentation, architecture, and quickstart:
+**https://docs.netbird.io/agent-network**
diff --git a/client/Dockerfile b/client/Dockerfile
index 53e4555ef..478b2d0e2 100644
--- a/client/Dockerfile
+++ b/client/Dockerfile
@@ -4,7 +4,7 @@
# sudo podman build -t localhost/netbird:latest -f client/Dockerfile --ignorefile .dockerignore-client .
# sudo podman run --rm -it --cap-add={BPF,NET_ADMIN,NET_RAW} localhost/netbird:latest
-FROM alpine:3.23.3
+FROM alpine:3.24
# iproute2: busybox doesn't display ip rules properly
RUN apk add --no-cache \
bash \
@@ -21,7 +21,7 @@ ENV \
NB_ENTRYPOINT_SERVICE_TIMEOUT="30"
ENTRYPOINT [ "/usr/local/bin/netbird-entrypoint.sh" ]
-
-ARG NETBIRD_BINARY=netbird
+ARG TARGETPLATFORM
+ARG NETBIRD_BINARY=$TARGETPLATFORM/netbird
COPY client/netbird-entrypoint.sh /usr/local/bin/netbird-entrypoint.sh
COPY "${NETBIRD_BINARY}" /usr/local/bin/netbird
diff --git a/client/Dockerfile-rootless b/client/Dockerfile-rootless
index 706bf40de..8141af6ed 100644
--- a/client/Dockerfile-rootless
+++ b/client/Dockerfile-rootless
@@ -4,7 +4,7 @@
# podman build -t localhost/netbird:latest -f client/Dockerfile --ignorefile .dockerignore-client .
# podman run --rm -it --cap-add={BPF,NET_ADMIN,NET_RAW} localhost/netbird:latest
-FROM alpine:3.22.0
+FROM alpine:3.24
RUN apk add --no-cache \
bash \
@@ -27,7 +27,7 @@ ENV \
NB_ENTRYPOINT_SERVICE_TIMEOUT="30"
ENTRYPOINT [ "/usr/local/bin/netbird-entrypoint.sh" ]
-
-ARG NETBIRD_BINARY=netbird
+ARG TARGETPLATFORM
+ARG NETBIRD_BINARY=$TARGETPLATFORM/netbird
COPY client/netbird-entrypoint.sh /usr/local/bin/netbird-entrypoint.sh
COPY "${NETBIRD_BINARY}" /usr/local/bin/netbird
diff --git a/client/android/profile_manager.go b/client/android/profile_manager.go
index 60e4d5c32..87c001396 100644
--- a/client/android/profile_manager.go
+++ b/client/android/profile_manager.go
@@ -6,7 +6,6 @@ import (
"fmt"
"os"
"path/filepath"
- "strings"
log "github.com/sirupsen/logrus"
@@ -24,6 +23,7 @@ const (
// Profile represents a profile for gomobile
type Profile struct {
+ ID string
Name string
IsActive bool
}
@@ -53,10 +53,10 @@ func (p *ProfileArray) Get(i int) *Profile {
├── state.json ← Default profile state
├── active_profile.json ← Active profile tracker (JSON with Name + Username)
└── profiles/ ← Subdirectory for non-default profiles
- ├── work.json ← Work profile config
- ├── work.state.json ← Work profile state
- ├── personal.json ← Personal profile config
- └── personal.state.json ← Personal profile state
+ ├── work.json ← Legacy work profile config
+ ├── work.state.json ← Legacy work profile state
+ ├── 4c5f5c8198c3989cffb5b5394f5a7ae0.json ← ID profile config
+ ├── 4c5f5c8198c3989cffb5b5394f5a7ae0.state.json ← ID profile state
*/
// ProfileManager manages profiles for Android
@@ -99,6 +99,7 @@ func (pm *ProfileManager) ListProfiles() (*ProfileArray, error) {
var profiles []*Profile
for _, p := range internalProfiles {
profiles = append(profiles, &Profile{
+ ID: p.ID.String(),
Name: p.Name,
IsActive: p.IsActive,
})
@@ -108,55 +109,65 @@ func (pm *ProfileManager) ListProfiles() (*ProfileArray, error) {
}
// GetActiveProfile returns the currently active profile name
-func (pm *ProfileManager) GetActiveProfile() (string, error) {
+func (pm *ProfileManager) GetActiveProfile() (*Profile, error) {
// Use ServiceManager to stay consistent with ListProfiles
// ServiceManager uses active_profile.json
activeState, err := pm.serviceMgr.GetActiveProfileState()
if err != nil {
- return "", fmt.Errorf("failed to get active profile: %w", err)
+ return nil, fmt.Errorf("failed to get active profile: %w", err)
}
- return activeState.Name, nil
+
+ // ActiveProfileState only stores the ID (and username), not the display
+ // name. Resolve the ID to the full profile so callers get the real Name.
+ prof, err := pm.serviceMgr.ResolveProfile(activeState.ID.String(), androidUsername)
+ if err != nil {
+ return nil, fmt.Errorf("failed to resolve active profile %q: %w", activeState.ID, err)
+ }
+ return &Profile{ID: prof.ID.String(), Name: prof.Name, IsActive: true}, nil
}
// SwitchProfile switches to a different profile
-func (pm *ProfileManager) SwitchProfile(profileName string) error {
+func (pm *ProfileManager) SwitchProfile(id string) error {
// Use ServiceManager to stay consistent with ListProfiles
// ServiceManager uses active_profile.json
err := pm.serviceMgr.SetActiveProfileState(&profilemanager.ActiveProfileState{
- Name: profileName,
+ ID: profilemanager.ID(id),
Username: androidUsername,
})
if err != nil {
return fmt.Errorf("failed to switch profile: %w", err)
}
- log.Infof("switched to profile: %s", profileName)
+ log.Infof("switched to profile: %s", id)
return nil
}
// AddProfile creates a new profile
func (pm *ProfileManager) AddProfile(profileName string) error {
// Use ServiceManager (creates profile in profiles/ directory)
- if err := pm.serviceMgr.AddProfile(profileName, androidUsername); err != nil {
+ profile, err := pm.serviceMgr.AddProfile(profileName, androidUsername)
+ if err != nil {
return fmt.Errorf("failed to add profile: %w", err)
}
- log.Infof("created new profile: %s", profileName)
+ log.Infof("created new profile: %s", profile.ID)
return nil
}
// LogoutProfile logs out from a profile (clears authentication)
-func (pm *ProfileManager) LogoutProfile(profileName string) error {
- profileName = sanitizeProfileName(profileName)
-
- configPath, err := pm.getProfileConfigPath(profileName)
+func (pm *ProfileManager) LogoutProfile(id string) error {
+ configPath, err := pm.getProfileConfigPath(id)
if err != nil {
return err
}
+ if !profilemanager.IsValidProfileFilenameStem(profilemanager.ID(id)) {
+ return fmt.Errorf("id '%s' is not valid", id)
+ }
+
// Check if profile exists
if _, err := os.Stat(configPath); os.IsNotExist(err) {
- return fmt.Errorf("profile '%s' does not exist", profileName)
+ return fmt.Errorf("profile '%s' does not exist", id)
}
// Read current config using internal profilemanager
@@ -174,53 +185,57 @@ func (pm *ProfileManager) LogoutProfile(profileName string) error {
return fmt.Errorf("failed to save config: %w", err)
}
- log.Infof("logged out from profile: %s", profileName)
+ log.Infof("logged out from profile: %s", id)
return nil
}
// RemoveProfile deletes a profile
-func (pm *ProfileManager) RemoveProfile(profileName string) error {
+func (pm *ProfileManager) RemoveProfile(id string) error {
// Use ServiceManager (removes profile from profiles/ directory)
- if err := pm.serviceMgr.RemoveProfile(profileName, androidUsername); err != nil {
+ if err := pm.serviceMgr.RemoveProfile(profilemanager.ID(id), androidUsername); err != nil {
return fmt.Errorf("failed to remove profile: %w", err)
}
- log.Infof("removed profile: %s", profileName)
+ log.Infof("removed profile: %s", id)
return nil
}
// getProfileConfigPath returns the config file path for a profile
// This is needed for Android-specific path handling (netbird.cfg for default profile)
-func (pm *ProfileManager) getProfileConfigPath(profileName string) (string, error) {
- if profileName == "" || profileName == profilemanager.DefaultProfileName {
+func (pm *ProfileManager) getProfileConfigPath(id string) (string, error) {
+ if !profilemanager.IsValidProfileFilenameStem(profilemanager.ID(id)) {
+ return "", fmt.Errorf("id %q is not valid", id)
+ }
+
+ if id == profilemanager.DefaultProfileName {
// Android uses netbird.cfg for default profile instead of default.json
// Default profile is stored in root configDir, not in profiles/
return filepath.Join(pm.configDir, defaultConfigFilename), nil
}
- // Non-default profiles are stored in profiles subdirectory
- // This matches the Java Preferences.java expectation
- profileName = sanitizeProfileName(profileName)
profilesDir := filepath.Join(pm.configDir, profilesSubdir)
- return filepath.Join(profilesDir, profileName+".json"), nil
+ return filepath.Join(profilesDir, id+".json"), nil
}
-// GetConfigPath returns the config file path for a given profile
+// GetConfigPath returns the config file path for a given profile id
// Java should call this instead of constructing paths with Preferences.configFile()
-func (pm *ProfileManager) GetConfigPath(profileName string) (string, error) {
- return pm.getProfileConfigPath(profileName)
+func (pm *ProfileManager) GetConfigPath(id string) (string, error) {
+ return pm.getProfileConfigPath(id)
}
// GetStateFilePath returns the state file path for a given profile
// Java should call this instead of constructing paths with Preferences.stateFile()
-func (pm *ProfileManager) GetStateFilePath(profileName string) (string, error) {
- if profileName == "" || profileName == profilemanager.DefaultProfileName {
+func (pm *ProfileManager) GetStateFilePath(id string) (string, error) {
+ if id == "" || id == profilemanager.DefaultProfileName {
return filepath.Join(pm.configDir, "state.json"), nil
}
- profileName = sanitizeProfileName(profileName)
+ if !profilemanager.IsValidProfileFilenameStem(profilemanager.ID(id)) {
+ return "", fmt.Errorf("id %q is not valid", id)
+ }
+
profilesDir := filepath.Join(pm.configDir, profilesSubdir)
- return filepath.Join(profilesDir, profileName+".state.json"), nil
+ return filepath.Join(profilesDir, id+".state.json"), nil
}
// GetActiveConfigPath returns the config file path for the currently active profile
@@ -230,7 +245,7 @@ func (pm *ProfileManager) GetActiveConfigPath() (string, error) {
if err != nil {
return "", fmt.Errorf("failed to get active profile: %w", err)
}
- return pm.GetConfigPath(activeProfile)
+ return pm.GetConfigPath(activeProfile.ID)
}
// GetActiveStateFilePath returns the state file path for the currently active profile
@@ -240,18 +255,5 @@ func (pm *ProfileManager) GetActiveStateFilePath() (string, error) {
if err != nil {
return "", fmt.Errorf("failed to get active profile: %w", err)
}
- return pm.GetStateFilePath(activeProfile)
-}
-
-// sanitizeProfileName removes invalid characters from profile name
-func sanitizeProfileName(name string) string {
- // Keep only alphanumeric, underscore, and hyphen
- var result strings.Builder
- for _, r := range name {
- if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') ||
- (r >= '0' && r <= '9') || r == '_' || r == '-' {
- result.WriteRune(r)
- }
- }
- return result.String()
+ return pm.GetStateFilePath(activeProfile.ID)
}
diff --git a/client/cmd/debug.go b/client/cmd/debug.go
index 02a742b28..57e75f663 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"
@@ -85,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: string(activeProf.ID),
+ 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 {
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/login.go b/client/cmd/login.go
index bd37e30f1..a7ee960b1 100644
--- a/client/cmd/login.go
+++ b/client/cmd/login.go
@@ -96,17 +96,19 @@ func doDaemonLogin(ctx context.Context, cmd *cobra.Command, providedSetupKey str
dnsLabelsReq = dnsLabelsValidated.ToSafeStringList()
}
+ handle := activeProf.ID.String()
+
loginRequest := proto.LoginRequest{
SetupKey: providedSetupKey,
ManagementUrl: managementURL,
IsUnixDesktopClient: isUnixRunningDesktop(),
Hostname: hostName,
DnsLabels: dnsLabelsReq,
- ProfileName: &activeProf.Name,
+ ProfileName: &handle,
Username: &username,
}
- profileState, err := pm.GetProfileState(activeProf.Name)
+ profileState, err := pm.GetProfileState(activeProf.ID)
if err != nil {
log.Debugf("failed to get profile state for login hint: %v", err)
} else if profileState.Email != "" {
@@ -170,14 +172,13 @@ func getActiveProfile(ctx context.Context, pm *profilemanager.ProfileManager, pr
return activeProf, nil
}
-func switchProfileOnDaemon(ctx context.Context, pm *profilemanager.ProfileManager, profileName string, username string) error {
- err := switchProfile(context.Background(), profileName, username)
+func switchProfileOnDaemon(ctx context.Context, pm *profilemanager.ProfileManager, handle string, username string) error {
+ resolvedID, err := switchProfile(ctx, handle, username)
if err != nil {
return fmt.Errorf("switch profile on daemon: %v", err)
}
- err = pm.SwitchProfile(profileName)
- if err != nil {
+ if err := pm.SwitchProfile(resolvedID); err != nil {
return fmt.Errorf("switch profile: %v", err)
}
@@ -205,11 +206,15 @@ func switchProfileOnDaemon(ctx context.Context, pm *profilemanager.ProfileManage
return nil
}
-func switchProfile(ctx context.Context, profileName string, username string) error {
+// switchProfile asks the daemon to switch to the profile identified by
+// handle (a name, ID, or unique ID prefix). Returns the resolved profile
+// ID so the caller can update the local active-profile state without
+// re-resolving the handle.
+func switchProfile(ctx context.Context, handle string, username string) (profilemanager.ID, error) {
conn, err := DialClientGRPCServer(ctx, daemonAddr)
if err != nil {
//nolint
- return fmt.Errorf("failed to connect to daemon error: %v\n"+
+ return "", fmt.Errorf("failed to connect to daemon error: %v\n"+
"If the daemon is not running please run: "+
"\nnetbird service install \nnetbird service start\n", err)
}
@@ -217,15 +222,15 @@ func switchProfile(ctx context.Context, profileName string, username string) err
client := proto.NewDaemonServiceClient(conn)
- _, err = client.SwitchProfile(ctx, &proto.SwitchProfileRequest{
- ProfileName: &profileName,
+ resp, err := client.SwitchProfile(ctx, &proto.SwitchProfileRequest{
+ ProfileName: &handle,
Username: &username,
})
if err != nil {
- return fmt.Errorf("switch profile failed: %v", err)
+ return "", fmt.Errorf("switch profile failed: %w", err)
}
- return nil
+ return profilemanager.ID(resp.Id), nil
}
func doForegroundLogin(ctx context.Context, cmd *cobra.Command, setupKey string, activeProf *profilemanager.Profile) error {
@@ -249,7 +254,7 @@ func doForegroundLogin(ctx context.Context, cmd *cobra.Command, setupKey string,
return fmt.Errorf("read config file %s: %v", configFilePath, err)
}
- err = foregroundLogin(ctx, cmd, config, setupKey, activeProf.Name)
+ err = foregroundLogin(ctx, cmd, config, setupKey, activeProf.ID)
if err != nil {
return fmt.Errorf("foreground login failed: %v", err)
}
@@ -277,7 +282,7 @@ func handleSSOLogin(ctx context.Context, cmd *cobra.Command, loginResp *proto.Lo
return nil
}
-func foregroundLogin(ctx context.Context, cmd *cobra.Command, config *profilemanager.Config, setupKey, profileName string) error {
+func foregroundLogin(ctx context.Context, cmd *cobra.Command, config *profilemanager.Config, setupKey string, profileID profilemanager.ID) error {
authClient, err := auth.NewAuth(ctx, config.PrivateKey, config.ManagementURL, config)
if err != nil {
return fmt.Errorf("failed to create auth client: %v", err)
@@ -291,7 +296,7 @@ func foregroundLogin(ctx context.Context, cmd *cobra.Command, config *profileman
jwtToken := ""
if setupKey == "" && needsLogin {
- tokenInfo, err := foregroundGetTokenInfo(ctx, cmd, config, profileName)
+ tokenInfo, err := foregroundGetTokenInfo(ctx, cmd, config, profileID)
if err != nil {
return fmt.Errorf("interactive sso login failed: %v", err)
}
@@ -306,10 +311,10 @@ func foregroundLogin(ctx context.Context, cmd *cobra.Command, config *profileman
return nil
}
-func foregroundGetTokenInfo(ctx context.Context, cmd *cobra.Command, config *profilemanager.Config, profileName string) (*auth.TokenInfo, error) {
+func foregroundGetTokenInfo(ctx context.Context, cmd *cobra.Command, config *profilemanager.Config, profileID profilemanager.ID) (*auth.TokenInfo, error) {
hint := ""
pm := profilemanager.NewProfileManager()
- profileState, err := pm.GetProfileState(profileName)
+ profileState, err := pm.GetProfileState(profileID)
if err != nil {
log.Debugf("failed to get profile state for login hint: %v", err)
} else if profileState.Email != "" {
diff --git a/client/cmd/login_test.go b/client/cmd/login_test.go
index 47522e189..0aa1856b1 100644
--- a/client/cmd/login_test.go
+++ b/client/cmd/login_test.go
@@ -27,7 +27,7 @@ func TestLogin(t *testing.T) {
profilemanager.ActiveProfileStatePath = tempDir + "/active_profile.json"
sm := profilemanager.ServiceManager{}
err = sm.SetActiveProfileState(&profilemanager.ActiveProfileState{
- Name: "default",
+ ID: "default",
Username: currUser.Username,
})
if err != nil {
diff --git a/client/cmd/profile.go b/client/cmd/profile.go
index d6e81760f..268034e70 100644
--- a/client/cmd/profile.go
+++ b/client/cmd/profile.go
@@ -2,11 +2,16 @@ package cmd
import (
"context"
+ "errors"
"fmt"
"os/user"
+ "strings"
+ "text/tabwriter"
"time"
"github.com/spf13/cobra"
+ "google.golang.org/grpc/codes"
+ gstatus "google.golang.org/grpc/status"
"github.com/netbirdio/netbird/client/internal"
"github.com/netbirdio/netbird/client/internal/profilemanager"
@@ -14,6 +19,8 @@ import (
"github.com/netbirdio/netbird/util"
)
+var profileListShowID bool
+
var profileCmd = &cobra.Command{
Use: "profile",
Short: "Manage NetBird client profiles",
@@ -31,27 +38,40 @@ var profileListCmd = &cobra.Command{
var profileAddCmd = &cobra.Command{
Use: "add ",
Short: "Add a new profile",
- Long: `Add a new profile to the NetBird client. The profile name must be unique.`,
+ Long: `Add a new profile. Profile name is free-form, a unique ID is generated for the on-disk config file.`,
Args: cobra.ExactArgs(1),
RunE: addProfileFunc,
}
+var profileRenameCmd = &cobra.Command{
+ Use: "rename ",
+ Short: "Renames an existing profile",
+ Long: `Renames an existing profile (by a name, ID, or unique ID prefix). Profile name is free-form.`,
+ Args: cobra.ExactArgs(2),
+ RunE: renameProfileFunc,
+}
+
var profileRemoveCmd = &cobra.Command{
- Use: "remove ",
- Short: "Remove a profile",
- Long: `Remove a profile from the NetBird client. The profile must not be inactive.`,
- Args: cobra.ExactArgs(1),
- RunE: removeProfileFunc,
+ Use: "remove ",
+ Short: "Remove a profile",
+ Long: `Remove a profile by name, ID, or unique ID prefix.`,
+ Aliases: []string{"rm"},
+ Args: cobra.ExactArgs(1),
+ RunE: removeProfileFunc,
}
var profileSelectCmd = &cobra.Command{
- Use: "select ",
+ Use: "select ",
Short: "Select a profile",
- Long: `Make the specified profile active. This will switch the client to use the selected profile's configuration.`,
+ Long: `Make the specified profile active. Accepts a name, ID, or unique ID prefix.`,
Args: cobra.ExactArgs(1),
RunE: selectProfileFunc,
}
+func init() {
+ profileListCmd.Flags().BoolVar(&profileListShowID, "show-id", false, "show the profile ID column")
+}
+
func setupCmd(cmd *cobra.Command) error {
SetFlagsFromEnvVars(rootCmd)
SetFlagsFromEnvVars(cmd)
@@ -65,6 +85,7 @@ func setupCmd(cmd *cobra.Command) error {
return nil
}
+
func listProfilesFunc(cmd *cobra.Command, _ []string) error {
if err := setupCmd(cmd); err != nil {
return err
@@ -83,25 +104,33 @@ func listProfilesFunc(cmd *cobra.Command, _ []string) error {
daemonClient := proto.NewDaemonServiceClient(conn)
- profiles, err := daemonClient.ListProfiles(cmd.Context(), &proto.ListProfilesRequest{
+ resp, err := daemonClient.ListProfiles(cmd.Context(), &proto.ListProfilesRequest{
Username: currUser.Username,
})
if err != nil {
return err
}
- // list profiles, add a tick if the profile is active
- cmd.Println("Found", len(profiles.Profiles), "profiles:")
- for _, profile := range profiles.Profiles {
- // use a cross to indicate the passive profiles
- activeMarker := "✗"
- if profile.IsActive {
- activeMarker = "✓"
- }
- cmd.Println(activeMarker, profile.Name)
+ tw := tabwriter.NewWriter(cmd.OutOrStdout(), 0, 0, 2, ' ', 0)
+ if profileListShowID {
+ fmt.Fprintln(tw, "ID\tNAME\tACTIVE")
+ } else {
+ fmt.Fprintln(tw, "NAME\tACTIVE")
}
-
- return nil
+ for _, profile := range resp.Profiles {
+ marker := ""
+ if profile.IsActive {
+ marker = "✓"
+ }
+ name := profilemanager.StripCtrlChars(profile.Name)
+ id := profilemanager.ID(profile.Id)
+ if profileListShowID {
+ fmt.Fprintf(tw, "%s\t%s\t%s\n", id.ShortID(), name, marker)
+ } else {
+ fmt.Fprintf(tw, "%s\t%s\n", name, marker)
+ }
+ }
+ return tw.Flush()
}
func addProfileFunc(cmd *cobra.Command, args []string) error {
@@ -109,6 +138,41 @@ func addProfileFunc(cmd *cobra.Command, args []string) error {
return err
}
+ currUser, err := user.Current()
+ if err != nil {
+ return fmt.Errorf("get current user: %w", err)
+ }
+
+ conn, err := DialClientGRPCServer(cmd.Context(), daemonAddr)
+ if err != nil {
+ return fmt.Errorf("connect to service CLI interface: %w", err)
+ }
+ defer conn.Close()
+
+ daemonClient := proto.NewDaemonServiceClient(conn)
+ profileName := args[0]
+
+ id, err := addProfileOnDaemon(cmd.Context(), daemonClient, profileName, currUser.Username)
+ if err != nil {
+ return err
+ }
+
+ dupCount, _ := countProfilesWithName(cmd.Context(), daemonClient, currUser.Username, profileName)
+ if dupCount > 1 {
+ cmd.Printf("Warning: %d other profile(s) already use the name %q.\n", dupCount-1, profileName)
+ cmd.Println("Use `netbird profile list --show-id` to disambiguate later.")
+ }
+
+ cmd.Printf("Profile added: %s %s\n", id.ShortID(), profilemanager.StripCtrlChars(profileName))
+ return nil
+
+}
+
+func renameProfileFunc(cmd *cobra.Command, args []string) error {
+ if err := setupCmd(cmd); err != nil {
+ return err
+ }
+
conn, err := DialClientGRPCServer(cmd.Context(), daemonAddr)
if err != nil {
return fmt.Errorf("connect to service CLI interface: %w", err)
@@ -121,21 +185,43 @@ func addProfileFunc(cmd *cobra.Command, args []string) error {
}
daemonClient := proto.NewDaemonServiceClient(conn)
+ handle := args[0]
+ newProfilename := args[1]
- profileName := args[0]
-
- _, err = daemonClient.AddProfile(cmd.Context(), &proto.AddProfileRequest{
- ProfileName: profileName,
- Username: currUser.Username,
+ resp, err := daemonClient.RenameProfile(cmd.Context(), &proto.RenameProfileRequest{
+ Handle: handle,
+ Username: currUser.Username,
+ NewProfileName: newProfilename,
})
if err != nil {
- return err
+ return wrapAmbiguityError(err, handle)
}
- cmd.Println("Profile added successfully:", profileName)
+ dupCount, _ := countProfilesWithName(cmd.Context(), daemonClient, currUser.Username, newProfilename)
+ if dupCount > 1 {
+ cmd.Printf("Warning: %d other profile(s) already use the name %q.\n", dupCount-1, newProfilename)
+ cmd.Println("Use `netbird profile list --show-id` to disambiguate later.")
+ }
+
+ cmd.Printf("Profile renamed from %s to %s\n", profilemanager.StripCtrlChars(resp.OldProfileName), profilemanager.StripCtrlChars(newProfilename))
+
return nil
}
+func countProfilesWithName(ctx context.Context, c proto.DaemonServiceClient, username, name string) (int, error) {
+ resp, err := c.ListProfiles(ctx, &proto.ListProfilesRequest{Username: username})
+ if err != nil {
+ return 0, err
+ }
+ n := 0
+ for _, p := range resp.Profiles {
+ if p.Name == name {
+ n++
+ }
+ }
+ return n, nil
+}
+
func removeProfileFunc(cmd *cobra.Command, args []string) error {
if err := setupCmd(cmd); err != nil {
return err
@@ -153,18 +239,17 @@ func removeProfileFunc(cmd *cobra.Command, args []string) error {
}
daemonClient := proto.NewDaemonServiceClient(conn)
+ handle := args[0]
- profileName := args[0]
-
- _, err = daemonClient.RemoveProfile(cmd.Context(), &proto.RemoveProfileRequest{
- ProfileName: profileName,
+ resp, err := daemonClient.RemoveProfile(cmd.Context(), &proto.RemoveProfileRequest{
+ ProfileName: handle,
Username: currUser.Username,
})
if err != nil {
- return err
+ return wrapAmbiguityError(err, handle)
}
- cmd.Println("Profile removed successfully:", profileName)
+ cmd.Printf("Profile removed: %s\n", resp.Id)
return nil
}
@@ -174,7 +259,7 @@ func selectProfileFunc(cmd *cobra.Command, args []string) error {
}
profileManager := profilemanager.NewProfileManager()
- profileName := args[0]
+ handle := args[0]
currUser, err := user.Current()
if err != nil {
@@ -191,32 +276,15 @@ func selectProfileFunc(cmd *cobra.Command, args []string) error {
daemonClient := proto.NewDaemonServiceClient(conn)
- profiles, err := daemonClient.ListProfiles(ctx, &proto.ListProfilesRequest{
- Username: currUser.Username,
+ switchResp, err := daemonClient.SwitchProfile(ctx, &proto.SwitchProfileRequest{
+ ProfileName: &handle,
+ Username: &currUser.Username,
})
if err != nil {
- return fmt.Errorf("list profiles: %w", err)
+ return wrapAmbiguityError(err, handle)
}
- var profileExists bool
-
- for _, profile := range profiles.Profiles {
- if profile.Name == profileName {
- profileExists = true
- break
- }
- }
-
- if !profileExists {
- return fmt.Errorf("profile %s does not exist", profileName)
- }
-
- if err := switchProfile(cmd.Context(), profileName, currUser.Username); err != nil {
- return err
- }
-
- err = profileManager.SwitchProfile(profileName)
- if err != nil {
+ if err := profileManager.SwitchProfile(profilemanager.ID(switchResp.Id)); err != nil {
return err
}
@@ -231,6 +299,46 @@ func selectProfileFunc(cmd *cobra.Command, args []string) error {
}
}
- cmd.Println("Profile switched successfully to:", profileName)
+ id := profilemanager.ID(switchResp.Id)
+ cmd.Printf("Profile switched to: %s\n", id.ShortID())
return nil
}
+
+// wrapAmbiguityError turns the daemon's gRPC InvalidArgument errors
+// (which carry the resolver's message verbatim) into CLI-friendly text
+// that points the user at --show-id.
+func wrapAmbiguityError(err error, handle string) error {
+ if err == nil {
+ return nil
+ }
+ st, ok := gstatus.FromError(err)
+ if !ok {
+ return err
+ }
+ switch st.Code() {
+ case codes.InvalidArgument:
+ msg := st.Message()
+ if strings.Contains(msg, "ambiguous") {
+ return errors.New(msg + "\nRun `netbird profile list --show-id` to see IDs, then select by ID prefix:\n netbird profile select|remove ")
+ }
+ case codes.NotFound:
+ return fmt.Errorf("profile %q not found", handle)
+ }
+ return err
+}
+
+// addProfileOnDaemon issues the AddProfile RPC on an existing daemon client
+// and returns the new profile's ID. It is the single entry point for profile
+// creation, shared by `netbird profile add` and the `netbird up --profile
+// ` auto-create path.
+func addProfileOnDaemon(ctx context.Context, client proto.DaemonServiceClient, profileName, username string) (profilemanager.ID, error) {
+ resp, err := client.AddProfile(ctx, &proto.AddProfileRequest{
+ ProfileName: profileName,
+ Username: username,
+ })
+ if err != nil {
+ return "", fmt.Errorf("add profile failed: %w", err)
+ }
+
+ return profilemanager.ID(resp.Id), nil
+}
diff --git a/client/cmd/root.go b/client/cmd/root.go
index 0a0aa4197..f3fde2f1c 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,10 +180,17 @@ 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)
profileCmd.AddCommand(profileAddCmd)
+ profileCmd.AddCommand(profileRenameCmd)
profileCmd.AddCommand(profileRemoveCmd)
profileCmd.AddCommand(profileSelectCmd)
diff --git a/client/cmd/service_privileged_test.go b/client/cmd/service_privileged_test.go
new file mode 100644
index 000000000..075d7f378
--- /dev/null
+++ b/client/cmd/service_privileged_test.go
@@ -0,0 +1,196 @@
+//go:build privileged
+
+package cmd
+
+import (
+ "context"
+ "fmt"
+ "os"
+ "runtime"
+ "testing"
+ "time"
+
+ "github.com/kardianos/service"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+const (
+ serviceStartTimeout = 10 * time.Second
+ serviceStopTimeout = 5 * time.Second
+ statusPollInterval = 500 * time.Millisecond
+)
+
+// waitForServiceStatus waits for service to reach expected status with timeout
+func waitForServiceStatus(expectedStatus service.Status, timeout time.Duration) (bool, error) {
+ cfg, err := newSVCConfig()
+ if err != nil {
+ return false, err
+ }
+
+ ctxSvc, cancel := context.WithCancel(context.Background())
+ defer cancel()
+
+ s, err := newSVC(newProgram(ctxSvc, cancel), cfg)
+ if err != nil {
+ return false, err
+ }
+
+ ctx, timeoutCancel := context.WithTimeout(context.Background(), timeout)
+ defer timeoutCancel()
+
+ ticker := time.NewTicker(statusPollInterval)
+ defer ticker.Stop()
+
+ for {
+ select {
+ case <-ctx.Done():
+ return false, fmt.Errorf("timeout waiting for service status %v", expectedStatus)
+ case <-ticker.C:
+ status, err := s.Status()
+ if err != nil {
+ // Continue polling on transient errors
+ continue
+ }
+ if status == expectedStatus {
+ return true, nil
+ }
+ }
+ }
+}
+
+// TestServiceLifecycle tests the complete service lifecycle
+func TestServiceLifecycle(t *testing.T) {
+ // TODO: Add support for Windows and macOS
+ if runtime.GOOS != "linux" && runtime.GOOS != "freebsd" {
+ t.Skipf("Skipping service lifecycle test on unsupported OS: %s", runtime.GOOS)
+ }
+
+ if os.Getenv("CONTAINER") == "true" {
+ t.Skip("Skipping service lifecycle test in container environment")
+ }
+
+ originalServiceName := serviceName
+ serviceName = "netbirdtest" + fmt.Sprintf("%d", time.Now().Unix())
+ defer func() {
+ serviceName = originalServiceName
+ }()
+
+ tempDir := t.TempDir()
+ configPath = fmt.Sprintf("%s/netbird-test-config.json", tempDir)
+ logLevel = "info"
+ daemonAddr = fmt.Sprintf("unix://%s/netbird-test.sock", tempDir)
+
+ // Ensure cleanup even if a subtest fails and Stop/Uninstall subtests don't run.
+ t.Cleanup(func() {
+ cfg, err := newSVCConfig()
+ if err != nil {
+ t.Errorf("cleanup: create service config: %v", err)
+ return
+ }
+ ctxSvc, cancel := context.WithCancel(context.Background())
+ defer cancel()
+ s, err := newSVC(newProgram(ctxSvc, cancel), cfg)
+ if err != nil {
+ t.Errorf("cleanup: create service: %v", err)
+ return
+ }
+
+ // If the subtests already cleaned up, there's nothing to do.
+ if _, err := s.Status(); err != nil {
+ return
+ }
+
+ if err := s.Stop(); err != nil {
+ t.Errorf("cleanup: stop service: %v", err)
+ }
+ if err := s.Uninstall(); err != nil {
+ t.Errorf("cleanup: uninstall service: %v", err)
+ }
+ })
+
+ ctx := context.Background()
+
+ t.Run("Install", func(t *testing.T) {
+ installCmd.SetContext(ctx)
+ err := installCmd.RunE(installCmd, []string{})
+ require.NoError(t, err)
+
+ cfg, err := newSVCConfig()
+ require.NoError(t, err)
+
+ ctxSvc, cancel := context.WithCancel(context.Background())
+ defer cancel()
+
+ s, err := newSVC(newProgram(ctxSvc, cancel), cfg)
+ require.NoError(t, err)
+
+ status, err := s.Status()
+ assert.NoError(t, err)
+ assert.NotEqual(t, service.StatusUnknown, status)
+ })
+
+ t.Run("Start", func(t *testing.T) {
+ startCmd.SetContext(ctx)
+ err := startCmd.RunE(startCmd, []string{})
+ require.NoError(t, err)
+
+ running, err := waitForServiceStatus(service.StatusRunning, serviceStartTimeout)
+ require.NoError(t, err)
+ assert.True(t, running)
+ })
+
+ t.Run("Restart", func(t *testing.T) {
+ restartCmd.SetContext(ctx)
+ err := restartCmd.RunE(restartCmd, []string{})
+ require.NoError(t, err)
+
+ running, err := waitForServiceStatus(service.StatusRunning, serviceStartTimeout)
+ require.NoError(t, err)
+ assert.True(t, running)
+ })
+
+ t.Run("Reconfigure", func(t *testing.T) {
+ originalLogLevel := logLevel
+ logLevel = "debug"
+ defer func() {
+ logLevel = originalLogLevel
+ }()
+
+ reconfigureCmd.SetContext(ctx)
+ err := reconfigureCmd.RunE(reconfigureCmd, []string{})
+ require.NoError(t, err)
+
+ running, err := waitForServiceStatus(service.StatusRunning, serviceStartTimeout)
+ require.NoError(t, err)
+ assert.True(t, running)
+ })
+
+ t.Run("Stop", func(t *testing.T) {
+ stopCmd.SetContext(ctx)
+ err := stopCmd.RunE(stopCmd, []string{})
+ require.NoError(t, err)
+
+ stopped, err := waitForServiceStatus(service.StatusStopped, serviceStopTimeout)
+ require.NoError(t, err)
+ assert.True(t, stopped)
+ })
+
+ t.Run("Uninstall", func(t *testing.T) {
+ uninstallCmd.SetContext(ctx)
+ err := uninstallCmd.RunE(uninstallCmd, []string{})
+ require.NoError(t, err)
+
+ cfg, err := newSVCConfig()
+ require.NoError(t, err)
+
+ ctxSvc, cancel := context.WithCancel(context.Background())
+ defer cancel()
+
+ s, err := newSVC(newProgram(ctxSvc, cancel), cfg)
+ require.NoError(t, err)
+
+ _, err = s.Status()
+ assert.Error(t, err)
+ })
+}
diff --git a/client/cmd/service_test.go b/client/cmd/service_test.go
index ce6f71550..22eba206d 100644
--- a/client/cmd/service_test.go
+++ b/client/cmd/service_test.go
@@ -1,16 +1,12 @@
package cmd
import (
- "context"
- "fmt"
"os"
"os/signal"
"runtime"
"syscall"
"testing"
- "time"
- "github.com/kardianos/service"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
@@ -31,186 +27,6 @@ func TestMain(m *testing.M) {
os.Exit(m.Run())
}
-const (
- serviceStartTimeout = 10 * time.Second
- serviceStopTimeout = 5 * time.Second
- statusPollInterval = 500 * time.Millisecond
-)
-
-// waitForServiceStatus waits for service to reach expected status with timeout
-func waitForServiceStatus(expectedStatus service.Status, timeout time.Duration) (bool, error) {
- cfg, err := newSVCConfig()
- if err != nil {
- return false, err
- }
-
- ctxSvc, cancel := context.WithCancel(context.Background())
- defer cancel()
-
- s, err := newSVC(newProgram(ctxSvc, cancel), cfg)
- if err != nil {
- return false, err
- }
-
- ctx, timeoutCancel := context.WithTimeout(context.Background(), timeout)
- defer timeoutCancel()
-
- ticker := time.NewTicker(statusPollInterval)
- defer ticker.Stop()
-
- for {
- select {
- case <-ctx.Done():
- return false, fmt.Errorf("timeout waiting for service status %v", expectedStatus)
- case <-ticker.C:
- status, err := s.Status()
- if err != nil {
- // Continue polling on transient errors
- continue
- }
- if status == expectedStatus {
- return true, nil
- }
- }
- }
-}
-
-// TestServiceLifecycle tests the complete service lifecycle
-func TestServiceLifecycle(t *testing.T) {
- // TODO: Add support for Windows and macOS
- if runtime.GOOS != "linux" && runtime.GOOS != "freebsd" {
- t.Skipf("Skipping service lifecycle test on unsupported OS: %s", runtime.GOOS)
- }
-
- if os.Getenv("CONTAINER") == "true" {
- t.Skip("Skipping service lifecycle test in container environment")
- }
-
- originalServiceName := serviceName
- serviceName = "netbirdtest" + fmt.Sprintf("%d", time.Now().Unix())
- defer func() {
- serviceName = originalServiceName
- }()
-
- tempDir := t.TempDir()
- configPath = fmt.Sprintf("%s/netbird-test-config.json", tempDir)
- logLevel = "info"
- daemonAddr = fmt.Sprintf("unix://%s/netbird-test.sock", tempDir)
-
- // Ensure cleanup even if a subtest fails and Stop/Uninstall subtests don't run.
- t.Cleanup(func() {
- cfg, err := newSVCConfig()
- if err != nil {
- t.Errorf("cleanup: create service config: %v", err)
- return
- }
- ctxSvc, cancel := context.WithCancel(context.Background())
- defer cancel()
- s, err := newSVC(newProgram(ctxSvc, cancel), cfg)
- if err != nil {
- t.Errorf("cleanup: create service: %v", err)
- return
- }
-
- // If the subtests already cleaned up, there's nothing to do.
- if _, err := s.Status(); err != nil {
- return
- }
-
- if err := s.Stop(); err != nil {
- t.Errorf("cleanup: stop service: %v", err)
- }
- if err := s.Uninstall(); err != nil {
- t.Errorf("cleanup: uninstall service: %v", err)
- }
- })
-
- ctx := context.Background()
-
- t.Run("Install", func(t *testing.T) {
- installCmd.SetContext(ctx)
- err := installCmd.RunE(installCmd, []string{})
- require.NoError(t, err)
-
- cfg, err := newSVCConfig()
- require.NoError(t, err)
-
- ctxSvc, cancel := context.WithCancel(context.Background())
- defer cancel()
-
- s, err := newSVC(newProgram(ctxSvc, cancel), cfg)
- require.NoError(t, err)
-
- status, err := s.Status()
- assert.NoError(t, err)
- assert.NotEqual(t, service.StatusUnknown, status)
- })
-
- t.Run("Start", func(t *testing.T) {
- startCmd.SetContext(ctx)
- err := startCmd.RunE(startCmd, []string{})
- require.NoError(t, err)
-
- running, err := waitForServiceStatus(service.StatusRunning, serviceStartTimeout)
- require.NoError(t, err)
- assert.True(t, running)
- })
-
- t.Run("Restart", func(t *testing.T) {
- restartCmd.SetContext(ctx)
- err := restartCmd.RunE(restartCmd, []string{})
- require.NoError(t, err)
-
- running, err := waitForServiceStatus(service.StatusRunning, serviceStartTimeout)
- require.NoError(t, err)
- assert.True(t, running)
- })
-
- t.Run("Reconfigure", func(t *testing.T) {
- originalLogLevel := logLevel
- logLevel = "debug"
- defer func() {
- logLevel = originalLogLevel
- }()
-
- reconfigureCmd.SetContext(ctx)
- err := reconfigureCmd.RunE(reconfigureCmd, []string{})
- require.NoError(t, err)
-
- running, err := waitForServiceStatus(service.StatusRunning, serviceStartTimeout)
- require.NoError(t, err)
- assert.True(t, running)
- })
-
- t.Run("Stop", func(t *testing.T) {
- stopCmd.SetContext(ctx)
- err := stopCmd.RunE(stopCmd, []string{})
- require.NoError(t, err)
-
- stopped, err := waitForServiceStatus(service.StatusStopped, serviceStopTimeout)
- require.NoError(t, err)
- assert.True(t, stopped)
- })
-
- t.Run("Uninstall", func(t *testing.T) {
- uninstallCmd.SetContext(ctx)
- err := uninstallCmd.RunE(uninstallCmd, []string{})
- require.NoError(t, err)
-
- cfg, err := newSVCConfig()
- require.NoError(t, err)
-
- ctxSvc, cancel := context.WithCancel(context.Background())
- defer cancel()
-
- s, err := newSVC(newProgram(ctxSvc, cancel), cfg)
- require.NoError(t, err)
-
- _, err = s.Status()
- assert.Error(t, err)
- })
-}
-
// TestServiceEnvVars tests environment variable parsing
func TestServiceEnvVars(t *testing.T) {
tests := []struct {
diff --git a/client/cmd/status.go b/client/cmd/status.go
index 103b3044a..5a7559cf1 100644
--- a/client/cmd/status.go
+++ b/client/cmd/status.go
@@ -11,7 +11,6 @@ import (
"google.golang.org/grpc/status"
"github.com/netbirdio/netbird/client/internal"
- "github.com/netbirdio/netbird/client/internal/profilemanager"
"github.com/netbirdio/netbird/client/proto"
nbstatus "github.com/netbirdio/netbird/client/status"
"github.com/netbirdio/netbird/util"
@@ -111,11 +110,10 @@ func statusFunc(cmd *cobra.Command, args []string) error {
return nil
}
- pm := profilemanager.NewProfileManager()
- var profName string
- if activeProf, err := pm.GetActiveProfile(); err == nil {
- profName = activeProf.Name
- }
+ // Resolve the active profile's display name via the daemon, which runs
+ // as root and can read the per-user profile files. The local profile
+ // manager only knows the active profile ID, not its display name.
+ profName := getActiveProfileName(ctx)
var outputInformationHolder = nbstatus.ConvertToStatusOutputOverview(resp.GetFullStatus(), nbstatus.ConvertOptions{
Anonymize: anonymizeFlag,
@@ -167,6 +165,25 @@ func getStatus(ctx context.Context, fullPeerStatus bool, shouldRunProbes bool) (
return resp, nil
}
+// getActiveProfileName asks the daemon for the active profile's display
+// name. The daemon runs as root and can read the per-user profile files to
+// resolve the ID to its human-readable name. Returns an empty string on any
+// error so status output degrades gracefully.
+func getActiveProfileName(ctx context.Context) string {
+ conn, err := DialClientGRPCServer(ctx, daemonAddr)
+ if err != nil {
+ return ""
+ }
+ defer conn.Close()
+
+ resp, err := proto.NewDaemonServiceClient(conn).GetActiveProfile(ctx, &proto.GetActiveProfileRequest{})
+ if err != nil {
+ return ""
+ }
+
+ return resp.GetProfileName()
+}
+
func parseFilters() error {
switch strings.ToLower(statusFilter) {
case "", "idle", "connecting", "connected":
diff --git a/client/cmd/up.go b/client/cmd/up.go
index cabd0aacf..0506bc65b 100644
--- a/client/cmd/up.go
+++ b/client/cmd/up.go
@@ -128,16 +128,9 @@ func upFunc(cmd *cobra.Command, args []string) error {
var profileSwitched bool
// switch profile if provided
if profileName != "" {
- err = switchProfile(cmd.Context(), profileName, username.Username)
- if err != nil {
+ if err := switchOrCreateProfile(cmd.Context(), pm, profileName, username.Username); err != nil {
return fmt.Errorf("switch profile: %v", err)
}
-
- err = pm.SwitchProfile(profileName)
- if err != nil {
- return fmt.Errorf("switch profile: %v", err)
- }
-
profileSwitched = true
}
@@ -152,6 +145,52 @@ func upFunc(cmd *cobra.Command, args []string) error {
return runInDaemonMode(ctx, cmd, pm, activeProf, profileSwitched)
}
+// switchOrCreateProfile switches the active profile to the one identified by
+// handle, creating it first when it does not exist yet. This restores the
+// pre-0.73 behaviour where `netbird up --profile ` auto-creates a
+// missing profile instead of failing.
+func switchOrCreateProfile(ctx context.Context, pm *profilemanager.ProfileManager, handle, username string) error {
+ resolvedID, err := switchProfile(ctx, handle, username)
+ if err != nil {
+ st, ok := gstatus.FromError(err)
+ if !ok || st.Code() != codes.NotFound {
+ return err
+ }
+ // Don't fail immediately on a create error: a concurrent run may
+ // have created the profile between the NotFound above and this
+ // call, in which case the retried switch still succeeds. Only
+ // surface the create error if the switch also fails.
+ _, createErr := createProfile(ctx, handle, username)
+ if resolvedID, err = switchProfile(ctx, handle, username); err != nil {
+ if createErr != nil {
+ return fmt.Errorf("create profile: %w", createErr)
+ }
+ return err
+ }
+ }
+
+ if err := pm.SwitchProfile(resolvedID); err != nil {
+ return err
+ }
+ return nil
+}
+
+// createProfile dials the daemon and creates a new profile with the given
+// display name, returning its generated ID. Use addProfileOnDaemon directly
+// when a daemon client is already available to reuse the connection.
+func createProfile(ctx context.Context, profileName, username string) (profilemanager.ID, error) {
+ conn, err := DialClientGRPCServer(ctx, daemonAddr)
+ if err != nil {
+ //nolint
+ return "", fmt.Errorf("failed to connect to daemon error: %v\n"+
+ "If the daemon is not running please run: "+
+ "\nnetbird service install \nnetbird service start\n", err)
+ }
+ defer conn.Close()
+
+ return addProfileOnDaemon(ctx, proto.NewDaemonServiceClient(conn), profileName, username)
+}
+
func runInForegroundMode(ctx context.Context, cmd *cobra.Command, activeProf *profilemanager.Profile) error {
// override the default profile filepath if provided
if configPath != "" {
@@ -190,7 +229,7 @@ func runInForegroundMode(ctx context.Context, cmd *cobra.Command, activeProf *pr
_, _ = profilemanager.UpdateOldManagementURL(ctx, config, configFilePath)
- err = foregroundLogin(ctx, cmd, config, providedSetupKey, activeProf.Name)
+ err = foregroundLogin(ctx, cmd, config, providedSetupKey, activeProf.ID)
if err != nil {
return fmt.Errorf("foreground login failed: %v", err)
}
@@ -261,10 +300,10 @@ func runInDaemonMode(ctx context.Context, cmd *cobra.Command, pm *profilemanager
}
// set the new config
- req := setupSetConfigReq(customDNSAddressConverted, cmd, activeProf.Name, username.Username)
+ req := setupSetConfigReq(customDNSAddressConverted, cmd, activeProf.ID.String(), username.Username)
if _, err := client.SetConfig(ctx, req); err != nil {
if st, ok := gstatus.FromError(err); ok && st.Code() == codes.Unavailable {
- log.Warnf("setConfig method is not available in the daemon")
+ log.Warnf("setConfig method is not available in the daemon: %s", st.Message())
} else {
return fmt.Errorf("call service setConfig method: %v", err)
}
@@ -289,10 +328,11 @@ func doDaemonUp(ctx context.Context, cmd *cobra.Command, client proto.DaemonServ
return fmt.Errorf("setup login request: %v", err)
}
- loginRequest.ProfileName = &activeProf.Name
+ profileID := activeProf.ID.String()
+ loginRequest.ProfileName = &profileID
loginRequest.Username = &username
- profileState, err := pm.GetProfileState(activeProf.Name)
+ profileState, err := pm.GetProfileState(activeProf.ID)
if err != nil {
log.Debugf("failed to get profile state for login hint: %v", err)
} else if profileState.Email != "" {
@@ -329,7 +369,7 @@ func doDaemonUp(ctx context.Context, cmd *cobra.Command, client proto.DaemonServ
}
if _, err := client.Up(ctx, &proto.UpRequest{
- ProfileName: &activeProf.Name,
+ ProfileName: &profileID,
Username: &username,
}); err != nil {
return fmt.Errorf("call service up method: %v", err)
diff --git a/client/cmd/up_daemon_test.go b/client/cmd/up_daemon_test.go
index 682a45365..ea4cdf162 100644
--- a/client/cmd/up_daemon_test.go
+++ b/client/cmd/up_daemon_test.go
@@ -29,14 +29,14 @@ func TestUpDaemon(t *testing.T) {
}
sm := profilemanager.ServiceManager{}
- err = sm.AddProfile("test1", currUser.Username)
+ created, err := sm.AddProfile("test1", currUser.Username)
if err != nil {
t.Fatalf("failed to add profile: %v", err)
return
}
err = sm.SetActiveProfileState(&profilemanager.ActiveProfileState{
- Name: "test1",
+ ID: created.ID,
Username: currUser.Username,
})
if err != nil {
diff --git a/client/embed/embed.go b/client/embed/embed.go
index 04bc60fb8..d0d88b177 100644
--- a/client/embed/embed.go
+++ b/client/embed/embed.go
@@ -279,6 +279,12 @@ func (c *Client) Start(startCtx context.Context) error {
select {
case <-startCtx.Done():
+ // ConnectClient.Stop now cancels its own run context and waits for the
+ // run loop to tear the engine down, so this cancel() is no longer
+ // required to break the deadlock and could be removed. It is kept as a
+ // defensive belt-and-suspenders: cancelling the parent context first
+ // guarantees the run loop is unblocked even if Stop's contract regresses.
+ 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 +448,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/manager_linux_test.go b/client/firewall/iptables/manager_linux_test.go
index e1d834713..7e0061c00 100644
--- a/client/firewall/iptables/manager_linux_test.go
+++ b/client/firewall/iptables/manager_linux_test.go
@@ -1,4 +1,4 @@
-//go:build integration && !android
+//go:build privileged
package iptables
diff --git a/client/firewall/iptables/router_linux_test.go b/client/firewall/iptables/router_linux_test.go
index 2a9d25266..6c4ae9425 100644
--- a/client/firewall/iptables/router_linux_test.go
+++ b/client/firewall/iptables/router_linux_test.go
@@ -1,4 +1,4 @@
-//go:build integration && !android
+//go:build !android && privileged
package iptables
diff --git a/client/firewall/iptables/testhelpers_linux_test.go b/client/firewall/iptables/testhelpers_linux_test.go
index 6f4aba1da..fe44f7cc3 100644
--- a/client/firewall/iptables/testhelpers_linux_test.go
+++ b/client/firewall/iptables/testhelpers_linux_test.go
@@ -1,4 +1,4 @@
-//go:build integration && !android
+//go:build privileged
package iptables
diff --git a/client/firewall/nftables/interface_allower_integration_linux_test.go b/client/firewall/nftables/interface_allower_integration_linux_test.go
index a654727d3..4d4bc6187 100644
--- a/client/firewall/nftables/interface_allower_integration_linux_test.go
+++ b/client/firewall/nftables/interface_allower_integration_linux_test.go
@@ -1,4 +1,4 @@
-//go:build integration && !android
+//go:build privileged
package nftables
diff --git a/client/firewall/nftables/manager_linux_test.go b/client/firewall/nftables/manager_linux_test.go
index e9077afba..0ca56409e 100644
--- a/client/firewall/nftables/manager_linux_test.go
+++ b/client/firewall/nftables/manager_linux_test.go
@@ -1,4 +1,4 @@
-//go:build integration && !android
+//go:build privileged
package nftables
diff --git a/client/firewall/nftables/router_linux_test.go b/client/firewall/nftables/router_linux_test.go
index 9b41bb905..e38ec846d 100644
--- a/client/firewall/nftables/router_linux_test.go
+++ b/client/firewall/nftables/router_linux_test.go
@@ -1,4 +1,4 @@
-//go:build integration && !android
+//go:build !android && privileged
package nftables
diff --git a/client/firewall/nftables/testhelpers_linux_test.go b/client/firewall/nftables/testhelpers_linux_test.go
index 12f2106a4..72db3f7d2 100644
--- a/client/firewall/nftables/testhelpers_linux_test.go
+++ b/client/firewall/nftables/testhelpers_linux_test.go
@@ -1,4 +1,4 @@
-//go:build integration && !android
+//go:build privileged
package nftables
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/iface_test.go b/client/iface/iface_test.go
index dbeb69bc6..8ff2bbb54 100644
--- a/client/iface/iface_test.go
+++ b/client/iface/iface_test.go
@@ -1,3 +1,5 @@
+//go:build privileged
+
package iface
import (
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..e34dd3b6b 100644
--- a/client/iface/wgproxy/proxy_linux_test.go
+++ b/client/iface/wgproxy/proxy_linux_test.go
@@ -1,4 +1,4 @@
-//go:build linux && !android
+//go:build linux && !android && privileged
package wgproxy
@@ -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..4fb9ed77a 100644
--- a/client/iface/wgproxy/proxy_seed_test.go
+++ b/client/iface/wgproxy/proxy_seed_test.go
@@ -1,4 +1,4 @@
-//go:build !linux
+//go:build !linux || !privileged
package wgproxy
@@ -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/iface/wgproxy/redirect_test.go b/client/iface/wgproxy/redirect_test.go
index b52eead25..135970838 100644
--- a/client/iface/wgproxy/redirect_test.go
+++ b/client/iface/wgproxy/redirect_test.go
@@ -1,4 +1,4 @@
-//go:build linux && !android
+//go:build linux && !android && privileged
package wgproxy
@@ -26,64 +26,6 @@ func compareUDPAddr(addr1, addr2 net.Addr) bool {
return udpAddr1.IP.Equal(udpAddr2.IP) && udpAddr1.Port == udpAddr2.Port
}
-// TestRedirectAs_eBPF_IPv4 tests RedirectAs with eBPF proxy using IPv4 addresses
-func TestRedirectAs_eBPF_IPv4(t *testing.T) {
- wgPort := 51850
- ebpfProxy := ebpf.NewWGEBPFProxy(wgPort, 1280)
- if err := ebpfProxy.Listen(); err != nil {
- t.Fatalf("failed to initialize ebpf proxy: %v", err)
- }
- defer func() {
- if err := ebpfProxy.Free(); err != nil {
- t.Errorf("failed to free ebpf proxy: %v", err)
- }
- }()
-
- proxy := ebpf.NewProxyWrapper(ebpfProxy)
-
- // NetBird UDP address of the remote peer
- nbAddr := &net.UDPAddr{
- IP: net.ParseIP("100.108.111.177"),
- Port: 38746,
- }
-
- p2pEndpoint := &net.UDPAddr{
- IP: net.ParseIP("192.168.0.56"),
- Port: 51820,
- }
-
- testRedirectAs(t, proxy, wgPort, nbAddr, p2pEndpoint)
-}
-
-// TestRedirectAs_eBPF_IPv6 tests RedirectAs with eBPF proxy using IPv6 addresses
-func TestRedirectAs_eBPF_IPv6(t *testing.T) {
- wgPort := 51851
- ebpfProxy := ebpf.NewWGEBPFProxy(wgPort, 1280)
- if err := ebpfProxy.Listen(); err != nil {
- t.Fatalf("failed to initialize ebpf proxy: %v", err)
- }
- defer func() {
- if err := ebpfProxy.Free(); err != nil {
- t.Errorf("failed to free ebpf proxy: %v", err)
- }
- }()
-
- proxy := ebpf.NewProxyWrapper(ebpfProxy)
-
- // NetBird UDP address of the remote peer
- nbAddr := &net.UDPAddr{
- IP: net.ParseIP("100.108.111.177"),
- Port: 38746,
- }
-
- p2pEndpoint := &net.UDPAddr{
- IP: net.ParseIP("fe80::56"),
- Port: 51820,
- }
-
- testRedirectAs(t, proxy, wgPort, nbAddr, p2pEndpoint)
-}
-
// TestRedirectAs_UDP_IPv4 tests RedirectAs with UDP proxy using IPv4 addresses
func TestRedirectAs_UDP_IPv4(t *testing.T) {
wgPort := 51852
@@ -256,6 +198,64 @@ func testRedirectAs(t *testing.T, proxy Proxy, wgPort int, nbAddr, p2pEndpoint *
}
}
+// TestRedirectAs_eBPF_IPv4 tests RedirectAs with eBPF proxy using IPv4 addresses
+func TestRedirectAs_eBPF_IPv4(t *testing.T) {
+ wgPort := 51850
+ ebpfProxy := ebpf.NewWGEBPFProxy(wgPort, 1280)
+ if err := ebpfProxy.Listen(); err != nil {
+ t.Fatalf("failed to initialize ebpf proxy: %v", err)
+ }
+ defer func() {
+ if err := ebpfProxy.Free(); err != nil {
+ t.Errorf("failed to free ebpf proxy: %v", err)
+ }
+ }()
+
+ proxy := ebpf.NewProxyWrapper(ebpfProxy)
+
+ // NetBird UDP address of the remote peer
+ nbAddr := &net.UDPAddr{
+ IP: net.ParseIP("100.108.111.177"),
+ Port: 38746,
+ }
+
+ p2pEndpoint := &net.UDPAddr{
+ IP: net.ParseIP("192.168.0.56"),
+ Port: 51820,
+ }
+
+ testRedirectAs(t, proxy, wgPort, nbAddr, p2pEndpoint)
+}
+
+// TestRedirectAs_eBPF_IPv6 tests RedirectAs with eBPF proxy using IPv6 addresses
+func TestRedirectAs_eBPF_IPv6(t *testing.T) {
+ wgPort := 51851
+ ebpfProxy := ebpf.NewWGEBPFProxy(wgPort, 1280)
+ if err := ebpfProxy.Listen(); err != nil {
+ t.Fatalf("failed to initialize ebpf proxy: %v", err)
+ }
+ defer func() {
+ if err := ebpfProxy.Free(); err != nil {
+ t.Errorf("failed to free ebpf proxy: %v", err)
+ }
+ }()
+
+ proxy := ebpf.NewProxyWrapper(ebpfProxy)
+
+ // NetBird UDP address of the remote peer
+ nbAddr := &net.UDPAddr{
+ IP: net.ParseIP("100.108.111.177"),
+ Port: 38746,
+ }
+
+ p2pEndpoint := &net.UDPAddr{
+ IP: net.ParseIP("fe80::56"),
+ Port: 51820,
+ }
+
+ testRedirectAs(t, proxy, wgPort, nbAddr, p2pEndpoint)
+}
+
// TestRedirectAs_Multiple_Switches tests switching between multiple endpoints
func TestRedirectAs_Multiple_Switches(t *testing.T) {
wgPort := 51856
diff --git a/client/internal/connect.go b/client/internal/connect.go
index e38bc2f58..7cd2bab22 100644
--- a/client/internal/connect.go
+++ b/client/internal/connect.go
@@ -11,6 +11,7 @@ import (
"runtime/debug"
"strings"
"sync"
+ "sync/atomic"
"time"
"github.com/cenkalti/backoff/v4"
@@ -54,6 +55,10 @@ var androidRunOverride func(c *ConnectClient, runningChan chan struct{}, logPath
type ConnectClient struct {
ctx context.Context
+ runCancel context.CancelFunc
+ runExited chan struct{}
+ runOnce sync.Once
+ runStarted atomic.Bool
config *profilemanager.Config
statusRecorder *peer.Status
@@ -70,8 +75,14 @@ func NewConnectClient(
config *profilemanager.Config,
statusRecorder *peer.Status,
) *ConnectClient {
+ // Derive the run context here so Stop owns the cancel that unblocks the run
+ // loop. runCancel is set once at construction, so Stop can call it without
+ // racing the run loop's startup. Callers therefore need not cancel before Stop.
+ runCtx, runCancel := context.WithCancel(ctx)
return &ConnectClient{
- ctx: ctx,
+ ctx: runCtx,
+ runCancel: runCancel,
+ runExited: make(chan struct{}),
config: config,
statusRecorder: statusRecorder,
engineMutex: sync.Mutex{},
@@ -118,6 +129,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)
@@ -127,11 +140,17 @@ 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 {
+ // Mark the loop as started and signal exit on return so Stop can wait for
+ // the loop to finish (and skip the wait if the loop never ran).
+ c.runStarted.Store(true)
+ defer c.runOnce.Do(func() { close(c.runExited) })
+
defer func() {
if r := recover(); r != nil {
rec := c.statusRecorder
@@ -287,7 +306,7 @@ func (c *ConnectClient) run(mobileDependency MobileDependency, runningChan chan
log.Debug(err)
if s, ok := gstatus.FromError(err); ok && (s.Code() == codes.PermissionDenied) {
state.Set(StatusNeedsLogin)
- _ = c.Stop()
+ c.runCancel()
return backoff.Permanent(wrapErr(err)) // unrecoverable error
}
return wrapErr(err)
@@ -407,14 +426,10 @@ func (c *ConnectClient) run(mobileDependency MobileDependency, runningChan chan
c.engine = nil
c.engineMutex.Unlock()
- // todo: consider to remove this condition. Is not thread safe.
- // We should always call Stop(), but we need to verify that it is idempotent
- if engine.wgInterface != nil {
- log.Infof("ensuring %s is removed, Netbird engine context cancelled", engine.wgInterface.Name())
+ log.Infof("ensuring wg interface is removed, Netbird engine context cancelled")
- if err := engine.Stop(); err != nil {
- log.Errorf("Failed to stop engine: %v", err)
- }
+ if err := engine.Stop(); err != nil {
+ log.Errorf("Failed to stop engine: %v", err)
}
c.statusRecorder.ClientTeardown()
@@ -430,12 +445,12 @@ func (c *ConnectClient) run(mobileDependency MobileDependency, runningChan chan
}
c.statusRecorder.ClientStart()
- err = backoff.Retry(operation, backOff)
+ err = backoff.Retry(operation, backoff.WithContext(backOff, c.ctx))
if err != nil {
log.Debugf("exiting client retry loop due to unrecoverable error: %s", err)
if s, ok := gstatus.FromError(err); ok && (s.Code() == codes.PermissionDenied) {
state.Set(StatusNeedsLogin)
- _ = c.Stop()
+ c.runCancel()
}
return err
}
@@ -513,11 +528,9 @@ func (c *ConnectClient) Status() StatusType {
}
func (c *ConnectClient) Stop() error {
- engine := c.Engine()
- if engine != nil {
- if err := engine.Stop(); err != nil {
- return fmt.Errorf("stop engine: %w", err)
- }
+ c.runCancel()
+ if c.runStarted.Load() {
+ <-c.runExited
}
return nil
}
diff --git a/client/internal/debug/debug.go b/client/internal/debug/debug.go
index 9ab18dd80..a65d8bd05 100644
--- a/client/internal/debug/debug.go
+++ b/client/internal/debug/debug.go
@@ -250,6 +250,7 @@ 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
@@ -276,6 +277,7 @@ 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()
@@ -299,6 +301,7 @@ 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,
@@ -516,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)
@@ -842,8 +853,11 @@ func (g *BundleGenerator) maskSecrets() {
}
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
}
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_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..ca7785d35 100644
--- a/client/internal/debug/debug_test.go
+++ b/client/internal/debug/debug_test.go
@@ -843,6 +843,8 @@ 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",
+ "Name": "non-config: profile name is not needed for debug purposes",
+ "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/mgmt/mgmt.go b/client/internal/dns/mgmt/mgmt.go
index 988e427fb..ddc8cf585 100644
--- a/client/internal/dns/mgmt/mgmt.go
+++ b/client/internal/dns/mgmt/mgmt.go
@@ -51,13 +51,20 @@ type cachedRecord struct {
}
// Resolver caches critical NetBird infrastructure domains.
-// records, refreshing, mgmtDomain and serverDomains are all guarded by mutex.
+// records, refreshing, failedResolves, mgmtDomain and serverDomains are all
+// guarded by mutex.
type Resolver struct {
records map[dns.Question]*cachedRecord
mgmtDomain *domain.Domain
serverDomains *dnsconfig.ServerDomains
mutex sync.RWMutex
+ // failedResolves records the last failed initial resolve per domain so a
+ // domain that never resolves isn't retried on every server-domains update
+ // until refreshBackoff elapses. Entries are cleared on success and pruned
+ // to the current server-domains set.
+ failedResolves map[domain.Domain]time.Time
+
chain ChainResolver
chainMaxPriority int
refreshGroup singleflight.Group
@@ -76,9 +83,10 @@ type Resolver struct {
// NewResolver creates a new management domains cache resolver.
func NewResolver() *Resolver {
return &Resolver{
- records: make(map[dns.Question]*cachedRecord),
- refreshing: make(map[dns.Question]*atomic.Bool),
- cacheTTL: resolveCacheTTL(),
+ records: make(map[dns.Question]*cachedRecord),
+ refreshing: make(map[dns.Question]*atomic.Bool),
+ failedResolves: make(map[domain.Domain]time.Time),
+ cacheTTL: resolveCacheTTL(),
}
}
@@ -173,7 +181,9 @@ func (m *Resolver) continueToNext(w dns.ResponseWriter, r *dns.Msg) {
// AddDomain resolves a domain and stores its A/AAAA records in the cache.
// A family that resolves NODATA (nil err, zero records) evicts any stale
-// entry for that qtype.
+// entry for that qtype. When one family hard-errors while the other succeeds,
+// the resolved family is still cached but AddDomain returns an error so the
+// caller retries the incomplete resolve rather than treating it as complete.
func (m *Resolver) AddDomain(ctx context.Context, d domain.Domain) error {
dnsName := strings.ToLower(dns.Fqdn(d.PunycodeString()))
@@ -203,6 +213,10 @@ func (m *Resolver) AddDomain(ctx context.Context, d domain.Domain) error {
log.Debugf("added/updated domain=%s with %d A records and %d AAAA records",
d.SafeString(), len(aRecords), len(aaaaRecords))
+ if errA != nil || errAAAA != nil {
+ return fmt.Errorf("resolve %s: incomplete, a family failed: %w", d.SafeString(), errors.Join(errA, errAAAA))
+ }
+
return nil
}
@@ -462,6 +476,7 @@ func (m *Resolver) RemoveDomain(d domain.Domain) error {
delete(m.records, qAAAA)
delete(m.refreshing, qA)
delete(m.refreshing, qAAAA)
+ delete(m.failedResolves, d)
log.Debugf("removed domain=%s from cache", d.SafeString())
return nil
@@ -505,6 +520,7 @@ func (m *Resolver) UpdateFromServerDomains(ctx context.Context, serverDomains dn
allDomains := m.extractDomainsFromServerDomains(updatedServerDomains)
currentDomains := m.GetCachedDomains()
removedDomains = m.removeStaleDomains(currentDomains, allDomains)
+ m.pruneFailedResolves(allDomains)
}
m.addNewDomains(ctx, newDomains)
@@ -577,13 +593,85 @@ func (m *Resolver) isManagementDomain(domain domain.Domain) bool {
return m.mgmtDomain != nil && domain == *m.mgmtDomain
}
-// addNewDomains resolves and caches all domains from the update
+// addNewDomains resolves and caches domains that are not yet in the cache,
+// running the lookups concurrently. Domains already cached are skipped and left
+// to the stale-while-revalidate refresh path, so a sync never re-resolves them
+// synchronously: once NetBird owns the OS resolver the resolve runs through the
+// handler chain and would otherwise dial the managed upstreams under the engine
+// sync lock on every update.
func (m *Resolver) addNewDomains(ctx context.Context, newDomains domain.List) {
+ var wg sync.WaitGroup
+ seen := make(map[domain.Domain]struct{}, len(newDomains))
for _, newDomain := range newDomains {
- if err := m.AddDomain(ctx, newDomain); err != nil {
- log.Warnf("failed to add/update domain=%s: %v", newDomain.SafeString(), err)
- } else {
- log.Debugf("added/updated management cache domain=%s", newDomain.SafeString())
+ if _, dup := seen[newDomain]; dup {
+ continue
+ }
+ seen[newDomain] = struct{}{}
+
+ if !m.needsResolve(newDomain) {
+ continue
+ }
+
+ wg.Add(1)
+ go func(d domain.Domain) {
+ defer wg.Done()
+ if err := m.AddDomain(ctx, d); err != nil {
+ m.markResolveFailed(d)
+ log.Warnf("failed to add/update domain=%s: %v", d.SafeString(), err)
+ return
+ }
+ m.clearResolveFailed(d)
+ log.Debugf("added/updated management cache domain=%s", d.SafeString())
+ }(newDomain)
+ }
+ wg.Wait()
+}
+
+// needsResolve reports whether d should be resolved now. A recent failed or
+// incomplete resolve gates retries on the backoff even when one family is
+// already cached, so a transiently-failed family is retried instead of being
+// treated as fully resolved. Otherwise a domain with any cached record is left
+// to the stale-while-revalidate refresh path.
+func (m *Resolver) needsResolve(d domain.Domain) bool {
+ dnsName := strings.ToLower(dns.Fqdn(d.PunycodeString()))
+
+ m.mutex.RLock()
+ defer m.mutex.RUnlock()
+
+ if failedAt, ok := m.failedResolves[d]; ok {
+ return time.Since(failedAt) >= refreshBackoff
+ }
+
+ for _, qtype := range []uint16{dns.TypeA, dns.TypeAAAA} {
+ q := dns.Question{Name: dnsName, Qtype: qtype, Qclass: dns.ClassINET}
+ if _, ok := m.records[q]; ok {
+ return false
+ }
+ }
+ return true
+}
+
+func (m *Resolver) markResolveFailed(d domain.Domain) {
+ m.mutex.Lock()
+ m.failedResolves[d] = time.Now()
+ m.mutex.Unlock()
+}
+
+func (m *Resolver) clearResolveFailed(d domain.Domain) {
+ m.mutex.Lock()
+ delete(m.failedResolves, d)
+ m.mutex.Unlock()
+}
+
+// pruneFailedResolves drops failure markers for domains no longer present in
+// the server-domains set, keeping the map bounded to the current set (a
+// failed-only domain has no cached record, so RemoveDomain never sees it).
+func (m *Resolver) pruneFailedResolves(domains domain.List) {
+ m.mutex.Lock()
+ defer m.mutex.Unlock()
+ for d := range m.failedResolves {
+ if !slices.Contains(domains, d) {
+ delete(m.failedResolves, d)
}
}
}
diff --git a/client/internal/dns/mgmt/mgmt_refresh_test.go b/client/internal/dns/mgmt/mgmt_refresh_test.go
index 9faa5a0b8..64a5342e2 100644
--- a/client/internal/dns/mgmt/mgmt_refresh_test.go
+++ b/client/internal/dns/mgmt/mgmt_refresh_test.go
@@ -21,6 +21,7 @@ type fakeChain struct {
mu sync.Mutex
calls map[string]int
answers map[string][]dns.RR
+ qErr map[string]error
err error
hasRoot bool
onLookup func()
@@ -30,6 +31,7 @@ func newFakeChain() *fakeChain {
return &fakeChain{
calls: map[string]int{},
answers: map[string][]dns.RR{},
+ qErr: map[string]error{},
hasRoot: true,
}
}
@@ -47,6 +49,9 @@ func (f *fakeChain) ResolveInternal(ctx context.Context, msg *dns.Msg, maxPriori
f.calls[key]++
answers := f.answers[key]
err := f.err
+ if err == nil {
+ err = f.qErr[key]
+ }
onLookup := f.onLookup
f.mu.Unlock()
@@ -75,6 +80,12 @@ func (f *fakeChain) setAnswer(name string, qtype uint16, ip string) {
}
}
+func (f *fakeChain) setErr(name string, qtype uint16, err error) {
+ f.mu.Lock()
+ defer f.mu.Unlock()
+ f.qErr[name+"|"+dns.TypeToString[qtype]] = err
+}
+
func (f *fakeChain) callCount(name string, qtype uint16) int {
f.mu.Lock()
defer f.mu.Unlock()
diff --git a/client/internal/dns/mgmt/mgmt_resolve_test.go b/client/internal/dns/mgmt/mgmt_resolve_test.go
new file mode 100644
index 000000000..5cfbac8f0
--- /dev/null
+++ b/client/internal/dns/mgmt/mgmt_resolve_test.go
@@ -0,0 +1,183 @@
+package mgmt
+
+import (
+ "context"
+ "errors"
+ "sync/atomic"
+ "testing"
+ "time"
+
+ "github.com/miekg/dns"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+
+ dnsconfig "github.com/netbirdio/netbird/client/internal/dns/config"
+ "github.com/netbirdio/netbird/shared/management/domain"
+)
+
+// A domain already in the cache must not be re-resolved on a subsequent server
+// domains update; it is left to the stale-while-revalidate refresh path.
+func TestResolver_UpdateFromServerDomains_SkipsCached(t *testing.T) {
+ r := NewResolver()
+ chain := newFakeChain()
+ chain.setAnswer("signal.example.com.", dns.TypeA, "10.0.0.2")
+ r.SetChainResolver(chain, 50)
+
+ sd := dnsconfig.ServerDomains{Signal: domain.Domain("signal.example.com")}
+
+ _, err := r.UpdateFromServerDomains(context.Background(), sd)
+ require.NoError(t, err)
+ require.Equal(t, 1, chain.callCount("signal.example.com.", dns.TypeA),
+ "first update must resolve the domain")
+
+ _, err = r.UpdateFromServerDomains(context.Background(), sd)
+ require.NoError(t, err)
+ assert.Equal(t, 1, chain.callCount("signal.example.com.", dns.TypeA),
+ "cached domain must not be re-resolved on a subsequent update")
+}
+
+// New domains in a single update must resolve concurrently rather than serially.
+func TestResolver_AddNewDomains_ResolvesConcurrently(t *testing.T) {
+ r := NewResolver()
+ chain := newFakeChain()
+
+ var inflight, maxInflight atomic.Int32
+ chain.onLookup = func() {
+ n := inflight.Add(1)
+ for {
+ old := maxInflight.Load()
+ if n <= old || maxInflight.CompareAndSwap(old, n) {
+ break
+ }
+ }
+ time.Sleep(50 * time.Millisecond)
+ inflight.Add(-1)
+ }
+
+ relays := []domain.Domain{"a.example.com", "b.example.com", "c.example.com", "d.example.com"}
+ for _, d := range relays {
+ chain.setAnswer(dns.Fqdn(string(d)), dns.TypeA, "10.0.0.2")
+ }
+ r.SetChainResolver(chain, 50)
+
+ start := time.Now()
+ _, err := r.UpdateFromServerDomains(context.Background(), dnsconfig.ServerDomains{Relay: relays})
+ require.NoError(t, err)
+ elapsed := time.Since(start)
+
+ assert.GreaterOrEqual(t, int(maxInflight.Load()), 2, "domains must resolve concurrently")
+ // Serial resolution of 4 domains would take at least 4*50ms; concurrent is far less.
+ assert.Less(t, elapsed, 300*time.Millisecond, "resolution should not be serial")
+}
+
+// A domain that fails to resolve must not be retried on every update; the
+// failure backoff suppresses re-resolution until it expires.
+func TestResolver_UpdateFromServerDomains_BacksOffFailures(t *testing.T) {
+ r := NewResolver()
+ chain := newFakeChain()
+ chain.err = errors.New("resolve boom")
+ r.SetChainResolver(chain, 50)
+
+ sd := dnsconfig.ServerDomains{Signal: domain.Domain("signal.example.com")}
+
+ _, err := r.UpdateFromServerDomains(context.Background(), sd)
+ require.NoError(t, err)
+ require.Equal(t, 1, chain.callCount("signal.example.com.", dns.TypeA),
+ "first update must attempt the resolve")
+
+ _, err = r.UpdateFromServerDomains(context.Background(), sd)
+ require.NoError(t, err)
+ assert.Equal(t, 1, chain.callCount("signal.example.com.", dns.TypeA),
+ "failed resolve must back off and not retry on the next update")
+}
+
+// A domain listed under more than one server-domain type (e.g. STUN and TURN on
+// the same host) must be resolved once per update, not once per occurrence.
+func TestResolver_AddNewDomains_DedupesDuplicateDomains(t *testing.T) {
+ r := NewResolver()
+ chain := newFakeChain()
+ chain.setAnswer("dup.example.com.", dns.TypeA, "10.0.0.9")
+ r.SetChainResolver(chain, 50)
+
+ sd := dnsconfig.ServerDomains{
+ Stuns: []domain.Domain{"dup.example.com"},
+ Turns: []domain.Domain{"dup.example.com"},
+ }
+
+ _, err := r.UpdateFromServerDomains(context.Background(), sd)
+ require.NoError(t, err)
+ assert.Equal(t, 1, chain.callCount("dup.example.com.", dns.TypeA),
+ "a domain appearing under multiple server-domain types must resolve once")
+}
+
+// A failure marker must be dropped once its domain leaves the server-domains set
+// so the map stays bounded to the current set.
+func TestResolver_UpdateFromServerDomains_PrunesFailedResolves(t *testing.T) {
+ r := NewResolver()
+ chain := newFakeChain()
+ chain.err = errors.New("resolve boom")
+ r.SetChainResolver(chain, 50)
+
+ _, err := r.UpdateFromServerDomains(context.Background(), dnsconfig.ServerDomains{Signal: domain.Domain("gone.example.com")})
+ require.NoError(t, err)
+ r.mutex.RLock()
+ _, marked := r.failedResolves[domain.Domain("gone.example.com")]
+ r.mutex.RUnlock()
+ require.True(t, marked, "failed resolve must be recorded")
+
+ _, err = r.UpdateFromServerDomains(context.Background(), dnsconfig.ServerDomains{Signal: domain.Domain("other.example.com")})
+ require.NoError(t, err)
+ r.mutex.RLock()
+ _, stillMarked := r.failedResolves[domain.Domain("gone.example.com")]
+ r.mutex.RUnlock()
+ assert.False(t, stillMarked, "failure marker for a domain no longer in the set must be pruned")
+}
+
+// When one family hard-errors while the other resolves, the domain is cached
+// for the working family but recorded as incomplete so the failed family is
+// retried under backoff instead of being treated as fully resolved forever.
+func TestResolver_AddNewDomains_RetriesPartialFamilyFailure(t *testing.T) {
+ d := domain.Domain("relay.example.com")
+ r := NewResolver()
+ chain := newFakeChain()
+ chain.setAnswer("relay.example.com.", dns.TypeA, "10.0.0.2")
+ chain.setErr("relay.example.com.", dns.TypeAAAA, errors.New("servfail"))
+ r.SetChainResolver(chain, 50)
+
+ _, err := r.UpdateFromServerDomains(context.Background(), dnsconfig.ServerDomains{Relay: []domain.Domain{d}})
+ require.NoError(t, err)
+
+ r.mutex.RLock()
+ _, aCached := r.records[dns.Question{Name: "relay.example.com.", Qtype: dns.TypeA, Qclass: dns.ClassINET}]
+ _, marked := r.failedResolves[d]
+ r.mutex.RUnlock()
+ require.True(t, aCached, "the working family must still be cached")
+ require.True(t, marked, "a partial failure must be recorded so the failed family is retried")
+
+ assert.False(t, r.needsResolve(d), "within the backoff window the domain is not retried")
+
+ r.mutex.Lock()
+ r.failedResolves[d] = time.Now().Add(-2 * refreshBackoff)
+ r.mutex.Unlock()
+ assert.True(t, r.needsResolve(d), "after the backoff elapses the domain is retried to pick up the missing family")
+}
+
+// A family that returns NODATA (legitimately absent, e.g. an IPv4-only host) is
+// not a failure: the domain must not be marked for retry, otherwise it would be
+// re-resolved on every sync.
+func TestResolver_AddNewDomains_NodataIsNotFailure(t *testing.T) {
+ d := domain.Domain("v4only.example.com")
+ r := NewResolver()
+ chain := newFakeChain()
+ chain.setAnswer("v4only.example.com.", dns.TypeA, "10.0.0.2")
+ r.SetChainResolver(chain, 50)
+
+ _, err := r.UpdateFromServerDomains(context.Background(), dnsconfig.ServerDomains{Relay: []domain.Domain{d}})
+ require.NoError(t, err)
+
+ r.mutex.RLock()
+ _, marked := r.failedResolves[d]
+ r.mutex.RUnlock()
+ assert.False(t, marked, "a NODATA family must not be recorded as a failure")
+ assert.False(t, r.needsResolve(d), "an IPv4-only host must not be re-resolved on later syncs")
+}
diff --git a/client/internal/dns/resutil/resolve.go b/client/internal/dns/resutil/resolve.go
index 5a3744719..a2599aee7 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
@@ -195,3 +207,35 @@ func FormatAnswers(answers []dns.RR) string {
}
return "[" + strings.Join(parts, ", ") + "]"
}
+
+// StripOPT removes any OPT pseudo-RRs from the message's Extra section. Per
+// RFC 6891 a responder must not include an OPT RR toward a client that did not
+// advertise EDNS0.
+func StripOPT(msg *dns.Msg) {
+ if len(msg.Extra) == 0 {
+ return
+ }
+ out := msg.Extra[:0]
+ for _, rr := range msg.Extra {
+ if _, ok := rr.(*dns.OPT); ok {
+ continue
+ }
+ out = append(out, rr)
+ }
+ msg.Extra = out
+}
+
+// ExtractEDE returns the first Extended DNS Error (RFC 8914) option carried in
+// the message, if present.
+func ExtractEDE(msg *dns.Msg) (*dns.EDNS0_EDE, bool) {
+ opt := msg.IsEdns0()
+ if opt == nil {
+ return nil, false
+ }
+ for _, o := range opt.Option {
+ if ede, ok := o.(*dns.EDNS0_EDE); ok {
+ return ede, true
+ }
+ }
+ return nil, false
+}
diff --git a/client/internal/dns/resutil/resolve_test.go b/client/internal/dns/resutil/resolve_test.go
new file mode 100644
index 000000000..e6a8cc6a5
--- /dev/null
+++ b/client/internal/dns/resutil/resolve_test.go
@@ -0,0 +1,161 @@
+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")
+}
+
+func TestStripOPT(t *testing.T) {
+ rm := &dns.Msg{
+ Extra: []dns.RR{
+ &dns.OPT{Hdr: dns.RR_Header{Name: ".", Rrtype: dns.TypeOPT}},
+ &dns.A{Hdr: dns.RR_Header{Name: "x.", Rrtype: dns.TypeA}, A: net.IPv4(1, 2, 3, 4)},
+ },
+ }
+ StripOPT(rm)
+ assert.Len(t, rm.Extra, 1, "OPT should be removed, A kept")
+ _, isOPT := rm.Extra[0].(*dns.OPT)
+ assert.False(t, isOPT, "remaining record must not be OPT")
+}
+
+func TestExtractEDE(t *testing.T) {
+ t.Run("no edns", func(t *testing.T) {
+ _, ok := ExtractEDE(&dns.Msg{})
+ assert.False(t, ok, "message without OPT has no EDE")
+ })
+
+ t.Run("edns without ede", func(t *testing.T) {
+ rm := &dns.Msg{}
+ rm.SetEdns0(4096, false)
+ _, ok := ExtractEDE(rm)
+ assert.False(t, ok, "OPT without EDE option returns false")
+ })
+
+ t.Run("with ede", func(t *testing.T) {
+ rm := &dns.Msg{}
+ opt := &dns.OPT{Hdr: dns.RR_Header{Name: ".", Rrtype: dns.TypeOPT}}
+ opt.Option = append(opt.Option, &dns.EDNS0_EDE{InfoCode: 49152, ExtraText: "upstream timeout"})
+ rm.Extra = append(rm.Extra, opt)
+
+ ede, ok := ExtractEDE(rm)
+ assert.True(t, ok, "EDE option should be found")
+ assert.Equal(t, uint16(49152), ede.InfoCode)
+ assert.Equal(t, "upstream timeout", ede.ExtraText)
+ })
+}
diff --git a/client/internal/dns/server.go b/client/internal/dns/server.go
index dcd4cb9d0..7556c66cc 100644
--- a/client/internal/dns/server.go
+++ b/client/internal/dns/server.go
@@ -6,6 +6,7 @@ import (
"fmt"
"net/netip"
"net/url"
+ "os"
"slices"
"strings"
"sync"
@@ -38,11 +39,15 @@ const (
// defaultWarningDelayBase is the starting grace window before a
// "Nameserver group unreachable" event fires for a group that's
// never been healthy and only has overlay upstreams with no
- // Connected peer. Per-server and overridable; see warningDelayFor.
- defaultWarningDelayBase = 30 * time.Second
+ // Connected peer. Per-server and overridable via envWarningDelay;
+ // see warningDelay.
+ defaultWarningDelayBase = 60 * time.Second
// warningDelayBonusCap caps the route-count bonus added to the
- // base grace window. See warningDelayFor.
+ // base grace window. See warningDelay.
warningDelayBonusCap = 30 * time.Second
+ // envWarningDelay overrides defaultWarningDelayBase with a Go duration
+ // string (e.g. "90s", "2m"). Invalid or non-positive values are ignored.
+ envWarningDelay = "NB_DNS_HEALTH_WARNING_DELAY"
)
// errNoUsableNameservers signals that a merged-domain group has no usable
@@ -135,7 +140,7 @@ type DefaultServer struct {
disableSys bool
mux sync.Mutex
service service
- dnsMuxMap registeredHandlerMap
+ dnsMuxHandlers []handlerWrapper
localResolver *local.Resolver
wgInterface WGIface
hostManager hostManager
@@ -199,8 +204,6 @@ type handlerWrapper struct {
priority int
}
-type registeredHandlerMap map[types.HandlerID]handlerWrapper
-
// DefaultServerConfig holds configuration parameters for NewDefaultServer
type DefaultServerConfig struct {
WgInterface WGIface
@@ -289,7 +292,6 @@ func newDefaultServer(
service: dnsService,
handlerChain: handlerChain,
extraDomains: make(map[domain.Domain]int),
- dnsMuxMap: make(registeredHandlerMap),
localResolver: local.NewResolver(),
wgInterface: wgInterface,
statusRecorder: statusRecorder,
@@ -298,7 +300,7 @@ func newDefaultServer(
hostManager: &noopHostConfigurator{},
mgmtCacheResolver: mgmtCacheResolver,
currentConfigHash: ^uint64(0), // Initialize to max uint64 to ensure first config is always applied
- warningDelayBase: defaultWarningDelayBase,
+ warningDelayBase: warningDelayBaseFromEnv(),
healthRefresh: make(chan struct{}, 1),
}
// Wire the local resolver against the peer status recorder so it can
@@ -328,7 +330,7 @@ func (s *DefaultServer) SetRouteSources(selected, active func() route.HAMap) {
type routeSettable interface {
setSelectedRoutes(func() route.HAMap)
}
- for _, entry := range s.dnsMuxMap {
+ for _, entry := range s.dnsMuxHandlers {
if h, ok := entry.handler.(routeSettable); ok {
h.setSelectedRoutes(selected)
}
@@ -978,19 +980,23 @@ func (s *DefaultServer) usableNameServers(nameServers []nbdns.NameServer) []neti
func (s *DefaultServer) updateMux(muxUpdates []handlerWrapper) {
// this will introduce a short period of time when the server is not able to handle DNS requests
- for _, existing := range s.dnsMuxMap {
+ for _, existing := range s.dnsMuxHandlers {
s.deregisterHandler([]string{existing.domain}, existing.priority)
- existing.handler.Stop()
+ // The local resolver is a persistent singleton shared by every custom
+ // zone and reused across config updates. Its chain registrations are
+ // per-config and must be deregistered, but Stop() cancels its lookup
+ // context (breaking external CNAME-target resolution) and clears its
+ // records, so it must not be torn down here.
+ if existing.handler != s.localResolver {
+ existing.handler.Stop()
+ }
}
- muxUpdateMap := make(registeredHandlerMap)
-
for _, update := range muxUpdates {
s.registerHandler([]string{update.domain}, update.handler, update.priority)
- muxUpdateMap[update.handler.ID()] = update
}
- s.dnsMuxMap = muxUpdateMap
+ s.dnsMuxHandlers = muxUpdates
}
// updateNSGroupStates records the new group set and pokes the refresher.
@@ -1154,6 +1160,26 @@ func (s *DefaultServer) projectUnhealthy(p *nsGroupProj, servers []netip.AddrPor
return false
}
+// warningDelayBaseFromEnv returns the base grace window, honoring
+// envWarningDelay when it holds a valid positive Go duration. Invalid or
+// non-positive values fall back to defaultWarningDelayBase.
+func warningDelayBaseFromEnv() time.Duration {
+ val := os.Getenv(envWarningDelay)
+ if val == "" {
+ return defaultWarningDelayBase
+ }
+ d, err := time.ParseDuration(val)
+ if err != nil {
+ log.Warnf("invalid %s value %q, using default %v: %v", envWarningDelay, val, defaultWarningDelayBase, err)
+ return defaultWarningDelayBase
+ }
+ if d <= 0 {
+ log.Warnf("%s must be positive, got %v, using default %v", envWarningDelay, d, defaultWarningDelayBase)
+ return defaultWarningDelayBase
+ }
+ return d
+}
+
// warningDelay returns the grace window for the given selected-route
// count. Scales gently: +1s per 100 routes, capped by
// warningDelayBonusCap. Parallel handshakes mean handshake time grows
@@ -1204,7 +1230,7 @@ func (s *DefaultServer) groupHasImmediateUpstream(servers []netip.AddrPort, snap
// in more than one handler.
func (s *DefaultServer) collectUpstreamHealth() map[netip.AddrPort]UpstreamHealth {
merged := make(map[netip.AddrPort]UpstreamHealth)
- for _, entry := range s.dnsMuxMap {
+ for _, entry := range s.dnsMuxHandlers {
reporter, ok := entry.handler.(upstreamHealthReporter)
if !ok {
continue
diff --git a/client/internal/dns/server_privileged_test.go b/client/internal/dns/server_privileged_test.go
new file mode 100644
index 000000000..a03aea169
--- /dev/null
+++ b/client/internal/dns/server_privileged_test.go
@@ -0,0 +1,485 @@
+//go:build privileged
+
+package dns
+
+import (
+ "context"
+ "fmt"
+ "net/netip"
+ "os"
+ "testing"
+
+ "github.com/golang/mock/gomock"
+ "github.com/miekg/dns"
+ "github.com/stretchr/testify/assert"
+ "golang.zx2c4.com/wireguard/wgctrl/wgtypes"
+
+ "github.com/netbirdio/netbird/client/iface"
+ pfmock "github.com/netbirdio/netbird/client/iface/mocks"
+ "github.com/netbirdio/netbird/client/iface/wgaddr"
+ "github.com/netbirdio/netbird/client/internal/dns/local"
+ "github.com/netbirdio/netbird/client/internal/dns/test"
+ "github.com/netbirdio/netbird/client/internal/peer"
+ "github.com/netbirdio/netbird/client/internal/stdnet"
+ nbdns "github.com/netbirdio/netbird/dns"
+)
+
+func TestUpdateDNSServer(t *testing.T) {
+
+ nameServers := []nbdns.NameServer{
+ {
+ IP: netip.MustParseAddr("8.8.8.8"),
+ NSType: nbdns.UDPNameServerType,
+ Port: 53,
+ },
+ {
+ IP: netip.MustParseAddr("8.8.4.4"),
+ NSType: nbdns.UDPNameServerType,
+ Port: 53,
+ },
+ }
+
+ testCases := []struct {
+ name string
+ initUpstreamMap []handlerWrapper
+ initLocalZones []nbdns.CustomZone
+ initSerial uint64
+ inputSerial uint64
+ inputUpdate nbdns.Config
+ shouldFail bool
+ expectedUpstreamMap []handlerWrapper
+ expectedLocalQs []dns.Question
+ }{
+ {
+ name: "Initial Config Should Succeed",
+ initUpstreamMap: nil,
+ initSerial: 0,
+ inputSerial: 1,
+ inputUpdate: nbdns.Config{
+ ServiceEnable: true,
+ CustomZones: []nbdns.CustomZone{
+ {
+ Domain: "netbird.cloud",
+ Records: zoneRecords,
+ },
+ },
+ NameServerGroups: []*nbdns.NameServerGroup{
+ {
+ Domains: []string{"netbird.io"},
+ NameServers: nameServers,
+ },
+ {
+ NameServers: nameServers,
+ Primary: true,
+ },
+ },
+ },
+ expectedUpstreamMap: []handlerWrapper{
+ {
+ domain: "netbird.io",
+ priority: PriorityUpstream,
+ },
+ {
+ domain: "netbird.cloud",
+ priority: PriorityLocal,
+ },
+ {
+ domain: nbdns.RootZone,
+ priority: PriorityDefault,
+ },
+ },
+ expectedLocalQs: []dns.Question{{Name: "peera.netbird.cloud.", Qtype: dns.TypeA, Qclass: dns.ClassINET}},
+ },
+ {
+ name: "New Config Should Succeed",
+ initLocalZones: []nbdns.CustomZone{{Domain: "netbird.cloud", Records: []nbdns.SimpleRecord{{Name: "netbird.cloud", Type: 1, Class: nbdns.DefaultClass, TTL: 300, RData: "10.0.0.1"}}}},
+ initUpstreamMap: []handlerWrapper{
+ {
+ domain: "netbird.cloud",
+ handler: &mockHandler{},
+ priority: PriorityUpstream,
+ },
+ },
+ initSerial: 0,
+ inputSerial: 1,
+ inputUpdate: nbdns.Config{
+ ServiceEnable: true,
+ CustomZones: []nbdns.CustomZone{
+ {
+ Domain: "netbird.cloud",
+ Records: zoneRecords,
+ },
+ },
+ NameServerGroups: []*nbdns.NameServerGroup{
+ {
+ Domains: []string{"netbird.io"},
+ NameServers: nameServers,
+ },
+ },
+ },
+ expectedUpstreamMap: []handlerWrapper{
+ {
+ domain: "netbird.io",
+ priority: PriorityUpstream,
+ },
+ {
+ domain: "netbird.cloud",
+ priority: PriorityLocal,
+ },
+ },
+ expectedLocalQs: []dns.Question{{Name: zoneRecords[0].Name, Qtype: 1, Qclass: 1}},
+ },
+ {
+ name: "Smaller Config Serial Should Be Skipped",
+ initLocalZones: []nbdns.CustomZone{},
+ initUpstreamMap: nil,
+ initSerial: 2,
+ inputSerial: 1,
+ shouldFail: true,
+ },
+ {
+ name: "Empty NS Group Domain Or Not Primary Element Should Fail",
+ initLocalZones: []nbdns.CustomZone{},
+ initUpstreamMap: nil,
+ initSerial: 0,
+ inputSerial: 1,
+ inputUpdate: nbdns.Config{
+ ServiceEnable: true,
+ CustomZones: []nbdns.CustomZone{
+ {
+ Domain: "netbird.cloud",
+ Records: zoneRecords,
+ },
+ },
+ NameServerGroups: []*nbdns.NameServerGroup{
+ {
+ NameServers: nameServers,
+ },
+ },
+ },
+ shouldFail: true,
+ },
+ {
+ name: "Invalid NS Group Nameservers list Should Fail",
+ initLocalZones: []nbdns.CustomZone{},
+ initUpstreamMap: nil,
+ initSerial: 0,
+ inputSerial: 1,
+ inputUpdate: nbdns.Config{
+ ServiceEnable: true,
+ CustomZones: []nbdns.CustomZone{
+ {
+ Domain: "netbird.cloud",
+ Records: zoneRecords,
+ },
+ },
+ NameServerGroups: []*nbdns.NameServerGroup{
+ {
+ NameServers: nameServers,
+ },
+ },
+ },
+ shouldFail: true,
+ },
+ {
+ name: "Invalid Custom Zone Records list Should Skip",
+ initLocalZones: []nbdns.CustomZone{},
+ initUpstreamMap: nil,
+ initSerial: 0,
+ inputSerial: 1,
+ inputUpdate: nbdns.Config{
+ ServiceEnable: true,
+ CustomZones: []nbdns.CustomZone{
+ {
+ Domain: "netbird.cloud",
+ },
+ },
+ NameServerGroups: []*nbdns.NameServerGroup{
+ {
+ NameServers: nameServers,
+ Primary: true,
+ },
+ },
+ },
+ expectedUpstreamMap: []handlerWrapper{{
+ domain: ".",
+ priority: PriorityDefault,
+ }},
+ },
+ {
+ name: "Empty Config Should Succeed and Clean Maps",
+ initLocalZones: []nbdns.CustomZone{{Domain: "netbird.cloud", Records: []nbdns.SimpleRecord{{Name: "netbird.cloud", Type: int(dns.TypeA), Class: nbdns.DefaultClass, TTL: 300, RData: "10.0.0.1"}}}},
+ initUpstreamMap: []handlerWrapper{
+ {
+ domain: zoneRecords[0].Name,
+ handler: &mockHandler{},
+ priority: PriorityUpstream,
+ },
+ },
+ initSerial: 0,
+ inputSerial: 1,
+ inputUpdate: nbdns.Config{ServiceEnable: true},
+ expectedUpstreamMap: nil,
+ expectedLocalQs: []dns.Question{},
+ },
+ {
+ name: "Disabled Service Should clean map",
+ initLocalZones: []nbdns.CustomZone{{Domain: "netbird.cloud", Records: []nbdns.SimpleRecord{{Name: "netbird.cloud", Type: int(dns.TypeA), Class: nbdns.DefaultClass, TTL: 300, RData: "10.0.0.1"}}}},
+ initUpstreamMap: []handlerWrapper{
+ {
+ domain: zoneRecords[0].Name,
+ handler: &mockHandler{},
+ priority: PriorityUpstream,
+ },
+ },
+ initSerial: 0,
+ inputSerial: 1,
+ inputUpdate: nbdns.Config{ServiceEnable: false},
+ expectedUpstreamMap: nil,
+ expectedLocalQs: []dns.Question{},
+ },
+ }
+
+ for n, testCase := range testCases {
+ t.Run(testCase.name, func(t *testing.T) {
+ privKey, _ := wgtypes.GenerateKey()
+ newNet, err := stdnet.NewNet(context.Background(), nil)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ opts := iface.WGIFaceOpts{
+ IFaceName: fmt.Sprintf("utun230%d", n),
+ Address: wgaddr.MustParseWGAddress(fmt.Sprintf("100.66.100.%d/32", n+1)),
+ WGPort: 33100,
+ WGPrivKey: privKey.String(),
+ MTU: iface.DefaultMTU,
+ TransportNet: newNet,
+ }
+
+ wgIface, err := iface.NewWGIFace(opts)
+ if err != nil {
+ t.Fatal(err)
+ }
+ err = wgIface.Create()
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer func() {
+ err = wgIface.Close()
+ if err != nil {
+ t.Log(err)
+ }
+ }()
+ dnsServer, err := NewDefaultServer(context.Background(), DefaultServerConfig{
+ WgInterface: wgIface,
+ CustomAddress: "",
+ StatusRecorder: peer.NewRecorder("mgm"),
+ StateManager: nil,
+ DisableSys: false,
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ err = dnsServer.Initialize()
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer func() {
+ err = dnsServer.hostManager.restoreHostDNS()
+ if err != nil {
+ t.Log(err)
+ }
+ }()
+
+ dnsServer.dnsMuxHandlers = testCase.initUpstreamMap
+ dnsServer.localResolver.Update(testCase.initLocalZones)
+ dnsServer.updateSerial = testCase.initSerial
+
+ err = dnsServer.UpdateDNSServer(testCase.inputSerial, testCase.inputUpdate)
+ if err != nil {
+ if testCase.shouldFail {
+ return
+ }
+ t.Fatalf("update dns server should not fail, got error: %v", err)
+ }
+
+ if len(dnsServer.dnsMuxHandlers) != len(testCase.expectedUpstreamMap) {
+ t.Fatalf("update upstream failed, map size is different than expected, want %d, got %d", len(testCase.expectedUpstreamMap), len(dnsServer.dnsMuxHandlers))
+ }
+
+ for _, expected := range testCase.expectedUpstreamMap {
+ found := false
+ for _, got := range dnsServer.dnsMuxHandlers {
+ if got.domain == expected.domain && got.priority == expected.priority {
+ found = true
+ break
+ }
+ }
+ if !found {
+ t.Fatalf("update upstream failed, handler for domain=%s priority=%d not found in dnsMuxHandlers: %#v", expected.domain, expected.priority, dnsServer.dnsMuxHandlers)
+ }
+ }
+
+ var responseMSG *dns.Msg
+ responseWriter := &test.MockResponseWriter{
+ WriteMsgFunc: func(m *dns.Msg) error {
+ responseMSG = m
+ return nil
+ },
+ }
+ for _, q := range testCase.expectedLocalQs {
+ dnsServer.localResolver.ServeDNS(responseWriter, &dns.Msg{
+ Question: []dns.Question{q},
+ })
+ }
+
+ if len(testCase.expectedLocalQs) > 0 {
+ assert.NotNil(t, responseMSG, "response message should not be nil")
+ assert.Equal(t, dns.RcodeSuccess, responseMSG.Rcode, "response code should be success")
+ assert.NotEmpty(t, responseMSG.Answer, "response message should have answers")
+ }
+ })
+ }
+}
+
+func TestDNSFakeResolverHandleUpdates(t *testing.T) {
+ ov := os.Getenv("NB_WG_KERNEL_DISABLED")
+ defer t.Setenv("NB_WG_KERNEL_DISABLED", ov)
+
+ t.Setenv("NB_WG_KERNEL_DISABLED", "true")
+ newNet, err := stdnet.NewNet(context.Background(), []string{"utun2301"})
+ if err != nil {
+ t.Errorf("create stdnet: %v", err)
+ return
+ }
+
+ privKey, _ := wgtypes.GeneratePrivateKey()
+ opts := iface.WGIFaceOpts{
+ IFaceName: "utun2301",
+ Address: wgaddr.MustParseWGAddress("100.66.100.1/32"),
+ WGPort: 33100,
+ WGPrivKey: privKey.String(),
+ MTU: iface.DefaultMTU,
+ TransportNet: newNet,
+ }
+ wgIface, err := iface.NewWGIFace(opts)
+ if err != nil {
+ t.Errorf("build interface wireguard: %v", err)
+ return
+ }
+
+ err = wgIface.Create()
+ if err != nil {
+ t.Errorf("create and init wireguard interface: %v", err)
+ return
+ }
+ defer func() {
+ if err = wgIface.Close(); err != nil {
+ t.Logf("close wireguard interface: %v", err)
+ }
+ }()
+
+ ctrl := gomock.NewController(t)
+ defer ctrl.Finish()
+
+ packetfilter := pfmock.NewMockPacketFilter(ctrl)
+ packetfilter.EXPECT().FilterOutbound(gomock.Any(), gomock.Any()).AnyTimes()
+ packetfilter.EXPECT().SetUDPPacketHook(gomock.Any(), gomock.Any(), gomock.Any()).AnyTimes()
+ packetfilter.EXPECT().SetTCPPacketHook(gomock.Any(), gomock.Any(), gomock.Any()).AnyTimes()
+
+ if err := wgIface.SetFilter(packetfilter); err != nil {
+ t.Errorf("set packet filter: %v", err)
+ return
+ }
+
+ dnsServer, err := NewDefaultServer(context.Background(), DefaultServerConfig{
+ WgInterface: wgIface,
+ CustomAddress: "",
+ StatusRecorder: peer.NewRecorder("mgm"),
+ StateManager: nil,
+ DisableSys: false,
+ })
+ if err != nil {
+ t.Errorf("create DNS server: %v", err)
+ return
+ }
+
+ err = dnsServer.Initialize()
+ if err != nil {
+ t.Errorf("run DNS server: %v", err)
+ return
+ }
+ defer func() {
+ if err = dnsServer.hostManager.restoreHostDNS(); err != nil {
+ t.Logf("restore DNS settings on the host: %v", err)
+ return
+ }
+ }()
+
+ dnsServer.dnsMuxHandlers = []handlerWrapper{
+ {
+ domain: zoneRecords[0].Name,
+ handler: &local.Resolver{},
+ priority: PriorityUpstream,
+ },
+ }
+ dnsServer.localResolver.Update([]nbdns.CustomZone{{Domain: "netbird.cloud", Records: []nbdns.SimpleRecord{{Name: "netbird.cloud", Type: int(dns.TypeA), Class: nbdns.DefaultClass, TTL: 300, RData: "10.0.0.1"}}}})
+ dnsServer.updateSerial = 0
+
+ nameServers := []nbdns.NameServer{
+ {
+ IP: netip.MustParseAddr("8.8.8.8"),
+ NSType: nbdns.UDPNameServerType,
+ Port: 53,
+ },
+ {
+ IP: netip.MustParseAddr("8.8.4.4"),
+ NSType: nbdns.UDPNameServerType,
+ Port: 53,
+ },
+ }
+
+ update := nbdns.Config{
+ ServiceEnable: true,
+ CustomZones: []nbdns.CustomZone{
+ {
+ Domain: "netbird.cloud",
+ Records: zoneRecords,
+ },
+ },
+ NameServerGroups: []*nbdns.NameServerGroup{
+ {
+ Domains: []string{"netbird.io"},
+ NameServers: nameServers,
+ },
+ {
+ NameServers: nameServers,
+ Primary: true,
+ },
+ },
+ }
+
+ // Start the server with regular configuration
+ if err := dnsServer.UpdateDNSServer(1, update); err != nil {
+ t.Fatalf("update dns server should not fail, got error: %v", err)
+ return
+ }
+
+ update2 := update
+ update2.ServiceEnable = false
+ // Disable the server, stop the listener
+ if err := dnsServer.UpdateDNSServer(2, update2); err != nil {
+ t.Fatalf("update dns server should not fail, got error: %v", err)
+ return
+ }
+
+ update3 := update2
+ update3.NameServerGroups = update3.NameServerGroups[:1]
+ // But service still get updates and we checking that we handle
+ // internal state in the right way
+ if err := dnsServer.UpdateDNSServer(3, update3); err != nil {
+ t.Fatalf("update dns server should not fail, got error: %v", err)
+ return
+ }
+}
diff --git a/client/internal/dns/server_test.go b/client/internal/dns/server_test.go
index 36c7c8b8d..0144a4a8b 100644
--- a/client/internal/dns/server_test.go
+++ b/client/internal/dns/server_test.go
@@ -10,7 +10,6 @@ import (
"testing"
"time"
- "github.com/golang/mock/gomock"
"github.com/miekg/dns"
log "github.com/sirupsen/logrus"
"github.com/stretchr/testify/assert"
@@ -23,7 +22,6 @@ import (
"github.com/netbirdio/netbird/client/iface"
"github.com/netbirdio/netbird/client/iface/configurer"
"github.com/netbirdio/netbird/client/iface/device"
- pfmock "github.com/netbirdio/netbird/client/iface/mocks"
"github.com/netbirdio/netbird/client/iface/wgaddr"
"github.com/netbirdio/netbird/client/internal/dns/local"
"github.com/netbirdio/netbird/client/internal/dns/test"
@@ -104,481 +102,6 @@ func init() {
formatter.SetTextFormatter(log.StandardLogger())
}
-func generateDummyHandler(d string, servers []nbdns.NameServer) *upstreamResolverBase {
- var srvs []netip.AddrPort
- for _, srv := range servers {
- srvs = append(srvs, srv.AddrPort())
- }
- u := &upstreamResolverBase{
- domain: domain.Domain(d),
- cancel: func() {},
- }
- u.addRace(srvs)
- return u
-}
-
-func TestUpdateDNSServer(t *testing.T) {
-
- nameServers := []nbdns.NameServer{
- {
- IP: netip.MustParseAddr("8.8.8.8"),
- NSType: nbdns.UDPNameServerType,
- Port: 53,
- },
- {
- IP: netip.MustParseAddr("8.8.4.4"),
- NSType: nbdns.UDPNameServerType,
- Port: 53,
- },
- }
-
- dummyHandler := local.NewResolver()
-
- testCases := []struct {
- name string
- initUpstreamMap registeredHandlerMap
- initLocalZones []nbdns.CustomZone
- initSerial uint64
- inputSerial uint64
- inputUpdate nbdns.Config
- shouldFail bool
- expectedUpstreamMap registeredHandlerMap
- expectedLocalQs []dns.Question
- }{
- {
- name: "Initial Config Should Succeed",
- initUpstreamMap: make(registeredHandlerMap),
- initSerial: 0,
- inputSerial: 1,
- inputUpdate: nbdns.Config{
- ServiceEnable: true,
- CustomZones: []nbdns.CustomZone{
- {
- Domain: "netbird.cloud",
- Records: zoneRecords,
- },
- },
- NameServerGroups: []*nbdns.NameServerGroup{
- {
- Domains: []string{"netbird.io"},
- NameServers: nameServers,
- },
- {
- NameServers: nameServers,
- Primary: true,
- },
- },
- },
- expectedUpstreamMap: registeredHandlerMap{
- generateDummyHandler("netbird.io", nameServers).ID(): handlerWrapper{
- domain: "netbird.io",
- handler: dummyHandler,
- priority: PriorityUpstream,
- },
- dummyHandler.ID(): handlerWrapper{
- domain: "netbird.cloud",
- handler: dummyHandler,
- priority: PriorityLocal,
- },
- generateDummyHandler(".", nameServers).ID(): handlerWrapper{
- domain: nbdns.RootZone,
- handler: dummyHandler,
- priority: PriorityDefault,
- },
- },
- expectedLocalQs: []dns.Question{{Name: "peera.netbird.cloud.", Qtype: dns.TypeA, Qclass: dns.ClassINET}},
- },
- {
- name: "New Config Should Succeed",
- initLocalZones: []nbdns.CustomZone{{Domain: "netbird.cloud", Records: []nbdns.SimpleRecord{{Name: "netbird.cloud", Type: 1, Class: nbdns.DefaultClass, TTL: 300, RData: "10.0.0.1"}}}},
- initUpstreamMap: registeredHandlerMap{
- generateDummyHandler(zoneRecords[0].Name, nameServers).ID(): handlerWrapper{
- domain: "netbird.cloud",
- handler: dummyHandler,
- priority: PriorityUpstream,
- },
- },
- initSerial: 0,
- inputSerial: 1,
- inputUpdate: nbdns.Config{
- ServiceEnable: true,
- CustomZones: []nbdns.CustomZone{
- {
- Domain: "netbird.cloud",
- Records: zoneRecords,
- },
- },
- NameServerGroups: []*nbdns.NameServerGroup{
- {
- Domains: []string{"netbird.io"},
- NameServers: nameServers,
- },
- },
- },
- expectedUpstreamMap: registeredHandlerMap{
- generateDummyHandler("netbird.io", nameServers).ID(): handlerWrapper{
- domain: "netbird.io",
- handler: dummyHandler,
- priority: PriorityUpstream,
- },
- "local-resolver": handlerWrapper{
- domain: "netbird.cloud",
- handler: dummyHandler,
- priority: PriorityLocal,
- },
- },
- expectedLocalQs: []dns.Question{{Name: zoneRecords[0].Name, Qtype: 1, Qclass: 1}},
- },
- {
- name: "Smaller Config Serial Should Be Skipped",
- initLocalZones: []nbdns.CustomZone{},
- initUpstreamMap: make(registeredHandlerMap),
- initSerial: 2,
- inputSerial: 1,
- shouldFail: true,
- },
- {
- name: "Empty NS Group Domain Or Not Primary Element Should Fail",
- initLocalZones: []nbdns.CustomZone{},
- initUpstreamMap: make(registeredHandlerMap),
- initSerial: 0,
- inputSerial: 1,
- inputUpdate: nbdns.Config{
- ServiceEnable: true,
- CustomZones: []nbdns.CustomZone{
- {
- Domain: "netbird.cloud",
- Records: zoneRecords,
- },
- },
- NameServerGroups: []*nbdns.NameServerGroup{
- {
- NameServers: nameServers,
- },
- },
- },
- shouldFail: true,
- },
- {
- name: "Invalid NS Group Nameservers list Should Fail",
- initLocalZones: []nbdns.CustomZone{},
- initUpstreamMap: make(registeredHandlerMap),
- initSerial: 0,
- inputSerial: 1,
- inputUpdate: nbdns.Config{
- ServiceEnable: true,
- CustomZones: []nbdns.CustomZone{
- {
- Domain: "netbird.cloud",
- Records: zoneRecords,
- },
- },
- NameServerGroups: []*nbdns.NameServerGroup{
- {
- NameServers: nameServers,
- },
- },
- },
- shouldFail: true,
- },
- {
- name: "Invalid Custom Zone Records list Should Skip",
- initLocalZones: []nbdns.CustomZone{},
- initUpstreamMap: make(registeredHandlerMap),
- initSerial: 0,
- inputSerial: 1,
- inputUpdate: nbdns.Config{
- ServiceEnable: true,
- CustomZones: []nbdns.CustomZone{
- {
- Domain: "netbird.cloud",
- },
- },
- NameServerGroups: []*nbdns.NameServerGroup{
- {
- NameServers: nameServers,
- Primary: true,
- },
- },
- },
- expectedUpstreamMap: registeredHandlerMap{generateDummyHandler(".", nameServers).ID(): handlerWrapper{
- domain: ".",
- handler: dummyHandler,
- priority: PriorityDefault,
- }},
- },
- {
- name: "Empty Config Should Succeed and Clean Maps",
- initLocalZones: []nbdns.CustomZone{{Domain: "netbird.cloud", Records: []nbdns.SimpleRecord{{Name: "netbird.cloud", Type: int(dns.TypeA), Class: nbdns.DefaultClass, TTL: 300, RData: "10.0.0.1"}}}},
- initUpstreamMap: registeredHandlerMap{
- generateDummyHandler(zoneRecords[0].Name, nameServers).ID(): handlerWrapper{
- domain: zoneRecords[0].Name,
- handler: dummyHandler,
- priority: PriorityUpstream,
- },
- },
- initSerial: 0,
- inputSerial: 1,
- inputUpdate: nbdns.Config{ServiceEnable: true},
- expectedUpstreamMap: make(registeredHandlerMap),
- expectedLocalQs: []dns.Question{},
- },
- {
- name: "Disabled Service Should clean map",
- initLocalZones: []nbdns.CustomZone{{Domain: "netbird.cloud", Records: []nbdns.SimpleRecord{{Name: "netbird.cloud", Type: int(dns.TypeA), Class: nbdns.DefaultClass, TTL: 300, RData: "10.0.0.1"}}}},
- initUpstreamMap: registeredHandlerMap{
- generateDummyHandler(zoneRecords[0].Name, nameServers).ID(): handlerWrapper{
- domain: zoneRecords[0].Name,
- handler: dummyHandler,
- priority: PriorityUpstream,
- },
- },
- initSerial: 0,
- inputSerial: 1,
- inputUpdate: nbdns.Config{ServiceEnable: false},
- expectedUpstreamMap: make(registeredHandlerMap),
- expectedLocalQs: []dns.Question{},
- },
- }
-
- for n, testCase := range testCases {
- t.Run(testCase.name, func(t *testing.T) {
- privKey, _ := wgtypes.GenerateKey()
- newNet, err := stdnet.NewNet(context.Background(), nil)
- if err != nil {
- t.Fatal(err)
- }
-
- opts := iface.WGIFaceOpts{
- IFaceName: fmt.Sprintf("utun230%d", n),
- Address: wgaddr.MustParseWGAddress(fmt.Sprintf("100.66.100.%d/32", n+1)),
- WGPort: 33100,
- WGPrivKey: privKey.String(),
- MTU: iface.DefaultMTU,
- TransportNet: newNet,
- }
-
- wgIface, err := iface.NewWGIFace(opts)
- if err != nil {
- t.Fatal(err)
- }
- err = wgIface.Create()
- if err != nil {
- t.Fatal(err)
- }
- defer func() {
- err = wgIface.Close()
- if err != nil {
- t.Log(err)
- }
- }()
- dnsServer, err := NewDefaultServer(context.Background(), DefaultServerConfig{
- WgInterface: wgIface,
- CustomAddress: "",
- StatusRecorder: peer.NewRecorder("mgm"),
- StateManager: nil,
- DisableSys: false,
- })
- if err != nil {
- t.Fatal(err)
- }
- err = dnsServer.Initialize()
- if err != nil {
- t.Fatal(err)
- }
- defer func() {
- err = dnsServer.hostManager.restoreHostDNS()
- if err != nil {
- t.Log(err)
- }
- }()
-
- dnsServer.dnsMuxMap = testCase.initUpstreamMap
- dnsServer.localResolver.Update(testCase.initLocalZones)
- dnsServer.updateSerial = testCase.initSerial
-
- err = dnsServer.UpdateDNSServer(testCase.inputSerial, testCase.inputUpdate)
- if err != nil {
- if testCase.shouldFail {
- return
- }
- t.Fatalf("update dns server should not fail, got error: %v", err)
- }
-
- if len(dnsServer.dnsMuxMap) != len(testCase.expectedUpstreamMap) {
- t.Fatalf("update upstream failed, map size is different than expected, want %d, got %d", len(testCase.expectedUpstreamMap), len(dnsServer.dnsMuxMap))
- }
-
- for key := range testCase.expectedUpstreamMap {
- _, found := dnsServer.dnsMuxMap[key]
- if !found {
- t.Fatalf("update upstream failed, key %s was not found in the dnsMuxMap: %#v", key, dnsServer.dnsMuxMap)
- }
- }
-
- var responseMSG *dns.Msg
- responseWriter := &test.MockResponseWriter{
- WriteMsgFunc: func(m *dns.Msg) error {
- responseMSG = m
- return nil
- },
- }
- for _, q := range testCase.expectedLocalQs {
- dnsServer.localResolver.ServeDNS(responseWriter, &dns.Msg{
- Question: []dns.Question{q},
- })
- }
-
- if len(testCase.expectedLocalQs) > 0 {
- assert.NotNil(t, responseMSG, "response message should not be nil")
- assert.Equal(t, dns.RcodeSuccess, responseMSG.Rcode, "response code should be success")
- assert.NotEmpty(t, responseMSG.Answer, "response message should have answers")
- }
- })
- }
-}
-
-func TestDNSFakeResolverHandleUpdates(t *testing.T) {
- ov := os.Getenv("NB_WG_KERNEL_DISABLED")
- defer t.Setenv("NB_WG_KERNEL_DISABLED", ov)
-
- t.Setenv("NB_WG_KERNEL_DISABLED", "true")
- newNet, err := stdnet.NewNet(context.Background(), []string{"utun2301"})
- if err != nil {
- t.Errorf("create stdnet: %v", err)
- return
- }
-
- privKey, _ := wgtypes.GeneratePrivateKey()
- opts := iface.WGIFaceOpts{
- IFaceName: "utun2301",
- Address: wgaddr.MustParseWGAddress("100.66.100.1/32"),
- WGPort: 33100,
- WGPrivKey: privKey.String(),
- MTU: iface.DefaultMTU,
- TransportNet: newNet,
- }
- wgIface, err := iface.NewWGIFace(opts)
- if err != nil {
- t.Errorf("build interface wireguard: %v", err)
- return
- }
-
- err = wgIface.Create()
- if err != nil {
- t.Errorf("create and init wireguard interface: %v", err)
- return
- }
- defer func() {
- if err = wgIface.Close(); err != nil {
- t.Logf("close wireguard interface: %v", err)
- }
- }()
-
- ctrl := gomock.NewController(t)
- defer ctrl.Finish()
-
- packetfilter := pfmock.NewMockPacketFilter(ctrl)
- packetfilter.EXPECT().FilterOutbound(gomock.Any(), gomock.Any()).AnyTimes()
- packetfilter.EXPECT().SetUDPPacketHook(gomock.Any(), gomock.Any(), gomock.Any()).AnyTimes()
- packetfilter.EXPECT().SetTCPPacketHook(gomock.Any(), gomock.Any(), gomock.Any()).AnyTimes()
-
- if err := wgIface.SetFilter(packetfilter); err != nil {
- t.Errorf("set packet filter: %v", err)
- return
- }
-
- dnsServer, err := NewDefaultServer(context.Background(), DefaultServerConfig{
- WgInterface: wgIface,
- CustomAddress: "",
- StatusRecorder: peer.NewRecorder("mgm"),
- StateManager: nil,
- DisableSys: false,
- })
- if err != nil {
- t.Errorf("create DNS server: %v", err)
- return
- }
-
- err = dnsServer.Initialize()
- if err != nil {
- t.Errorf("run DNS server: %v", err)
- return
- }
- defer func() {
- if err = dnsServer.hostManager.restoreHostDNS(); err != nil {
- t.Logf("restore DNS settings on the host: %v", err)
- return
- }
- }()
-
- dnsServer.dnsMuxMap = registeredHandlerMap{
- "id1": handlerWrapper{
- domain: zoneRecords[0].Name,
- handler: &local.Resolver{},
- priority: PriorityUpstream,
- },
- }
- dnsServer.localResolver.Update([]nbdns.CustomZone{{Domain: "netbird.cloud", Records: []nbdns.SimpleRecord{{Name: "netbird.cloud", Type: int(dns.TypeA), Class: nbdns.DefaultClass, TTL: 300, RData: "10.0.0.1"}}}})
- dnsServer.updateSerial = 0
-
- nameServers := []nbdns.NameServer{
- {
- IP: netip.MustParseAddr("8.8.8.8"),
- NSType: nbdns.UDPNameServerType,
- Port: 53,
- },
- {
- IP: netip.MustParseAddr("8.8.4.4"),
- NSType: nbdns.UDPNameServerType,
- Port: 53,
- },
- }
-
- update := nbdns.Config{
- ServiceEnable: true,
- CustomZones: []nbdns.CustomZone{
- {
- Domain: "netbird.cloud",
- Records: zoneRecords,
- },
- },
- NameServerGroups: []*nbdns.NameServerGroup{
- {
- Domains: []string{"netbird.io"},
- NameServers: nameServers,
- },
- {
- NameServers: nameServers,
- Primary: true,
- },
- },
- }
-
- // Start the server with regular configuration
- if err := dnsServer.UpdateDNSServer(1, update); err != nil {
- t.Fatalf("update dns server should not fail, got error: %v", err)
- return
- }
-
- update2 := update
- update2.ServiceEnable = false
- // Disable the server, stop the listener
- if err := dnsServer.UpdateDNSServer(2, update2); err != nil {
- t.Fatalf("update dns server should not fail, got error: %v", err)
- return
- }
-
- update3 := update2
- update3.NameServerGroups = update3.NameServerGroups[:1]
- // But service still get updates and we checking that we handle
- // internal state in the right way
- if err := dnsServer.UpdateDNSServer(3, update3); err != nil {
- t.Fatalf("update dns server should not fail, got error: %v", err)
- return
- }
-}
-
func TestDNSServerStartStop(t *testing.T) {
testCases := []struct {
name string
@@ -1029,15 +552,15 @@ func (m *mockService) RegisterMux(string, dns.Handler) {}
func (m *mockService) DeregisterMux(string) {}
func TestDefaultServer_UpdateMux(t *testing.T) {
- baseMatchHandlers := registeredHandlerMap{
- "upstream-group1": {
+ baseMatchHandlers := []handlerWrapper{
+ {
domain: "example.com",
handler: &mockHandler{
Id: "upstream-group1",
},
priority: PriorityUpstream,
},
- "upstream-group2": {
+ {
domain: "example.com",
handler: &mockHandler{
Id: "upstream-group2",
@@ -1046,15 +569,15 @@ func TestDefaultServer_UpdateMux(t *testing.T) {
},
}
- baseRootHandlers := registeredHandlerMap{
- "upstream-root1": {
+ baseRootHandlers := []handlerWrapper{
+ {
domain: ".",
handler: &mockHandler{
Id: "upstream-root1",
},
priority: PriorityDefault,
},
- "upstream-root2": {
+ {
domain: ".",
handler: &mockHandler{
Id: "upstream-root2",
@@ -1063,22 +586,22 @@ func TestDefaultServer_UpdateMux(t *testing.T) {
},
}
- baseMixedHandlers := registeredHandlerMap{
- "upstream-group1": {
+ baseMixedHandlers := []handlerWrapper{
+ {
domain: "example.com",
handler: &mockHandler{
Id: "upstream-group1",
},
priority: PriorityUpstream,
},
- "upstream-group2": {
+ {
domain: "example.com",
handler: &mockHandler{
Id: "upstream-group2",
},
priority: PriorityUpstream - 1,
},
- "upstream-other": {
+ {
domain: "other.com",
handler: &mockHandler{
Id: "upstream-other",
@@ -1089,7 +612,7 @@ func TestDefaultServer_UpdateMux(t *testing.T) {
tests := []struct {
name string
- initialHandlers registeredHandlerMap
+ initialHandlers []handlerWrapper
updates []handlerWrapper
expectedHandlers map[string]string // map[HandlerID]domain
description string
@@ -1373,32 +896,38 @@ func TestDefaultServer_UpdateMux(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
server := &DefaultServer{
- dnsMuxMap: tt.initialHandlers,
- handlerChain: NewHandlerChain(),
- service: &mockService{},
+ dnsMuxHandlers: tt.initialHandlers,
+ handlerChain: NewHandlerChain(),
+ service: &mockService{},
}
// Perform the update
server.updateMux(tt.updates)
// Verify the results
- assert.Equal(t, len(tt.expectedHandlers), len(server.dnsMuxMap),
+ assert.Equal(t, len(tt.expectedHandlers), len(server.dnsMuxHandlers),
"Number of handlers after update doesn't match expected")
// Check each expected handler
for id, expectedDomain := range tt.expectedHandlers {
- handler, exists := server.dnsMuxMap[types.HandlerID(id)]
- assert.True(t, exists, "Expected handler %s not found", id)
- if exists {
- assert.Equal(t, expectedDomain, handler.domain,
+ var found *handlerWrapper
+ for i := range server.dnsMuxHandlers {
+ if server.dnsMuxHandlers[i].handler.ID() == types.HandlerID(id) {
+ found = &server.dnsMuxHandlers[i]
+ break
+ }
+ }
+ assert.NotNil(t, found, "Expected handler %s not found", id)
+ if found != nil {
+ assert.Equal(t, expectedDomain, found.domain,
"Domain mismatch for handler %s", id)
}
}
// Verify no unexpected handlers exist
- for HandlerID := range server.dnsMuxMap {
- _, expected := tt.expectedHandlers[string(HandlerID)]
- assert.True(t, expected, "Unexpected handler found: %s", HandlerID)
+ for _, entry := range server.dnsMuxHandlers {
+ _, expected := tt.expectedHandlers[string(entry.handler.ID())]
+ assert.True(t, expected, "Unexpected handler found: %s", entry.handler.ID())
}
// Verify the handlerChain state and order
@@ -1413,7 +942,7 @@ func TestDefaultServer_UpdateMux(t *testing.T) {
// Verify handler exists in mux
foundInMux := false
- for _, muxEntry := range server.dnsMuxMap {
+ for _, muxEntry := range server.dnsMuxHandlers {
if chainEntry.Handler == muxEntry.handler &&
chainEntry.Priority == muxEntry.priority &&
chainEntry.Pattern == dns.Fqdn(muxEntry.domain) {
@@ -1422,12 +951,108 @@ func TestDefaultServer_UpdateMux(t *testing.T) {
}
}
assert.True(t, foundInMux,
- "Handler in chain not found in dnsMuxMap")
+ "Handler in chain not found in dnsMuxHandlers")
}
})
}
}
+// chainHasPattern reports whether the handler chain holds an entry registered
+// for the given fqdn pattern at the given priority.
+func chainHasPattern(s *DefaultServer, pattern string, priority int) bool {
+ for _, h := range s.handlerChain.handlers {
+ if h.OrigPattern == pattern && h.Priority == priority {
+ return true
+ }
+ }
+ return false
+}
+
+// TestDefaultServer_UpdateMux_SharedHandlerZoneRemoval verifies that updateMux
+// tracks each (handler, domain) registration independently when one handler
+// serves multiple zones. Every custom zone is served by the same handler
+// instance (the local resolver, whose ID is the constant "local-resolver"), so
+// removing one zone must deregister exactly that zone's chain entry and leave
+// the others in place. Tracking registrations by handler ID alone collapses all
+// zones onto one entry, leaving removed zones in the chain to answer
+// authoritatively with no records.
+func TestDefaultServer_UpdateMux_SharedHandlerZoneRemoval(t *testing.T) {
+ // One handler serves every custom zone, mirroring s.localResolver.
+ shared := &mockHandler{Id: "local-resolver"}
+
+ server := &DefaultServer{
+ handlerChain: NewHandlerChain(),
+ service: &mockService{},
+ }
+
+ // Two custom zones under the same handler. The surviving zone is registered
+ // last, mirroring the management emission order.
+ server.updateMux([]handlerWrapper{
+ {domain: "userzone.test", handler: shared, priority: PriorityLocal},
+ {domain: "peerzone.test", handler: shared, priority: PriorityLocal},
+ })
+
+ require.True(t, chainHasPattern(server, "userzone.test.", PriorityLocal),
+ "userzone.test should be registered after the first update")
+ require.True(t, chainHasPattern(server, "peerzone.test.", PriorityLocal),
+ "peerzone.test should be registered after the first update")
+
+ // Remove one zone, keep the other.
+ server.updateMux([]handlerWrapper{
+ {domain: "peerzone.test", handler: shared, priority: PriorityLocal},
+ })
+
+ assert.True(t, chainHasPattern(server, "peerzone.test.", PriorityLocal),
+ "peerzone.test should remain after removing userzone.test")
+ assert.False(t, chainHasPattern(server, "userzone.test.", PriorityLocal),
+ "userzone.test handler must be deregistered, not leaked in the chain")
+}
+
+// TestDefaultServer_UpdateMux_PreservesLocalResolver verifies that updateMux
+// does not tear down the shared local resolver during reconfiguration. The
+// resolver is a process-lifetime singleton reused across config updates;
+// Stop() cancels its lookup context (breaking external CNAME-target
+// resolution) and clears its records. updateMux must deregister its chain
+// entries without stopping it. Records surviving a teardown update is the
+// observable proxy: Stop() would have cleared them.
+func TestDefaultServer_UpdateMux_PreservesLocalResolver(t *testing.T) {
+ resolver := local.NewResolver()
+ require.NoError(t, resolver.RegisterRecord(nbdns.SimpleRecord{
+ Name: "peer.netbird.cloud.",
+ Type: int(dns.TypeA),
+ Class: nbdns.DefaultClass,
+ TTL: 300,
+ RData: "10.0.0.1",
+ }))
+
+ server := &DefaultServer{
+ handlerChain: NewHandlerChain(),
+ service: &mockService{},
+ localResolver: resolver,
+ }
+
+ server.updateMux([]handlerWrapper{
+ {domain: "netbird.cloud", handler: resolver, priority: PriorityLocal},
+ })
+
+ // Remove the zone. The resolver must survive so its records and lookup
+ // context stay intact for the next registration.
+ server.updateMux(nil)
+
+ var response *dns.Msg
+ resolver.ServeDNS(&test.MockResponseWriter{
+ WriteMsgFunc: func(m *dns.Msg) error {
+ response = m
+ return nil
+ },
+ }, &dns.Msg{Question: []dns.Question{{Name: "peer.netbird.cloud.", Qtype: dns.TypeA, Qclass: dns.ClassINET}}})
+
+ require.NotNil(t, response, "local resolver should answer after teardown")
+ assert.Equal(t, dns.RcodeSuccess, response.Rcode,
+ "local resolver records must survive teardown; updateMux must not Stop() the shared resolver")
+ assert.NotEmpty(t, response.Answer, "answer should contain the surviving record")
+}
+
func TestExtraDomains(t *testing.T) {
tests := []struct {
name string
@@ -2049,7 +1674,6 @@ func TestBuildUpstreamHandler_MergesGroupsPerDomain(t *testing.T) {
localResolver: local.NewResolver(),
handlerChain: NewHandlerChain(),
hostManager: &noopHostConfigurator{},
- dnsMuxMap: make(registeredHandlerMap),
}
groups := []*nbdns.NameServerGroup{
@@ -2207,7 +1831,7 @@ func TestEvaluateNSGroupHealth(t *testing.T) {
}
}
-// healthStubHandler is a minimal dnsMuxMap entry that exposes a fixed
+// healthStubHandler is a minimal dnsMuxHandlers entry that exposes a fixed
// UpstreamHealth snapshot, letting tests drive recomputeNSGroupStates
// without spinning up real handlers.
type healthStubHandler struct {
@@ -2283,12 +1907,11 @@ func newProjTestFixture(t *testing.T) *projTestFixture {
ctx: context.Background(),
wgInterface: &mocWGIface{},
statusRecorder: recorder,
- dnsMuxMap: make(registeredHandlerMap),
selectedRoutes: func() route.HAMap { return fx.selected },
activeRoutes: func() route.HAMap { return fx.active },
warningDelayBase: defaultWarningDelayBase,
}
- fx.server.dnsMuxMap["example.com"] = handlerWrapper{domain: "example.com", handler: fx.stub, priority: PriorityUpstream}
+ fx.server.dnsMuxHandlers = []handlerWrapper{{domain: "example.com", handler: fx.stub, priority: PriorityUpstream}}
fx.server.mux.Lock()
fx.server.updateNSGroupStates([]*nbdns.NameServerGroup{fx.group})
@@ -2395,7 +2018,6 @@ func TestProjection_OverlayAddrNoRouteDelaysWarning(t *testing.T) {
ctx: context.Background(),
wgInterface: &mocWGIface{},
statusRecorder: recorder,
- dnsMuxMap: make(registeredHandlerMap),
selectedRoutes: func() route.HAMap { return nil },
activeRoutes: func() route.HAMap { return nil },
warningDelayBase: 50 * time.Millisecond,
@@ -2407,7 +2029,7 @@ func TestProjection_OverlayAddrNoRouteDelaysWarning(t *testing.T) {
stub := &healthStubHandler{health: map[netip.AddrPort]UpstreamHealth{
overlayPeer: {LastFail: time.Now(), LastErr: "timeout"},
}}
- server.dnsMuxMap["example.com"] = handlerWrapper{domain: "example.com", handler: stub, priority: PriorityUpstream}
+ server.dnsMuxHandlers = []handlerWrapper{{domain: "example.com", handler: stub, priority: PriorityUpstream}}
server.mux.Lock()
server.updateNSGroupStates([]*nbdns.NameServerGroup{group})
@@ -2444,7 +2066,6 @@ func TestProjection_StopClearsHealthState(t *testing.T) {
service: NewServiceViaMemory(wgIface),
hostManager: &noopHostConfigurator{},
extraDomains: map[domain.Domain]int{},
- dnsMuxMap: make(registeredHandlerMap),
statusRecorder: peer.NewRecorder("mgm"),
selectedRoutes: func() route.HAMap { return nil },
activeRoutes: func() route.HAMap { return nil },
@@ -2459,7 +2080,7 @@ func TestProjection_StopClearsHealthState(t *testing.T) {
NameServers: []nbdns.NameServer{{IP: srv.Addr(), NSType: nbdns.UDPNameServerType, Port: int(srv.Port())}},
}
stub := &healthStubHandler{health: map[netip.AddrPort]UpstreamHealth{srv: {LastOk: time.Now()}}}
- server.dnsMuxMap["example.com"] = handlerWrapper{domain: "example.com", handler: stub, priority: PriorityUpstream}
+ server.dnsMuxHandlers = []handlerWrapper{{domain: "example.com", handler: stub, priority: PriorityUpstream}}
server.mux.Lock()
server.updateNSGroupStates([]*nbdns.NameServerGroup{group})
@@ -2484,6 +2105,32 @@ func TestProjection_StopClearsHealthState(t *testing.T) {
// rule 3: startup failures while the peer is handshaking, then the peer
// comes up and a query succeeds before the grace window elapses. No
// warning should ever have fired, and no recovery either.
+func TestWarningDelayBaseFromEnv(t *testing.T) {
+ tests := []struct {
+ name string
+ set bool
+ val string
+ want time.Duration
+ }{
+ {name: "unset uses default", set: false, want: defaultWarningDelayBase},
+ {name: "valid override", set: true, val: "90s", want: 90 * time.Second},
+ {name: "valid minutes", set: true, val: "2m", want: 2 * time.Minute},
+ {name: "invalid falls back", set: true, val: "notaduration", want: defaultWarningDelayBase},
+ {name: "zero falls back", set: true, val: "0s", want: defaultWarningDelayBase},
+ {name: "negative falls back", set: true, val: "-30s", want: defaultWarningDelayBase},
+ }
+
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ t.Setenv(envWarningDelay, tc.val)
+ if !tc.set {
+ os.Unsetenv(envWarningDelay)
+ }
+ assert.Equal(t, tc.want, warningDelayBaseFromEnv(), "grace window base")
+ })
+ }
+}
+
func TestProjection_OverlayRecoversDuringGrace(t *testing.T) {
fx := newProjTestFixture(t)
fx.server.warningDelayBase = 200 * time.Millisecond
@@ -2595,7 +2242,6 @@ func TestProjection_MixedGroupEmitsImmediately(t *testing.T) {
server := &DefaultServer{
ctx: context.Background(),
statusRecorder: recorder,
- dnsMuxMap: make(registeredHandlerMap),
selectedRoutes: func() route.HAMap { return overlayMap },
activeRoutes: func() route.HAMap { return nil },
warningDelayBase: time.Hour,
@@ -2613,7 +2259,7 @@ func TestProjection_MixedGroupEmitsImmediately(t *testing.T) {
overlay: {LastFail: time.Now(), LastErr: "timeout"},
},
}
- server.dnsMuxMap["example.com"] = handlerWrapper{domain: "example.com", handler: stub, priority: PriorityUpstream}
+ server.dnsMuxHandlers = []handlerWrapper{{domain: "example.com", handler: stub, priority: PriorityUpstream}}
server.mux.Lock()
server.updateNSGroupStates([]*nbdns.NameServerGroup{group})
@@ -2640,7 +2286,6 @@ func TestDNSLoopPrevention(t *testing.T) {
localResolver: local.NewResolver(),
handlerChain: NewHandlerChain(),
hostManager: &noopHostConfigurator{},
- dnsMuxMap: make(registeredHandlerMap),
}
tests := []struct {
diff --git a/client/internal/dns/upstream.go b/client/internal/dns/upstream.go
index a4f713d68..72fc0450c 100644
--- a/client/internal/dns/upstream.go
+++ b/client/internal/dns/upstream.go
@@ -443,29 +443,32 @@ func (u *upstreamResolverBase) queryUpstream(parentCtx context.Context, r *dns.M
return raceResult{}, &upstreamFailure{upstream: upstream, reason: "no response"}
}
+ // A valid response means the upstream is reachable, whatever the Rcode.
+ u.markUpstreamOk(upstream)
+
proto := ""
if upstreamProto != nil {
proto = upstreamProto.protocol
}
if rm.Rcode == dns.RcodeServerFailure || rm.Rcode == dns.RcodeRefused {
+ // SERVFAIL and REFUSED are per-question outcomes (DNSSEC-bogus names,
+ // refused zones, transient recursion errors), not reachability
+ // problems: fail over for a better answer but keep the upstream healthy.
if code, ok := nonRetryableEDE(rm); ok {
if !hadEdns {
- stripOPT(rm)
+ resutil.StripOPT(rm)
}
- u.markUpstreamOk(upstream)
return raceResult{msg: rm, upstream: upstream, protocol: proto, ede: edeName(code)}, nil
}
reason := dns.RcodeToString[rm.Rcode]
- u.markUpstreamFail(upstream, reason)
return raceResult{}, &upstreamFailure{upstream: upstream, reason: reason}
}
if !hadEdns {
- stripOPT(rm)
+ resutil.StripOPT(rm)
}
- u.markUpstreamOk(upstream)
return raceResult{msg: rm, upstream: upstream, protocol: proto}, nil
}
@@ -520,22 +523,6 @@ func upstreamUDPSize() uint16 {
return dns.MinMsgSize
}
-// stripOPT removes any OPT pseudo-RRs from the response's Extra section so
-// the response complies with RFC 6891 when the client did not advertise EDNS0.
-func stripOPT(rm *dns.Msg) {
- if len(rm.Extra) == 0 {
- return
- }
- out := rm.Extra[:0]
- for _, rr := range rm.Extra {
- if _, ok := rr.(*dns.OPT); ok {
- continue
- }
- out = append(out, rr)
- }
- rm.Extra = out
-}
-
func (u *upstreamResolverBase) handleUpstreamError(err error, upstream netip.AddrPort, startTime time.Time) *upstreamFailure {
if !errors.Is(err, context.DeadlineExceeded) && !isTimeout(err) {
return &upstreamFailure{upstream: upstream, reason: err.Error()}
diff --git a/client/internal/dns/upstream_test.go b/client/internal/dns/upstream_test.go
index 8b3c589f1..4c2784545 100644
--- a/client/internal/dns/upstream_test.go
+++ b/client/internal/dns/upstream_test.go
@@ -517,6 +517,78 @@ func TestUpstreamResolver_HealthTracking(t *testing.T) {
assert.NotContains(t, health, bad, "sibling upstream should not be queried when primary answers")
}
+// TestUpstreamResolver_HealthTracking_ResponseMeansReachable verifies that an
+// upstream which answers with SERVFAIL or REFUSED is recorded as healthy:
+// those are per-question outcomes from a reachable server and must not mark
+// the upstream unhealthy. Only transport failures (timeouts) do.
+func TestUpstreamResolver_HealthTracking_ResponseMeansReachable(t *testing.T) {
+ a := netip.MustParseAddrPort("192.0.2.10:53")
+ b := netip.MustParseAddrPort("192.0.2.11:53")
+ timeoutErr := &net.OpError{Op: "read", Err: fmt.Errorf("i/o timeout")}
+
+ tests := []struct {
+ name string
+ respA mockUpstreamResponse
+ respB mockUpstreamResponse
+ wantHealthy bool
+ }{
+ {
+ name: "both SERVFAIL are reachable",
+ respA: mockUpstreamResponse{msg: buildMockResponse(dns.RcodeServerFailure, "")},
+ respB: mockUpstreamResponse{msg: buildMockResponse(dns.RcodeServerFailure, "")},
+ wantHealthy: true,
+ },
+ {
+ name: "both REFUSED are reachable",
+ respA: mockUpstreamResponse{msg: buildMockResponse(dns.RcodeRefused, "")},
+ respB: mockUpstreamResponse{msg: buildMockResponse(dns.RcodeRefused, "")},
+ wantHealthy: true,
+ },
+ {
+ name: "timeout marks unhealthy",
+ respA: mockUpstreamResponse{err: timeoutErr},
+ respB: mockUpstreamResponse{err: timeoutErr},
+ wantHealthy: false,
+ },
+ }
+
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ mockClient := &mockUpstreamResolverPerServer{
+ responses: map[string]mockUpstreamResponse{
+ a.String(): tc.respA,
+ b.String(): tc.respB,
+ },
+ rtt: time.Millisecond,
+ }
+
+ ctx, cancel := context.WithCancel(context.Background())
+ defer cancel()
+
+ resolver := &upstreamResolverBase{
+ ctx: ctx,
+ upstreamClient: mockClient,
+ upstreamTimeout: UpstreamTimeout,
+ }
+ resolver.addRace([]netip.AddrPort{a, b})
+
+ responseWriter := &test.MockResponseWriter{WriteMsgFunc: func(m *dns.Msg) error { return nil }}
+ resolver.ServeDNS(responseWriter, new(dns.Msg).SetQuestion("example.com.", dns.TypeA))
+
+ health := resolver.UpstreamHealth()
+ require.Contains(t, health, a, "primary upstream should have a health record")
+ if tc.wantHealthy {
+ assert.False(t, health[a].LastOk.IsZero(), "responding upstream should have LastOk set")
+ assert.True(t, health[a].LastFail.IsZero(), "responding upstream should not be marked failed")
+ assert.Empty(t, health[a].LastErr, "responding upstream should have no error")
+ } else {
+ assert.False(t, health[a].LastFail.IsZero(), "timed-out upstream should be marked failed")
+ assert.NotEmpty(t, health[a].LastErr, "timed-out upstream should record an error")
+ }
+ })
+ }
+}
+
func TestFormatFailures(t *testing.T) {
testCases := []struct {
name string
@@ -913,19 +985,6 @@ func TestEDEName(t *testing.T) {
assert.Equal(t, "EDE 9999", edeName(9999), "unknown code falls back to numeric")
}
-func TestStripOPT(t *testing.T) {
- rm := &dns.Msg{
- Extra: []dns.RR{
- &dns.OPT{Hdr: dns.RR_Header{Name: ".", Rrtype: dns.TypeOPT}},
- &dns.A{Hdr: dns.RR_Header{Name: "x.", Rrtype: dns.TypeA}, A: net.IPv4(1, 2, 3, 4)},
- },
- }
- stripOPT(rm)
- assert.Len(t, rm.Extra, 1, "OPT should be removed, A kept")
- _, isOPT := rm.Extra[0].(*dns.OPT)
- assert.False(t, isOPT, "remaining record must not be OPT")
-}
-
func TestUpstreamResolver_NonRetryableEDEShortCircuits(t *testing.T) {
upstream1 := netip.MustParseAddrPort("192.0.2.1:53")
upstream2 := netip.MustParseAddrPort("192.0.2.2:53")
diff --git a/client/internal/dnsfwd/forwarder.go b/client/internal/dnsfwd/forwarder.go
index 2e8ef84ab..c15a8520f 100644
--- a/client/internal/dnsfwd/forwarder.go
+++ b/client/internal/dnsfwd/forwarder.go
@@ -26,6 +26,15 @@ import (
const errResolveFailed = "failed to resolve query for domain=%s: %v"
const upstreamTimeout = 15 * time.Second
+// EDE info codes the forwarder emits on upstream failures so the querying
+// client can see the reason without inspecting this peer's logs. They live in
+// the RFC 8914 Private Use range (49152-65535); the Go resolver never exposes a
+// real upstream EDE here, so these cannot collide with a genuine code.
+const (
+ edeNetbirdUpstreamTimeout uint16 = 49152
+ edeNetbirdUpstreamFailure uint16 = 49153
+)
+
type resolver interface {
LookupNetIP(ctx context.Context, network, host string) ([]netip.Addr, error)
}
@@ -220,7 +229,7 @@ func (f *DNSForwarder) handleDNSQuery(logger *log.Entry, w dns.ResponseWriter, q
result := resutil.LookupIP(ctx, f.resolver, network, qname, question.Qtype)
if result.Err != nil {
- f.handleDNSError(ctx, logger, w, question, resp, qname, result, startTime)
+ f.handleDNSError(ctx, logger, w, question, resp, qname, result, query.IsEdns0() != nil, startTime)
return
}
@@ -333,6 +342,7 @@ func (f *DNSForwarder) handleDNSError(
resp *dns.Msg,
domain string,
result resutil.LookupResult,
+ reqHasEdns bool,
startTime time.Time,
) {
qType := question.Qtype
@@ -374,6 +384,10 @@ func (f *DNSForwarder) handleDNSError(
logger.Warnf(errResolveFailed, domain, result.Err)
}
+ if reqHasEdns {
+ attachEDE(resp, edeCodeFor(dnsErr), edeText(dnsErr))
+ }
+
f.writeResponse(logger, w, resp, domain, startTime)
}
@@ -414,3 +428,33 @@ func (f *DNSForwarder) getMatchingEntries(domain string) (route.ResID, []*Forwar
return selectedResId, matches
}
+
+// edeCodeFor maps an upstream lookup error to the NetBird EDE info code.
+func edeCodeFor(dnsErr *net.DNSError) uint16 {
+ if dnsErr != nil && dnsErr.IsTimeout {
+ return edeNetbirdUpstreamTimeout
+ }
+ return edeNetbirdUpstreamFailure
+}
+
+// edeText builds the EDE extra-text describing the class of upstream failure.
+// It deliberately omits the upstream server address, which may be an internal
+// resolver and is exposed to any client permitted to use the route; the full
+// detail stays in the forwarder's local log.
+func edeText(dnsErr *net.DNSError) string {
+ if dnsErr != nil && dnsErr.IsTimeout {
+ return "netbird forwarder: upstream timeout"
+ }
+ return "netbird forwarder: upstream failure"
+}
+
+// attachEDE adds an Extended DNS Error (RFC 8914) option to the response,
+// creating the OPT pseudo-record if the response does not already carry one.
+func attachEDE(resp *dns.Msg, code uint16, text string) {
+ opt := resp.IsEdns0()
+ if opt == nil {
+ resp.SetEdns0(dns.DefaultMsgSize, false)
+ opt = resp.IsEdns0()
+ }
+ opt.Option = append(opt.Option, &dns.EDNS0_EDE{InfoCode: code, ExtraText: text})
+}
diff --git a/client/internal/dnsfwd/forwarder_test.go b/client/internal/dnsfwd/forwarder_test.go
index 7325ef8a7..046595473 100644
--- a/client/internal/dnsfwd/forwarder_test.go
+++ b/client/internal/dnsfwd/forwarder_test.go
@@ -16,6 +16,7 @@ import (
"github.com/stretchr/testify/require"
firewall "github.com/netbirdio/netbird/client/firewall/manager"
+ "github.com/netbirdio/netbird/client/internal/dns/resutil"
"github.com/netbirdio/netbird/client/internal/dns/test"
"github.com/netbirdio/netbird/client/internal/peer"
"github.com/netbirdio/netbird/route"
@@ -617,6 +618,85 @@ func TestDNSForwarder_ResponseCodes(t *testing.T) {
}
}
+func TestDNSForwarder_UpstreamFailureEDE(t *testing.T) {
+ tests := []struct {
+ name string
+ lookupErr error
+ reqEdns bool
+ wantEDE bool
+ wantCode uint16
+ wantTextHas string
+ }{
+ {
+ name: "timeout with edns0",
+ lookupErr: &net.DNSError{Err: "i/o timeout", Server: "10.0.0.53:53", IsTimeout: true},
+ reqEdns: true,
+ wantEDE: true,
+ wantCode: edeNetbirdUpstreamTimeout,
+ wantTextHas: "netbird forwarder: upstream timeout",
+ },
+ {
+ name: "server failure with edns0",
+ lookupErr: &net.DNSError{Err: "server misbehaving", Server: "10.0.0.53:53"},
+ reqEdns: true,
+ wantEDE: true,
+ wantCode: edeNetbirdUpstreamFailure,
+ wantTextHas: "netbird forwarder: upstream failure",
+ },
+ {
+ name: "no edns0 in request omits ede",
+ lookupErr: &net.DNSError{Err: "server misbehaving", Server: "10.0.0.53:53"},
+ reqEdns: false,
+ wantEDE: false,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ mockResolver := &MockResolver{}
+ forwarder := NewDNSForwarder(netip.MustParseAddrPort("127.0.0.1:0"), 300, nil, &peer.Status{}, nil)
+ forwarder.resolver = mockResolver
+
+ d, err := domain.FromString("example.com")
+ require.NoError(t, err)
+ forwarder.UpdateDomains([]*ForwarderEntry{{Domain: d, ResID: "test-res"}})
+
+ mockResolver.On("LookupNetIP", mock.Anything, "ip4", "example.com.").
+ Return([]netip.Addr(nil), tt.lookupErr).Once()
+
+ query := &dns.Msg{}
+ query.SetQuestion("example.com.", dns.TypeA)
+ if tt.reqEdns {
+ query.SetEdns0(dns.DefaultMsgSize, false)
+ }
+
+ var writtenResp *dns.Msg
+ mockWriter := &test.MockResponseWriter{
+ WriteMsgFunc: func(m *dns.Msg) error {
+ writtenResp = m
+ return nil
+ },
+ }
+
+ forwarder.handleDNSQuery(log.NewEntry(log.StandardLogger()), mockWriter, query, time.Now())
+ mockResolver.AssertExpectations(t)
+
+ require.NotNil(t, writtenResp, "expected a response")
+ assert.Equal(t, dns.RcodeServerFailure, writtenResp.Rcode, "upstream failure must be SERVFAIL")
+
+ ede, ok := resutil.ExtractEDE(writtenResp)
+ if !tt.wantEDE {
+ assert.False(t, ok, "response must not carry EDE")
+ return
+ }
+ require.True(t, ok, "response must carry EDE")
+ assert.Equal(t, tt.wantCode, ede.InfoCode, "EDE info code")
+ assert.Contains(t, ede.ExtraText, tt.wantTextHas, "EDE extra-text")
+ assert.NotContains(t, ede.ExtraText, "10.0.0.53", "must not leak upstream server address")
+ })
+ }
+}
+
func TestDNSForwarder_TCPTruncation(t *testing.T) {
// Test that large UDP responses are truncated with TC bit set
mockResolver := &MockResolver{}
diff --git a/client/internal/engine.go b/client/internal/engine.go
index 98ab7a216..50b347bb3 100644
--- a/client/internal/engine.go
+++ b/client/internal/engine.go
@@ -53,7 +53,6 @@ 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"
@@ -87,6 +86,8 @@ const (
var ErrResetConnection = fmt.Errorf("reset connection")
+var ErrEngineAlreadyStarted = errors.New("engine already started")
+
type EngineConfig struct {
WgPort int
WgIfaceName string
@@ -200,6 +201,8 @@ type Engine struct {
ctx context.Context
cancel context.CancelFunc
+ started bool
+
wgInterface WGIface
udpMux *udpmux.UniversalUDPMuxDefault
@@ -280,9 +283,15 @@ func NewEngine(
services EngineServices,
mobileDep MobileDependency,
) *Engine {
+ // The engine is single-use: a fresh instance is built per connection
+ // cycle (see Client.run), so the run context is created once here rather
+ // than in Start.
+ ctx, cancel := context.WithCancel(clientCtx)
engine := &Engine{
clientCtx: clientCtx,
clientCancel: clientCancel,
+ ctx: ctx,
+ cancel: cancel,
signal: services.SignalClient,
signaler: peer.NewSignaler(services.SignalClient, config.WgPrivateKey),
mgmClient: services.MgmClient,
@@ -315,8 +324,34 @@ func (e *Engine) Stop() error {
log.Debugf("tried stopping engine that is nil")
return nil
}
+ e.cancel()
e.syncMsgMux.Lock()
+ e.stopLocked()
+
+ e.syncMsgMux.Unlock()
+
+ timeout := e.calculateShutdownTimeout()
+ log.Debugf("waiting for goroutines to finish with timeout: %v", timeout)
+ shutdownCtx, cancel := context.WithTimeout(context.Background(), timeout)
+ defer cancel()
+
+ if err := waitWithContext(shutdownCtx, &e.shutdownWg); err != nil {
+ log.Warnf("shutdown timeout exceeded after %v, some goroutines may still be running", timeout)
+ }
+
+ log.Infof("stopped Netbird Engine")
+
+ return nil
+}
+
+// stopLocked tears down everything Start may have brought up, in the order
+// teardown requires (DNS before the interface goes down, flow manager after).
+// The caller must hold syncMsgMux. It is shared by Stop and by Start's failure
+// path, so a partially-initialized engine is cleaned up the same way; every
+// step is nil-guarded. It does not wait on shutdownWg — the caller does that
+// after releasing the lock, since the goroutines also take syncMsgMux.
+func (e *Engine) stopLocked() {
if e.connMgr != nil {
e.connMgr.Close()
}
@@ -367,10 +402,6 @@ func (e *Engine) Stop() error {
// so dbus and friends don't complain because of a missing interface
e.stopDNSServer()
- if e.cancel != nil {
- e.cancel()
- }
-
e.jobExecutorWG.Wait() // block until job goroutines finish
e.close()
@@ -389,21 +420,6 @@ func (e *Engine) Stop() error {
if err := e.stateManager.PersistState(context.Background()); err != nil {
log.Errorf("failed to persist state: %v", err)
}
-
- e.syncMsgMux.Unlock()
-
- timeout := e.calculateShutdownTimeout()
- log.Debugf("waiting for goroutines to finish with timeout: %v", timeout)
- shutdownCtx, cancel := context.WithTimeout(context.Background(), timeout)
- defer cancel()
-
- if err := waitWithContext(shutdownCtx, &e.shutdownWg); err != nil {
- log.Warnf("shutdown timeout exceeded after %v, some goroutines may still be running", timeout)
- }
-
- log.Infof("stopped Netbird Engine")
-
- return nil
}
// calculateShutdownTimeout returns shutdown timeout: 10s base + 100ms per peer, capped at 30s.
@@ -441,18 +457,38 @@ func waitWithContext(ctx context.Context, wg *sync.WaitGroup) error {
// Start creates a new WireGuard tunnel interface and listens to events from Signal and Management services
// Connections to remote peers are not established here.
// However, they will be established once an event with a list of peers to connect to will be received from Management Service
-func (e *Engine) Start(netbirdConfig *mgmProto.NetbirdConfig, mgmtURL *url.URL) error {
+func (e *Engine) Start(netbirdConfig *mgmProto.NetbirdConfig, mgmtURL *url.URL) (err error) {
e.syncMsgMux.Lock()
defer e.syncMsgMux.Unlock()
- if err := iface.ValidateMTU(e.config.MTU); err != nil {
+ // The engine is single-use. Reject a duplicate start and a start on an
+ // already-stopped engine (run context cancelled).
+ if e.started {
+ return ErrEngineAlreadyStarted
+ }
+
+ if ctxErr := e.ctx.Err(); ctxErr != nil {
+ return fmt.Errorf("engine already stopped: %w", ctxErr)
+ }
+
+ e.started = true
+
+ // Tear down any partially-initialized state on a failed start. Cancel the
+ // run context first so goroutines started before the failure (connMgr,
+ // srWatcher, monitors) unwind, then stopLocked mirrors Stop's teardown (we
+ // already hold syncMsgMux), cleaning up route/DNS/flow/state managers too,
+ // not just what close() covers.
+ defer func() {
+ if err != nil {
+ e.cancel()
+ e.stopLocked()
+ }
+ }()
+
+ if err = iface.ValidateMTU(e.config.MTU); err != nil {
return fmt.Errorf("invalid MTU configuration: %w", err)
}
- if e.cancel != nil {
- e.cancel()
- }
- e.ctx, e.cancel = context.WithCancel(e.clientCtx)
e.exposeManager = expose.NewManager(e.ctx, e.mgmClient)
wgIface, err := e.newWgIface()
@@ -486,13 +522,11 @@ func (e *Engine) Start(netbirdConfig *mgmProto.NetbirdConfig, mgmtURL *url.URL)
initialRoutes, dnsConfig, dnsFeatureFlag, err := e.readInitialSettings()
if err != nil {
- e.close()
return fmt.Errorf("read initial settings: %w", err)
}
dnsServer, err := e.newDnsServer(dnsConfig)
if err != nil {
- e.close()
return fmt.Errorf("create dns server: %w", err)
}
e.dnsServer = dnsServer
@@ -527,12 +561,14 @@ func (e *Engine) Start(netbirdConfig *mgmProto.NetbirdConfig, mgmtURL *url.URL)
if err = e.wgInterfaceCreate(); err != nil {
log.Errorf("failed creating tunnel interface %s: [%s]", e.config.WgIfaceName, err.Error())
- e.close()
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
}
@@ -544,7 +580,6 @@ func (e *Engine) Start(netbirdConfig *mgmProto.NetbirdConfig, mgmtURL *url.URL)
e.udpMux, err = e.wgInterface.Up()
if err != nil {
log.Errorf("failed to pull up wgInterface [%s]: %s", e.wgInterface.Name(), err.Error())
- e.close()
return fmt.Errorf("up wg interface: %w", err)
}
@@ -569,9 +604,7 @@ func (e *Engine) Start(netbirdConfig *mgmProto.NetbirdConfig, mgmtURL *url.URL)
e.acl = acl.NewDefaultManager(e.firewall)
}
- err = e.dnsServer.Initialize()
- if err != nil {
- e.close()
+ if err := e.dnsServer.Initialize(); err != nil {
return fmt.Errorf("initialize dns server: %w", err)
}
@@ -583,7 +616,9 @@ func (e *Engine) Start(netbirdConfig *mgmProto.NetbirdConfig, mgmtURL *url.URL)
e.srWatcher = guard.NewSRWatcher(e.signal, e.relayManager, e.mobileDep.IFaceDiscover, iceCfg)
e.srWatcher.Start(peer.IsForceRelayed())
- e.receiveSignalEvents()
+ if err = e.receiveSignalEvents(); err != nil {
+ return err
+ }
e.receiveManagementEvents()
e.receiveJobEvents()
@@ -635,7 +670,6 @@ func (e *Engine) createFirewall() error {
func (e *Engine) initFirewall() error {
if err := e.routeManager.SetFirewall(e.firewall); err != nil {
- e.close()
return fmt.Errorf("set firewall: %w", err)
}
@@ -885,62 +919,25 @@ func (e *Engine) handleSync(update *mgmProto.SyncResponse) error {
e.handleAutoUpdateVersion(update.NetworkMap.PeerConfig.AutoUpdate)
}
- if update.GetNetbirdConfig() != nil {
- wCfg := update.GetNetbirdConfig()
- err := e.updateTURNs(wCfg.GetTurns())
- if err != nil {
- return fmt.Errorf("update TURNs: %w", err)
- }
+ if err := e.updateNetbirdConfig(update.GetNetbirdConfig()); err != nil {
+ return 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
+ // 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
+ nm := update.GetNetworkMap()
+ if nm == nil {
+ return nil
}
if err := e.updateChecksIfNew(update.Checks); err != nil {
return err
}
- nm := update.GetNetworkMap()
- if nm == nil {
- return nil
- }
-
- // Persist sync response under the dedicated lock (syncRespMux), not under syncMsgMux.
- // A non-nil syncStore is what marks persistence as enabled. Hold the lock 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.
- e.syncRespMux.RLock()
- if e.syncStore != nil {
- if err := e.syncStore.Set(update); err != nil {
- log.Errorf("failed to persist sync response: %v", err)
- } else {
- log.Debugf("sync response persisted with serial %d", nm.GetSerial())
- }
- }
- e.syncRespMux.RUnlock()
+ e.persistSyncResponse(update)
// only apply new changes and ignore old ones
if err := e.updateNetworkMap(nm); err != nil {
@@ -952,6 +949,64 @@ func (e *Engine) handleSync(update *mgmProto.SyncResponse) error {
return nil
}
+// 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
@@ -1016,7 +1071,7 @@ func (e *Engine) updateChecksIfNew(checks []*mgmProto.Checks) error {
}
e.checks = checks
- info, err := system.GetInfoWithChecks(e.ctx, checks)
+ info, err := system.GetInfoWithChecks(e.ctx, checks, e.overlayAddresses()...)
if err != nil {
log.Warnf("failed to get system info with checks: %v", err)
info = system.GetInfo(e.ctx)
@@ -1047,6 +1102,20 @@ func (e *Engine) updateChecksIfNew(checks []*mgmProto.Checks) error {
return nil
}
+// overlayAddresses returns our own WireGuard overlay address (v4 and v6) so it
+// can be excluded from the reported network addresses; the interface coming and
+// going otherwise churns the peer meta on the management server.
+func (e *Engine) overlayAddresses() []netip.Addr {
+ var ips []netip.Addr
+ if e.config.WgAddr.IP.IsValid() {
+ ips = append(ips, e.config.WgAddr.IP)
+ }
+ if e.config.WgAddr.HasIPv6() {
+ ips = append(ips, e.config.WgAddr.IPv6)
+ }
+ return ips
+}
+
func (e *Engine) updateConfig(conf *mgmProto.PeerConfig) error {
if e.wgInterface == nil {
return errors.New("wireguard interface is not initialized")
@@ -1190,7 +1259,7 @@ func (e *Engine) receiveManagementEvents() {
e.shutdownWg.Add(1)
go func() {
defer e.shutdownWg.Done()
- info, err := system.GetInfoWithChecks(e.ctx, e.checks)
+ info, err := system.GetInfoWithChecks(e.ctx, e.checks, e.overlayAddresses()...)
if err != nil {
log.Warnf("failed to get system info with checks: %v", err)
info = system.GetInfo(e.ctx)
@@ -1679,7 +1748,7 @@ func (e *Engine) createPeerConn(pubKey string, allowedIPs []netip.Prefix, agentV
}
// receiveSignalEvents connects to the Signal Service event stream to negotiate connection with remote peers
-func (e *Engine) receiveSignalEvents() {
+func (e *Engine) receiveSignalEvents() error {
e.shutdownWg.Add(1)
go func() {
defer e.shutdownWg.Done()
@@ -1695,6 +1764,13 @@ func (e *Engine) receiveSignalEvents() {
return e.ctx.Err()
}
+ // Self-addressed heartbeat: the signal client's receive watchdog
+ // round-trips this through the server to confirm the receive stream
+ // is delivering. Liveness is already recorded before this handler.
+ if msg.GetBody().GetType() == sProto.Body_HEARTBEAT {
+ return nil
+ }
+
conn, ok := e.peerStore.PeerConn(msg.Key)
if !ok {
return fmt.Errorf("wrongly addressed message %s", msg.Key)
@@ -1743,7 +1819,12 @@ func (e *Engine) receiveSignalEvents() {
}
}()
- e.signal.WaitStreamConnected()
+ // todo: consider to remove this blocker. I do not see benefit to block the Start operations
+ e.signal.WaitStreamConnected(e.ctx)
+ if err := e.ctx.Err(); err != nil {
+ return fmt.Errorf("wait for signal stream: %w", err)
+ }
+ return nil
}
func (e *Engine) parseNATExternalIPMappings() []string {
@@ -1893,7 +1974,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,
}
@@ -2141,21 +2221,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
diff --git a/client/internal/engine_privileged_test.go b/client/internal/engine_privileged_test.go
new file mode 100644
index 000000000..f787f741f
--- /dev/null
+++ b/client/internal/engine_privileged_test.go
@@ -0,0 +1,565 @@
+//go:build privileged
+
+package internal
+
+import (
+ "context"
+ "fmt"
+ "net"
+ "runtime"
+ "strings"
+ "sync"
+ "testing"
+ "time"
+
+ "github.com/golang/mock/gomock"
+ "github.com/google/uuid"
+ log "github.com/sirupsen/logrus"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+ "go.opentelemetry.io/otel"
+ "golang.zx2c4.com/wireguard/wgctrl/wgtypes"
+ "google.golang.org/grpc"
+ "google.golang.org/grpc/keepalive"
+
+ "github.com/netbirdio/netbird/client/iface"
+ "github.com/netbirdio/netbird/client/iface/device"
+ "github.com/netbirdio/netbird/client/iface/wgaddr"
+ "github.com/netbirdio/netbird/client/internal/dns"
+ "github.com/netbirdio/netbird/client/internal/peer"
+ nbssh "github.com/netbirdio/netbird/client/ssh"
+ "github.com/netbirdio/netbird/client/system"
+ nbdns "github.com/netbirdio/netbird/dns"
+ "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"
+ "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"
+ mgmt "github.com/netbirdio/netbird/shared/management/client"
+ mgmtProto "github.com/netbirdio/netbird/shared/management/proto"
+ relayClient "github.com/netbirdio/netbird/shared/relay/client"
+ signal "github.com/netbirdio/netbird/shared/signal/client"
+ "github.com/netbirdio/netbird/shared/signal/proto"
+ signalServer "github.com/netbirdio/netbird/signal/server"
+ "github.com/netbirdio/netbird/util"
+)
+
+func TestEngine_SSH(t *testing.T) {
+ key, err := wgtypes.GeneratePrivateKey()
+ if err != nil {
+ t.Fatal(err)
+ return
+ }
+
+ sshKey, err := nbssh.GeneratePrivateKey(nbssh.ED25519)
+ if err != nil {
+ t.Fatal(err)
+ return
+ }
+
+ ctx, cancel := context.WithCancel(CtxInitState(context.Background()))
+ defer cancel()
+
+ relayMgr := relayClient.NewManager(ctx, nil, key.PublicKey().String(), iface.DefaultMTU)
+ engine := NewEngine(
+ ctx, cancel,
+ &EngineConfig{
+ WgIfaceName: "utun101",
+ WgAddr: wgaddr.MustParseWGAddress("100.64.0.1/24"),
+ WgPrivateKey: key,
+ WgPort: 33100,
+ ServerSSHAllowed: true,
+ MTU: iface.DefaultMTU,
+ SSHKey: sshKey,
+ },
+ EngineServices{
+ SignalClient: &signal.MockClient{},
+ MgmClient: &mgmt.MockClient{},
+ RelayManager: relayMgr,
+ StatusRecorder: peer.NewRecorder("https://mgm"),
+ },
+ MobileDependency{},
+ )
+
+ engine.dnsServer = &dns.MockServer{
+ UpdateDNSServerFunc: func(serial uint64, update nbdns.Config) error { return nil },
+ }
+
+ err = engine.Start(nil, nil)
+ require.NoError(t, err)
+
+ defer func() {
+ err := engine.Stop()
+ if err != nil {
+ return
+ }
+ }()
+
+ peerWithSSH := &mgmtProto.RemotePeerConfig{
+ WgPubKey: "MNHf3Ma6z6mdLbriAJbqhX7+nM/B71lgw2+91q3LfhU=",
+ AllowedIps: []string{"100.64.0.21/24"},
+ SshConfig: &mgmtProto.SSHConfig{
+ SshPubKey: []byte("ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIFATYCqaQw/9id1Qkq3n16JYhDhXraI6Pc1fgB8ynEfQ"),
+ },
+ }
+
+ // SSH server is not enabled so SSH config of a remote peer should be ignored
+ networkMap := &mgmtProto.NetworkMap{
+ Serial: 6,
+ PeerConfig: nil,
+ RemotePeers: []*mgmtProto.RemotePeerConfig{peerWithSSH},
+ RemotePeersIsEmpty: false,
+ }
+
+ err = engine.updateNetworkMap(networkMap)
+ require.NoError(t, err)
+
+ assert.Nil(t, engine.sshServer)
+
+ // SSH server is enabled, therefore SSH config should be applied
+ networkMap = &mgmtProto.NetworkMap{
+ Serial: 7,
+ PeerConfig: &mgmtProto.PeerConfig{Address: "100.64.0.1/24",
+ SshConfig: &mgmtProto.SSHConfig{
+ SshEnabled: true,
+ JwtConfig: &mgmtProto.JWTConfig{
+ Issuer: "test-issuer",
+ Audience: "test-audience",
+ KeysLocation: "test-keys",
+ MaxTokenAge: 3600,
+ },
+ }},
+ RemotePeers: []*mgmtProto.RemotePeerConfig{peerWithSSH},
+ RemotePeersIsEmpty: false,
+ }
+
+ err = engine.updateNetworkMap(networkMap)
+ require.NoError(t, err)
+
+ time.Sleep(250 * time.Millisecond)
+ assert.NotNil(t, engine.sshServer)
+
+ // now remove peer
+ networkMap = &mgmtProto.NetworkMap{
+ Serial: 8,
+ RemotePeers: []*mgmtProto.RemotePeerConfig{},
+ RemotePeersIsEmpty: false,
+ }
+
+ err = engine.updateNetworkMap(networkMap)
+ require.NoError(t, err)
+
+ // time.Sleep(250 * time.Millisecond)
+ assert.NotNil(t, engine.sshServer)
+
+ // now disable SSH server
+ networkMap = &mgmtProto.NetworkMap{
+ Serial: 9,
+ PeerConfig: &mgmtProto.PeerConfig{Address: "100.64.0.1/24",
+ SshConfig: &mgmtProto.SSHConfig{SshEnabled: false}},
+ RemotePeers: []*mgmtProto.RemotePeerConfig{peerWithSSH},
+ RemotePeersIsEmpty: false,
+ }
+
+ err = engine.updateNetworkMap(networkMap)
+ require.NoError(t, err)
+
+ assert.Nil(t, engine.sshServer)
+}
+
+func TestEngine_Sync(t *testing.T) {
+ key, err := wgtypes.GeneratePrivateKey()
+ if err != nil {
+ t.Fatal(err)
+ return
+ }
+
+ ctx, cancel := context.WithCancel(CtxInitState(context.Background()))
+ defer cancel()
+
+ // feed updates to Engine via mocked Management client
+ updates := make(chan *mgmtProto.SyncResponse)
+ defer close(updates)
+ syncFunc := func(ctx context.Context, info *system.Info, msgHandler func(msg *mgmtProto.SyncResponse) error) error {
+ for msg := range updates {
+ err := msgHandler(msg)
+ if err != nil {
+ t.Fatal(err)
+ }
+ }
+ return nil
+ }
+ relayMgr := relayClient.NewManager(ctx, nil, key.PublicKey().String(), iface.DefaultMTU)
+ engine := NewEngine(ctx, cancel, &EngineConfig{
+ WgIfaceName: "utun103",
+ WgAddr: wgaddr.MustParseWGAddress("100.64.0.1/24"),
+ WgPrivateKey: key,
+ WgPort: 33100,
+ MTU: iface.DefaultMTU,
+ }, EngineServices{
+ SignalClient: &signal.MockClient{},
+ MgmClient: &mgmt.MockClient{SyncFunc: syncFunc},
+ RelayManager: relayMgr,
+ StatusRecorder: peer.NewRecorder("https://mgm"),
+ }, MobileDependency{})
+ engine.ctx = ctx
+
+ engine.dnsServer = &dns.MockServer{
+ UpdateDNSServerFunc: func(serial uint64, update nbdns.Config) error { return nil },
+ }
+
+ defer func() {
+ err := engine.Stop()
+ if err != nil {
+ return
+ }
+ }()
+
+ err = engine.Start(nil, nil)
+ if err != nil {
+ t.Fatal(err)
+ return
+ }
+
+ peer1 := &mgmtProto.RemotePeerConfig{
+ WgPubKey: "RRHf3Ma6z6mdLbriAJbqhX7+nM/B71lgw2+91q3LfhU=",
+ AllowedIps: []string{"100.64.0.10/24"},
+ }
+ peer2 := &mgmtProto.RemotePeerConfig{
+ WgPubKey: "LLHf3Ma6z6mdLbriAJbqhX9+nM/B71lgw2+91q3LlhU=",
+ AllowedIps: []string{"100.64.0.11/24"},
+ }
+ peer3 := &mgmtProto.RemotePeerConfig{
+ WgPubKey: "GGHf3Ma6z6mdLbriAJbqhX9+nM/B71lgw2+91q3LlhU=",
+ AllowedIps: []string{"100.64.0.12/24"},
+ }
+ // 1st update with just 1 peer and serial larger than the current serial of the engine => apply update
+ updates <- &mgmtProto.SyncResponse{
+ NetworkMap: &mgmtProto.NetworkMap{
+ Serial: 10,
+ PeerConfig: nil,
+ RemotePeers: []*mgmtProto.RemotePeerConfig{peer1, peer2, peer3},
+ RemotePeersIsEmpty: false,
+ },
+ }
+
+ timeout := time.After(time.Second * 2)
+ for {
+ select {
+ case <-timeout:
+ t.Fatalf("timeout while waiting for test to finish")
+ return
+ default:
+ }
+
+ if getPeers(engine) == 3 && engine.networkSerial == 10 {
+ break
+ }
+ }
+}
+
+func TestEngine_MultiplePeers(t *testing.T) {
+ // log.SetLevel(log.DebugLevel)
+
+ ctx, cancel := context.WithCancel(CtxInitState(context.Background()))
+ defer cancel()
+
+ sigServer, signalAddr, err := startSignal(t)
+ if err != nil {
+ t.Fatal(err)
+ return
+ }
+ defer sigServer.Stop()
+ mgmtServer, mgmtAddr, err := startManagement(t, t.TempDir(), "../testdata/store.sql")
+ if err != nil {
+ t.Fatal(err)
+ return
+ }
+ defer mgmtServer.GracefulStop()
+
+ setupKey := "A2C8E62B-38F5-4553-B31E-DD66C696CEBB"
+
+ mu := sync.Mutex{}
+ engines := []*Engine{}
+ numPeers := 10
+ wg := sync.WaitGroup{}
+ wg.Add(numPeers)
+ // create and start peers
+ for i := 0; i < numPeers; i++ {
+ j := i
+ go func() {
+ engine, err := createEngine(ctx, cancel, setupKey, j, mgmtAddr, signalAddr)
+ if err != nil {
+ wg.Done()
+ t.Errorf("unable to create the engine for peer %d with error %v", j, err)
+ return
+ }
+ engine.dnsServer = &dns.MockServer{}
+ mu.Lock()
+ defer mu.Unlock()
+ guid := fmt.Sprintf("{%s}", uuid.New().String())
+ device.CustomWindowsGUIDString = strings.ToLower(guid)
+ err = engine.Start(nil, nil)
+ if err != nil {
+ t.Errorf("unable to start engine for peer %d with error %v", j, err)
+ wg.Done()
+ return
+ }
+ engines = append(engines, engine)
+ wg.Done()
+ }()
+ }
+
+ // wait until all have been created and started
+ wg.Wait()
+ if len(engines) != numPeers {
+ t.Fatal("not all peers were started")
+ }
+ // check whether all the peer have expected peers connected
+
+ expectedConnected := numPeers * (numPeers - 1)
+
+ // adjust according to timeouts
+ timeout := 50 * time.Second
+ timeoutChan := time.After(timeout)
+ ticker := time.NewTicker(time.Second)
+ defer ticker.Stop()
+loop:
+ for {
+ select {
+ case <-timeoutChan:
+ t.Fatalf("waiting for expected connections timeout after %s", timeout.String())
+ break loop
+ case <-ticker.C:
+ totalConnected := 0
+ for _, engine := range engines {
+ totalConnected += getConnectedPeers(engine)
+ }
+ if totalConnected == expectedConnected {
+ log.Infof("total connected=%d", totalConnected)
+ break loop
+ }
+ log.Infof("total connected=%d", totalConnected)
+ }
+ }
+ // cleanup test
+ for n, peerEngine := range engines {
+ t.Logf("stopping peer with interface %s from multipeer test, loopIndex %d", peerEngine.wgInterface.Name(), n)
+ errStop := peerEngine.mgmClient.Close()
+ if errStop != nil {
+ log.Infoln("got error trying to close management clients from engine: ", errStop)
+ }
+ errStop = peerEngine.Stop()
+ if errStop != nil {
+ log.Infoln("got error trying to close testing peers engine: ", errStop)
+ }
+ }
+}
+
+var (
+ kaep = keepalive.EnforcementPolicy{
+ MinTime: 15 * time.Second,
+ PermitWithoutStream: true,
+ }
+
+ kasp = keepalive.ServerParameters{
+ MaxConnectionIdle: 15 * time.Second,
+ MaxConnectionAgeGrace: 5 * time.Second,
+ Time: 5 * time.Second,
+ Timeout: 2 * time.Second,
+ }
+)
+
+func createEngine(ctx context.Context, cancel context.CancelFunc, setupKey string, i int, mgmtAddr string, signalAddr string) (*Engine, error) {
+ key, err := wgtypes.GeneratePrivateKey()
+ if err != nil {
+ return nil, err
+ }
+ mgmtClient, err := mgmt.NewClient(ctx, mgmtAddr, key, false)
+ if err != nil {
+ return nil, err
+ }
+ signalClient, err := signal.NewClient(ctx, signalAddr, key, false)
+ if err != nil {
+ return nil, err
+ }
+
+ info := system.GetInfo(ctx)
+ resp, err := mgmtClient.Register(setupKey, "", info, nil, nil)
+ if err != nil {
+ return nil, err
+ }
+
+ var ifaceName string
+ if runtime.GOOS == "darwin" {
+ ifaceName = fmt.Sprintf("utun1%d", i)
+ } else {
+ ifaceName = fmt.Sprintf("wt%d", i)
+ }
+
+ wgPort := 33100 + i
+ conf := &EngineConfig{
+ WgIfaceName: ifaceName,
+ WgAddr: wgaddr.MustParseWGAddress(resp.PeerConfig.Address),
+ WgPrivateKey: key,
+ WgPort: wgPort,
+ MTU: iface.DefaultMTU,
+ }
+
+ relayMgr := relayClient.NewManager(ctx, nil, key.PublicKey().String(), iface.DefaultMTU)
+ e, err := NewEngine(ctx, cancel, conf, EngineServices{
+ SignalClient: signalClient,
+ MgmClient: mgmtClient,
+ RelayManager: relayMgr,
+ StatusRecorder: peer.NewRecorder("https://mgm"),
+ }, MobileDependency{}), nil
+ e.ctx = ctx
+ return e, err
+}
+
+func startSignal(t *testing.T) (*grpc.Server, string, error) {
+ t.Helper()
+
+ s := grpc.NewServer(grpc.KeepaliveEnforcementPolicy(kaep), grpc.KeepaliveParams(kasp))
+
+ lis, err := net.Listen("tcp", "localhost:0")
+ if err != nil {
+ log.Fatalf("failed to listen: %v", err)
+ }
+
+ srv, err := signalServer.NewServer(context.Background(), otel.Meter(""))
+ require.NoError(t, err)
+ proto.RegisterSignalExchangeServer(s, srv)
+
+ go func() {
+ if err = s.Serve(lis); err != nil {
+ log.Fatalf("failed to serve: %v", err)
+ }
+ }()
+
+ return s, lis.Addr().String(), nil
+}
+
+func startManagement(t *testing.T, dataDir, testFile string) (*grpc.Server, string, error) {
+ t.Helper()
+
+ config := &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: "localhost:10000",
+ },
+ Datadir: dataDir,
+ HttpConfig: nil,
+ }
+
+ lis, err := net.Listen("tcp", "localhost:0")
+ if err != nil {
+ return nil, "", err
+ }
+ s := grpc.NewServer(grpc.KeepaliveEnforcementPolicy(kaep), grpc.KeepaliveParams(kasp))
+
+ store, cleanUp, err := store.NewTestStoreFromSQL(context.Background(), testFile, config.Datadir)
+ if err != nil {
+ return nil, "", err
+ }
+ t.Cleanup(cleanUp)
+
+ eventStore := &activity.InMemoryEventStore{}
+ if err != nil {
+ return nil, "", err
+ }
+
+ permissionsManager := permissions.NewManager(store)
+ peersManager := peers.NewManager(store, permissionsManager)
+ jobManager := job.NewJobManager(nil, store, peersManager)
+
+ cacheStore, err := nbcache.NewStore(context.Background(), 100*time.Millisecond, 300*time.Millisecond, 100)
+ if err != nil {
+ return nil, "", err
+ }
+
+ ia, _ := validator.NewIntegratedValidator(context.Background(), peersManager, nil, eventStore, cacheStore)
+
+ 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 := server.NewAccountRequestBuffer(context.Background(), store)
+ networkMapController := controller.NewController(context.Background(), store, metrics, updateManager, requestBuffer, server.MockIntegratedValidator{}, settingsMockManager, "netbird.selfhosted", port_forwarding.NewControllerMock(), manager.NewEphemeralManager(store, peersManager), config)
+ accountManager, err := server.BuildManager(context.Background(), config, store, networkMapController, jobManager, nil, "", eventStore, nil, false, ia, metrics, port_forwarding.NewControllerMock(), settingsMockManager, permissionsManager, false, cacheStore)
+ if err != nil {
+ return nil, "", err
+ }
+
+ secretsManager, err := nbgrpc.NewTimeBasedAuthSecretsManager(updateManager, config.TURNConfig, config.Relay, settingsMockManager, groupsManager)
+ if err != nil {
+ return nil, "", err
+ }
+ mgmtServer, err := nbgrpc.NewServer(config, accountManager, settingsMockManager, jobManager, secretsManager, nil, nil, &server.MockIntegratedValidator{}, networkMapController, nil, nil)
+ if err != nil {
+ return nil, "", err
+ }
+ mgmtProto.RegisterManagementServiceServer(s, mgmtServer)
+ go func() {
+ if err = s.Serve(lis); err != nil {
+ log.Fatalf("failed to serve: %v", err)
+ }
+ }()
+
+ return s, lis.Addr().String(), nil
+}
+
+// getConnectedPeers returns a connection Status or nil if peer connection wasn't found
+func getConnectedPeers(e *Engine) int {
+ e.syncMsgMux.Lock()
+ defer e.syncMsgMux.Unlock()
+ i := 0
+ for _, id := range e.peerStore.PeersPubKey() {
+ conn, _ := e.peerStore.PeerConn(id)
+ if conn.IsConnected() {
+ i++
+ }
+ }
+ return i
+}
+
+func getPeers(e *Engine) int {
+ e.syncMsgMux.Lock()
+ defer e.syncMsgMux.Unlock()
+
+ return len(e.peerStore.PeersPubKey())
+}
diff --git a/client/internal/engine_test.go b/client/internal/engine_test.go
index 289f1906f..1ac9ceff7 100644
--- a/client/internal/engine_test.go
+++ b/client/internal/engine_test.go
@@ -6,37 +6,18 @@ import (
"net"
"net/netip"
"os"
- "runtime"
"strings"
"sync"
"testing"
"time"
- "github.com/golang/mock/gomock"
- "github.com/google/uuid"
- log "github.com/sirupsen/logrus"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
- "go.opentelemetry.io/otel"
wgdevice "golang.zx2c4.com/wireguard/device"
"golang.zx2c4.com/wireguard/tun/netstack"
"golang.zx2c4.com/wireguard/wgctrl/wgtypes"
- "google.golang.org/grpc"
- "google.golang.org/grpc/keepalive"
"github.com/netbirdio/netbird/client/internal/stdnet"
- "github.com/netbirdio/netbird/management/server/job"
-
- "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"
- "github.com/netbirdio/netbird/management/internals/modules/peers"
- "github.com/netbirdio/netbird/management/internals/modules/peers/ephemeral/manager"
- nbgrpc "github.com/netbirdio/netbird/management/internals/shared/grpc"
-
- "github.com/netbirdio/netbird/management/internals/server/config"
- "github.com/netbirdio/netbird/management/server/groups"
"github.com/netbirdio/netbird/client/iface"
"github.com/netbirdio/netbird/client/iface/configurer"
@@ -50,18 +31,7 @@ import (
icemaker "github.com/netbirdio/netbird/client/internal/peer/ice"
"github.com/netbirdio/netbird/client/internal/profilemanager"
"github.com/netbirdio/netbird/client/internal/routemanager"
- nbssh "github.com/netbirdio/netbird/client/ssh"
- "github.com/netbirdio/netbird/client/system"
nbdns "github.com/netbirdio/netbird/dns"
- "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/integrations/port_forwarding"
- "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"
"github.com/netbirdio/netbird/monotime"
"github.com/netbirdio/netbird/route"
mgmt "github.com/netbirdio/netbird/shared/management/client"
@@ -69,25 +39,9 @@ import (
"github.com/netbirdio/netbird/shared/netiputil"
relayClient "github.com/netbirdio/netbird/shared/relay/client"
signal "github.com/netbirdio/netbird/shared/signal/client"
- "github.com/netbirdio/netbird/shared/signal/proto"
- signalServer "github.com/netbirdio/netbird/signal/server"
"github.com/netbirdio/netbird/util"
)
-var (
- kaep = keepalive.EnforcementPolicy{
- MinTime: 15 * time.Second,
- PermitWithoutStream: true,
- }
-
- kasp = keepalive.ServerParameters{
- MaxConnectionIdle: 15 * time.Second,
- MaxConnectionAgeGrace: 5 * time.Second,
- Time: 5 * time.Second,
- Timeout: 2 * time.Second,
- }
-)
-
type MockWGIface struct {
CreateFunc func() error
CreateOnAndroidFunc func(routeRange []string, ip string, domains []string) error
@@ -234,129 +188,6 @@ func TestMain(m *testing.M) {
os.Exit(code)
}
-func TestEngine_SSH(t *testing.T) {
- key, err := wgtypes.GeneratePrivateKey()
- if err != nil {
- t.Fatal(err)
- return
- }
-
- sshKey, err := nbssh.GeneratePrivateKey(nbssh.ED25519)
- if err != nil {
- t.Fatal(err)
- return
- }
-
- ctx, cancel := context.WithCancel(context.Background())
- defer cancel()
-
- relayMgr := relayClient.NewManager(ctx, nil, key.PublicKey().String(), iface.DefaultMTU)
- engine := NewEngine(
- ctx, cancel,
- &EngineConfig{
- WgIfaceName: "utun101",
- WgAddr: wgaddr.MustParseWGAddress("100.64.0.1/24"),
- WgPrivateKey: key,
- WgPort: 33100,
- ServerSSHAllowed: true,
- MTU: iface.DefaultMTU,
- SSHKey: sshKey,
- },
- EngineServices{
- SignalClient: &signal.MockClient{},
- MgmClient: &mgmt.MockClient{},
- RelayManager: relayMgr,
- StatusRecorder: peer.NewRecorder("https://mgm"),
- },
- MobileDependency{},
- )
-
- engine.dnsServer = &dns.MockServer{
- UpdateDNSServerFunc: func(serial uint64, update nbdns.Config) error { return nil },
- }
-
- err = engine.Start(nil, nil)
- require.NoError(t, err)
-
- defer func() {
- err := engine.Stop()
- if err != nil {
- return
- }
- }()
-
- peerWithSSH := &mgmtProto.RemotePeerConfig{
- WgPubKey: "MNHf3Ma6z6mdLbriAJbqhX7+nM/B71lgw2+91q3LfhU=",
- AllowedIps: []string{"100.64.0.21/24"},
- SshConfig: &mgmtProto.SSHConfig{
- SshPubKey: []byte("ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIFATYCqaQw/9id1Qkq3n16JYhDhXraI6Pc1fgB8ynEfQ"),
- },
- }
-
- // SSH server is not enabled so SSH config of a remote peer should be ignored
- networkMap := &mgmtProto.NetworkMap{
- Serial: 6,
- PeerConfig: nil,
- RemotePeers: []*mgmtProto.RemotePeerConfig{peerWithSSH},
- RemotePeersIsEmpty: false,
- }
-
- err = engine.updateNetworkMap(networkMap)
- require.NoError(t, err)
-
- assert.Nil(t, engine.sshServer)
-
- // SSH server is enabled, therefore SSH config should be applied
- networkMap = &mgmtProto.NetworkMap{
- Serial: 7,
- PeerConfig: &mgmtProto.PeerConfig{Address: "100.64.0.1/24",
- SshConfig: &mgmtProto.SSHConfig{
- SshEnabled: true,
- JwtConfig: &mgmtProto.JWTConfig{
- Issuer: "test-issuer",
- Audience: "test-audience",
- KeysLocation: "test-keys",
- MaxTokenAge: 3600,
- },
- }},
- RemotePeers: []*mgmtProto.RemotePeerConfig{peerWithSSH},
- RemotePeersIsEmpty: false,
- }
-
- err = engine.updateNetworkMap(networkMap)
- require.NoError(t, err)
-
- time.Sleep(250 * time.Millisecond)
- assert.NotNil(t, engine.sshServer)
-
- // now remove peer
- networkMap = &mgmtProto.NetworkMap{
- Serial: 8,
- RemotePeers: []*mgmtProto.RemotePeerConfig{},
- RemotePeersIsEmpty: false,
- }
-
- err = engine.updateNetworkMap(networkMap)
- require.NoError(t, err)
-
- // time.Sleep(250 * time.Millisecond)
- assert.NotNil(t, engine.sshServer)
-
- // now disable SSH server
- networkMap = &mgmtProto.NetworkMap{
- Serial: 9,
- PeerConfig: &mgmtProto.PeerConfig{Address: "100.64.0.1/24",
- SshConfig: &mgmtProto.SSHConfig{SshEnabled: false}},
- RemotePeers: []*mgmtProto.RemotePeerConfig{peerWithSSH},
- RemotePeersIsEmpty: false,
- }
-
- err = engine.updateNetworkMap(networkMap)
- require.NoError(t, err)
-
- assert.Nil(t, engine.sshServer)
-}
-
func TestEngine_SSHUpdateLogic(t *testing.T) {
// Test that SSH server start/stop logic works based on config
engine := &Engine{
@@ -426,7 +257,7 @@ func TestEngine_UpdateNetworkMap(t *testing.T) {
return
}
- ctx, cancel := context.WithCancel(context.Background())
+ ctx, cancel := context.WithCancel(CtxInitState(context.Background()))
defer cancel()
relayMgr := relayClient.NewManager(ctx, nil, key.PublicKey().String(), iface.DefaultMTU)
@@ -631,97 +462,6 @@ func TestEngine_UpdateNetworkMap(t *testing.T) {
}
}
-func TestEngine_Sync(t *testing.T) {
- key, err := wgtypes.GeneratePrivateKey()
- if err != nil {
- t.Fatal(err)
- return
- }
-
- ctx, cancel := context.WithCancel(context.Background())
- defer cancel()
-
- // feed updates to Engine via mocked Management client
- updates := make(chan *mgmtProto.SyncResponse)
- defer close(updates)
- syncFunc := func(ctx context.Context, info *system.Info, msgHandler func(msg *mgmtProto.SyncResponse) error) error {
- for msg := range updates {
- err := msgHandler(msg)
- if err != nil {
- t.Fatal(err)
- }
- }
- return nil
- }
- relayMgr := relayClient.NewManager(ctx, nil, key.PublicKey().String(), iface.DefaultMTU)
- engine := NewEngine(ctx, cancel, &EngineConfig{
- WgIfaceName: "utun103",
- WgAddr: wgaddr.MustParseWGAddress("100.64.0.1/24"),
- WgPrivateKey: key,
- WgPort: 33100,
- MTU: iface.DefaultMTU,
- }, EngineServices{
- SignalClient: &signal.MockClient{},
- MgmClient: &mgmt.MockClient{SyncFunc: syncFunc},
- RelayManager: relayMgr,
- StatusRecorder: peer.NewRecorder("https://mgm"),
- }, MobileDependency{})
- engine.ctx = ctx
-
- engine.dnsServer = &dns.MockServer{
- UpdateDNSServerFunc: func(serial uint64, update nbdns.Config) error { return nil },
- }
-
- defer func() {
- err := engine.Stop()
- if err != nil {
- return
- }
- }()
-
- err = engine.Start(nil, nil)
- if err != nil {
- t.Fatal(err)
- return
- }
-
- peer1 := &mgmtProto.RemotePeerConfig{
- WgPubKey: "RRHf3Ma6z6mdLbriAJbqhX7+nM/B71lgw2+91q3LfhU=",
- AllowedIps: []string{"100.64.0.10/24"},
- }
- peer2 := &mgmtProto.RemotePeerConfig{
- WgPubKey: "LLHf3Ma6z6mdLbriAJbqhX9+nM/B71lgw2+91q3LlhU=",
- AllowedIps: []string{"100.64.0.11/24"},
- }
- peer3 := &mgmtProto.RemotePeerConfig{
- WgPubKey: "GGHf3Ma6z6mdLbriAJbqhX9+nM/B71lgw2+91q3LlhU=",
- AllowedIps: []string{"100.64.0.12/24"},
- }
- // 1st update with just 1 peer and serial larger than the current serial of the engine => apply update
- updates <- &mgmtProto.SyncResponse{
- NetworkMap: &mgmtProto.NetworkMap{
- Serial: 10,
- PeerConfig: nil,
- RemotePeers: []*mgmtProto.RemotePeerConfig{peer1, peer2, peer3},
- RemotePeersIsEmpty: false,
- },
- }
-
- timeout := time.After(time.Second * 2)
- for {
- select {
- case <-timeout:
- t.Fatalf("timeout while waiting for test to finish")
- return
- default:
- }
-
- if getPeers(engine) == 3 && engine.networkSerial == 10 {
- break
- }
- }
-}
-
func TestEngine_UpdateNetworkMapWithRoutes(t *testing.T) {
testCases := []struct {
name string
@@ -817,7 +557,7 @@ func TestEngine_UpdateNetworkMapWithRoutes(t *testing.T) {
return
}
- ctx, cancel := context.WithCancel(context.Background())
+ ctx, cancel := context.WithCancel(CtxInitState(context.Background()))
defer cancel()
wgIfaceName := fmt.Sprintf("utun%d", 104+n)
@@ -1024,7 +764,7 @@ func TestEngine_UpdateNetworkMapWithDNSUpdate(t *testing.T) {
return
}
- ctx, cancel := context.WithCancel(context.Background())
+ ctx, cancel := context.WithCancel(CtxInitState(context.Background()))
defer cancel()
wgIfaceName := fmt.Sprintf("utun%d", 104+n)
@@ -1105,104 +845,6 @@ func TestEngine_UpdateNetworkMapWithDNSUpdate(t *testing.T) {
}
}
-func TestEngine_MultiplePeers(t *testing.T) {
- // log.SetLevel(log.DebugLevel)
-
- ctx, cancel := context.WithCancel(CtxInitState(context.Background()))
- defer cancel()
-
- sigServer, signalAddr, err := startSignal(t)
- if err != nil {
- t.Fatal(err)
- return
- }
- defer sigServer.Stop()
- mgmtServer, mgmtAddr, err := startManagement(t, t.TempDir(), "../testdata/store.sql")
- if err != nil {
- t.Fatal(err)
- return
- }
- defer mgmtServer.GracefulStop()
-
- setupKey := "A2C8E62B-38F5-4553-B31E-DD66C696CEBB"
-
- mu := sync.Mutex{}
- engines := []*Engine{}
- numPeers := 10
- wg := sync.WaitGroup{}
- wg.Add(numPeers)
- // create and start peers
- for i := 0; i < numPeers; i++ {
- j := i
- go func() {
- engine, err := createEngine(ctx, cancel, setupKey, j, mgmtAddr, signalAddr)
- if err != nil {
- wg.Done()
- t.Errorf("unable to create the engine for peer %d with error %v", j, err)
- return
- }
- engine.dnsServer = &dns.MockServer{}
- mu.Lock()
- defer mu.Unlock()
- guid := fmt.Sprintf("{%s}", uuid.New().String())
- device.CustomWindowsGUIDString = strings.ToLower(guid)
- err = engine.Start(nil, nil)
- if err != nil {
- t.Errorf("unable to start engine for peer %d with error %v", j, err)
- wg.Done()
- return
- }
- engines = append(engines, engine)
- wg.Done()
- }()
- }
-
- // wait until all have been created and started
- wg.Wait()
- if len(engines) != numPeers {
- t.Fatal("not all peers was started")
- }
- // check whether all the peer have expected peers connected
-
- expectedConnected := numPeers * (numPeers - 1)
-
- // adjust according to timeouts
- timeout := 50 * time.Second
- timeoutChan := time.After(timeout)
- ticker := time.NewTicker(time.Second)
- defer ticker.Stop()
-loop:
- for {
- select {
- case <-timeoutChan:
- t.Fatalf("waiting for expected connections timeout after %s", timeout.String())
- break loop
- case <-ticker.C:
- totalConnected := 0
- for _, engine := range engines {
- totalConnected += getConnectedPeers(engine)
- }
- if totalConnected == expectedConnected {
- log.Infof("total connected=%d", totalConnected)
- break loop
- }
- log.Infof("total connected=%d", totalConnected)
- }
- }
- // cleanup test
- for n, peerEngine := range engines {
- t.Logf("stopping peer with interface %s from multipeer test, loopIndex %d", peerEngine.wgInterface.Name(), n)
- errStop := peerEngine.mgmClient.Close()
- if errStop != nil {
- log.Infoln("got error trying to close management clients from engine: ", errStop)
- }
- errStop = peerEngine.Stop()
- if errStop != nil {
- log.Infoln("got error trying to close testing peers engine: ", errStop)
- }
- }
-}
-
func Test_ParseNATExternalIPMappings(t *testing.T) {
ifaceList, err := net.Interfaces()
if err != nil {
@@ -1526,187 +1168,6 @@ func TestCompareNetIPLists(t *testing.T) {
}
}
-func createEngine(ctx context.Context, cancel context.CancelFunc, setupKey string, i int, mgmtAddr string, signalAddr string) (*Engine, error) {
- key, err := wgtypes.GeneratePrivateKey()
- if err != nil {
- return nil, err
- }
- mgmtClient, err := mgmt.NewClient(ctx, mgmtAddr, key, false)
- if err != nil {
- return nil, err
- }
- signalClient, err := signal.NewClient(ctx, signalAddr, key, false)
- if err != nil {
- return nil, err
- }
-
- info := system.GetInfo(ctx)
- resp, err := mgmtClient.Register(setupKey, "", info, nil, nil)
- if err != nil {
- return nil, err
- }
-
- var ifaceName string
- if runtime.GOOS == "darwin" {
- ifaceName = fmt.Sprintf("utun1%d", i)
- } else {
- ifaceName = fmt.Sprintf("wt%d", i)
- }
-
- wgPort := 33100 + i
- conf := &EngineConfig{
- WgIfaceName: ifaceName,
- WgAddr: wgaddr.MustParseWGAddress(resp.PeerConfig.Address),
- WgPrivateKey: key,
- WgPort: wgPort,
- MTU: iface.DefaultMTU,
- }
-
- relayMgr := relayClient.NewManager(ctx, nil, key.PublicKey().String(), iface.DefaultMTU)
- e, err := NewEngine(ctx, cancel, conf, EngineServices{
- SignalClient: signalClient,
- MgmClient: mgmtClient,
- RelayManager: relayMgr,
- StatusRecorder: peer.NewRecorder("https://mgm"),
- }, MobileDependency{}), nil
- e.ctx = ctx
- return e, err
-}
-
-func startSignal(t *testing.T) (*grpc.Server, string, error) {
- t.Helper()
-
- s := grpc.NewServer(grpc.KeepaliveEnforcementPolicy(kaep), grpc.KeepaliveParams(kasp))
-
- lis, err := net.Listen("tcp", "localhost:0")
- if err != nil {
- log.Fatalf("failed to listen: %v", err)
- }
-
- srv, err := signalServer.NewServer(context.Background(), otel.Meter(""))
- require.NoError(t, err)
- proto.RegisterSignalExchangeServer(s, srv)
-
- go func() {
- if err = s.Serve(lis); err != nil {
- log.Fatalf("failed to serve: %v", err)
- }
- }()
-
- return s, lis.Addr().String(), nil
-}
-
-func startManagement(t *testing.T, dataDir, testFile string) (*grpc.Server, string, error) {
- t.Helper()
-
- config := &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: "localhost:10000",
- },
- Datadir: dataDir,
- HttpConfig: nil,
- }
-
- lis, err := net.Listen("tcp", "localhost:0")
- if err != nil {
- return nil, "", err
- }
- s := grpc.NewServer(grpc.KeepaliveEnforcementPolicy(kaep), grpc.KeepaliveParams(kasp))
-
- store, cleanUp, err := store.NewTestStoreFromSQL(context.Background(), testFile, config.Datadir)
- if err != nil {
- return nil, "", err
- }
- t.Cleanup(cleanUp)
-
- eventStore := &activity.InMemoryEventStore{}
- if err != nil {
- return nil, "", err
- }
-
- permissionsManager := permissions.NewManager(store)
- peersManager := peers.NewManager(store, permissionsManager)
- jobManager := job.NewJobManager(nil, store, peersManager)
-
- cacheStore, err := nbcache.NewStore(context.Background(), 100*time.Millisecond, 300*time.Millisecond, 100)
- if err != nil {
- return nil, "", err
- }
-
- ia, _ := validator.NewIntegratedValidator(context.Background(), peersManager, nil, eventStore, cacheStore)
-
- 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 := server.NewAccountRequestBuffer(context.Background(), store)
- networkMapController := controller.NewController(context.Background(), store, metrics, updateManager, requestBuffer, server.MockIntegratedValidator{}, settingsMockManager, "netbird.selfhosted", port_forwarding.NewControllerMock(), manager.NewEphemeralManager(store, peersManager), config)
- accountManager, err := server.BuildManager(context.Background(), config, store, networkMapController, jobManager, nil, "", eventStore, nil, false, ia, metrics, port_forwarding.NewControllerMock(), settingsMockManager, permissionsManager, false, cacheStore)
- if err != nil {
- return nil, "", err
- }
-
- secretsManager, err := nbgrpc.NewTimeBasedAuthSecretsManager(updateManager, config.TURNConfig, config.Relay, settingsMockManager, groupsManager)
- if err != nil {
- return nil, "", err
- }
- mgmtServer, err := nbgrpc.NewServer(config, accountManager, settingsMockManager, jobManager, secretsManager, nil, nil, &server.MockIntegratedValidator{}, networkMapController, nil, nil)
- if err != nil {
- return nil, "", err
- }
- mgmtProto.RegisterManagementServiceServer(s, mgmtServer)
- go func() {
- if err = s.Serve(lis); err != nil {
- log.Fatalf("failed to serve: %v", err)
- }
- }()
-
- return s, lis.Addr().String(), nil
-}
-
-// getConnectedPeers returns a connection Status or nil if peer connection wasn't found
-func getConnectedPeers(e *Engine) int {
- e.syncMsgMux.Lock()
- defer e.syncMsgMux.Unlock()
- i := 0
- for _, id := range e.peerStore.PeersPubKey() {
- conn, _ := e.peerStore.PeerConn(id)
- if conn.IsConnected() {
- i++
- }
- }
- return i
-}
-
-func getPeers(e *Engine) int {
- e.syncMsgMux.Lock()
- defer e.syncMsgMux.Unlock()
-
- return len(e.peerStore.PeersPubKey())
-}
-
func mustEncodePrefix(t *testing.T, p netip.Prefix) []byte {
t.Helper()
b, err := netiputil.EncodePrefix(p)
diff --git a/client/internal/lazyconn/activity/listener_bind.go b/client/internal/lazyconn/activity/listener_bind.go
index 60b8baadb..666c3bc28 100644
--- a/client/internal/lazyconn/activity/listener_bind.go
+++ b/client/internal/lazyconn/activity/listener_bind.go
@@ -119,10 +119,6 @@ func (d *BindListener) ReadPackets() {
}
d.peerCfg.Log.Debugf("removing lazy endpoint for peer %s", d.peerCfg.PublicKey)
- if err := d.wgIface.RemovePeer(d.peerCfg.PublicKey); err != nil {
- d.peerCfg.Log.Errorf("failed to remove endpoint: %s", err)
- }
-
_ = d.lazyConn.Close()
d.bind.RemoveEndpoint(d.fakeIP)
d.done.Done()
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/handshaker.go b/client/internal/peer/handshaker.go
index 1d44096b6..56e82e6e3 100644
--- a/client/internal/peer/handshaker.go
+++ b/client/internal/peer/handshaker.go
@@ -195,14 +195,14 @@ func (h *Handshaker) sendOffer() error {
}
offer := h.buildOfferAnswer()
- h.log.Infof("sending offer with serial: %s", offer.SessionIDString())
+ h.log.Debugf("sending offer with serial: %s", offer.SessionIDString())
return h.signaler.SignalOffer(offer, h.config.Key)
}
func (h *Handshaker) sendAnswer() error {
answer := h.buildOfferAnswer()
- h.log.Infof("sending answer with serial: %s", answer.SessionIDString())
+ h.log.Debugf("sending answer with serial: %s", answer.SessionIDString())
return h.signaler.SignalAnswer(answer, h.config.Key)
}
diff --git a/client/internal/peer/status.go b/client/internal/peer/status.go
index b6c6c14ac..e48ac333c 100644
--- a/client/internal/peer/status.go
+++ b/client/internal/peer/status.go
@@ -192,7 +192,9 @@ func (s *StatusChangeSubscription) Events() chan map[string]RouterState {
// Pure read methods take RLock; anything that mutates state takes Lock.
type Status struct {
mux sync.RWMutex
+ muxRelays 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
@@ -231,6 +233,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),
@@ -242,8 +245,8 @@ func NewRecorder(mgmAddress string) *Status {
}
func (d *Status) SetRelayMgr(manager *relayClient.Manager) {
- d.mux.Lock()
- defer d.mux.Unlock()
+ d.muxRelays.Lock()
+ defer d.muxRelays.Unlock()
d.relayMgr = manager
}
@@ -282,6 +285,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
}
@@ -311,28 +320,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. Searches
-// both d.peers and d.offlinePeers — peers that have been moved into
-// the offline slice by ReplaceOfflinePeers are still part of the
-// account's roster and callers (DNS filter, embed.Client.IdentityForIP)
-// need to recognise them rather than treating them as unknown. 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
}
- for _, state := range d.offlinePeers {
- if (state.IP != "" && state.IP == ip) || (state.IPv6 != "" && state.IPv6 == ip) {
- return state, true
- }
+ state, ok := d.peers[key]
+ if ok {
+ return state, true
}
return State{}, false
}
@@ -342,12 +345,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
}
@@ -898,8 +907,8 @@ func (d *Status) MarkSignalConnected() {
}
func (d *Status) UpdateRelayStates(relayResults []relay.ProbeResult) {
- d.mux.Lock()
- defer d.mux.Unlock()
+ d.muxRelays.Lock()
+ defer d.muxRelays.Unlock()
d.relayStates = relayResults
}
@@ -1010,21 +1019,26 @@ func (d *Status) GetSignalState() SignalState {
// GetRelayStates returns the stun/turn/permanent relay states
func (d *Status) GetRelayStates() []relay.ProbeResult {
- d.mux.RLock()
- defer d.mux.RUnlock()
+ d.muxRelays.RLock()
if d.relayMgr == nil {
- return d.relayStates
+ defer d.muxRelays.RUnlock()
+ return slices.Clone(d.relayStates)
}
- // extend the list of stun, turn servers with relay address
+ relayMgr := d.relayMgr
+ // extend the list of stun, turn servers with the relay server connections
relayStates := slices.Clone(d.relayStates)
+ d.muxRelays.RUnlock()
- // 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
- for _, r := range d.relayMgr.ServerURLs() {
+ states := 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 := relayMgr.RelayConnectError(); connErr != nil {
+ err = connErr
+ }
+ for _, r := range relayMgr.ServerURLs() {
relayStates = append(relayStates, relay.ProbeResult{
URI: r,
Err: err,
@@ -1033,10 +1047,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 {
@@ -1397,6 +1415,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 97fb32c03..17ed47cd3 100644
--- a/client/internal/peer/status_test.go
+++ b/client/internal/peer/status_test.go
@@ -90,12 +90,11 @@ func TestStatus_PeerStateByIP_MatchesIPv6(t *testing.T) {
req.Equal("pk-1", state.PubKey, "matching state must carry the right pub key")
}
-// TestStatus_PeerStateByIP_MatchesOfflinePeers covers peers that have
-// been moved into the offline slice via ReplaceOfflinePeers. Callers
-// (DNS filter, embed.Client.IdentityForIP) need to treat them as known
-// rather than unknown — otherwise authentication / DNS filtering treats
-// known-but-offline peers as foreign IPs.
-func TestStatus_PeerStateByIP_MatchesOfflinePeers(t *testing.T) {
+// 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)
@@ -103,13 +102,31 @@ func TestStatus_PeerStateByIP_MatchesOfflinePeers(t *testing.T) {
{PubKey: "pk-offline", FQDN: "offline.netbird", IP: "100.64.0.20", IPv6: "fd00::20"},
})
- state, ok := status.PeerStateByIP("100.64.0.20")
- req.True(ok, "offline peer must resolve by IPv4 tunnel address")
- req.Equal("pk-offline", state.PubKey, "matching state must carry the offline peer's pub key")
+ _, ok := status.PeerStateByIP("100.64.0.20")
+ req.False(ok, "offline peer must not resolve by IPv4 tunnel address")
- state, ok = status.PeerStateByIP("fd00::20")
- req.True(ok, "offline peer must resolve by IPv6 tunnel address")
- req.Equal("pk-offline", state.PubKey, "IPv6 match must carry the offline peer's pub key")
+ _, 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) {
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/profilemanager/config.go b/client/internal/profilemanager/config.go
index cd5bc0680..5a71a981e 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
@@ -103,6 +108,10 @@ type ConfigInput struct {
// Config Configuration type
type Config struct {
+ // Name is the human-readable profile name shown in CLI/UI listings.
+ // It is independent of the profile's on-disk filename (which is the ID).
+ Name string
+
// Wireguard private key of local peer
PrivateKey string
PreSharedKey string
@@ -174,6 +183,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
@@ -248,6 +274,16 @@ func createNewConfig(input ConfigInput) (*Config, error) {
}
func (config *Config) apply(input ConfigInput) (updated bool, err error) {
+ if config.Name != "" {
+ sanitized, err := sanitizeDisplayName(config.Name)
+ if err != nil {
+ return false, fmt.Errorf("invalid profile name: %w", err)
+ }
+ if sanitized != config.Name {
+ config.Name = sanitized
+ updated = true
+ }
+ }
if config.ManagementURL == nil {
log.Infof("using default Management URL %s", DefaultManagementURL)
config.ManagementURL, err = parseURL("Management URL", DefaultManagementURL)
@@ -397,7 +433,7 @@ func (config *Config) apply(input ConfigInput) (updated bool, err error) {
updated = true
}
- if input.ServerSSHAllowed != nil && *input.ServerSSHAllowed != *config.ServerSSHAllowed {
+ if input.ServerSSHAllowed != nil && (config.ServerSSHAllowed == nil || *input.ServerSSHAllowed != *config.ServerSSHAllowed) {
if *input.ServerSSHAllowed {
log.Infof("enabling SSH server")
} else {
@@ -612,10 +648,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/profilemanager/config_test.go b/client/internal/profilemanager/config_test.go
index 5216f2423..736ff3412 100644
--- a/client/internal/profilemanager/config_test.go
+++ b/client/internal/profilemanager/config_test.go
@@ -242,6 +242,35 @@ func TestWireguardPortDefaultVsExplicit(t *testing.T) {
}
}
+func TestUpdateConfigServerSSHAllowedNotSet(t *testing.T) {
+ // Configs written before ServerSSHAllowed was introduced lack the field and
+ // unmarshal to nil. Supplying the SSH server flag on top of such a config must
+ // apply the value instead of panicking on a nil pointer dereference.
+ tests := []struct {
+ name string
+ input *bool
+ want bool
+ }{
+ {"enable", util.True(), true},
+ {"disable", util.False(), false},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ configPath := filepath.Join(t.TempDir(), "config.json")
+ require.NoError(t, os.WriteFile(configPath, []byte("{}"), 0600))
+
+ config, err := UpdateConfig(ConfigInput{
+ ConfigPath: configPath,
+ ServerSSHAllowed: tt.input,
+ })
+ require.NoError(t, err)
+ require.NotNil(t, config.ServerSSHAllowed, "ServerSSHAllowed should be set from input")
+ assert.Equal(t, tt.want, *config.ServerSSHAllowed)
+ })
+ }
+}
+
func TestUpdateOldManagementURL(t *testing.T) {
origProber := newMgmProber
newMgmProber = func(_ context.Context, _ string, _ wgtypes.Key, _ bool) (mgmProber, error) {
diff --git a/client/internal/profilemanager/id.go b/client/internal/profilemanager/id.go
new file mode 100644
index 000000000..3b82c8779
--- /dev/null
+++ b/client/internal/profilemanager/id.go
@@ -0,0 +1,118 @@
+package profilemanager
+
+import (
+ "crypto/rand"
+ "encoding/hex"
+ "fmt"
+ "path/filepath"
+ "strings"
+ "unicode"
+ "unicode/utf8"
+)
+
+const (
+ // profileIDByteLen is the number of random bytes generated for a new
+ // profile ID. The resulting hex string is twice this length.
+ profileIDByteLen = 16
+
+ // shortIDLen is the number of leading characters of an ID we render in
+ // list output. Profiles per device are few, so 8 chars is collision-safe
+ // in practice and easy to type as a prefix.
+ shortIDLen = 8
+
+ // maxProfileNameLen caps the human-readable profile name to keep table
+ // output legible and prevent denial-of-service via huge JSON fields.
+ maxProfileNameLen = 128
+
+ // maxProfileIDLen bounds the on-disk filename we'll accept. New
+ // IDs are 32 hex chars, legacy stems are sanitized profile names. The
+ // cap is generous enough to cover both without permitting absurdly
+ // long filenames.
+ maxProfileIDLen = 64
+)
+
+type ID string
+
+// generateProfileID returns a new random hex ID for a profile file.
+func generateProfileID() (ID, error) {
+ buf := make([]byte, profileIDByteLen)
+ if _, err := rand.Read(buf); err != nil {
+ return "", fmt.Errorf("read random bytes: %w", err)
+ }
+ return ID(hex.EncodeToString(buf)), nil
+}
+
+// IsValidProfileFilenameStem reports whether id is safe to use as the stem
+// of a profile JSON filename.
+func IsValidProfileFilenameStem(id ID) bool {
+ s := id.String()
+ if s == "" || len(s) > maxProfileIDLen {
+ return false
+ }
+ if s == defaultProfileName {
+ return true
+ }
+ if strings.ContainsAny(s, `/\`) || strings.Contains(s, "..") {
+ return false
+ }
+ // filepath.Base catches any leftover separators on platforms with
+ // exotic path conventions.
+ if filepath.Base(s) != s {
+ return false
+ }
+ for _, r := range s {
+ if !(unicode.IsLetter(r) || unicode.IsDigit(r) || r == '_' || r == '-') {
+ return false
+ }
+ }
+ return true
+}
+
+// sanitizeDisplayName normalizes a user-supplied profile display name for
+// storage. It strips ASCII control characters, rejects invalid UTF-8, and
+// caps the length. Emojis, spaces, punctuation, and non-ASCII letters are
+// preserved. Returns an error if nothing usable remains.
+func sanitizeDisplayName(name string) (string, error) {
+ if !utf8.ValidString(name) {
+ return "", fmt.Errorf("name is not valid UTF-8")
+ }
+ name = StripCtrlChars(name)
+ name = strings.TrimSpace(name)
+ if name == "" {
+ return "", fmt.Errorf("name is empty after sanitization")
+ }
+ if utf8.RuneCountInString(name) > maxProfileNameLen {
+ return "", fmt.Errorf("name exceeds %d characters", maxProfileNameLen)
+ }
+ return name, nil
+}
+
+// StripCtrlChars control characters from a name before printing it.
+func StripCtrlChars(name string) string {
+ var b strings.Builder
+ b.Grow(len(name))
+ for _, r := range name {
+ // Skip C0 controls and DEL, plus C1 controls (0x80–0x9F).
+ if r < 0x20 || r == 0x7F || (r >= 0x80 && r <= 0x9F) {
+ continue
+ }
+ b.WriteRune(r)
+ }
+ return b.String()
+}
+
+// ShortID truncates an ID for display.
+func (id ID) ShortID() string {
+ if id == DefaultProfileName {
+ return DefaultProfileName
+ }
+ runes := []rune(id)
+ if len(runes) <= shortIDLen {
+ return id.String()
+ }
+ return string(runes[:shortIDLen])
+}
+
+func (id ID) String() string {
+ return string(id)
+}
diff --git a/client/internal/profilemanager/profilemanager.go b/client/internal/profilemanager/profilemanager.go
index c87f521cb..e25d493d5 100644
--- a/client/internal/profilemanager/profilemanager.go
+++ b/client/internal/profilemanager/profilemanager.go
@@ -19,19 +19,41 @@ const (
)
type Profile struct {
- Name string
+ // ID is the on-disk filename stem (without .json). For new profiles
+ // it is a 32-char hex string; legacy profiles created before the
+ // ID-keyed layout keep their original name as their ID. The reserved
+ // value "default" identifies the special default profile.
+ ID ID
+ // Name is the human-readable display name. Falls back to ID when the
+ // underlying JSON has no "name" field set.
+ Name string
+ // Path is the absolute path to the profile JSON. Populated by the
+ // loader so callers do not have to reconstruct it from ID + dir.
+ Path string
IsActive bool
}
func (p *Profile) FilePath() (string, error) {
- if p.Name == "" {
- return "", fmt.Errorf("active profile name is empty")
+ if p.Path != "" {
+ return p.Path, nil
}
- if p.Name == defaultProfileName {
+ id := p.ID
+ if id == "" {
+ id = ID(p.Name)
+ }
+ if id == "" {
+ return "", fmt.Errorf("profile ID is empty")
+ }
+
+ if id == defaultProfileName {
return DefaultConfigPath, nil
}
+ if !IsValidProfileFilenameStem(id) {
+ return "", fmt.Errorf("invalid profile ID: %q", id)
+ }
+
username, err := user.Current()
if err != nil {
return "", fmt.Errorf("failed to get current user: %w", err)
@@ -42,10 +64,13 @@ func (p *Profile) FilePath() (string, error) {
return "", fmt.Errorf("failed to get config directory for user %s: %w", username.Username, err)
}
- return filepath.Join(configDir, p.Name+".json"), nil
+ return filepath.Join(configDir, id.String()+".json"), nil
}
func (p *Profile) IsDefault() bool {
+ if p.ID != "" {
+ return p.ID == defaultProfileName
+ }
return p.Name == defaultProfileName
}
@@ -57,18 +82,24 @@ func NewProfileManager() *ProfileManager {
return &ProfileManager{}
}
+// GetActiveProfile returns the active profile as recorded in the local
+// user state file. Only ID is populated.
func (pm *ProfileManager) GetActiveProfile() (*Profile, error) {
pm.mu.Lock()
defer pm.mu.Unlock()
- prof := pm.getActiveProfileState()
- return &Profile{Name: prof}, nil
+ id := pm.getActiveProfileState()
+ return &Profile{ID: id}, nil
}
-func (pm *ProfileManager) SwitchProfile(profileName string) error {
- profileName = sanitizeProfileName(profileName)
+// SwitchProfile records the given profile ID as active in the local user
+// state file.
+func (pm *ProfileManager) SwitchProfile(id ID) error {
+ if id != defaultProfileName && !IsValidProfileFilenameStem(id) {
+ return fmt.Errorf("invalid profile ID: %q", id)
+ }
- if err := pm.setActiveProfileState(profileName); err != nil {
+ if err := pm.setActiveProfileState(id); err != nil {
return fmt.Errorf("failed to switch profile: %w", err)
}
return nil
@@ -85,7 +116,7 @@ func sanitizeProfileName(name string) string {
}, name)
}
-func (pm *ProfileManager) getActiveProfileState() string {
+func (pm *ProfileManager) getActiveProfileState() ID {
configDir, err := getConfigDir()
if err != nil {
@@ -113,10 +144,10 @@ func (pm *ProfileManager) getActiveProfileState() string {
return defaultProfileName
}
- return profileName
+ return ID(profileName)
}
-func (pm *ProfileManager) setActiveProfileState(profileName string) error {
+func (pm *ProfileManager) setActiveProfileState(id ID) error {
configDir, err := getConfigDir()
if err != nil {
@@ -125,7 +156,7 @@ func (pm *ProfileManager) setActiveProfileState(profileName string) error {
statePath := filepath.Join(configDir, activeProfileStateFilename)
- err = os.WriteFile(statePath, []byte(profileName), 0600)
+ err = os.WriteFile(statePath, []byte(id), 0600)
if err != nil {
return fmt.Errorf("failed to write active profile state: %w", err)
}
@@ -142,7 +173,7 @@ func GetLoginHint() string {
return ""
}
- profileState, err := pm.GetProfileState(activeProf.Name)
+ profileState, err := pm.GetProfileState(activeProf.ID)
if err != nil {
log.Debugf("failed to get profile state for login hint: %v", err)
return ""
diff --git a/client/internal/profilemanager/profilemanager_test.go b/client/internal/profilemanager/profilemanager_test.go
index 79a7ae650..882a71d0a 100644
--- a/client/internal/profilemanager/profilemanager_test.go
+++ b/client/internal/profilemanager/profilemanager_test.go
@@ -50,14 +50,14 @@ func TestServiceManager_CreateAndGetDefaultProfile(t *testing.T) {
state, err := sm.GetActiveProfileState()
assert.NoError(t, err)
- assert.Equal(t, state.Name, defaultProfileName) // No active profile state yet
+ assert.Equal(t, defaultProfileName, state.ID.String()) // No active profile state yet
err = sm.SetActiveProfileStateToDefault()
assert.NoError(t, err)
active, err := sm.GetActiveProfileState()
assert.NoError(t, err)
- assert.Equal(t, "default", active.Name)
+ assert.Equal(t, "default", active.ID.String())
})
})
}
@@ -92,14 +92,14 @@ func TestServiceManager_SetActiveProfileState(t *testing.T) {
currUser, err := user.Current()
assert.NoError(t, err)
sm := &ServiceManager{}
- state := &ActiveProfileState{Name: "foo", Username: currUser.Username}
+ state := &ActiveProfileState{ID: "foo", Username: currUser.Username}
err = sm.SetActiveProfileState(state)
assert.NoError(t, err)
// Should error on nil or incomplete state
err = sm.SetActiveProfileState(nil)
assert.Error(t, err)
- err = sm.SetActiveProfileState(&ActiveProfileState{Name: "", Username: ""})
+ err = sm.SetActiveProfileState(&ActiveProfileState{ID: "", Username: ""})
assert.Error(t, err)
})
})
diff --git a/client/internal/profilemanager/service.go b/client/internal/profilemanager/service.go
index ef3eb1114..5ddd11b04 100644
--- a/client/internal/profilemanager/service.go
+++ b/client/internal/profilemanager/service.go
@@ -2,6 +2,7 @@ package profilemanager
import (
"context"
+ "encoding/json"
"errors"
"fmt"
"io"
@@ -23,12 +24,43 @@ var (
DefaultConfigPathDir = ""
DefaultConfigPath = ""
ActiveProfileStatePath = ""
-)
-var (
ErrorOldDefaultConfigNotFound = errors.New("old default config not found")
)
+// ErrAmbiguousHandle is returned when a profile handle (ID prefix or name)
+// matches more than one profile. Callers can render Candidates to help the
+// user disambiguate.
+type ErrAmbiguousHandle struct {
+ Handle string
+ Candidates []Profile
+ Kind AmbiguityKind
+}
+
+// AmbiguityKind describes which matcher produced the ambiguity, so callers
+// can tailor the error message.
+type AmbiguityKind int
+
+const (
+ AmbiguityKindIDPrefix AmbiguityKind = iota
+ AmbiguityKindName
+)
+
+// profileMeta is the minimal slice of a profile JSON we need, so we avoid
+// reading all fields
+type profileMeta struct {
+ Name string
+}
+
+func (e *ErrAmbiguousHandle) Error() string {
+ switch e.Kind {
+ case AmbiguityKindIDPrefix:
+ return fmt.Sprintf("ID prefix %q is ambiguous (matches %d profiles)", e.Handle, len(e.Candidates))
+ default:
+ return fmt.Sprintf("name %q is ambiguous (%d profiles share this name)", e.Handle, len(e.Candidates))
+ }
+}
+
func init() {
DefaultConfigPathDir = "/var/lib/netbird/"
@@ -54,25 +86,34 @@ func init() {
}
type ActiveProfileState struct {
- Name string `json:"name"`
+ // ID is the on-disk filename stem of the active profile. The JSON tag stays
+ // as "name" for backwards compatibility with active state files written
+ // before the ID-based config files. Legacy values were profile names, which
+ // were also the legacy filename stems, so they still resolve to the correct
+ // file on disk.
+ ID ID `json:"name"`
Username string `json:"username"`
}
func (a *ActiveProfileState) FilePath() (string, error) {
- if a.Name == "" {
- return "", fmt.Errorf("active profile name is empty")
+ if a.ID == "" {
+ return "", fmt.Errorf("active profile ID is empty")
}
- if a.Name == defaultProfileName {
+ if a.ID == defaultProfileName {
return DefaultConfigPath, nil
}
+ if !IsValidProfileFilenameStem(a.ID) {
+ return "", fmt.Errorf("invalid profile ID: %q", a.ID)
+ }
+
configDir, err := getConfigDirForUser(a.Username)
if err != nil {
return "", fmt.Errorf("failed to get config directory for user %s: %w", a.Username, err)
}
- return filepath.Join(configDir, a.Name+".json"), nil
+ return filepath.Join(configDir, a.ID.String()+".json"), nil
}
type ServiceManager struct {
@@ -178,7 +219,7 @@ func (s *ServiceManager) GetActiveProfileState() (*ActiveProfileState, error) {
return nil, fmt.Errorf("failed to set active profile to default: %w", err)
}
return &ActiveProfileState{
- Name: "default",
+ ID: defaultProfileName,
Username: "",
}, nil
} else {
@@ -186,12 +227,12 @@ func (s *ServiceManager) GetActiveProfileState() (*ActiveProfileState, error) {
}
}
- if activeProfile.Name == "" {
+ if activeProfile.ID == "" {
if err := s.SetActiveProfileStateToDefault(); err != nil {
return nil, fmt.Errorf("failed to set active profile to default: %w", err)
}
return &ActiveProfileState{
- Name: "default",
+ ID: defaultProfileName,
Username: "",
}, nil
}
@@ -216,25 +257,29 @@ func (s *ServiceManager) setDefaultActiveState() error {
}
func (s *ServiceManager) SetActiveProfileState(a *ActiveProfileState) error {
- if a == nil || a.Name == "" {
+ if a == nil || a.ID == "" {
return errors.New("invalid active profile state")
}
- if a.Name != defaultProfileName && a.Username == "" {
- return fmt.Errorf("username must be set for non-default profiles, got: %s", a.Name)
+ if a.ID != defaultProfileName && a.Username == "" {
+ return fmt.Errorf("username must be set for non-default profiles, got: %s", a.ID)
+ }
+
+ if a.ID != defaultProfileName && !IsValidProfileFilenameStem(a.ID) {
+ return fmt.Errorf("invalid profile ID: %q", a.ID)
}
if err := util.WriteJsonWithRestrictedPermission(context.Background(), ActiveProfileStatePath, a); err != nil {
return fmt.Errorf("failed to write active profile state: %w", err)
}
- log.Infof("active profile set to %s for %s", a.Name, a.Username)
+ log.Infof("active profile set to %s for %s", a.ID, a.Username)
return nil
}
func (s *ServiceManager) SetActiveProfileStateToDefault() error {
return s.SetActiveProfileState(&ActiveProfileState{
- Name: "default",
+ ID: defaultProfileName,
Username: "",
})
}
@@ -243,57 +288,117 @@ func (s *ServiceManager) DefaultProfilePath() string {
return DefaultConfigPath
}
-func (s *ServiceManager) AddProfile(profileName, username string) error {
+// AddProfile creates a new profile with a generated ID. The user-supplied
+// displayName is stored inside the JSON's name field, the on-disk filename
+// uses the generated ID.
+//
+// The returned Profile carries the freshly-generated ID so callers can
+// show it to the user (and so the gRPC AddProfileResponse can include
+// it).
+func (s *ServiceManager) AddProfile(displayName, username string) (*Profile, error) {
configDir, err := s.getConfigDir(username)
if err != nil {
- return fmt.Errorf("failed to get config directory: %w", err)
+ return nil, fmt.Errorf("failed to get config directory: %w", err)
}
- profileName = sanitizeProfileName(profileName)
-
- if profileName == defaultProfileName {
- return fmt.Errorf("cannot create profile with reserved name: %s", defaultProfileName)
- }
-
- profPath := filepath.Join(configDir, profileName+".json")
- profileExists, err := fileExists(profPath)
+ displayName, err = sanitizeDisplayName(displayName)
if err != nil {
- return fmt.Errorf("failed to check if profile exists: %w", err)
- }
- if profileExists {
- return ErrProfileAlreadyExists
+ return nil, fmt.Errorf("invalid profile name: %w", err)
}
+ id, err := generateProfileID()
+ if err != nil {
+ return nil, fmt.Errorf("generate profile id: %w", err)
+ }
+
+ profPath := filepath.Join(configDir, id.String()+".json")
cfg, err := createNewConfig(ConfigInput{ConfigPath: profPath})
if err != nil {
- return fmt.Errorf("failed to create new config: %w", err)
+ return nil, fmt.Errorf("failed to create new config: %w", err)
+ }
+ cfg.Name = displayName
+
+ if err := util.WriteJson(context.Background(), profPath, cfg); err != nil {
+ return nil, fmt.Errorf("failed to write profile config: %w", err)
}
- err = util.WriteJson(context.Background(), profPath, cfg)
+ return &Profile{
+ ID: id,
+ Name: displayName,
+ Path: profPath,
+ }, nil
+}
+
+func (s *ServiceManager) RenameProfile(id ID, username string, newName string) error {
+ displayName, err := sanitizeDisplayName(newName)
if err != nil {
- return fmt.Errorf("failed to write profile config: %w", err)
+ return fmt.Errorf("invalid profile name: %w", err)
}
+ if !IsValidProfileFilenameStem(id) {
+ return fmt.Errorf("invalid profile ID: %q", id)
+ }
+
+ profiles, err := s.loadAllProfiles(username)
+ if err != nil {
+ return fmt.Errorf("load profiles: %w", err)
+ }
+
+ var target *Profile
+ for i := range profiles {
+ if profiles[i].ID == id {
+ target = &profiles[i]
+ break
+ }
+ }
+ if target == nil {
+ return ErrProfileNotFound
+ }
+
+ data, err := os.ReadFile(target.Path)
+ if err != nil {
+ return err
+ }
+ var cfg Config
+ if err := json.Unmarshal(data, &cfg); err != nil {
+ return err
+ }
+ cfg.Name = displayName
+
+ if err := util.WriteJson(context.Background(), target.Path, cfg); err != nil {
+ return fmt.Errorf("failed to write profile name: %w", err)
+ }
return nil
}
-func (s *ServiceManager) RemoveProfile(profileName, username string) error {
- configDir, err := s.getConfigDir(username)
- if err != nil {
- return fmt.Errorf("failed to get config directory: %w", err)
+// RemoveProfile deletes the profile identified by id. Callers must have
+// already resolved any user-supplied handle to a concrete ID via
+// ResolveProfile.
+func (s *ServiceManager) RemoveProfile(id ID, username string) error {
+ if id == defaultProfileName {
+ defaultName := readProfileName(DefaultConfigPath)
+ if defaultName == "" {
+ defaultName = defaultProfileName
+ }
+ return fmt.Errorf("cannot remove default profile with name: %s", defaultName)
+ }
+ if !IsValidProfileFilenameStem(id) {
+ return fmt.Errorf("invalid profile ID: %q", id)
}
- profileName = sanitizeProfileName(profileName)
-
- if profileName == defaultProfileName {
- return fmt.Errorf("cannot remove profile with reserved name: %s", defaultProfileName)
- }
- profPath := filepath.Join(configDir, profileName+".json")
- profileExists, err := fileExists(profPath)
+ profiles, err := s.loadAllProfiles(username)
if err != nil {
- return fmt.Errorf("failed to check if profile exists: %w", err)
+ return fmt.Errorf("load profiles: %w", err)
}
- if !profileExists {
+
+ var target *Profile
+ for i := range profiles {
+ if profiles[i].ID == id {
+ target = &profiles[i]
+ break
+ }
+ }
+ if target == nil {
return ErrProfileNotFound
}
@@ -301,57 +406,26 @@ func (s *ServiceManager) RemoveProfile(profileName, username string) error {
if err != nil && !errors.Is(err, ErrNoActiveProfile) {
return fmt.Errorf("failed to get active profile: %w", err)
}
-
- if activeProf != nil && activeProf.Name == profileName {
- return fmt.Errorf("cannot remove active profile: %s", profileName)
+ if activeProf != nil && activeProf.ID == id {
+ return fmt.Errorf("cannot remove active profile: %s", id)
}
- err = util.RemoveJson(profPath)
- if err != nil {
+ if err := util.RemoveJson(target.Path); err != nil {
return fmt.Errorf("failed to remove profile config: %w", err)
}
+
+ stateFile := filepath.Join(filepath.Dir(target.Path), id.String()+".state.json")
+ if err := os.Remove(stateFile); err != nil && !os.IsNotExist(err) {
+ log.Warnf("failed to remove profile state file %s: %v", stateFile, err)
+ }
+
return nil
}
+// ListProfiles returns every profile for the given user, including the
+// default profile, with IsActive flags set.
func (s *ServiceManager) ListProfiles(username string) ([]Profile, error) {
- configDir, err := s.getConfigDir(username)
- if err != nil {
- return nil, fmt.Errorf("failed to get config directory: %w", err)
- }
-
- files, err := util.ListFiles(configDir, "*.json")
- if err != nil {
- return nil, fmt.Errorf("failed to list profile files: %w", err)
- }
-
- var filtered []string
- for _, file := range files {
- if strings.HasSuffix(file, "state.json") {
- continue // skip state files
- }
- filtered = append(filtered, file)
- }
- sort.Strings(filtered)
-
- var activeProfName string
- activeProf, err := s.GetActiveProfileState()
- if err == nil {
- activeProfName = activeProf.Name
- }
-
- var profiles []Profile
- // add default profile always
- profiles = append(profiles, Profile{Name: defaultProfileName, IsActive: activeProfName == "" || activeProfName == defaultProfileName})
- for _, file := range filtered {
- profileName := strings.TrimSuffix(filepath.Base(file), ".json")
- var isActive bool
- if activeProfName != "" && activeProfName == profileName {
- isActive = true
- }
- profiles = append(profiles, Profile{Name: profileName, IsActive: isActive})
- }
-
- return profiles, nil
+ return s.loadAllProfiles(username)
}
// GetStatePath returns the path to the state file based on the operating system
@@ -369,7 +443,12 @@ func (s *ServiceManager) GetStatePath() string {
return defaultStatePath
}
- if activeProf.Name == defaultProfileName {
+ if activeProf.ID == defaultProfileName {
+ return defaultStatePath
+ }
+
+ if !IsValidProfileFilenameStem(activeProf.ID) {
+ log.Warnf("invalid active profile ID %q, using default state path", activeProf.ID)
return defaultStatePath
}
@@ -379,7 +458,7 @@ func (s *ServiceManager) GetStatePath() string {
return defaultStatePath
}
- return filepath.Join(configDir, activeProf.Name+".state.json")
+ return filepath.Join(configDir, activeProf.ID.String()+".state.json")
}
// getConfigDir returns the profiles directory, using profilesDir if set, otherwise getConfigDirForUser
@@ -390,3 +469,169 @@ func (s *ServiceManager) getConfigDir(username string) (string, error) {
return getConfigDirForUser(username)
}
+
+// loadAllProfiles returns every profile visible to the daemon for the
+// given user, including the default profile. The returned slice is sorted
+// by ID for a stable display order.
+//
+// Each Profile is fully populated: ID is the filename stem, Name comes
+// from the JSON's "name" field (falling back to the filename stem when absent)
+// and Path is built from a basename read off disk.
+func (s *ServiceManager) loadAllProfiles(username string) ([]Profile, error) {
+ activeID, activeIsDefault := s.activeProfileID()
+ defaultName := readProfileName(DefaultConfigPath)
+ if defaultName == "" {
+ defaultName = defaultProfileName
+ }
+
+ profiles := []Profile{{
+ ID: defaultProfileName,
+ Name: defaultName,
+ Path: DefaultConfigPath,
+ IsActive: activeIsDefault,
+ }}
+
+ configDir, err := s.getConfigDir(username)
+ if err != nil {
+ return nil, fmt.Errorf("get config directory: %w", err)
+ }
+
+ entries, err := os.ReadDir(configDir)
+ if err != nil {
+ if errors.Is(err, os.ErrNotExist) {
+ return profiles, nil
+ }
+ return nil, fmt.Errorf("read profile directory: %w", err)
+ }
+
+ var fileProfiles []Profile
+ for _, entry := range entries {
+ if entry.IsDir() {
+ continue
+ }
+ base := entry.Name()
+ if !strings.HasSuffix(base, ".json") {
+ continue
+ }
+ if strings.HasSuffix(base, ".state.json") {
+ continue
+ }
+ stem := ID(strings.TrimSuffix(base, ".json"))
+ if stem == defaultProfileName {
+ // default lives at the top-level config dir, not under /
+ continue
+ }
+ if !IsValidProfileFilenameStem(ID(stem)) {
+ continue
+ }
+ path := filepath.Join(configDir, base)
+ name := readProfileName(path)
+ if name == "" {
+ name = stem.String()
+ }
+ fileProfiles = append(fileProfiles, Profile{
+ ID: stem,
+ Name: name,
+ Path: path,
+ IsActive: stem == ID(activeID),
+ })
+ }
+
+ sort.Slice(fileProfiles, func(i, j int) bool {
+ if fileProfiles[i].Name != fileProfiles[j].Name {
+ return fileProfiles[i].Name < fileProfiles[j].Name
+ }
+ // Sort tie-break on ID so duplicate names always render in the same order.
+ return fileProfiles[i].ID < fileProfiles[j].ID
+ })
+ profiles = append(profiles, fileProfiles...)
+ return profiles, nil
+}
+
+// readProfileName parses just the "name" field from the profile Json.
+func readProfileName(path string) string {
+ data, err := os.ReadFile(path)
+ if err != nil {
+ return ""
+ }
+ var meta profileMeta
+ if err := json.Unmarshal(data, &meta); err != nil {
+ return ""
+ }
+ return meta.Name
+}
+
+// activeProfileID returns the currently-active profile's ID. The second
+// return value is true when the active profile is the default one.
+func (s *ServiceManager) activeProfileID() (ID, bool) {
+ state, err := s.GetActiveProfileState()
+ if err != nil || state == nil {
+ return defaultProfileName, true
+ }
+ if state.ID == "" || state.ID == defaultProfileName {
+ return defaultProfileName, true
+ }
+ return state.ID, false
+}
+
+// ResolveProfile turns a user-supplied handle into a Profile. Resolution
+// precedence is: exact ID match, then unique exact name, then unique ID
+// prefix. Ambiguous matches return *ErrAmbiguousHandle so callers can
+// surface the candidates.
+func (s *ServiceManager) ResolveProfile(handle, username string) (*Profile, error) {
+ if handle == "" {
+ return nil, fmt.Errorf("profile handle is empty")
+ }
+
+ profiles, err := s.loadAllProfiles(username)
+ if err != nil {
+ return nil, err
+ }
+
+ for i := range profiles {
+ if profiles[i].ID == ID(handle) {
+ return &profiles[i], nil
+ }
+ }
+
+ var nameMatches []Profile
+ for i := range profiles {
+ if profiles[i].Name == handle {
+ nameMatches = append(nameMatches, profiles[i])
+ }
+ }
+ if len(nameMatches) == 1 {
+ return &nameMatches[0], nil
+ }
+ if len(nameMatches) > 1 {
+ return nil, &ErrAmbiguousHandle{
+ Handle: handle,
+ Candidates: nameMatches,
+ Kind: AmbiguityKindName,
+ }
+ }
+
+ // ID prefix match. Skip the default profile so `select d` does not
+ // accidentally pick it via prefix.
+ var prefixMatches []Profile
+ for i := range profiles {
+ if profiles[i].ID == defaultProfileName {
+ continue
+ }
+ if strings.HasPrefix(profiles[i].ID.String(), handle) {
+ prefixMatches = append(prefixMatches, profiles[i])
+ }
+ }
+ if len(prefixMatches) == 1 {
+ return &prefixMatches[0], nil
+ }
+ if len(prefixMatches) > 1 {
+ return nil, &ErrAmbiguousHandle{
+ Handle: handle,
+ Candidates: prefixMatches,
+ Kind: AmbiguityKindIDPrefix,
+ }
+ }
+
+ return nil, ErrProfileNotFound
+}
diff --git a/client/internal/profilemanager/service_test.go b/client/internal/profilemanager/service_test.go
new file mode 100644
index 000000000..5e051b15d
--- /dev/null
+++ b/client/internal/profilemanager/service_test.go
@@ -0,0 +1,230 @@
+package profilemanager
+
+import (
+ "context"
+ "errors"
+ "os"
+ "os/user"
+ "path/filepath"
+ "strings"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+
+ "github.com/netbirdio/netbird/util"
+)
+
+// withTestSM wires up patched globals + a clean config dir and returns a
+// fully initialized ServiceManager plus the username we are scoped to.
+func withTestSM(t *testing.T, fn func(sm *ServiceManager, username string)) {
+ t.Helper()
+ withTempConfigDir(t, func(configDir string) {
+ withPatchedGlobals(t, configDir, func() {
+ u, err := user.Current()
+ require.NoError(t, err)
+ sm := &ServiceManager{}
+ require.NoError(t, sm.CreateDefaultProfile())
+ fn(sm, u.Username)
+ })
+ })
+}
+
+func TestServiceProfile_ExactID(t *testing.T) {
+ withTestSM(t, func(sm *ServiceManager, username string) {
+ created, err := sm.AddProfile("work", username)
+ require.NoError(t, err)
+
+ got, err := sm.ResolveProfile(created.ID.String(), username)
+ require.NoError(t, err)
+ assert.Equal(t, created.ID, got.ID)
+ assert.Equal(t, "work", got.Name)
+ })
+}
+
+func TestServiceProfile_IDPrefix(t *testing.T) {
+ withTestSM(t, func(sm *ServiceManager, username string) {
+ created, err := sm.AddProfile("work", username)
+ require.NoError(t, err)
+
+ prefix := created.ID[:4]
+ got, err := sm.ResolveProfile(prefix.String(), username)
+ require.NoError(t, err)
+ assert.Equal(t, created.ID, got.ID)
+ })
+}
+
+func TestServiceProfile_AmbiguousPrefix(t *testing.T) {
+ withTestSM(t, func(sm *ServiceManager, username string) {
+ // Plant two profiles whose IDs share a known prefix by writing
+ // the files directly, since generated IDs are random.
+ configDir, err := sm.getConfigDir(username)
+ require.NoError(t, err)
+ for _, id := range []string{"abcd1111aaaa", "abcd2222bbbb"} {
+ path := filepath.Join(configDir, id+".json")
+ require.NoError(t, util.WriteJson(context.Background(), path, &Config{Name: id}))
+ }
+
+ _, err = sm.ResolveProfile("abcd", username)
+ var amb *ErrAmbiguousHandle
+ require.ErrorAs(t, err, &amb)
+ assert.Equal(t, AmbiguityKindIDPrefix, amb.Kind)
+ assert.Len(t, amb.Candidates, 2)
+ })
+}
+
+func TestServiceProfile_ExactNameUnique(t *testing.T) {
+ withTestSM(t, func(sm *ServiceManager, username string) {
+ _, err := sm.AddProfile("work", username)
+ require.NoError(t, err)
+
+ got, err := sm.ResolveProfile("work", username)
+ require.NoError(t, err)
+ assert.Equal(t, "work", got.Name)
+ })
+}
+
+func TestServiceProfile_AmbiguousName(t *testing.T) {
+ withTestSM(t, func(sm *ServiceManager, username string) {
+ _, err := sm.AddProfile("work", username)
+ require.NoError(t, err)
+ _, err = sm.AddProfile("work", username)
+ require.NoError(t, err)
+
+ _, err = sm.ResolveProfile("work", username)
+ var amb *ErrAmbiguousHandle
+ require.ErrorAs(t, err, &amb)
+ assert.Equal(t, AmbiguityKindName, amb.Kind)
+ assert.Len(t, amb.Candidates, 2)
+ })
+}
+
+func TestServiceProfile_NotFound(t *testing.T) {
+ withTestSM(t, func(sm *ServiceManager, username string) {
+ _, err := sm.ResolveProfile("nope", username)
+ assert.ErrorIs(t, err, ErrProfileNotFound)
+ })
+}
+
+func TestServiceProfile_DefaultByExactID(t *testing.T) {
+ withTestSM(t, func(sm *ServiceManager, username string) {
+ got, err := sm.ResolveProfile(defaultProfileName, username)
+ require.NoError(t, err)
+ assert.Equal(t, defaultProfileName, got.ID.String())
+ })
+}
+
+func TestServiceProfile_LegacyFilenameCoexists(t *testing.T) {
+ // Legacy profiles stored as .json with no "name" JSON field
+ // should still be discoverable by name and removable by name.
+ withTestSM(t, func(sm *ServiceManager, username string) {
+ configDir, err := sm.getConfigDir(username)
+ require.NoError(t, err)
+ path := filepath.Join(configDir, "legacy.json")
+ require.NoError(t, util.WriteJson(context.Background(), path, &Config{}))
+
+ got, err := sm.ResolveProfile("legacy", username)
+ require.NoError(t, err)
+ assert.Equal(t, "legacy", got.ID.String())
+ // Name falls back to the filename stem when JSON omits it.
+ assert.Equal(t, "legacy", got.Name)
+ })
+}
+
+func TestAddProfile_AllowsDuplicateWithFlag(t *testing.T) {
+ withTestSM(t, func(sm *ServiceManager, username string) {
+ first, err := sm.AddProfile("work", username)
+ require.NoError(t, err)
+
+ second, err := sm.AddProfile("work", username)
+ require.NoError(t, err)
+ assert.NotEqual(t, first.ID, second.ID)
+ assert.Equal(t, "work", second.Name)
+ })
+}
+
+func TestAddProfile_RejectsInvalidNames(t *testing.T) {
+ withTestSM(t, func(sm *ServiceManager, username string) {
+ cases := []string{
+ "", // empty
+ "\x00\x01", // only control chars (becomes empty)
+ strings.Repeat("a", maxProfileNameLen+1), // too long
+ }
+ for _, name := range cases {
+ _, err := sm.AddProfile(name, username)
+ assert.Error(t, err, "expected error for %q", name)
+ }
+ })
+}
+
+func TestRemoveProfile_RejectsInvalidID(t *testing.T) {
+ withTestSM(t, func(sm *ServiceManager, username string) {
+ err := sm.RemoveProfile("../escape", username)
+ assert.Error(t, err)
+ })
+}
+
+func TestSanitizeDisplayName(t *testing.T) {
+ cases := []struct {
+ in string
+ want string
+ wantErr bool
+ }{
+ {"work", "work", false},
+ {"My Work Account", "My Work Account", false},
+ {"emoji 🚀 ok", "emoji 🚀 ok", false},
+ {"漢字テスト", "漢字テスト", false},
+ {"with\x00null", "withnull", false},
+ {"\x01\x02\x03", "", true},
+ {"", "", true},
+ }
+ for _, tc := range cases {
+ got, err := sanitizeDisplayName(tc.in)
+ if tc.wantErr {
+ assert.Error(t, err, "case %q", tc.in)
+ continue
+ }
+ assert.NoError(t, err, "case %q", tc.in)
+ assert.Equal(t, tc.want, got, "case %q", tc.in)
+ }
+}
+
+func TestIsValidProfileFilenameStem(t *testing.T) {
+ cases := []struct {
+ in string
+ want bool
+ }{
+ {"default", true},
+ {"abc123def456", true},
+ {"legacy-name", true},
+ {"legacy_name", true},
+ {"", false},
+ {"..", false},
+ {"../etc", false},
+ {"foo/bar", false},
+ {`foo\bar`, false},
+ {"with space", false},
+ {"with.dot", false},
+ {strings.Repeat("a", maxProfileIDLen+1), false},
+ }
+ for _, tc := range cases {
+ got := IsValidProfileFilenameStem(ID(tc.in))
+ assert.Equal(t, tc.want, got, "case %q", tc.in)
+ }
+}
+
+func TestRemoveProfile_DeletesStateFile(t *testing.T) {
+ withTestSM(t, func(sm *ServiceManager, username string) {
+ created, err := sm.AddProfile("work", username)
+ require.NoError(t, err)
+
+ configDir, err := sm.getConfigDir(username)
+ require.NoError(t, err)
+ statePath := filepath.Join(configDir, created.ID.String()+".state.json")
+ require.NoError(t, os.WriteFile(statePath, []byte(`{"email":"a@b"}`), 0600))
+
+ require.NoError(t, sm.RemoveProfile(created.ID, username))
+ _, err = os.Stat(statePath)
+ assert.True(t, errors.Is(err, os.ErrNotExist), "state file should be removed")
+ })
+}
diff --git a/client/internal/profilemanager/state.go b/client/internal/profilemanager/state.go
index f09391ede..1bf3318af 100644
--- a/client/internal/profilemanager/state.go
+++ b/client/internal/profilemanager/state.go
@@ -13,13 +13,20 @@ type ProfileState struct {
Email string `json:"email"`
}
-func (pm *ProfileManager) GetProfileState(profileName string) (*ProfileState, error) {
+// GetProfileState reads the per-profile state file keyed by profile ID.
+// The state file lives in the user's config directory. Legacy state files
+// keyed by the old profile name remain readable.
+func (pm *ProfileManager) GetProfileState(id ID) (*ProfileState, error) {
configDir, err := getConfigDir()
if err != nil {
return nil, fmt.Errorf("get config directory: %w", err)
}
- stateFile := filepath.Join(configDir, profileName+".state.json")
+ if id != defaultProfileName && !IsValidProfileFilenameStem(id) {
+ return nil, fmt.Errorf("invalid profile ID: %q", id)
+ }
+
+ stateFile := filepath.Join(configDir, id.String()+".state.json")
stateFileExists, err := fileExists(stateFile)
if err != nil {
return nil, fmt.Errorf("failed to check if profile state file exists: %w", err)
@@ -51,7 +58,12 @@ func (pm *ProfileManager) SetActiveProfileState(state *ProfileState) error {
return fmt.Errorf("get active profile: %w", err)
}
- stateFile := filepath.Join(configDir, activeProf.Name+".state.json")
+ id := activeProf.ID
+ if id != defaultProfileName && !IsValidProfileFilenameStem(id) {
+ return fmt.Errorf("invalid active profile ID: %q", id)
+ }
+
+ stateFile := filepath.Join(configDir, id.String()+".state.json")
err = util.WriteJsonWithRestrictedPermission(context.Background(), stateFile, state)
if err != nil {
return fmt.Errorf("write profile state: %w", err)
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/dnsinterceptor/handler.go b/client/internal/routemanager/dnsinterceptor/handler.go
index e25cc2a5c..22f3355c8 100644
--- a/client/internal/routemanager/dnsinterceptor/handler.go
+++ b/client/internal/routemanager/dnsinterceptor/handler.go
@@ -251,6 +251,14 @@ func (d *DnsInterceptor) ServeDNS(w dns.ResponseWriter, r *dns.Msg) {
r.MsgHdr.AuthenticatedData = true
}
+ // Advertise EDNS0 to the forwarder so it may return an Extended DNS Error
+ // describing why a lookup failed. The OPT is stripped from the reply when
+ // the original client did not request EDNS0.
+ hadEdns := r.IsEdns0() != nil
+ if !hadEdns {
+ r.SetEdns0(dns.DefaultMsgSize, false)
+ }
+
upstream := net.JoinHostPort(upstreamIP.String(), strconv.FormatUint(uint64(d.forwarderPort.Load()), 10))
ctx, cancel := context.WithTimeout(context.Background(), dnsTimeout)
defer cancel()
@@ -260,6 +268,13 @@ func (d *DnsInterceptor) ServeDNS(w dns.ResponseWriter, r *dns.Msg) {
return
}
+ if ede, ok := resutil.ExtractEDE(reply); ok {
+ resutil.SetMeta(w, "ede", fmt.Sprintf("%d %s", ede.InfoCode, ede.ExtraText))
+ }
+ if !hadEdns {
+ resutil.StripOPT(reply)
+ }
+
resutil.SetMeta(w, "peer", peerKey)
reply.Id = r.Id
diff --git a/client/internal/routemanager/manager.go b/client/internal/routemanager/manager.go
index f10a2b5e0..22458d575 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"
@@ -332,6 +333,8 @@ func (m *DefaultManager) Stop(stateManager *statemanager.Manager) {
}
}
+ m.notifier.Close()
+
m.mux.Lock()
defer m.mux.Unlock()
m.clientRoutes = nil
@@ -700,6 +703,8 @@ 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.
@@ -716,6 +721,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_test.go b/client/internal/routemanager/manager_test.go
index 926f06bc9..18b44820a 100644
--- a/client/internal/routemanager/manager_test.go
+++ b/client/internal/routemanager/manager_test.go
@@ -1,3 +1,5 @@
+//go:build privileged
+
package routemanager
import (
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/notifier/notifier_android.go b/client/internal/routemanager/notifier/notifier_android.go
index 140a583f7..49300dbb2 100644
--- a/client/internal/routemanager/notifier/notifier_android.go
+++ b/client/internal/routemanager/notifier/notifier_android.go
@@ -16,7 +16,7 @@ import (
type Notifier struct {
initialRoutes []*route.Route
currentRoutes []*route.Route
- fakeIPRoutes []*route.Route
+ fakeIPRoutes []*route.Route
listener listener.NetworkChangeListener
listenerMux sync.Mutex
@@ -119,3 +119,7 @@ func (n *Notifier) GetInitialRouteRanges() []string {
sort.Strings(initialStrings)
return initialStrings
}
+
+func (n *Notifier) Close() {
+ // unused
+}
diff --git a/client/internal/routemanager/notifier/notifier_ios.go b/client/internal/routemanager/notifier/notifier_ios.go
index 27a2a722d..d0888f3a1 100644
--- a/client/internal/routemanager/notifier/notifier_ios.go
+++ b/client/internal/routemanager/notifier/notifier_ios.go
@@ -3,6 +3,7 @@
package notifier
import (
+ "container/list"
"net/netip"
"slices"
"sort"
@@ -14,19 +15,26 @@ import (
)
type Notifier struct {
+ mu sync.Mutex
+ cond *sync.Cond
currentPrefixes []string
-
- listener listener.NetworkChangeListener
- listenerMux sync.Mutex
+ listener listener.NetworkChangeListener
+ queue *list.List
+ closed bool
}
func NewNotifier() *Notifier {
- return &Notifier{}
+ n := &Notifier{
+ queue: list.New(),
+ }
+ n.cond = sync.NewCond(&n.mu)
+ go n.deliverLoop()
+ return n
}
func (n *Notifier) SetListener(listener listener.NetworkChangeListener) {
- n.listenerMux.Lock()
- defer n.listenerMux.Unlock()
+ n.mu.Lock()
+ defer n.mu.Unlock()
n.listener = listener
}
@@ -43,32 +51,52 @@ func (n *Notifier) OnNewRoutes(route.HAMap) {
}
func (n *Notifier) OnNewPrefixes(prefixes []netip.Prefix) {
- newNets := make([]string, 0)
+ newNets := make([]string, 0, len(prefixes))
for _, prefix := range prefixes {
newNets = append(newNets, prefix.String())
}
sort.Strings(newNets)
+ n.mu.Lock()
if slices.Equal(n.currentPrefixes, newNets) {
+ n.mu.Unlock()
return
}
-
n.currentPrefixes = newNets
- n.notify()
+ routes := strings.Join(n.currentPrefixes, ",")
+ n.queue.PushBack(routes)
+ n.cond.Signal()
+ n.mu.Unlock()
}
-func (n *Notifier) notify() {
- n.listenerMux.Lock()
- defer n.listenerMux.Unlock()
- if n.listener == nil {
- return
- }
- go func(l listener.NetworkChangeListener) {
- l.OnNetworkChanged(strings.Join(n.currentPrefixes, ","))
- }(n.listener)
+func (n *Notifier) Close() {
+ n.mu.Lock()
+ n.closed = true
+ n.cond.Signal()
+ n.mu.Unlock()
}
func (n *Notifier) GetInitialRouteRanges() []string {
return nil
}
+
+func (n *Notifier) deliverLoop() {
+ for {
+ n.mu.Lock()
+ for n.queue.Len() == 0 && !n.closed {
+ n.cond.Wait()
+ }
+ if n.closed && n.queue.Len() == 0 {
+ n.mu.Unlock()
+ return
+ }
+ routes := n.queue.Remove(n.queue.Front()).(string)
+ l := n.listener
+ n.mu.Unlock()
+
+ if l != nil {
+ l.OnNetworkChanged(routes)
+ }
+ }
+}
diff --git a/client/internal/routemanager/notifier/notifier_other.go b/client/internal/routemanager/notifier/notifier_other.go
index f57cadb0b..71b1096c2 100644
--- a/client/internal/routemanager/notifier/notifier_other.go
+++ b/client/internal/routemanager/notifier/notifier_other.go
@@ -38,3 +38,7 @@ func (n *Notifier) OnNewPrefixes(prefixes []netip.Prefix) {
func (n *Notifier) GetInitialRouteRanges() []string {
return []string{}
}
+
+func (n *Notifier) Close() {
+ // unused
+}
diff --git a/client/internal/routemanager/systemops/rt_tables_linux_test.go b/client/internal/routemanager/systemops/rt_tables_linux_test.go
new file mode 100644
index 000000000..bc9cca8b1
--- /dev/null
+++ b/client/internal/routemanager/systemops/rt_tables_linux_test.go
@@ -0,0 +1,69 @@
+//go:build linux && !android
+
+package systemops
+
+import (
+ "fmt"
+ "os"
+ "strings"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestEntryExists(t *testing.T) {
+ tempDir := t.TempDir()
+ tempFilePath := fmt.Sprintf("%s/rt_tables", tempDir)
+
+ content := []string{
+ "1000 reserved",
+ fmt.Sprintf("%d %s", NetbirdVPNTableID, NetbirdVPNTableName),
+ "9999 other_table",
+ }
+ require.NoError(t, os.WriteFile(tempFilePath, []byte(strings.Join(content, "\n")), 0644))
+
+ file, err := os.Open(tempFilePath)
+ require.NoError(t, err)
+ defer func() {
+ assert.NoError(t, file.Close())
+ }()
+
+ tests := []struct {
+ name string
+ id int
+ shouldExist bool
+ err error
+ }{
+ {
+ name: "ExistsWithNetbirdPrefix",
+ id: 7120,
+ shouldExist: true,
+ err: nil,
+ },
+ {
+ name: "ExistsWithDifferentName",
+ id: 1000,
+ shouldExist: true,
+ err: ErrTableIDExists,
+ },
+ {
+ name: "DoesNotExist",
+ id: 1234,
+ shouldExist: false,
+ err: nil,
+ },
+ }
+
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ exists, err := entryExists(file, tc.id)
+ if tc.err != nil {
+ assert.ErrorIs(t, err, tc.err)
+ } else {
+ assert.NoError(t, err)
+ }
+ assert.Equal(t, tc.shouldExist, exists)
+ })
+ }
+}
diff --git a/client/internal/routemanager/systemops/systemops_bsd_privileged_test.go b/client/internal/routemanager/systemops/systemops_bsd_privileged_test.go
new file mode 100644
index 000000000..d45028c19
--- /dev/null
+++ b/client/internal/routemanager/systemops/systemops_bsd_privileged_test.go
@@ -0,0 +1,191 @@
+//go:build (darwin || dragonfly || freebsd || netbsd || openbsd) && privileged
+
+package systemops
+
+import (
+ "fmt"
+ "net"
+ "net/netip"
+ "os/exec"
+ "regexp"
+ "runtime"
+ "strings"
+ "sync"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func init() {
+ testCases = append(testCases, []testCase{
+ {
+ name: "To more specific route without custom dialer via vpn",
+ expectedInterface: expectedVPNint,
+ dialer: &net.Dialer{},
+ expectedPacket: createPacketExpectation("100.64.0.1", 12345, "10.10.0.2", 53),
+ },
+ }...)
+}
+
+func TestConcurrentRoutes(t *testing.T) {
+ baseIP := netip.MustParseAddr("192.0.2.0")
+
+ var intf *net.Interface
+ var nexthop Nexthop
+
+ _, intf = setupDummyInterface(t)
+ nexthop = Nexthop{netip.Addr{}, intf}
+
+ r := New(nil, nil)
+
+ var wg sync.WaitGroup
+ for i := 0; i < 1024; i++ {
+ wg.Add(1)
+ go func(ip netip.Addr) {
+ defer wg.Done()
+ prefix := netip.PrefixFrom(ip, 32)
+ if err := r.addToRouteTable(prefix, nexthop); err != nil {
+ t.Errorf("Failed to add route for %s: %v", prefix, err)
+ }
+ }(baseIP)
+ baseIP = baseIP.Next()
+ }
+
+ wg.Wait()
+
+ baseIP = netip.MustParseAddr("192.0.2.0")
+
+ for i := 0; i < 1024; i++ {
+ wg.Add(1)
+ go func(ip netip.Addr) {
+ defer wg.Done()
+ prefix := netip.PrefixFrom(ip, 32)
+ if err := r.removeFromRouteTable(prefix, nexthop); err != nil {
+ t.Errorf("Failed to remove route for %s: %v", prefix, err)
+ }
+ }(baseIP)
+ baseIP = baseIP.Next()
+ }
+
+ wg.Wait()
+}
+
+func createAndSetupDummyInterface(t *testing.T, intf string, ipAddressCIDR string) string {
+ t.Helper()
+
+ if runtime.GOOS == "darwin" {
+ err := exec.Command("ifconfig", intf, "alias", ipAddressCIDR).Run()
+ require.NoError(t, err, "Failed to create loopback alias")
+
+ t.Cleanup(func() {
+ err := exec.Command("ifconfig", intf, ipAddressCIDR, "-alias").Run()
+ assert.NoError(t, err, "Failed to remove loopback alias")
+ })
+
+ return intf
+ }
+
+ prefix, err := netip.ParsePrefix(ipAddressCIDR)
+ require.NoError(t, err, "Failed to parse prefix")
+
+ netIntf, err := net.InterfaceByName(intf)
+ require.NoError(t, err, "Failed to get interface by name")
+
+ nexthop := Nexthop{netip.Addr{}, netIntf}
+
+ r := New(nil, nil)
+ err = r.addToRouteTable(prefix, nexthop)
+ require.NoError(t, err, "Failed to add route to table")
+
+ t.Cleanup(func() {
+ err := r.removeFromRouteTable(prefix, nexthop)
+ assert.NoError(t, err, "Failed to remove route from table")
+ })
+
+ return intf
+}
+
+func addDummyRoute(t *testing.T, dstCIDR string, gw netip.Addr, _ string) {
+ t.Helper()
+
+ var originalNexthop net.IP
+ if dstCIDR == "0.0.0.0/0" {
+ var err error
+ originalNexthop, err = fetchOriginalGateway()
+ if err != nil {
+ t.Logf("Failed to fetch original gateway: %v", err)
+ }
+
+ if output, err := exec.Command("route", "delete", "-net", dstCIDR).CombinedOutput(); err != nil {
+ t.Logf("Failed to delete route: %v, output: %s", err, output)
+ }
+ }
+
+ t.Cleanup(func() {
+ if originalNexthop != nil {
+ err := exec.Command("route", "add", "-net", dstCIDR, originalNexthop.String()).Run()
+ assert.NoError(t, err, "Failed to restore original route")
+ }
+ })
+
+ err := exec.Command("route", "add", "-net", dstCIDR, gw.String()).Run()
+ require.NoError(t, err, "Failed to add route")
+
+ t.Cleanup(func() {
+ err := exec.Command("route", "delete", "-net", dstCIDR).Run()
+ assert.NoError(t, err, "Failed to remove route")
+ })
+}
+
+func fetchOriginalGateway() (net.IP, error) {
+ output, err := exec.Command("route", "-n", "get", "default").CombinedOutput()
+ if err != nil {
+ return nil, err
+ }
+
+ matches := regexp.MustCompile(`gateway: (\S+)`).FindStringSubmatch(string(output))
+ if len(matches) == 0 {
+ return nil, fmt.Errorf("gateway not found")
+ }
+
+ return net.ParseIP(matches[1]), nil
+}
+
+// setupDummyInterface creates a dummy tun interface for FreeBSD route testing
+func setupDummyInterface(t *testing.T) (netip.Addr, *net.Interface) {
+ t.Helper()
+
+ if runtime.GOOS == "darwin" {
+ return netip.AddrFrom4([4]byte{192, 168, 1, 2}), &net.Interface{Name: "lo0"}
+ }
+
+ output, err := exec.Command("ifconfig", "tun", "create").CombinedOutput()
+ require.NoError(t, err, "Failed to create tun interface: %s", string(output))
+
+ tunName := strings.TrimSpace(string(output))
+
+ output, err = exec.Command("ifconfig", tunName, "192.168.1.1", "netmask", "255.255.0.0", "192.168.1.2", "up").CombinedOutput()
+ require.NoError(t, err, "Failed to configure tun interface: %s", string(output))
+
+ intf, err := net.InterfaceByName(tunName)
+ require.NoError(t, err, "Failed to get interface by name")
+
+ t.Cleanup(func() {
+ if err := exec.Command("ifconfig", tunName, "destroy").Run(); err != nil {
+ t.Logf("Failed to destroy tun interface %s: %v", tunName, err)
+ }
+ })
+
+ return netip.AddrFrom4([4]byte{192, 168, 1, 2}), intf
+}
+
+func setupDummyInterfacesAndRoutes(t *testing.T) {
+ t.Helper()
+
+ defaultDummy := createAndSetupDummyInterface(t, expectedExternalInt, "192.168.0.1/24")
+ addDummyRoute(t, "0.0.0.0/0", netip.AddrFrom4([4]byte{192, 168, 0, 1}), defaultDummy)
+
+ otherDummy := createAndSetupDummyInterface(t, expectedInternalInt, "192.168.1.1/24")
+ addDummyRoute(t, "10.0.0.0/8", netip.AddrFrom4([4]byte{192, 168, 1, 1}), otherDummy)
+}
diff --git a/client/internal/routemanager/systemops/systemops_bsd_test.go b/client/internal/routemanager/systemops/systemops_bsd_test.go
index ec4fc406e..9650945b3 100644
--- a/client/internal/routemanager/systemops/systemops_bsd_test.go
+++ b/client/internal/routemanager/systemops/systemops_bsd_test.go
@@ -3,79 +3,24 @@
package systemops
import (
- "fmt"
- "net"
- "net/netip"
- "os/exec"
- "regexp"
- "runtime"
- "strings"
- "sync"
"testing"
"github.com/stretchr/testify/assert"
- "github.com/stretchr/testify/require"
"golang.org/x/net/route"
)
+// Interface names used by the shared routing test fixtures. Kept untagged (no
+// privileged build tag) so the non-privileged test files in this package compile.
+//
+//nolint:unused // consumed by the privileged-tagged routing tests
var expectedVPNint = "utun100"
+
+//nolint:unused // consumed by the privileged-tagged routing tests
var expectedExternalInt = "lo0"
+
+//nolint:unused // consumed by the privileged-tagged routing tests
var expectedInternalInt = "lo0"
-func init() {
- testCases = append(testCases, []testCase{
- {
- name: "To more specific route without custom dialer via vpn",
- expectedInterface: expectedVPNint,
- dialer: &net.Dialer{},
- expectedPacket: createPacketExpectation("100.64.0.1", 12345, "10.10.0.2", 53),
- },
- }...)
-}
-
-func TestConcurrentRoutes(t *testing.T) {
- baseIP := netip.MustParseAddr("192.0.2.0")
-
- var intf *net.Interface
- var nexthop Nexthop
-
- _, intf = setupDummyInterface(t)
- nexthop = Nexthop{netip.Addr{}, intf}
-
- r := New(nil, nil)
-
- var wg sync.WaitGroup
- for i := 0; i < 1024; i++ {
- wg.Add(1)
- go func(ip netip.Addr) {
- defer wg.Done()
- prefix := netip.PrefixFrom(ip, 32)
- if err := r.addToRouteTable(prefix, nexthop); err != nil {
- t.Errorf("Failed to add route for %s: %v", prefix, err)
- }
- }(baseIP)
- baseIP = baseIP.Next()
- }
-
- wg.Wait()
-
- baseIP = netip.MustParseAddr("192.0.2.0")
-
- for i := 0; i < 1024; i++ {
- wg.Add(1)
- go func(ip netip.Addr) {
- defer wg.Done()
- prefix := netip.PrefixFrom(ip, 32)
- if err := r.removeFromRouteTable(prefix, nexthop); err != nil {
- t.Errorf("Failed to remove route for %s: %v", prefix, err)
- }
- }(baseIP)
- baseIP = baseIP.Next()
- }
-
- wg.Wait()
-}
-
func TestBits(t *testing.T) {
tests := []struct {
name string
@@ -122,122 +67,3 @@ func TestBits(t *testing.T) {
})
}
}
-
-func createAndSetupDummyInterface(t *testing.T, intf string, ipAddressCIDR string) string {
- t.Helper()
-
- if runtime.GOOS == "darwin" {
- err := exec.Command("ifconfig", intf, "alias", ipAddressCIDR).Run()
- require.NoError(t, err, "Failed to create loopback alias")
-
- t.Cleanup(func() {
- err := exec.Command("ifconfig", intf, ipAddressCIDR, "-alias").Run()
- assert.NoError(t, err, "Failed to remove loopback alias")
- })
-
- return intf
- }
-
- prefix, err := netip.ParsePrefix(ipAddressCIDR)
- require.NoError(t, err, "Failed to parse prefix")
-
- netIntf, err := net.InterfaceByName(intf)
- require.NoError(t, err, "Failed to get interface by name")
-
- nexthop := Nexthop{netip.Addr{}, netIntf}
-
- r := New(nil, nil)
- err = r.addToRouteTable(prefix, nexthop)
- require.NoError(t, err, "Failed to add route to table")
-
- t.Cleanup(func() {
- err := r.removeFromRouteTable(prefix, nexthop)
- assert.NoError(t, err, "Failed to remove route from table")
- })
-
- return intf
-}
-
-func addDummyRoute(t *testing.T, dstCIDR string, gw netip.Addr, _ string) {
- t.Helper()
-
- var originalNexthop net.IP
- if dstCIDR == "0.0.0.0/0" {
- var err error
- originalNexthop, err = fetchOriginalGateway()
- if err != nil {
- t.Logf("Failed to fetch original gateway: %v", err)
- }
-
- if output, err := exec.Command("route", "delete", "-net", dstCIDR).CombinedOutput(); err != nil {
- t.Logf("Failed to delete route: %v, output: %s", err, output)
- }
- }
-
- t.Cleanup(func() {
- if originalNexthop != nil {
- err := exec.Command("route", "add", "-net", dstCIDR, originalNexthop.String()).Run()
- assert.NoError(t, err, "Failed to restore original route")
- }
- })
-
- err := exec.Command("route", "add", "-net", dstCIDR, gw.String()).Run()
- require.NoError(t, err, "Failed to add route")
-
- t.Cleanup(func() {
- err := exec.Command("route", "delete", "-net", dstCIDR).Run()
- assert.NoError(t, err, "Failed to remove route")
- })
-}
-
-func fetchOriginalGateway() (net.IP, error) {
- output, err := exec.Command("route", "-n", "get", "default").CombinedOutput()
- if err != nil {
- return nil, err
- }
-
- matches := regexp.MustCompile(`gateway: (\S+)`).FindStringSubmatch(string(output))
- if len(matches) == 0 {
- return nil, fmt.Errorf("gateway not found")
- }
-
- return net.ParseIP(matches[1]), nil
-}
-
-// setupDummyInterface creates a dummy tun interface for FreeBSD route testing
-func setupDummyInterface(t *testing.T) (netip.Addr, *net.Interface) {
- t.Helper()
-
- if runtime.GOOS == "darwin" {
- return netip.AddrFrom4([4]byte{192, 168, 1, 2}), &net.Interface{Name: "lo0"}
- }
-
- output, err := exec.Command("ifconfig", "tun", "create").CombinedOutput()
- require.NoError(t, err, "Failed to create tun interface: %s", string(output))
-
- tunName := strings.TrimSpace(string(output))
-
- output, err = exec.Command("ifconfig", tunName, "192.168.1.1", "netmask", "255.255.0.0", "192.168.1.2", "up").CombinedOutput()
- require.NoError(t, err, "Failed to configure tun interface: %s", string(output))
-
- intf, err := net.InterfaceByName(tunName)
- require.NoError(t, err, "Failed to get interface by name")
-
- t.Cleanup(func() {
- if err := exec.Command("ifconfig", tunName, "destroy").Run(); err != nil {
- t.Logf("Failed to destroy tun interface %s: %v", tunName, err)
- }
- })
-
- return netip.AddrFrom4([4]byte{192, 168, 1, 2}), intf
-}
-
-func setupDummyInterfacesAndRoutes(t *testing.T) {
- t.Helper()
-
- defaultDummy := createAndSetupDummyInterface(t, expectedExternalInt, "192.168.0.1/24")
- addDummyRoute(t, "0.0.0.0/0", netip.AddrFrom4([4]byte{192, 168, 0, 1}), defaultDummy)
-
- otherDummy := createAndSetupDummyInterface(t, expectedInternalInt, "192.168.1.1/24")
- addDummyRoute(t, "10.0.0.0/8", netip.AddrFrom4([4]byte{192, 168, 1, 1}), otherDummy)
-}
diff --git a/client/internal/routemanager/systemops/systemops_dialer_test.go b/client/internal/routemanager/systemops/systemops_dialer_test.go
new file mode 100644
index 000000000..f00f9099c
--- /dev/null
+++ b/client/internal/routemanager/systemops/systemops_dialer_test.go
@@ -0,0 +1,17 @@
+//go:build !android && !ios
+
+package systemops
+
+import (
+ "context"
+ "net"
+)
+
+// dialer is shared by the per-platform routing test cases. Kept untagged (no
+// privileged build tag) so the non-privileged test files compile on every platform.
+//
+//nolint:unused // consumed by the privileged-tagged routing tests
+type dialer interface {
+ Dial(network, address string) (net.Conn, error)
+ DialContext(ctx context.Context, network, address string) (net.Conn, error)
+}
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/routemanager/systemops/systemops_generic_test.go b/client/internal/routemanager/systemops/systemops_generic_test.go
index 5695c40c3..c4f739c30 100644
--- a/client/internal/routemanager/systemops/systemops_generic_test.go
+++ b/client/internal/routemanager/systemops/systemops_generic_test.go
@@ -1,4 +1,4 @@
-//go:build !android && !ios
+//go:build !android && !ios && privileged
package systemops
@@ -26,11 +26,6 @@ import (
nbnet "github.com/netbirdio/netbird/client/net"
)
-type dialer interface {
- Dial(network, address string) (net.Conn, error)
- DialContext(ctx context.Context, network, address string) (net.Conn, error)
-}
-
func TestAddVPNRoute(t *testing.T) {
testCases := []struct {
name string
@@ -515,125 +510,3 @@ func setupTestEnv(t *testing.T) {
// unique route in vpn table
setupRouteAndCleanup(t, r, netip.MustParsePrefix("172.16.0.0/12"), intf)
}
-
-func TestIsVpnRoute(t *testing.T) {
- tests := []struct {
- name string
- addr string
- vpnRoutes []string
- localRoutes []string
- expectedVpn bool
- expectedPrefix netip.Prefix
- }{
- {
- name: "Match in VPN routes",
- addr: "192.168.1.1",
- vpnRoutes: []string{"192.168.1.0/24"},
- localRoutes: []string{"10.0.0.0/8"},
- expectedVpn: true,
- expectedPrefix: netip.MustParsePrefix("192.168.1.0/24"),
- },
- {
- name: "Match in local routes",
- addr: "10.1.1.1",
- vpnRoutes: []string{"192.168.1.0/24"},
- localRoutes: []string{"10.0.0.0/8"},
- expectedVpn: false,
- expectedPrefix: netip.MustParsePrefix("10.0.0.0/8"),
- },
- {
- name: "No match",
- addr: "172.16.0.1",
- vpnRoutes: []string{"192.168.1.0/24"},
- localRoutes: []string{"10.0.0.0/8"},
- expectedVpn: false,
- expectedPrefix: netip.Prefix{},
- },
- {
- name: "Default route ignored",
- addr: "192.168.1.1",
- vpnRoutes: []string{"0.0.0.0/0", "192.168.1.0/24"},
- localRoutes: []string{"10.0.0.0/8"},
- expectedVpn: true,
- expectedPrefix: netip.MustParsePrefix("192.168.1.0/24"),
- },
- {
- name: "Default route matches but ignored",
- addr: "172.16.1.1",
- vpnRoutes: []string{"0.0.0.0/0", "192.168.1.0/24"},
- localRoutes: []string{"10.0.0.0/8"},
- expectedVpn: false,
- expectedPrefix: netip.Prefix{},
- },
- {
- name: "Longest prefix match local",
- addr: "192.168.1.1",
- vpnRoutes: []string{"192.168.0.0/16"},
- localRoutes: []string{"192.168.1.0/24"},
- expectedVpn: false,
- expectedPrefix: netip.MustParsePrefix("192.168.1.0/24"),
- },
- {
- name: "Longest prefix match local multiple",
- addr: "192.168.0.1",
- vpnRoutes: []string{"192.168.0.0/16", "192.168.0.0/25", "192.168.0.0/27"},
- localRoutes: []string{"192.168.0.0/24", "192.168.0.0/26", "192.168.0.0/28"},
- expectedVpn: false,
- expectedPrefix: netip.MustParsePrefix("192.168.0.0/28"),
- },
- {
- name: "Longest prefix match vpn",
- addr: "192.168.1.1",
- vpnRoutes: []string{"192.168.1.0/24"},
- localRoutes: []string{"192.168.0.0/16"},
- expectedVpn: true,
- expectedPrefix: netip.MustParsePrefix("192.168.1.0/24"),
- },
- {
- name: "Longest prefix match vpn multiple",
- addr: "192.168.0.1",
- vpnRoutes: []string{"192.168.0.0/16", "192.168.0.0/25", "192.168.0.0/27"},
- localRoutes: []string{"192.168.0.0/24", "192.168.0.0/26"},
- expectedVpn: true,
- expectedPrefix: netip.MustParsePrefix("192.168.0.0/27"),
- },
- {
- name: "Duplicate prefix in both",
- addr: "192.168.1.1",
- vpnRoutes: []string{"192.168.1.0/24"},
- localRoutes: []string{"192.168.1.0/24"},
- expectedVpn: false,
- expectedPrefix: netip.MustParsePrefix("192.168.1.0/24"),
- },
- }
-
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- addr, err := netip.ParseAddr(tt.addr)
- if err != nil {
- t.Fatalf("Failed to parse address %s: %v", tt.addr, err)
- }
-
- var vpnRoutes, localRoutes []netip.Prefix
- for _, route := range tt.vpnRoutes {
- prefix, err := netip.ParsePrefix(route)
- if err != nil {
- t.Fatalf("Failed to parse VPN route %s: %v", route, err)
- }
- vpnRoutes = append(vpnRoutes, prefix)
- }
-
- for _, route := range tt.localRoutes {
- prefix, err := netip.ParsePrefix(route)
- if err != nil {
- t.Fatalf("Failed to parse local route %s: %v", route, err)
- }
- localRoutes = append(localRoutes, prefix)
- }
-
- isVpn, matchedPrefix := isVpnRoute(addr, vpnRoutes, localRoutes)
- assert.Equal(t, tt.expectedVpn, isVpn, "isVpnRoute should return expectedVpn value")
- assert.Equal(t, tt.expectedPrefix, matchedPrefix, "isVpnRoute should return expectedVpn prefix")
- })
- }
-}
diff --git a/client/internal/routemanager/systemops/systemops_isvpnroute_test.go b/client/internal/routemanager/systemops/systemops_isvpnroute_test.go
new file mode 100644
index 000000000..677fe1287
--- /dev/null
+++ b/client/internal/routemanager/systemops/systemops_isvpnroute_test.go
@@ -0,0 +1,132 @@
+//go:build !android && !ios
+
+package systemops
+
+import (
+ "net/netip"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+)
+
+func TestIsVpnRoute(t *testing.T) {
+ tests := []struct {
+ name string
+ addr string
+ vpnRoutes []string
+ localRoutes []string
+ expectedVpn bool
+ expectedPrefix netip.Prefix
+ }{
+ {
+ name: "Match in VPN routes",
+ addr: "192.168.1.1",
+ vpnRoutes: []string{"192.168.1.0/24"},
+ localRoutes: []string{"10.0.0.0/8"},
+ expectedVpn: true,
+ expectedPrefix: netip.MustParsePrefix("192.168.1.0/24"),
+ },
+ {
+ name: "Match in local routes",
+ addr: "10.1.1.1",
+ vpnRoutes: []string{"192.168.1.0/24"},
+ localRoutes: []string{"10.0.0.0/8"},
+ expectedVpn: false,
+ expectedPrefix: netip.MustParsePrefix("10.0.0.0/8"),
+ },
+ {
+ name: "No match",
+ addr: "172.16.0.1",
+ vpnRoutes: []string{"192.168.1.0/24"},
+ localRoutes: []string{"10.0.0.0/8"},
+ expectedVpn: false,
+ expectedPrefix: netip.Prefix{},
+ },
+ {
+ name: "Default route ignored",
+ addr: "192.168.1.1",
+ vpnRoutes: []string{"0.0.0.0/0", "192.168.1.0/24"},
+ localRoutes: []string{"10.0.0.0/8"},
+ expectedVpn: true,
+ expectedPrefix: netip.MustParsePrefix("192.168.1.0/24"),
+ },
+ {
+ name: "Default route matches but ignored",
+ addr: "172.16.1.1",
+ vpnRoutes: []string{"0.0.0.0/0", "192.168.1.0/24"},
+ localRoutes: []string{"10.0.0.0/8"},
+ expectedVpn: false,
+ expectedPrefix: netip.Prefix{},
+ },
+ {
+ name: "Longest prefix match local",
+ addr: "192.168.1.1",
+ vpnRoutes: []string{"192.168.0.0/16"},
+ localRoutes: []string{"192.168.1.0/24"},
+ expectedVpn: false,
+ expectedPrefix: netip.MustParsePrefix("192.168.1.0/24"),
+ },
+ {
+ name: "Longest prefix match local multiple",
+ addr: "192.168.0.1",
+ vpnRoutes: []string{"192.168.0.0/16", "192.168.0.0/25", "192.168.0.0/27"},
+ localRoutes: []string{"192.168.0.0/24", "192.168.0.0/26", "192.168.0.0/28"},
+ expectedVpn: false,
+ expectedPrefix: netip.MustParsePrefix("192.168.0.0/28"),
+ },
+ {
+ name: "Longest prefix match vpn",
+ addr: "192.168.1.1",
+ vpnRoutes: []string{"192.168.1.0/24"},
+ localRoutes: []string{"192.168.0.0/16"},
+ expectedVpn: true,
+ expectedPrefix: netip.MustParsePrefix("192.168.1.0/24"),
+ },
+ {
+ name: "Longest prefix match vpn multiple",
+ addr: "192.168.0.1",
+ vpnRoutes: []string{"192.168.0.0/16", "192.168.0.0/25", "192.168.0.0/27"},
+ localRoutes: []string{"192.168.0.0/24", "192.168.0.0/26"},
+ expectedVpn: true,
+ expectedPrefix: netip.MustParsePrefix("192.168.0.0/27"),
+ },
+ {
+ name: "Duplicate prefix in both",
+ addr: "192.168.1.1",
+ vpnRoutes: []string{"192.168.1.0/24"},
+ localRoutes: []string{"192.168.1.0/24"},
+ expectedVpn: false,
+ expectedPrefix: netip.MustParsePrefix("192.168.1.0/24"),
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ addr, err := netip.ParseAddr(tt.addr)
+ if err != nil {
+ t.Fatalf("Failed to parse address %s: %v", tt.addr, err)
+ }
+
+ var vpnRoutes, localRoutes []netip.Prefix
+ for _, route := range tt.vpnRoutes {
+ prefix, err := netip.ParsePrefix(route)
+ if err != nil {
+ t.Fatalf("Failed to parse VPN route %s: %v", route, err)
+ }
+ vpnRoutes = append(vpnRoutes, prefix)
+ }
+
+ for _, route := range tt.localRoutes {
+ prefix, err := netip.ParsePrefix(route)
+ if err != nil {
+ t.Fatalf("Failed to parse local route %s: %v", route, err)
+ }
+ localRoutes = append(localRoutes, prefix)
+ }
+
+ isVpn, matchedPrefix := isVpnRoute(addr, vpnRoutes, localRoutes)
+ assert.Equal(t, tt.expectedVpn, isVpn, "isVpnRoute should return expectedVpn value")
+ assert.Equal(t, tt.expectedPrefix, matchedPrefix, "isVpnRoute should return expectedVpn prefix")
+ })
+ }
+}
diff --git a/client/internal/routemanager/systemops/systemops_linux_test.go b/client/internal/routemanager/systemops/systemops_linux_test.go
index 880296d91..06c528ce5 100644
--- a/client/internal/routemanager/systemops/systemops_linux_test.go
+++ b/client/internal/routemanager/systemops/systemops_linux_test.go
@@ -1,13 +1,10 @@
-//go:build !android
+//go:build linux && !android && privileged
package systemops
import (
"errors"
- "fmt"
"net"
- "os"
- "strings"
"syscall"
"testing"
@@ -18,10 +15,6 @@ import (
"github.com/netbirdio/netbird/client/internal/routemanager/vars"
)
-var expectedVPNint = "wgtest0"
-var expectedExternalInt = "dummyext0"
-var expectedInternalInt = "dummyint0"
-
func init() {
testCases = append(testCases, []testCase{
{
@@ -33,62 +26,6 @@ func init() {
}...)
}
-func TestEntryExists(t *testing.T) {
- tempDir := t.TempDir()
- tempFilePath := fmt.Sprintf("%s/rt_tables", tempDir)
-
- content := []string{
- "1000 reserved",
- fmt.Sprintf("%d %s", NetbirdVPNTableID, NetbirdVPNTableName),
- "9999 other_table",
- }
- require.NoError(t, os.WriteFile(tempFilePath, []byte(strings.Join(content, "\n")), 0644))
-
- file, err := os.Open(tempFilePath)
- require.NoError(t, err)
- defer func() {
- assert.NoError(t, file.Close())
- }()
-
- tests := []struct {
- name string
- id int
- shouldExist bool
- err error
- }{
- {
- name: "ExistsWithNetbirdPrefix",
- id: 7120,
- shouldExist: true,
- err: nil,
- },
- {
- name: "ExistsWithDifferentName",
- id: 1000,
- shouldExist: true,
- err: ErrTableIDExists,
- },
- {
- name: "DoesNotExist",
- id: 1234,
- shouldExist: false,
- err: nil,
- },
- }
-
- for _, tc := range tests {
- t.Run(tc.name, func(t *testing.T) {
- exists, err := entryExists(file, tc.id)
- if tc.err != nil {
- assert.ErrorIs(t, err, tc.err)
- } else {
- assert.NoError(t, err)
- }
- assert.Equal(t, tc.shouldExist, exists)
- })
- }
-}
-
func createAndSetupDummyInterface(t *testing.T, interfaceName, ipAddressCIDR string) string {
t.Helper()
diff --git a/client/internal/routemanager/systemops/systemops_routing_data_linux_test.go b/client/internal/routemanager/systemops/systemops_routing_data_linux_test.go
new file mode 100644
index 000000000..9be267980
--- /dev/null
+++ b/client/internal/routemanager/systemops/systemops_routing_data_linux_test.go
@@ -0,0 +1,15 @@
+//go:build linux && !android
+
+package systemops
+
+// Interface names used by the shared routing test fixtures. Kept untagged (no
+// privileged build tag) so the non-privileged test files in this package compile.
+//
+//nolint:unused // consumed by the privileged-tagged routing tests
+var expectedVPNint = "wgtest0"
+
+//nolint:unused // consumed by the privileged-tagged routing tests
+var expectedExternalInt = "dummyext0"
+
+//nolint:unused // consumed by the privileged-tagged routing tests
+var expectedInternalInt = "dummyint0"
diff --git a/client/internal/routemanager/systemops/systemops_routing_data_test.go b/client/internal/routemanager/systemops/systemops_routing_data_test.go
new file mode 100644
index 000000000..16f17f5b9
--- /dev/null
+++ b/client/internal/routemanager/systemops/systemops_routing_data_test.go
@@ -0,0 +1,83 @@
+//go:build (linux && !android) || (darwin && !ios) || freebsd || openbsd || netbsd || dragonfly
+
+package systemops
+
+import (
+ "net"
+
+ nbnet "github.com/netbirdio/netbird/client/net"
+)
+
+// Shared, non-privileged routing test fixtures. The privileged TestRouting (and its
+// per-platform init() appenders) consume these; they live here so the unprivileged
+// BSD/darwin test files compile without the privileged build tag.
+
+type PacketExpectation struct {
+ SrcIP net.IP
+ DstIP net.IP
+ SrcPort int
+ DstPort int
+ UDP bool
+ TCP bool
+}
+
+//nolint:unused // consumed by the privileged-tagged routing tests
+type testCase struct {
+ name string
+ expectedInterface string
+ dialer dialer
+ expectedPacket PacketExpectation
+}
+
+//nolint:unused // consumed by the privileged-tagged routing tests
+var testCases = []testCase{
+ {
+ name: "To external host without custom dialer via vpn",
+ expectedInterface: expectedVPNint,
+ dialer: &net.Dialer{},
+ expectedPacket: createPacketExpectation("100.64.0.1", 12345, "192.0.2.1", 53),
+ },
+ {
+ name: "To external host with custom dialer via physical interface",
+ expectedInterface: expectedExternalInt,
+ dialer: nbnet.NewDialer(),
+ expectedPacket: createPacketExpectation("192.168.0.1", 12345, "192.0.2.1", 53),
+ },
+
+ {
+ name: "To duplicate internal route with custom dialer via physical interface",
+ expectedInterface: expectedInternalInt,
+ dialer: nbnet.NewDialer(),
+ expectedPacket: createPacketExpectation("192.168.1.1", 12345, "10.0.0.2", 53),
+ },
+ {
+ name: "To duplicate internal route without custom dialer via physical interface", // local route takes precedence
+ expectedInterface: expectedInternalInt,
+ dialer: &net.Dialer{},
+ expectedPacket: createPacketExpectation("192.168.1.1", 12345, "10.0.0.2", 53),
+ },
+
+ {
+ name: "To unique vpn route with custom dialer via physical interface",
+ expectedInterface: expectedExternalInt,
+ dialer: nbnet.NewDialer(),
+ expectedPacket: createPacketExpectation("192.168.0.1", 12345, "172.16.0.2", 53),
+ },
+ {
+ name: "To unique vpn route without custom dialer via vpn",
+ expectedInterface: expectedVPNint,
+ dialer: &net.Dialer{},
+ expectedPacket: createPacketExpectation("100.64.0.1", 12345, "172.16.0.2", 53),
+ },
+}
+
+//nolint:unused // consumed by the privileged-tagged routing tests
+func createPacketExpectation(srcIP string, srcPort int, dstIP string, dstPort int) PacketExpectation {
+ return PacketExpectation{
+ SrcIP: net.ParseIP(srcIP),
+ DstIP: net.ParseIP(dstIP),
+ SrcPort: srcPort,
+ DstPort: dstPort,
+ UDP: true,
+ }
+}
diff --git a/client/internal/routemanager/systemops/systemops_unix_test.go b/client/internal/routemanager/systemops/systemops_unix_test.go
index 959c697e4..efb0ae4e4 100644
--- a/client/internal/routemanager/systemops/systemops_unix_test.go
+++ b/client/internal/routemanager/systemops/systemops_unix_test.go
@@ -1,4 +1,4 @@
-//go:build (linux && !android) || (darwin && !ios) || freebsd || openbsd || netbsd || dragonfly
+//go:build ((linux && !android) || (darwin && !ios) || freebsd || openbsd || netbsd || dragonfly) && privileged
package systemops
@@ -20,63 +20,6 @@ import (
nbnet "github.com/netbirdio/netbird/client/net"
)
-type PacketExpectation struct {
- SrcIP net.IP
- DstIP net.IP
- SrcPort int
- DstPort int
- UDP bool
- TCP bool
-}
-
-type testCase struct {
- name string
- expectedInterface string
- dialer dialer
- expectedPacket PacketExpectation
-}
-
-var testCases = []testCase{
- {
- name: "To external host without custom dialer via vpn",
- expectedInterface: expectedVPNint,
- dialer: &net.Dialer{},
- expectedPacket: createPacketExpectation("100.64.0.1", 12345, "192.0.2.1", 53),
- },
- {
- name: "To external host with custom dialer via physical interface",
- expectedInterface: expectedExternalInt,
- dialer: nbnet.NewDialer(),
- expectedPacket: createPacketExpectation("192.168.0.1", 12345, "192.0.2.1", 53),
- },
-
- {
- name: "To duplicate internal route with custom dialer via physical interface",
- expectedInterface: expectedInternalInt,
- dialer: nbnet.NewDialer(),
- expectedPacket: createPacketExpectation("192.168.1.1", 12345, "10.0.0.2", 53),
- },
- {
- name: "To duplicate internal route without custom dialer via physical interface", // local route takes precedence
- expectedInterface: expectedInternalInt,
- dialer: &net.Dialer{},
- expectedPacket: createPacketExpectation("192.168.1.1", 12345, "10.0.0.2", 53),
- },
-
- {
- name: "To unique vpn route with custom dialer via physical interface",
- expectedInterface: expectedExternalInt,
- dialer: nbnet.NewDialer(),
- expectedPacket: createPacketExpectation("192.168.0.1", 12345, "172.16.0.2", 53),
- },
- {
- name: "To unique vpn route without custom dialer via vpn",
- expectedInterface: expectedVPNint,
- dialer: &net.Dialer{},
- expectedPacket: createPacketExpectation("100.64.0.1", 12345, "172.16.0.2", 53),
- },
-}
-
func TestRouting(t *testing.T) {
nbnet.Init()
for _, tc := range testCases {
@@ -102,16 +45,6 @@ func TestRouting(t *testing.T) {
}
}
-func createPacketExpectation(srcIP string, srcPort int, dstIP string, dstPort int) PacketExpectation {
- return PacketExpectation{
- SrcIP: net.ParseIP(srcIP),
- DstIP: net.ParseIP(dstIP),
- SrcPort: srcPort,
- DstPort: dstPort,
- UDP: true,
- }
-}
-
func startPacketCapture(t *testing.T, intf, filter string) *pcap.Handle {
t.Helper()
diff --git a/client/internal/routemanager/systemops/systemops_windows_test.go b/client/internal/routemanager/systemops/systemops_windows_test.go
index 3561adec4..77e349bd6 100644
--- a/client/internal/routemanager/systemops/systemops_windows_test.go
+++ b/client/internal/routemanager/systemops/systemops_windows_test.go
@@ -1,3 +1,5 @@
+//go:build windows && privileged
+
package systemops
import (
diff --git a/client/internal/routemanager/systemops/v6route_bsd_test.go b/client/internal/routemanager/systemops/v6route_bsd_test.go
index 98ce29c6d..90e49f54e 100644
--- a/client/internal/routemanager/systemops/v6route_bsd_test.go
+++ b/client/internal/routemanager/systemops/v6route_bsd_test.go
@@ -11,6 +11,8 @@ import (
// ensureIPv6DefaultRoute installs an IPv6 default route via the loopback
// interface so route lookups for global IPv6 prefixes resolve in environments
// without v6 connectivity. If a default already exists it is left alone.
+//
+//nolint:unused // consumed by the privileged-tagged routing tests
func ensureIPv6DefaultRoute(t *testing.T) {
t.Helper()
diff --git a/client/internal/routemanager/systemops/v6route_linux_test.go b/client/internal/routemanager/systemops/v6route_linux_test.go
index 0b17cefff..449d4cbd2 100644
--- a/client/internal/routemanager/systemops/v6route_linux_test.go
+++ b/client/internal/routemanager/systemops/v6route_linux_test.go
@@ -1,4 +1,4 @@
-//go:build linux && !android
+//go:build linux && !android && privileged
package systemops
diff --git a/client/internal/routemanager/systemops/v6route_windows_test.go b/client/internal/routemanager/systemops/v6route_windows_test.go
index f79277b87..2c813a790 100644
--- a/client/internal/routemanager/systemops/v6route_windows_test.go
+++ b/client/internal/routemanager/systemops/v6route_windows_test.go
@@ -8,11 +8,14 @@ import (
"testing"
)
+//nolint:unused // consumed by the privileged-tagged routing tests
const loopbackIfaceWindows = "Loopback Pseudo-Interface 1"
// ensureIPv6DefaultRoute installs an IPv6 default route via the loopback
// interface so route lookups for global IPv6 prefixes resolve in environments
// without v6 connectivity. If a default already exists it is left alone.
+//
+//nolint:unused // consumed by the privileged-tagged routing tests
func ensureIPv6DefaultRoute(t *testing.T) {
t.Helper()
diff --git a/client/internal/routeselector/routeselector.go b/client/internal/routeselector/routeselector.go
index b9991cd37..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"
@@ -132,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()
@@ -151,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 {
@@ -187,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()
@@ -317,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/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/login.go b/client/ios/NetBirdSDK/login.go
index 9d447ef3f..432133999 100644
--- a/client/ios/NetBirdSDK/login.go
+++ b/client/ios/NetBirdSDK/login.go
@@ -36,6 +36,7 @@ type URLOpener interface {
// Auth can register or login new client
type Auth struct {
ctx context.Context
+ cancel context.CancelFunc
config *profilemanager.Config
cfgPath string
}
@@ -51,8 +52,19 @@ func NewAuth(cfgPath string, mgmURL string) (*Auth, error) {
return nil, err
}
+ // Use a cancellable context so Stop() can abort an in-progress interactive
+ // login. The PKCE flow's WaitToken blocks (and keeps its loopback HTTP server
+ // bound to a port) until the OAuth callback arrives or the flow expires;
+ // cancelling the context unblocks WaitToken, which then shuts that server down
+ // and frees the port for the next login attempt. iOS runs login in the main-app
+ // process (decoupled from the network extension), so without this the server
+ // lingers after the user dismisses the browser and the next connect stalls
+ // trying to bind the same port.
+ ctx, cancel := context.WithCancel(context.Background())
+
return &Auth{
- ctx: context.Background(),
+ ctx: ctx,
+ cancel: cancel,
config: cfg,
cfgPath: cfgPath,
}, nil
@@ -60,12 +72,24 @@ func NewAuth(cfgPath string, mgmURL string) (*Auth, error) {
// NewAuthWithConfig instantiate Auth based on existing config
func NewAuthWithConfig(ctx context.Context, config *profilemanager.Config) *Auth {
+ ctx, cancel := context.WithCancel(ctx)
return &Auth{
ctx: ctx,
+ cancel: cancel,
config: config,
}
}
+// Stop aborts an in-progress interactive login started via Login/LoginWithDeviceName.
+// It cancels the auth context, which unblocks the PKCE WaitToken and shuts down its
+// loopback HTTP server, freeing the redirect port. Safe to call multiple times and
+// safe to call when no login is running.
+func (a *Auth) Stop() {
+ if a.cancel != nil {
+ a.cancel()
+ }
+}
+
// SaveConfigIfSSOSupported test the connectivity with the management server by retrieving the server device flow info.
// If it returns a flow info than save the configuration and return true. If it gets a codes.NotFound, it means that SSO
// is not supported and returns false without saving the configuration. For other errors return false.
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..109fb322e
--- /dev/null
+++ b/client/mdm/policy.go
@@ -0,0 +1,247 @@
+// 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..47a6ed2c9
--- /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 79fa1418a..488b0186c 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"`
@@ -1836,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
}
@@ -1895,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"`
@@ -3931,9 +3954,11 @@ func (x *GetEventsResponse) GetEvents() []*SystemEvent {
}
type SwitchProfileRequest struct {
- state protoimpl.MessageState `protogen:"open.v1"`
- ProfileName *string `protobuf:"bytes,1,opt,name=profileName,proto3,oneof" json:"profileName,omitempty"`
- Username *string `protobuf:"bytes,2,opt,name=username,proto3,oneof" json:"username,omitempty"`
+ state protoimpl.MessageState `protogen:"open.v1"`
+ // profileName is treated as a handle: exact ID, unique ID prefix, or
+ // unique display name. The daemon resolves it server-side.
+ ProfileName *string `protobuf:"bytes,1,opt,name=profileName,proto3,oneof" json:"profileName,omitempty"`
+ Username *string `protobuf:"bytes,2,opt,name=username,proto3,oneof" json:"username,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
@@ -3983,7 +4008,11 @@ func (x *SwitchProfileRequest) GetUsername() string {
}
type SwitchProfileResponse struct {
- state protoimpl.MessageState `protogen:"open.v1"`
+ state protoimpl.MessageState `protogen:"open.v1"`
+ // id is the resolved on-disk ID of the profile that became active.
+ // Lets CLI clients update their local active-profile state without
+ // duplicating the resolution logic.
+ Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
@@ -4018,6 +4047,13 @@ func (*SwitchProfileResponse) Descriptor() ([]byte, []int) {
return file_daemon_proto_rawDescGZIP(), []int{55}
}
+func (x *SwitchProfileResponse) GetId() string {
+ if x != nil {
+ return x.Id
+ }
+ return ""
+}
+
type SetConfigRequest struct {
state protoimpl.MessageState `protogen:"open.v1"`
Username string `protobuf:"bytes,1,opt,name=username,proto3" json:"username,omitempty"`
@@ -4374,9 +4410,11 @@ func (*SetConfigResponse) Descriptor() ([]byte, []int) {
}
type AddProfileRequest struct {
- state protoimpl.MessageState `protogen:"open.v1"`
- Username string `protobuf:"bytes,1,opt,name=username,proto3" json:"username,omitempty"`
- ProfileName string `protobuf:"bytes,2,opt,name=profileName,proto3" json:"profileName,omitempty"`
+ state protoimpl.MessageState `protogen:"open.v1"`
+ Username string `protobuf:"bytes,1,opt,name=username,proto3" json:"username,omitempty"`
+ // profileName carries the human-readable display name for the new
+ // profile. The on-disk filename is a separately-generated ID.
+ ProfileName string `protobuf:"bytes,2,opt,name=profileName,proto3" json:"profileName,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
@@ -4426,7 +4464,10 @@ func (x *AddProfileRequest) GetProfileName() string {
}
type AddProfileResponse struct {
- state protoimpl.MessageState `protogen:"open.v1"`
+ state protoimpl.MessageState `protogen:"open.v1"`
+ // id is the generated on-disk ID of the new profile. CLI clients
+ // display a truncated form, UI clients can ignore it.
+ Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
@@ -4461,17 +4502,133 @@ func (*AddProfileResponse) Descriptor() ([]byte, []int) {
return file_daemon_proto_rawDescGZIP(), []int{59}
}
+func (x *AddProfileResponse) GetId() string {
+ if x != nil {
+ return x.Id
+ }
+ return ""
+}
+
+type RenameProfileRequest struct {
+ state protoimpl.MessageState `protogen:"open.v1"`
+ Username string `protobuf:"bytes,1,opt,name=username,proto3" json:"username,omitempty"`
+ // handle: an exact ID, a unique ID prefix, or a unique display name.
+ Handle string `protobuf:"bytes,2,opt,name=handle,proto3" json:"handle,omitempty"`
+ // newProfileName is the new human-readable display name for the profile.
+ NewProfileName string `protobuf:"bytes,3,opt,name=newProfileName,proto3" json:"newProfileName,omitempty"`
+ unknownFields protoimpl.UnknownFields
+ sizeCache protoimpl.SizeCache
+}
+
+func (x *RenameProfileRequest) Reset() {
+ *x = RenameProfileRequest{}
+ mi := &file_daemon_proto_msgTypes[60]
+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+ ms.StoreMessageInfo(mi)
+}
+
+func (x *RenameProfileRequest) String() string {
+ return protoimpl.X.MessageStringOf(x)
+}
+
+func (*RenameProfileRequest) ProtoMessage() {}
+
+func (x *RenameProfileRequest) ProtoReflect() protoreflect.Message {
+ mi := &file_daemon_proto_msgTypes[60]
+ 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 RenameProfileRequest.ProtoReflect.Descriptor instead.
+func (*RenameProfileRequest) Descriptor() ([]byte, []int) {
+ return file_daemon_proto_rawDescGZIP(), []int{60}
+}
+
+func (x *RenameProfileRequest) GetUsername() string {
+ if x != nil {
+ return x.Username
+ }
+ return ""
+}
+
+func (x *RenameProfileRequest) GetHandle() string {
+ if x != nil {
+ return x.Handle
+ }
+ return ""
+}
+
+func (x *RenameProfileRequest) GetNewProfileName() string {
+ if x != nil {
+ return x.NewProfileName
+ }
+ return ""
+}
+
+type RenameProfileResponse struct {
+ state protoimpl.MessageState `protogen:"open.v1"`
+ // confirm the old profile name after resolving handle.
+ OldProfileName string `protobuf:"bytes,1,opt,name=oldProfileName,proto3" json:"oldProfileName,omitempty"`
+ unknownFields protoimpl.UnknownFields
+ sizeCache protoimpl.SizeCache
+}
+
+func (x *RenameProfileResponse) Reset() {
+ *x = RenameProfileResponse{}
+ mi := &file_daemon_proto_msgTypes[61]
+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+ ms.StoreMessageInfo(mi)
+}
+
+func (x *RenameProfileResponse) String() string {
+ return protoimpl.X.MessageStringOf(x)
+}
+
+func (*RenameProfileResponse) ProtoMessage() {}
+
+func (x *RenameProfileResponse) ProtoReflect() protoreflect.Message {
+ mi := &file_daemon_proto_msgTypes[61]
+ 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 RenameProfileResponse.ProtoReflect.Descriptor instead.
+func (*RenameProfileResponse) Descriptor() ([]byte, []int) {
+ return file_daemon_proto_rawDescGZIP(), []int{61}
+}
+
+func (x *RenameProfileResponse) GetOldProfileName() string {
+ if x != nil {
+ return x.OldProfileName
+ }
+ return ""
+}
+
type RemoveProfileRequest struct {
- state protoimpl.MessageState `protogen:"open.v1"`
- Username string `protobuf:"bytes,1,opt,name=username,proto3" json:"username,omitempty"`
- ProfileName string `protobuf:"bytes,2,opt,name=profileName,proto3" json:"profileName,omitempty"`
+ state protoimpl.MessageState `protogen:"open.v1"`
+ Username string `protobuf:"bytes,1,opt,name=username,proto3" json:"username,omitempty"`
+ // profileName is treated as a handle: an exact ID, a unique ID
+ // prefix, or a unique display name. Resolution happens server-side.
+ ProfileName string `protobuf:"bytes,2,opt,name=profileName,proto3" json:"profileName,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *RemoveProfileRequest) Reset() {
*x = RemoveProfileRequest{}
- mi := &file_daemon_proto_msgTypes[60]
+ mi := &file_daemon_proto_msgTypes[62]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -4483,7 +4640,7 @@ func (x *RemoveProfileRequest) String() string {
func (*RemoveProfileRequest) ProtoMessage() {}
func (x *RemoveProfileRequest) ProtoReflect() protoreflect.Message {
- mi := &file_daemon_proto_msgTypes[60]
+ mi := &file_daemon_proto_msgTypes[62]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -4496,7 +4653,7 @@ func (x *RemoveProfileRequest) ProtoReflect() protoreflect.Message {
// Deprecated: Use RemoveProfileRequest.ProtoReflect.Descriptor instead.
func (*RemoveProfileRequest) Descriptor() ([]byte, []int) {
- return file_daemon_proto_rawDescGZIP(), []int{60}
+ return file_daemon_proto_rawDescGZIP(), []int{62}
}
func (x *RemoveProfileRequest) GetUsername() string {
@@ -4514,14 +4671,17 @@ func (x *RemoveProfileRequest) GetProfileName() string {
}
type RemoveProfileResponse struct {
- state protoimpl.MessageState `protogen:"open.v1"`
+ state protoimpl.MessageState `protogen:"open.v1"`
+ // id is the full resolved ID of the removed profile, so callers can
+ // confirm exactly which profile a name/prefix handle resolved to.
+ Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *RemoveProfileResponse) Reset() {
*x = RemoveProfileResponse{}
- mi := &file_daemon_proto_msgTypes[61]
+ mi := &file_daemon_proto_msgTypes[63]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -4533,7 +4693,7 @@ func (x *RemoveProfileResponse) String() string {
func (*RemoveProfileResponse) ProtoMessage() {}
func (x *RemoveProfileResponse) ProtoReflect() protoreflect.Message {
- mi := &file_daemon_proto_msgTypes[61]
+ mi := &file_daemon_proto_msgTypes[63]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -4546,7 +4706,14 @@ func (x *RemoveProfileResponse) ProtoReflect() protoreflect.Message {
// Deprecated: Use RemoveProfileResponse.ProtoReflect.Descriptor instead.
func (*RemoveProfileResponse) Descriptor() ([]byte, []int) {
- return file_daemon_proto_rawDescGZIP(), []int{61}
+ return file_daemon_proto_rawDescGZIP(), []int{63}
+}
+
+func (x *RemoveProfileResponse) GetId() string {
+ if x != nil {
+ return x.Id
+ }
+ return ""
}
type ListProfilesRequest struct {
@@ -4558,7 +4725,7 @@ type ListProfilesRequest struct {
func (x *ListProfilesRequest) Reset() {
*x = ListProfilesRequest{}
- mi := &file_daemon_proto_msgTypes[62]
+ mi := &file_daemon_proto_msgTypes[64]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -4570,7 +4737,7 @@ func (x *ListProfilesRequest) String() string {
func (*ListProfilesRequest) ProtoMessage() {}
func (x *ListProfilesRequest) ProtoReflect() protoreflect.Message {
- mi := &file_daemon_proto_msgTypes[62]
+ mi := &file_daemon_proto_msgTypes[64]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -4583,7 +4750,7 @@ func (x *ListProfilesRequest) ProtoReflect() protoreflect.Message {
// Deprecated: Use ListProfilesRequest.ProtoReflect.Descriptor instead.
func (*ListProfilesRequest) Descriptor() ([]byte, []int) {
- return file_daemon_proto_rawDescGZIP(), []int{62}
+ return file_daemon_proto_rawDescGZIP(), []int{64}
}
func (x *ListProfilesRequest) GetUsername() string {
@@ -4602,7 +4769,7 @@ type ListProfilesResponse struct {
func (x *ListProfilesResponse) Reset() {
*x = ListProfilesResponse{}
- mi := &file_daemon_proto_msgTypes[63]
+ mi := &file_daemon_proto_msgTypes[65]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -4614,7 +4781,7 @@ func (x *ListProfilesResponse) String() string {
func (*ListProfilesResponse) ProtoMessage() {}
func (x *ListProfilesResponse) ProtoReflect() protoreflect.Message {
- mi := &file_daemon_proto_msgTypes[63]
+ mi := &file_daemon_proto_msgTypes[65]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -4627,7 +4794,7 @@ func (x *ListProfilesResponse) ProtoReflect() protoreflect.Message {
// Deprecated: Use ListProfilesResponse.ProtoReflect.Descriptor instead.
func (*ListProfilesResponse) Descriptor() ([]byte, []int) {
- return file_daemon_proto_rawDescGZIP(), []int{63}
+ return file_daemon_proto_rawDescGZIP(), []int{65}
}
func (x *ListProfilesResponse) GetProfiles() []*Profile {
@@ -4641,13 +4808,14 @@ type Profile struct {
state protoimpl.MessageState `protogen:"open.v1"`
Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"`
IsActive bool `protobuf:"varint,2,opt,name=is_active,json=isActive,proto3" json:"is_active,omitempty"`
+ Id string `protobuf:"bytes,3,opt,name=id,proto3" json:"id,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *Profile) Reset() {
*x = Profile{}
- mi := &file_daemon_proto_msgTypes[64]
+ mi := &file_daemon_proto_msgTypes[66]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -4659,7 +4827,7 @@ func (x *Profile) String() string {
func (*Profile) ProtoMessage() {}
func (x *Profile) ProtoReflect() protoreflect.Message {
- mi := &file_daemon_proto_msgTypes[64]
+ mi := &file_daemon_proto_msgTypes[66]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -4672,7 +4840,7 @@ func (x *Profile) ProtoReflect() protoreflect.Message {
// Deprecated: Use Profile.ProtoReflect.Descriptor instead.
func (*Profile) Descriptor() ([]byte, []int) {
- return file_daemon_proto_rawDescGZIP(), []int{64}
+ return file_daemon_proto_rawDescGZIP(), []int{66}
}
func (x *Profile) GetName() string {
@@ -4689,6 +4857,13 @@ func (x *Profile) GetIsActive() bool {
return false
}
+func (x *Profile) GetId() string {
+ if x != nil {
+ return x.Id
+ }
+ return ""
+}
+
type GetActiveProfileRequest struct {
state protoimpl.MessageState `protogen:"open.v1"`
unknownFields protoimpl.UnknownFields
@@ -4697,7 +4872,7 @@ type GetActiveProfileRequest struct {
func (x *GetActiveProfileRequest) Reset() {
*x = GetActiveProfileRequest{}
- mi := &file_daemon_proto_msgTypes[65]
+ mi := &file_daemon_proto_msgTypes[67]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -4709,7 +4884,7 @@ func (x *GetActiveProfileRequest) String() string {
func (*GetActiveProfileRequest) ProtoMessage() {}
func (x *GetActiveProfileRequest) ProtoReflect() protoreflect.Message {
- mi := &file_daemon_proto_msgTypes[65]
+ mi := &file_daemon_proto_msgTypes[67]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -4722,20 +4897,21 @@ func (x *GetActiveProfileRequest) ProtoReflect() protoreflect.Message {
// Deprecated: Use GetActiveProfileRequest.ProtoReflect.Descriptor instead.
func (*GetActiveProfileRequest) Descriptor() ([]byte, []int) {
- return file_daemon_proto_rawDescGZIP(), []int{65}
+ return file_daemon_proto_rawDescGZIP(), []int{67}
}
type GetActiveProfileResponse struct {
state protoimpl.MessageState `protogen:"open.v1"`
ProfileName string `protobuf:"bytes,1,opt,name=profileName,proto3" json:"profileName,omitempty"`
Username string `protobuf:"bytes,2,opt,name=username,proto3" json:"username,omitempty"`
+ Id string `protobuf:"bytes,3,opt,name=id,proto3" json:"id,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *GetActiveProfileResponse) Reset() {
*x = GetActiveProfileResponse{}
- mi := &file_daemon_proto_msgTypes[66]
+ mi := &file_daemon_proto_msgTypes[68]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -4747,7 +4923,7 @@ func (x *GetActiveProfileResponse) String() string {
func (*GetActiveProfileResponse) ProtoMessage() {}
func (x *GetActiveProfileResponse) ProtoReflect() protoreflect.Message {
- mi := &file_daemon_proto_msgTypes[66]
+ mi := &file_daemon_proto_msgTypes[68]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -4760,7 +4936,7 @@ func (x *GetActiveProfileResponse) ProtoReflect() protoreflect.Message {
// Deprecated: Use GetActiveProfileResponse.ProtoReflect.Descriptor instead.
func (*GetActiveProfileResponse) Descriptor() ([]byte, []int) {
- return file_daemon_proto_rawDescGZIP(), []int{66}
+ return file_daemon_proto_rawDescGZIP(), []int{68}
}
func (x *GetActiveProfileResponse) GetProfileName() string {
@@ -4777,6 +4953,13 @@ func (x *GetActiveProfileResponse) GetUsername() string {
return ""
}
+func (x *GetActiveProfileResponse) GetId() string {
+ if x != nil {
+ return x.Id
+ }
+ return ""
+}
+
type LogoutRequest struct {
state protoimpl.MessageState `protogen:"open.v1"`
ProfileName *string `protobuf:"bytes,1,opt,name=profileName,proto3,oneof" json:"profileName,omitempty"`
@@ -4787,7 +4970,7 @@ type LogoutRequest struct {
func (x *LogoutRequest) Reset() {
*x = LogoutRequest{}
- mi := &file_daemon_proto_msgTypes[67]
+ mi := &file_daemon_proto_msgTypes[69]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -4799,7 +4982,7 @@ func (x *LogoutRequest) String() string {
func (*LogoutRequest) ProtoMessage() {}
func (x *LogoutRequest) ProtoReflect() protoreflect.Message {
- mi := &file_daemon_proto_msgTypes[67]
+ mi := &file_daemon_proto_msgTypes[69]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -4812,7 +4995,7 @@ func (x *LogoutRequest) ProtoReflect() protoreflect.Message {
// Deprecated: Use LogoutRequest.ProtoReflect.Descriptor instead.
func (*LogoutRequest) Descriptor() ([]byte, []int) {
- return file_daemon_proto_rawDescGZIP(), []int{67}
+ return file_daemon_proto_rawDescGZIP(), []int{69}
}
func (x *LogoutRequest) GetProfileName() string {
@@ -4837,7 +5020,7 @@ type LogoutResponse struct {
func (x *LogoutResponse) Reset() {
*x = LogoutResponse{}
- mi := &file_daemon_proto_msgTypes[68]
+ mi := &file_daemon_proto_msgTypes[70]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -4849,7 +5032,7 @@ func (x *LogoutResponse) String() string {
func (*LogoutResponse) ProtoMessage() {}
func (x *LogoutResponse) ProtoReflect() protoreflect.Message {
- mi := &file_daemon_proto_msgTypes[68]
+ mi := &file_daemon_proto_msgTypes[70]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -4862,7 +5045,7 @@ func (x *LogoutResponse) ProtoReflect() protoreflect.Message {
// Deprecated: Use LogoutResponse.ProtoReflect.Descriptor instead.
func (*LogoutResponse) Descriptor() ([]byte, []int) {
- return file_daemon_proto_rawDescGZIP(), []int{68}
+ return file_daemon_proto_rawDescGZIP(), []int{70}
}
type GetFeaturesRequest struct {
@@ -4873,7 +5056,7 @@ type GetFeaturesRequest struct {
func (x *GetFeaturesRequest) Reset() {
*x = GetFeaturesRequest{}
- mi := &file_daemon_proto_msgTypes[69]
+ mi := &file_daemon_proto_msgTypes[71]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -4885,7 +5068,7 @@ func (x *GetFeaturesRequest) String() string {
func (*GetFeaturesRequest) ProtoMessage() {}
func (x *GetFeaturesRequest) ProtoReflect() protoreflect.Message {
- mi := &file_daemon_proto_msgTypes[69]
+ mi := &file_daemon_proto_msgTypes[71]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -4898,7 +5081,7 @@ func (x *GetFeaturesRequest) ProtoReflect() protoreflect.Message {
// Deprecated: Use GetFeaturesRequest.ProtoReflect.Descriptor instead.
func (*GetFeaturesRequest) Descriptor() ([]byte, []int) {
- return file_daemon_proto_rawDescGZIP(), []int{69}
+ return file_daemon_proto_rawDescGZIP(), []int{71}
}
type GetFeaturesResponse struct {
@@ -4912,7 +5095,7 @@ type GetFeaturesResponse struct {
func (x *GetFeaturesResponse) Reset() {
*x = GetFeaturesResponse{}
- mi := &file_daemon_proto_msgTypes[70]
+ mi := &file_daemon_proto_msgTypes[72]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -4924,7 +5107,7 @@ func (x *GetFeaturesResponse) String() string {
func (*GetFeaturesResponse) ProtoMessage() {}
func (x *GetFeaturesResponse) ProtoReflect() protoreflect.Message {
- mi := &file_daemon_proto_msgTypes[70]
+ mi := &file_daemon_proto_msgTypes[72]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -4937,7 +5120,7 @@ func (x *GetFeaturesResponse) ProtoReflect() protoreflect.Message {
// Deprecated: Use GetFeaturesResponse.ProtoReflect.Descriptor instead.
func (*GetFeaturesResponse) Descriptor() ([]byte, []int) {
- return file_daemon_proto_rawDescGZIP(), []int{70}
+ return file_daemon_proto_rawDescGZIP(), []int{72}
}
func (x *GetFeaturesResponse) GetDisableProfiles() bool {
@@ -4961,6 +5144,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[73]
+ 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[73]
+ 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{73}
+}
+
+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
@@ -4969,7 +5201,7 @@ type TriggerUpdateRequest struct {
func (x *TriggerUpdateRequest) Reset() {
*x = TriggerUpdateRequest{}
- mi := &file_daemon_proto_msgTypes[71]
+ mi := &file_daemon_proto_msgTypes[74]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -4981,7 +5213,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[74]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -4994,7 +5226,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{74}
}
type TriggerUpdateResponse struct {
@@ -5007,7 +5239,7 @@ type TriggerUpdateResponse struct {
func (x *TriggerUpdateResponse) Reset() {
*x = TriggerUpdateResponse{}
- mi := &file_daemon_proto_msgTypes[72]
+ mi := &file_daemon_proto_msgTypes[75]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -5019,7 +5251,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[75]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -5032,7 +5264,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{75}
}
func (x *TriggerUpdateResponse) GetSuccess() bool {
@@ -5060,7 +5292,7 @@ type GetPeerSSHHostKeyRequest struct {
func (x *GetPeerSSHHostKeyRequest) Reset() {
*x = GetPeerSSHHostKeyRequest{}
- mi := &file_daemon_proto_msgTypes[73]
+ mi := &file_daemon_proto_msgTypes[76]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -5072,7 +5304,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[76]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -5085,7 +5317,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{76}
}
func (x *GetPeerSSHHostKeyRequest) GetPeerAddress() string {
@@ -5112,7 +5344,7 @@ type GetPeerSSHHostKeyResponse struct {
func (x *GetPeerSSHHostKeyResponse) Reset() {
*x = GetPeerSSHHostKeyResponse{}
- mi := &file_daemon_proto_msgTypes[74]
+ mi := &file_daemon_proto_msgTypes[77]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -5124,7 +5356,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[77]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -5137,7 +5369,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{77}
}
func (x *GetPeerSSHHostKeyResponse) GetSshHostKey() []byte {
@@ -5179,7 +5411,7 @@ type RequestJWTAuthRequest struct {
func (x *RequestJWTAuthRequest) Reset() {
*x = RequestJWTAuthRequest{}
- mi := &file_daemon_proto_msgTypes[75]
+ mi := &file_daemon_proto_msgTypes[78]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -5191,7 +5423,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[78]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -5204,7 +5436,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{78}
}
func (x *RequestJWTAuthRequest) GetHint() string {
@@ -5237,7 +5469,7 @@ type RequestJWTAuthResponse struct {
func (x *RequestJWTAuthResponse) Reset() {
*x = RequestJWTAuthResponse{}
- mi := &file_daemon_proto_msgTypes[76]
+ mi := &file_daemon_proto_msgTypes[79]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -5249,7 +5481,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[79]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -5262,7 +5494,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{79}
}
func (x *RequestJWTAuthResponse) GetVerificationURI() string {
@@ -5327,7 +5559,7 @@ type WaitJWTTokenRequest struct {
func (x *WaitJWTTokenRequest) Reset() {
*x = WaitJWTTokenRequest{}
- mi := &file_daemon_proto_msgTypes[77]
+ mi := &file_daemon_proto_msgTypes[80]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -5339,7 +5571,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[80]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -5352,7 +5584,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{80}
}
func (x *WaitJWTTokenRequest) GetDeviceCode() string {
@@ -5384,7 +5616,7 @@ type WaitJWTTokenResponse struct {
func (x *WaitJWTTokenResponse) Reset() {
*x = WaitJWTTokenResponse{}
- mi := &file_daemon_proto_msgTypes[78]
+ mi := &file_daemon_proto_msgTypes[81]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -5396,7 +5628,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[81]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -5409,7 +5641,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{81}
}
func (x *WaitJWTTokenResponse) GetToken() string {
@@ -5442,7 +5674,7 @@ type StartCPUProfileRequest struct {
func (x *StartCPUProfileRequest) Reset() {
*x = StartCPUProfileRequest{}
- mi := &file_daemon_proto_msgTypes[79]
+ mi := &file_daemon_proto_msgTypes[82]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -5454,7 +5686,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[82]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -5467,7 +5699,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{82}
}
// StartCPUProfileResponse confirms CPU profiling has started
@@ -5479,7 +5711,7 @@ type StartCPUProfileResponse struct {
func (x *StartCPUProfileResponse) Reset() {
*x = StartCPUProfileResponse{}
- mi := &file_daemon_proto_msgTypes[80]
+ mi := &file_daemon_proto_msgTypes[83]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -5491,7 +5723,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[83]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -5504,7 +5736,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{83}
}
// StopCPUProfileRequest for stopping CPU profiling
@@ -5516,7 +5748,7 @@ type StopCPUProfileRequest struct {
func (x *StopCPUProfileRequest) Reset() {
*x = StopCPUProfileRequest{}
- mi := &file_daemon_proto_msgTypes[81]
+ mi := &file_daemon_proto_msgTypes[84]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -5528,7 +5760,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[84]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -5541,7 +5773,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{84}
}
// StopCPUProfileResponse confirms CPU profiling has stopped
@@ -5553,7 +5785,7 @@ type StopCPUProfileResponse struct {
func (x *StopCPUProfileResponse) Reset() {
*x = StopCPUProfileResponse{}
- mi := &file_daemon_proto_msgTypes[82]
+ mi := &file_daemon_proto_msgTypes[85]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -5565,7 +5797,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[85]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -5578,7 +5810,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{85}
}
type InstallerResultRequest struct {
@@ -5589,7 +5821,7 @@ type InstallerResultRequest struct {
func (x *InstallerResultRequest) Reset() {
*x = InstallerResultRequest{}
- mi := &file_daemon_proto_msgTypes[83]
+ mi := &file_daemon_proto_msgTypes[86]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -5601,7 +5833,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[86]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -5614,7 +5846,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{86}
}
type InstallerResultResponse struct {
@@ -5627,7 +5859,7 @@ type InstallerResultResponse struct {
func (x *InstallerResultResponse) Reset() {
*x = InstallerResultResponse{}
- mi := &file_daemon_proto_msgTypes[84]
+ mi := &file_daemon_proto_msgTypes[87]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -5639,7 +5871,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[87]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -5652,7 +5884,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{87}
}
func (x *InstallerResultResponse) GetSuccess() bool {
@@ -5685,7 +5917,7 @@ type ExposeServiceRequest struct {
func (x *ExposeServiceRequest) Reset() {
*x = ExposeServiceRequest{}
- mi := &file_daemon_proto_msgTypes[85]
+ mi := &file_daemon_proto_msgTypes[88]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -5697,7 +5929,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[88]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -5710,7 +5942,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{88}
}
func (x *ExposeServiceRequest) GetPort() uint32 {
@@ -5781,7 +6013,7 @@ type ExposeServiceEvent struct {
func (x *ExposeServiceEvent) Reset() {
*x = ExposeServiceEvent{}
- mi := &file_daemon_proto_msgTypes[86]
+ mi := &file_daemon_proto_msgTypes[89]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -5793,7 +6025,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[89]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -5806,7 +6038,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{89}
}
func (x *ExposeServiceEvent) GetEvent() isExposeServiceEvent_Event {
@@ -5847,7 +6079,7 @@ type ExposeServiceReady struct {
func (x *ExposeServiceReady) Reset() {
*x = ExposeServiceReady{}
- mi := &file_daemon_proto_msgTypes[87]
+ mi := &file_daemon_proto_msgTypes[90]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -5859,7 +6091,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[90]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -5872,7 +6104,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{90}
}
func (x *ExposeServiceReady) GetServiceName() string {
@@ -5917,7 +6149,7 @@ type StartCaptureRequest struct {
func (x *StartCaptureRequest) Reset() {
*x = StartCaptureRequest{}
- mi := &file_daemon_proto_msgTypes[88]
+ mi := &file_daemon_proto_msgTypes[91]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -5929,7 +6161,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[91]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -5942,7 +6174,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{91}
}
func (x *StartCaptureRequest) GetTextOutput() bool {
@@ -5996,7 +6228,7 @@ type CapturePacket struct {
func (x *CapturePacket) Reset() {
*x = CapturePacket{}
- mi := &file_daemon_proto_msgTypes[89]
+ mi := &file_daemon_proto_msgTypes[92]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -6008,7 +6240,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[92]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -6021,7 +6253,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{92}
}
func (x *CapturePacket) GetData() []byte {
@@ -6042,7 +6274,7 @@ type StartBundleCaptureRequest struct {
func (x *StartBundleCaptureRequest) Reset() {
*x = StartBundleCaptureRequest{}
- mi := &file_daemon_proto_msgTypes[90]
+ mi := &file_daemon_proto_msgTypes[93]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -6054,7 +6286,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[93]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -6067,7 +6299,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{93}
}
func (x *StartBundleCaptureRequest) GetTimeout() *durationpb.Duration {
@@ -6085,7 +6317,7 @@ type StartBundleCaptureResponse struct {
func (x *StartBundleCaptureResponse) Reset() {
*x = StartBundleCaptureResponse{}
- mi := &file_daemon_proto_msgTypes[91]
+ mi := &file_daemon_proto_msgTypes[94]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -6097,7 +6329,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[94]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -6110,7 +6342,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{94}
}
type StopBundleCaptureRequest struct {
@@ -6121,7 +6353,7 @@ type StopBundleCaptureRequest struct {
func (x *StopBundleCaptureRequest) Reset() {
*x = StopBundleCaptureRequest{}
- mi := &file_daemon_proto_msgTypes[92]
+ mi := &file_daemon_proto_msgTypes[95]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -6133,7 +6365,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[95]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -6146,7 +6378,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{95}
}
type StopBundleCaptureResponse struct {
@@ -6157,7 +6389,7 @@ type StopBundleCaptureResponse struct {
func (x *StopBundleCaptureResponse) Reset() {
*x = StopBundleCaptureResponse{}
- mi := &file_daemon_proto_msgTypes[93]
+ mi := &file_daemon_proto_msgTypes[96]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -6169,7 +6401,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[96]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -6182,7 +6414,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{96}
}
type PortInfo_Range struct {
@@ -6195,7 +6427,7 @@ type PortInfo_Range struct {
func (x *PortInfo_Range) Reset() {
*x = PortInfo_Range{}
- mi := &file_daemon_proto_msgTypes[95]
+ mi := &file_daemon_proto_msgTypes[98]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -6207,7 +6439,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[98]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -6348,7 +6580,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" +
@@ -6380,7 +6612,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" +
@@ -6423,12 +6656,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" +
@@ -6598,8 +6832,9 @@ const file_daemon_proto_rawDesc = "" +
"\vprofileName\x18\x01 \x01(\tH\x00R\vprofileName\x88\x01\x01\x12\x1f\n" +
"\busername\x18\x02 \x01(\tH\x01R\busername\x88\x01\x01B\x0e\n" +
"\f_profileNameB\v\n" +
- "\t_username\"\x17\n" +
- "\x15SwitchProfileResponse\"\x98\x11\n" +
+ "\t_username\"'\n" +
+ "\x15SwitchProfileResponse\x12\x0e\n" +
+ "\x02id\x18\x01 \x01(\tR\x02id\"\x98\x11\n" +
"\x10SetConfigRequest\x12\x1a\n" +
"\busername\x18\x01 \x01(\tR\busername\x12 \n" +
"\vprofileName\x18\x02 \x01(\tR\vprofileName\x12$\n" +
@@ -6668,23 +6903,33 @@ const file_daemon_proto_rawDesc = "" +
"\x11SetConfigResponse\"Q\n" +
"\x11AddProfileRequest\x12\x1a\n" +
"\busername\x18\x01 \x01(\tR\busername\x12 \n" +
- "\vprofileName\x18\x02 \x01(\tR\vprofileName\"\x14\n" +
- "\x12AddProfileResponse\"T\n" +
+ "\vprofileName\x18\x02 \x01(\tR\vprofileName\"$\n" +
+ "\x12AddProfileResponse\x12\x0e\n" +
+ "\x02id\x18\x01 \x01(\tR\x02id\"r\n" +
+ "\x14RenameProfileRequest\x12\x1a\n" +
+ "\busername\x18\x01 \x01(\tR\busername\x12\x16\n" +
+ "\x06handle\x18\x02 \x01(\tR\x06handle\x12&\n" +
+ "\x0enewProfileName\x18\x03 \x01(\tR\x0enewProfileName\"?\n" +
+ "\x15RenameProfileResponse\x12&\n" +
+ "\x0eoldProfileName\x18\x01 \x01(\tR\x0eoldProfileName\"T\n" +
"\x14RemoveProfileRequest\x12\x1a\n" +
"\busername\x18\x01 \x01(\tR\busername\x12 \n" +
- "\vprofileName\x18\x02 \x01(\tR\vprofileName\"\x17\n" +
- "\x15RemoveProfileResponse\"1\n" +
+ "\vprofileName\x18\x02 \x01(\tR\vprofileName\"'\n" +
+ "\x15RemoveProfileResponse\x12\x0e\n" +
+ "\x02id\x18\x01 \x01(\tR\x02id\"1\n" +
"\x13ListProfilesRequest\x12\x1a\n" +
"\busername\x18\x01 \x01(\tR\busername\"C\n" +
"\x14ListProfilesResponse\x12+\n" +
- "\bprofiles\x18\x01 \x03(\v2\x0f.daemon.ProfileR\bprofiles\":\n" +
+ "\bprofiles\x18\x01 \x03(\v2\x0f.daemon.ProfileR\bprofiles\"J\n" +
"\aProfile\x12\x12\n" +
"\x04name\x18\x01 \x01(\tR\x04name\x12\x1b\n" +
- "\tis_active\x18\x02 \x01(\bR\bisActive\"\x19\n" +
- "\x17GetActiveProfileRequest\"X\n" +
+ "\tis_active\x18\x02 \x01(\bR\bisActive\x12\x0e\n" +
+ "\x02id\x18\x03 \x01(\tR\x02id\"\x19\n" +
+ "\x17GetActiveProfileRequest\"h\n" +
"\x18GetActiveProfileResponse\x12 \n" +
"\vprofileName\x18\x01 \x01(\tR\vprofileName\x12\x1a\n" +
- "\busername\x18\x02 \x01(\tR\busername\"t\n" +
+ "\busername\x18\x02 \x01(\tR\busername\x12\x0e\n" +
+ "\x02id\x18\x03 \x01(\tR\x02id\"t\n" +
"\rLogoutRequest\x12%\n" +
"\vprofileName\x18\x01 \x01(\tH\x00R\vprofileName\x88\x01\x01\x12\x1f\n" +
"\busername\x18\x02 \x01(\tH\x01R\busername\x88\x01\x01B\x0e\n" +
@@ -6695,7 +6940,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" +
@@ -6793,7 +7040,7 @@ const file_daemon_proto_rawDesc = "" +
"\n" +
"EXPOSE_UDP\x10\x03\x12\x0e\n" +
"\n" +
- "EXPOSE_TLS\x10\x042\xaf\x17\n" +
+ "EXPOSE_TLS\x10\x042\xff\x17\n" +
"\rDaemonService\x126\n" +
"\x05Login\x12\x14.daemon.LoginRequest\x1a\x15.daemon.LoginResponse\"\x00\x12K\n" +
"\fWaitSSOLogin\x12\x1b.daemon.WaitSSOLoginRequest\x1a\x1c.daemon.WaitSSOLoginResponse\"\x00\x12-\n" +
@@ -6824,6 +7071,7 @@ const file_daemon_proto_rawDesc = "" +
"\tSetConfig\x12\x18.daemon.SetConfigRequest\x1a\x19.daemon.SetConfigResponse\"\x00\x12E\n" +
"\n" +
"AddProfile\x12\x19.daemon.AddProfileRequest\x1a\x1a.daemon.AddProfileResponse\"\x00\x12N\n" +
+ "\rRenameProfile\x12\x1c.daemon.RenameProfileRequest\x1a\x1d.daemon.RenameProfileResponse\"\x00\x12N\n" +
"\rRemoveProfile\x12\x1c.daemon.RemoveProfileRequest\x1a\x1d.daemon.RemoveProfileResponse\"\x00\x12K\n" +
"\fListProfiles\x12\x1b.daemon.ListProfilesRequest\x1a\x1c.daemon.ListProfilesResponse\"\x00\x12W\n" +
"\x10GetActiveProfile\x12\x1f.daemon.GetActiveProfileRequest\x1a .daemon.GetActiveProfileResponse\"\x00\x129\n" +
@@ -6851,7 +7099,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, 100)
var file_daemon_proto_goTypes = []any{
(LogLevel)(0), // 0: daemon.LogLevel
(ExposeProtocol)(0), // 1: daemon.ExposeProtocol
@@ -6917,52 +7165,55 @@ var file_daemon_proto_goTypes = []any{
(*SetConfigResponse)(nil), // 61: daemon.SetConfigResponse
(*AddProfileRequest)(nil), // 62: daemon.AddProfileRequest
(*AddProfileResponse)(nil), // 63: daemon.AddProfileResponse
- (*RemoveProfileRequest)(nil), // 64: daemon.RemoveProfileRequest
- (*RemoveProfileResponse)(nil), // 65: daemon.RemoveProfileResponse
- (*ListProfilesRequest)(nil), // 66: daemon.ListProfilesRequest
- (*ListProfilesResponse)(nil), // 67: daemon.ListProfilesResponse
- (*Profile)(nil), // 68: daemon.Profile
- (*GetActiveProfileRequest)(nil), // 69: daemon.GetActiveProfileRequest
- (*GetActiveProfileResponse)(nil), // 70: daemon.GetActiveProfileResponse
- (*LogoutRequest)(nil), // 71: daemon.LogoutRequest
- (*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
+ (*RenameProfileRequest)(nil), // 64: daemon.RenameProfileRequest
+ (*RenameProfileResponse)(nil), // 65: daemon.RenameProfileResponse
+ (*RemoveProfileRequest)(nil), // 66: daemon.RemoveProfileRequest
+ (*RemoveProfileResponse)(nil), // 67: daemon.RemoveProfileResponse
+ (*ListProfilesRequest)(nil), // 68: daemon.ListProfilesRequest
+ (*ListProfilesResponse)(nil), // 69: daemon.ListProfilesResponse
+ (*Profile)(nil), // 70: daemon.Profile
+ (*GetActiveProfileRequest)(nil), // 71: daemon.GetActiveProfileRequest
+ (*GetActiveProfileResponse)(nil), // 72: daemon.GetActiveProfileResponse
+ (*LogoutRequest)(nil), // 73: daemon.LogoutRequest
+ (*LogoutResponse)(nil), // 74: daemon.LogoutResponse
+ (*GetFeaturesRequest)(nil), // 75: daemon.GetFeaturesRequest
+ (*GetFeaturesResponse)(nil), // 76: daemon.GetFeaturesResponse
+ (*MDMManagedFieldsViolation)(nil), // 77: daemon.MDMManagedFieldsViolation
+ (*TriggerUpdateRequest)(nil), // 78: daemon.TriggerUpdateRequest
+ (*TriggerUpdateResponse)(nil), // 79: daemon.TriggerUpdateResponse
+ (*GetPeerSSHHostKeyRequest)(nil), // 80: daemon.GetPeerSSHHostKeyRequest
+ (*GetPeerSSHHostKeyResponse)(nil), // 81: daemon.GetPeerSSHHostKeyResponse
+ (*RequestJWTAuthRequest)(nil), // 82: daemon.RequestJWTAuthRequest
+ (*RequestJWTAuthResponse)(nil), // 83: daemon.RequestJWTAuthResponse
+ (*WaitJWTTokenRequest)(nil), // 84: daemon.WaitJWTTokenRequest
+ (*WaitJWTTokenResponse)(nil), // 85: daemon.WaitJWTTokenResponse
+ (*StartCPUProfileRequest)(nil), // 86: daemon.StartCPUProfileRequest
+ (*StartCPUProfileResponse)(nil), // 87: daemon.StartCPUProfileResponse
+ (*StopCPUProfileRequest)(nil), // 88: daemon.StopCPUProfileRequest
+ (*StopCPUProfileResponse)(nil), // 89: daemon.StopCPUProfileResponse
+ (*InstallerResultRequest)(nil), // 90: daemon.InstallerResultRequest
+ (*InstallerResultResponse)(nil), // 91: daemon.InstallerResultResponse
+ (*ExposeServiceRequest)(nil), // 92: daemon.ExposeServiceRequest
+ (*ExposeServiceEvent)(nil), // 93: daemon.ExposeServiceEvent
+ (*ExposeServiceReady)(nil), // 94: daemon.ExposeServiceReady
+ (*StartCaptureRequest)(nil), // 95: daemon.StartCaptureRequest
+ (*CapturePacket)(nil), // 96: daemon.CapturePacket
+ (*StartBundleCaptureRequest)(nil), // 97: daemon.StartBundleCaptureRequest
+ (*StartBundleCaptureResponse)(nil), // 98: daemon.StartBundleCaptureResponse
+ (*StopBundleCaptureRequest)(nil), // 99: daemon.StopBundleCaptureRequest
+ (*StopBundleCaptureResponse)(nil), // 100: daemon.StopBundleCaptureResponse
+ nil, // 101: daemon.Network.ResolvedIPsEntry
+ (*PortInfo_Range)(nil), // 102: daemon.PortInfo.Range
+ nil, // 103: daemon.SystemEvent.MetadataEntry
+ (*durationpb.Duration)(nil), // 104: google.protobuf.Duration
+ (*timestamppb.Timestamp)(nil), // 105: google.protobuf.Timestamp
}
var file_daemon_proto_depIdxs = []int32{
- 101, // 0: daemon.LoginRequest.dnsRouteInterval:type_name -> google.protobuf.Duration
+ 104, // 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
+ 105, // 2: daemon.PeerState.connStatusUpdate:type_name -> google.protobuf.Timestamp
+ 105, // 3: daemon.PeerState.lastWireguardHandshake:type_name -> google.protobuf.Timestamp
+ 104, // 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
@@ -6973,8 +7224,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
+ 101, // 15: daemon.Network.resolvedIPs:type_name -> daemon.Network.ResolvedIPsEntry
+ 102, // 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
@@ -6985,15 +7236,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
+ 105, // 27: daemon.SystemEvent.timestamp:type_name -> google.protobuf.Timestamp
+ 103, // 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
- 68, // 31: daemon.ListProfilesResponse.profiles:type_name -> daemon.Profile
+ 104, // 30: daemon.SetConfigRequest.dnsRouteInterval:type_name -> google.protobuf.Duration
+ 70, // 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
+ 94, // 33: daemon.ExposeServiceEvent.ready:type_name -> daemon.ExposeServiceReady
+ 104, // 34: daemon.StartCaptureRequest.duration:type_name -> google.protobuf.Duration
+ 104, // 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
@@ -7013,68 +7264,70 @@ 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
+ 95, // 55: daemon.DaemonService.StartCapture:input_type -> daemon.StartCaptureRequest
+ 97, // 56: daemon.DaemonService.StartBundleCapture:input_type -> daemon.StartBundleCaptureRequest
+ 99, // 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
60, // 61: daemon.DaemonService.SetConfig:input_type -> daemon.SetConfigRequest
62, // 62: daemon.DaemonService.AddProfile:input_type -> daemon.AddProfileRequest
- 64, // 63: daemon.DaemonService.RemoveProfile:input_type -> daemon.RemoveProfileRequest
- 66, // 64: daemon.DaemonService.ListProfiles:input_type -> daemon.ListProfilesRequest
- 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
- 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
- 12, // 79: daemon.DaemonService.Status:output_type -> daemon.StatusResponse
- 14, // 80: daemon.DaemonService.Down:output_type -> daemon.DownResponse
- 16, // 81: daemon.DaemonService.GetConfig:output_type -> daemon.GetConfigResponse
- 27, // 82: daemon.DaemonService.ListNetworks:output_type -> daemon.ListNetworksResponse
- 29, // 83: daemon.DaemonService.SelectNetworks:output_type -> daemon.SelectNetworksResponse
- 29, // 84: daemon.DaemonService.DeselectNetworks:output_type -> daemon.SelectNetworksResponse
- 34, // 85: daemon.DaemonService.ForwardingRules:output_type -> daemon.ForwardingRulesResponse
- 36, // 86: daemon.DaemonService.DebugBundle:output_type -> daemon.DebugBundleResponse
- 38, // 87: daemon.DaemonService.GetLogLevel:output_type -> daemon.GetLogLevelResponse
- 40, // 88: daemon.DaemonService.SetLogLevel:output_type -> daemon.SetLogLevelResponse
- 43, // 89: daemon.DaemonService.ListStates:output_type -> daemon.ListStatesResponse
- 45, // 90: daemon.DaemonService.CleanState:output_type -> daemon.CleanStateResponse
- 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
- 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
- 61, // 100: daemon.DaemonService.SetConfig:output_type -> daemon.SetConfigResponse
- 63, // 101: daemon.DaemonService.AddProfile:output_type -> daemon.AddProfileResponse
- 65, // 102: daemon.DaemonService.RemoveProfile:output_type -> daemon.RemoveProfileResponse
- 67, // 103: daemon.DaemonService.ListProfiles:output_type -> daemon.ListProfilesResponse
- 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
- 76, // [76:115] is the sub-list for method output_type
- 37, // [37:76] is the sub-list for method input_type
+ 64, // 63: daemon.DaemonService.RenameProfile:input_type -> daemon.RenameProfileRequest
+ 66, // 64: daemon.DaemonService.RemoveProfile:input_type -> daemon.RemoveProfileRequest
+ 68, // 65: daemon.DaemonService.ListProfiles:input_type -> daemon.ListProfilesRequest
+ 71, // 66: daemon.DaemonService.GetActiveProfile:input_type -> daemon.GetActiveProfileRequest
+ 73, // 67: daemon.DaemonService.Logout:input_type -> daemon.LogoutRequest
+ 75, // 68: daemon.DaemonService.GetFeatures:input_type -> daemon.GetFeaturesRequest
+ 78, // 69: daemon.DaemonService.TriggerUpdate:input_type -> daemon.TriggerUpdateRequest
+ 80, // 70: daemon.DaemonService.GetPeerSSHHostKey:input_type -> daemon.GetPeerSSHHostKeyRequest
+ 82, // 71: daemon.DaemonService.RequestJWTAuth:input_type -> daemon.RequestJWTAuthRequest
+ 84, // 72: daemon.DaemonService.WaitJWTToken:input_type -> daemon.WaitJWTTokenRequest
+ 86, // 73: daemon.DaemonService.StartCPUProfile:input_type -> daemon.StartCPUProfileRequest
+ 88, // 74: daemon.DaemonService.StopCPUProfile:input_type -> daemon.StopCPUProfileRequest
+ 90, // 75: daemon.DaemonService.GetInstallerResult:input_type -> daemon.InstallerResultRequest
+ 92, // 76: daemon.DaemonService.ExposeService:input_type -> daemon.ExposeServiceRequest
+ 6, // 77: daemon.DaemonService.Login:output_type -> daemon.LoginResponse
+ 8, // 78: daemon.DaemonService.WaitSSOLogin:output_type -> daemon.WaitSSOLoginResponse
+ 10, // 79: daemon.DaemonService.Up:output_type -> daemon.UpResponse
+ 12, // 80: daemon.DaemonService.Status:output_type -> daemon.StatusResponse
+ 14, // 81: daemon.DaemonService.Down:output_type -> daemon.DownResponse
+ 16, // 82: daemon.DaemonService.GetConfig:output_type -> daemon.GetConfigResponse
+ 27, // 83: daemon.DaemonService.ListNetworks:output_type -> daemon.ListNetworksResponse
+ 29, // 84: daemon.DaemonService.SelectNetworks:output_type -> daemon.SelectNetworksResponse
+ 29, // 85: daemon.DaemonService.DeselectNetworks:output_type -> daemon.SelectNetworksResponse
+ 34, // 86: daemon.DaemonService.ForwardingRules:output_type -> daemon.ForwardingRulesResponse
+ 36, // 87: daemon.DaemonService.DebugBundle:output_type -> daemon.DebugBundleResponse
+ 38, // 88: daemon.DaemonService.GetLogLevel:output_type -> daemon.GetLogLevelResponse
+ 40, // 89: daemon.DaemonService.SetLogLevel:output_type -> daemon.SetLogLevelResponse
+ 43, // 90: daemon.DaemonService.ListStates:output_type -> daemon.ListStatesResponse
+ 45, // 91: daemon.DaemonService.CleanState:output_type -> daemon.CleanStateResponse
+ 47, // 92: daemon.DaemonService.DeleteState:output_type -> daemon.DeleteStateResponse
+ 49, // 93: daemon.DaemonService.SetSyncResponsePersistence:output_type -> daemon.SetSyncResponsePersistenceResponse
+ 53, // 94: daemon.DaemonService.TracePacket:output_type -> daemon.TracePacketResponse
+ 96, // 95: daemon.DaemonService.StartCapture:output_type -> daemon.CapturePacket
+ 98, // 96: daemon.DaemonService.StartBundleCapture:output_type -> daemon.StartBundleCaptureResponse
+ 100, // 97: daemon.DaemonService.StopBundleCapture:output_type -> daemon.StopBundleCaptureResponse
+ 55, // 98: daemon.DaemonService.SubscribeEvents:output_type -> daemon.SystemEvent
+ 57, // 99: daemon.DaemonService.GetEvents:output_type -> daemon.GetEventsResponse
+ 59, // 100: daemon.DaemonService.SwitchProfile:output_type -> daemon.SwitchProfileResponse
+ 61, // 101: daemon.DaemonService.SetConfig:output_type -> daemon.SetConfigResponse
+ 63, // 102: daemon.DaemonService.AddProfile:output_type -> daemon.AddProfileResponse
+ 65, // 103: daemon.DaemonService.RenameProfile:output_type -> daemon.RenameProfileResponse
+ 67, // 104: daemon.DaemonService.RemoveProfile:output_type -> daemon.RemoveProfileResponse
+ 69, // 105: daemon.DaemonService.ListProfiles:output_type -> daemon.ListProfilesResponse
+ 72, // 106: daemon.DaemonService.GetActiveProfile:output_type -> daemon.GetActiveProfileResponse
+ 74, // 107: daemon.DaemonService.Logout:output_type -> daemon.LogoutResponse
+ 76, // 108: daemon.DaemonService.GetFeatures:output_type -> daemon.GetFeaturesResponse
+ 79, // 109: daemon.DaemonService.TriggerUpdate:output_type -> daemon.TriggerUpdateResponse
+ 81, // 110: daemon.DaemonService.GetPeerSSHHostKey:output_type -> daemon.GetPeerSSHHostKeyResponse
+ 83, // 111: daemon.DaemonService.RequestJWTAuth:output_type -> daemon.RequestJWTAuthResponse
+ 85, // 112: daemon.DaemonService.WaitJWTToken:output_type -> daemon.WaitJWTTokenResponse
+ 87, // 113: daemon.DaemonService.StartCPUProfile:output_type -> daemon.StartCPUProfileResponse
+ 89, // 114: daemon.DaemonService.StopCPUProfile:output_type -> daemon.StopCPUProfileResponse
+ 91, // 115: daemon.DaemonService.GetInstallerResult:output_type -> daemon.InstallerResultResponse
+ 93, // 116: daemon.DaemonService.ExposeService:output_type -> daemon.ExposeServiceEvent
+ 77, // [77:117] is the sub-list for method output_type
+ 37, // [37:77] is the sub-list for method input_type
37, // [37:37] is the sub-list for extension type_name
37, // [37:37] is the sub-list for extension extendee
0, // [0:37] is the sub-list for field type_name
@@ -7096,9 +7349,9 @@ func file_daemon_proto_init() {
file_daemon_proto_msgTypes[48].OneofWrappers = []any{}
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[69].OneofWrappers = []any{}
+ file_daemon_proto_msgTypes[78].OneofWrappers = []any{}
+ file_daemon_proto_msgTypes[89].OneofWrappers = []any{
(*ExposeServiceEvent_Ready)(nil),
}
type x struct{}
@@ -7107,7 +7360,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: 100,
NumExtensions: 0,
NumServices: 1,
},
diff --git a/client/proto/daemon.proto b/client/proto/daemon.proto
index 6982e4a1c..c1e3fe513 100644
--- a/client/proto/daemon.proto
+++ b/client/proto/daemon.proto
@@ -85,6 +85,8 @@ service DaemonService {
rpc AddProfile(AddProfileRequest) returns (AddProfileResponse) {}
+ rpc RenameProfile(RenameProfileRequest) returns (RenameProfileResponse) {}
+
rpc RemoveProfile(RemoveProfileRequest) returns (RemoveProfileResponse) {}
rpc ListProfiles(ListProfilesRequest) returns (ListProfilesResponse) {}
@@ -314,6 +316,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
@@ -371,6 +380,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 {
@@ -615,11 +627,18 @@ message GetEventsResponse {
}
message SwitchProfileRequest {
+ // profileName is treated as a handle: exact ID, unique ID prefix, or
+ // unique display name. The daemon resolves it server-side.
optional string profileName = 1;
optional string username = 2;
}
-message SwitchProfileResponse {}
+message SwitchProfileResponse {
+ // id is the resolved on-disk ID of the profile that became active.
+ // Lets CLI clients update their local active-profile state without
+ // duplicating the resolution logic.
+ string id = 1;
+}
message SetConfigRequest {
string username = 1;
@@ -686,17 +705,42 @@ message SetConfigResponse{}
message AddProfileRequest {
string username = 1;
+ // profileName carries the human-readable display name for the new
+ // profile. The on-disk filename is a separately-generated ID.
string profileName = 2;
}
-message AddProfileResponse {}
+message AddProfileResponse {
+ // id is the generated on-disk ID of the new profile. CLI clients
+ // display a truncated form, UI clients can ignore it.
+ string id = 1;
+}
+
+message RenameProfileRequest {
+ string username = 1;
+ // handle: an exact ID, a unique ID prefix, or a unique display name.
+ string handle = 2;
+ // newProfileName is the new human-readable display name for the profile.
+ string newProfileName = 3;
+}
+
+message RenameProfileResponse {
+ // confirm the old profile name after resolving handle.
+ string oldProfileName = 1;
+}
message RemoveProfileRequest {
string username = 1;
+ // profileName is treated as a handle: an exact ID, a unique ID
+ // prefix, or a unique display name. Resolution happens server-side.
string profileName = 2;
}
-message RemoveProfileResponse {}
+message RemoveProfileResponse {
+ // id is the full resolved ID of the removed profile, so callers can
+ // confirm exactly which profile a name/prefix handle resolved to.
+ string id = 1;
+}
message ListProfilesRequest {
string username = 1;
@@ -709,6 +753,7 @@ message ListProfilesResponse {
message Profile {
string name = 1;
bool is_active = 2;
+ string id = 3;
}
message GetActiveProfileRequest {}
@@ -716,6 +761,7 @@ message GetActiveProfileRequest {}
message GetActiveProfileResponse {
string profileName = 1;
string username = 2;
+ string id = 3;
}
message LogoutRequest {
@@ -733,6 +779,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/daemon_grpc.pb.go b/client/proto/daemon_grpc.pb.go
index 66a8efcc3..5f585aafc 100644
--- a/client/proto/daemon_grpc.pb.go
+++ b/client/proto/daemon_grpc.pb.go
@@ -45,6 +45,7 @@ const (
DaemonService_SwitchProfile_FullMethodName = "/daemon.DaemonService/SwitchProfile"
DaemonService_SetConfig_FullMethodName = "/daemon.DaemonService/SetConfig"
DaemonService_AddProfile_FullMethodName = "/daemon.DaemonService/AddProfile"
+ DaemonService_RenameProfile_FullMethodName = "/daemon.DaemonService/RenameProfile"
DaemonService_RemoveProfile_FullMethodName = "/daemon.DaemonService/RemoveProfile"
DaemonService_ListProfiles_FullMethodName = "/daemon.DaemonService/ListProfiles"
DaemonService_GetActiveProfile_FullMethodName = "/daemon.DaemonService/GetActiveProfile"
@@ -112,6 +113,7 @@ type DaemonServiceClient interface {
SwitchProfile(ctx context.Context, in *SwitchProfileRequest, opts ...grpc.CallOption) (*SwitchProfileResponse, error)
SetConfig(ctx context.Context, in *SetConfigRequest, opts ...grpc.CallOption) (*SetConfigResponse, error)
AddProfile(ctx context.Context, in *AddProfileRequest, opts ...grpc.CallOption) (*AddProfileResponse, error)
+ RenameProfile(ctx context.Context, in *RenameProfileRequest, opts ...grpc.CallOption) (*RenameProfileResponse, error)
RemoveProfile(ctx context.Context, in *RemoveProfileRequest, opts ...grpc.CallOption) (*RemoveProfileResponse, error)
ListProfiles(ctx context.Context, in *ListProfilesRequest, opts ...grpc.CallOption) (*ListProfilesResponse, error)
GetActiveProfile(ctx context.Context, in *GetActiveProfileRequest, opts ...grpc.CallOption) (*GetActiveProfileResponse, error)
@@ -422,6 +424,16 @@ func (c *daemonServiceClient) AddProfile(ctx context.Context, in *AddProfileRequ
return out, nil
}
+func (c *daemonServiceClient) RenameProfile(ctx context.Context, in *RenameProfileRequest, opts ...grpc.CallOption) (*RenameProfileResponse, error) {
+ cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
+ out := new(RenameProfileResponse)
+ err := c.cc.Invoke(ctx, DaemonService_RenameProfile_FullMethodName, in, out, cOpts...)
+ if err != nil {
+ return nil, err
+ }
+ return out, nil
+}
+
func (c *daemonServiceClient) RemoveProfile(ctx context.Context, in *RemoveProfileRequest, opts ...grpc.CallOption) (*RemoveProfileResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(RemoveProfileResponse)
@@ -613,6 +625,7 @@ type DaemonServiceServer interface {
SwitchProfile(context.Context, *SwitchProfileRequest) (*SwitchProfileResponse, error)
SetConfig(context.Context, *SetConfigRequest) (*SetConfigResponse, error)
AddProfile(context.Context, *AddProfileRequest) (*AddProfileResponse, error)
+ RenameProfile(context.Context, *RenameProfileRequest) (*RenameProfileResponse, error)
RemoveProfile(context.Context, *RemoveProfileRequest) (*RemoveProfileResponse, error)
ListProfiles(context.Context, *ListProfilesRequest) (*ListProfilesResponse, error)
GetActiveProfile(context.Context, *GetActiveProfileRequest) (*GetActiveProfileResponse, error)
@@ -723,6 +736,9 @@ func (UnimplementedDaemonServiceServer) SetConfig(context.Context, *SetConfigReq
func (UnimplementedDaemonServiceServer) AddProfile(context.Context, *AddProfileRequest) (*AddProfileResponse, error) {
return nil, status.Error(codes.Unimplemented, "method AddProfile not implemented")
}
+func (UnimplementedDaemonServiceServer) RenameProfile(context.Context, *RenameProfileRequest) (*RenameProfileResponse, error) {
+ return nil, status.Error(codes.Unimplemented, "method RenameProfile not implemented")
+}
func (UnimplementedDaemonServiceServer) RemoveProfile(context.Context, *RemoveProfileRequest) (*RemoveProfileResponse, error) {
return nil, status.Error(codes.Unimplemented, "method RemoveProfile not implemented")
}
@@ -1237,6 +1253,24 @@ func _DaemonService_AddProfile_Handler(srv interface{}, ctx context.Context, dec
return interceptor(ctx, in, info, handler)
}
+func _DaemonService_RenameProfile_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
+ in := new(RenameProfileRequest)
+ if err := dec(in); err != nil {
+ return nil, err
+ }
+ if interceptor == nil {
+ return srv.(DaemonServiceServer).RenameProfile(ctx, in)
+ }
+ info := &grpc.UnaryServerInfo{
+ Server: srv,
+ FullMethod: DaemonService_RenameProfile_FullMethodName,
+ }
+ handler := func(ctx context.Context, req interface{}) (interface{}, error) {
+ return srv.(DaemonServiceServer).RenameProfile(ctx, req.(*RenameProfileRequest))
+ }
+ return interceptor(ctx, in, info, handler)
+}
+
func _DaemonService_RemoveProfile_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(RemoveProfileRequest)
if err := dec(in); err != nil {
@@ -1567,6 +1601,10 @@ var DaemonService_ServiceDesc = grpc.ServiceDesc{
MethodName: "AddProfile",
Handler: _DaemonService_AddProfile_Handler,
},
+ {
+ MethodName: "RenameProfile",
+ Handler: _DaemonService_RenameProfile_Handler,
+ },
{
MethodName: "RemoveProfile",
Handler: _DaemonService_RemoveProfile_Handler,
diff --git a/client/server/login_overrides_test.go b/client/server/login_overrides_test.go
index c45557c59..5a2298764 100644
--- a/client/server/login_overrides_test.go
+++ b/client/server/login_overrides_test.go
@@ -79,7 +79,7 @@ func TestPersistLoginOverrides(t *testing.T) {
_, err := profilemanager.UpdateOrCreateConfig(seed)
require.NoError(t, err, "seed config")
- activeProf := &profilemanager.ActiveProfileState{Name: "default"}
+ activeProf := &profilemanager.ActiveProfileState{ID: "default"}
err = persistLoginOverrides(activeProf, tt.newMgmtURL, tt.newPSK)
require.NoError(t, err, "persistLoginOverrides")
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..3f6dabc56 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)
+ }
}
- profState := profilemanager.ActiveProfileState{
- Name: msg.ProfileName,
- Username: msg.Username,
+ // 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
}
- profPath, err := profState.FilePath()
+ config, err := s.setConfigInputFromRequest(msg)
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 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 (s *Server) setConfigInputFromRequest(msg *proto.SetConfigRequest) (profilemanager.ConfigInput, error) {
var config profilemanager.ConfigInput
+ resolved, err := s.resolveProfileHandle(msg.ProfileName, msg.Username)
+ if err != nil {
+ log.Errorf("failed to resolve profile %q: %v", msg.ProfileName, err)
+ return config, err
+ }
+ profPath := resolved.Path
+ if profPath == "" {
+ profPath = profilemanager.DefaultConfigPath
+ }
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()
@@ -446,30 +535,9 @@ func (s *Server) Login(callerCtx context.Context, msg *proto.LoginRequest) (*pro
}
if msg.ProfileName != nil {
- if *msg.ProfileName != "default" && (msg.Username == nil || *msg.Username == "") {
- log.Errorf("profile name is set to %s, but username is not provided", *msg.ProfileName)
- return nil, fmt.Errorf("profile name is set to %s, but username is not provided", *msg.ProfileName)
- }
-
- var username string
- if *msg.ProfileName != "default" {
- username = *msg.Username
- }
-
- if *msg.ProfileName != activeProf.Name && username != activeProf.Username {
- if s.checkProfilesDisabled() {
- log.Errorf("profiles are disabled, you cannot use this feature without profiles enabled")
- return nil, gstatus.Errorf(codes.Unavailable, errProfilesDisabled)
- }
-
- log.Infof("switching to profile %s for user '%s'", *msg.ProfileName, username)
- if err := s.profileManager.SetActiveProfileState(&profilemanager.ActiveProfileState{
- Name: *msg.ProfileName,
- Username: username,
- }); err != nil {
- log.Errorf("failed to set active profile state: %v", err)
- return nil, fmt.Errorf("failed to set active profile state: %w", err)
- }
+ if _, err := s.switchProfileIfNeeded(*msg.ProfileName, msg.Username, activeProf); err != nil {
+ log.Errorf("failed to switch profile: %v", err)
+ return nil, err
}
}
@@ -479,7 +547,7 @@ func (s *Server) Login(callerCtx context.Context, msg *proto.LoginRequest) (*pro
return nil, fmt.Errorf("failed to get active profile state: %w", err)
}
- log.Infof("active profile: %s for %s", activeProf.Name, activeProf.Username)
+ log.Infof("active profile: %s for %s", activeProf.ID, activeProf.Username)
s.mutex.Lock()
@@ -652,7 +720,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 {
@@ -711,10 +785,10 @@ func (s *Server) Up(callerCtx context.Context, msg *proto.UpRequest) (*proto.UpR
}
if msg != nil && msg.ProfileName != nil {
- if err := s.switchProfileIfNeeded(*msg.ProfileName, msg.Username, activeProf); err != nil {
+ if _, err := s.switchProfileIfNeeded(*msg.ProfileName, msg.Username, activeProf); err != nil {
s.mutex.Unlock()
log.Errorf("failed to switch profile: %v", err)
- return nil, fmt.Errorf("failed to switch profile: %w", err)
+ return nil, err
}
}
@@ -725,7 +799,7 @@ func (s *Server) Up(callerCtx context.Context, msg *proto.UpRequest) (*proto.UpR
return nil, fmt.Errorf("failed to get active profile state: %w", err)
}
- log.Infof("active profile: %s for %s", activeProf.Name, activeProf.Username)
+ log.Infof("active profile: %s for %s", activeProf.ID, activeProf.Username)
config, _, err := s.getConfig(activeProf)
if err != nil {
@@ -743,6 +817,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)
@@ -768,34 +843,60 @@ func (s *Server) waitForUp(callerCtx context.Context) (*proto.UpResponse, error)
}
}
-func (s *Server) switchProfileIfNeeded(profileName string, userName *string, activeProf *profilemanager.ActiveProfileState) error {
- if profileName != "default" && (userName == nil || *userName == "") {
- log.Errorf("profile name is set to %s, but username is not provided", profileName)
- return fmt.Errorf("profile name is set to %s, but username is not provided", profileName)
+// resolveProfileHandle resolves a wire-level profile handle (display
+// name, ID, or unique ID prefix) to a concrete profile. Returns gRPC
+// status errors so handlers can return them directly.
+func (s *Server) resolveProfileHandle(handle, username string) (*profilemanager.Profile, error) {
+ p, err := s.profileManager.ResolveProfile(handle, username)
+ if err == nil {
+ return p, nil
+ }
+ var amb *profilemanager.ErrAmbiguousHandle
+ if errors.As(err, &amb) {
+ return nil, gstatus.Errorf(codes.InvalidArgument, "%v", amb)
+ }
+ if errors.Is(err, profilemanager.ErrProfileNotFound) {
+ return nil, gstatus.Errorf(codes.NotFound, "profile %q not found", handle)
+ }
+ return nil, fmt.Errorf("resolve profile: %w", err)
+}
+
+// switchProfileIfNeeded resolves the user-supplied handle, updates the
+// active profile state if it differs from the current one, and returns
+// the resolved profile so callers can include its ID in RPC responses.
+func (s *Server) switchProfileIfNeeded(handle string, userName *string, activeProf *profilemanager.ActiveProfileState) (*profilemanager.Profile, error) {
+ if handle != profilemanager.DefaultProfileName && (userName == nil || *userName == "") {
+ log.Errorf("profile name is set to %s, but username is not provided", handle)
+ return nil, fmt.Errorf("profile name is set to %s, but username is not provided", handle)
}
var username string
- if profileName != "default" {
+ if handle != profilemanager.DefaultProfileName {
username = *userName
}
- if profileName != activeProf.Name || username != activeProf.Username {
+ resolved, err := s.resolveProfileHandle(handle, username)
+ if err != nil {
+ return nil, err
+ }
+
+ if resolved.ID != activeProf.ID || username != activeProf.Username {
if s.checkProfilesDisabled() {
log.Errorf("profiles are disabled, you cannot use this feature without profiles enabled")
- return gstatus.Errorf(codes.Unavailable, errProfilesDisabled)
+ return nil, gstatus.Errorf(codes.Unavailable, errProfilesDisabled)
}
- log.Infof("switching to profile %s for user %s", profileName, username)
+ log.Infof("switching to profile %s (%s) for user %s", resolved.Name, resolved.ID, username)
if err := s.profileManager.SetActiveProfileState(&profilemanager.ActiveProfileState{
- Name: profileName,
+ ID: resolved.ID,
Username: username,
}); err != nil {
log.Errorf("failed to set active profile state: %v", err)
- return fmt.Errorf("failed to set active profile state: %w", err)
+ return nil, fmt.Errorf("failed to set active profile state: %w", err)
}
}
- return nil
+ return resolved, nil
}
// SwitchProfile switches the active profile in the daemon.
@@ -810,9 +911,9 @@ func (s *Server) SwitchProfile(callerCtx context.Context, msg *proto.SwitchProfi
}
if msg != nil && msg.ProfileName != nil {
- if err := s.switchProfileIfNeeded(*msg.ProfileName, msg.Username, activeProf); err != nil {
+ if _, err := s.switchProfileIfNeeded(*msg.ProfileName, msg.Username, activeProf); err != nil {
log.Errorf("failed to switch profile: %v", err)
- return nil, fmt.Errorf("failed to switch profile: %w", err)
+ return nil, err
}
}
activeProf, err = s.profileManager.GetActiveProfileState()
@@ -828,7 +929,7 @@ func (s *Server) SwitchProfile(callerCtx context.Context, msg *proto.SwitchProfi
s.config = config
- return &proto.SwitchProfileResponse{}, nil
+ return &proto.SwitchProfileResponse{Id: activeProf.ID.String()}, nil
}
// Down engine work in the daemon.
@@ -871,6 +972,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()
@@ -886,6 +993,10 @@ func (s *Server) cleanupConnection() error {
return nil
}
+ // TODO: consider calling s.connectClient.Stop() instead of engine.Stop().
+ // actCancel() lets the run loop stop the engine too, so both stop it
+ // concurrently; ConnectClient.Stop cancels and waits for the run loop,
+ // making the run loop the sole owner of engine shutdown.
if engine != nil {
if err := engine.Stop(); err != nil {
return err
@@ -912,22 +1023,27 @@ func (s *Server) Logout(ctx context.Context, msg *proto.LogoutRequest) (*proto.L
}
func (s *Server) handleProfileLogout(ctx context.Context, msg *proto.LogoutRequest) (*proto.LogoutResponse, error) {
- if err := s.validateProfileOperation(*msg.ProfileName, true); err != nil {
- return nil, err
- }
-
if msg.Username == nil || *msg.Username == "" {
return nil, gstatus.Errorf(codes.InvalidArgument, "username must be provided when profile name is specified")
}
username := *msg.Username
- if err := s.logoutFromProfile(ctx, *msg.ProfileName, username); err != nil {
- log.Errorf("failed to logout from profile %s: %v", *msg.ProfileName, err)
+ resolved, err := s.resolveProfileHandle(*msg.ProfileName, username)
+ if err != nil {
+ return nil, err
+ }
+
+ if err := s.validateProfileOperation(resolved.ID, true); err != nil {
+ return nil, err
+ }
+
+ if err := s.logoutFromProfile(ctx, resolved); err != nil {
+ log.Errorf("failed to logout from profile %s: %v", resolved.ID, err)
return nil, gstatus.Errorf(codes.Internal, "logout: %v", err)
}
activeProf, _ := s.profileManager.GetActiveProfileState()
- if activeProf != nil && activeProf.Name == *msg.ProfileName {
+ if activeProf != nil && activeProf.ID == resolved.ID {
if err := s.cleanupConnection(); err != nil && !errors.Is(err, ErrServiceNotUp) {
log.Errorf("failed to cleanup connection: %v", err)
}
@@ -989,30 +1105,30 @@ func (s *Server) getConfig(activeProf *profilemanager.ActiveProfileState) (*prof
return config, configExisted, nil
}
-func (s *Server) canRemoveProfile(profileName string) error {
- if profileName == profilemanager.DefaultProfileName {
+func (s *Server) canRemoveProfile(id profilemanager.ID) error {
+ if id == profilemanager.DefaultProfileName {
return fmt.Errorf("remove profile with reserved name: %s", profilemanager.DefaultProfileName)
}
activeProf, err := s.profileManager.GetActiveProfileState()
- if err == nil && activeProf.Name == profileName {
- return fmt.Errorf("remove active profile: %s", profileName)
+ if err == nil && activeProf.ID == id {
+ return fmt.Errorf("remove active profile: %s", id)
}
return nil
}
-func (s *Server) validateProfileOperation(profileName string, allowActiveProfile bool) error {
+func (s *Server) validateProfileOperation(id profilemanager.ID, allowActiveProfile bool) error {
if s.checkProfilesDisabled() {
return gstatus.Errorf(codes.Unavailable, errProfilesDisabled)
}
- if profileName == "" {
+ if id == "" {
return gstatus.Errorf(codes.InvalidArgument, "profile name must be provided")
}
if !allowActiveProfile {
- if err := s.canRemoveProfile(profileName); err != nil {
+ if err := s.canRemoveProfile(id); err != nil {
return gstatus.Errorf(codes.InvalidArgument, "%v", err)
}
}
@@ -1020,25 +1136,20 @@ func (s *Server) validateProfileOperation(profileName string, allowActiveProfile
return nil
}
-// logoutFromProfile logs out from a specific profile by loading its config and sending logout request
-func (s *Server) logoutFromProfile(ctx context.Context, profileName, username string) error {
+func (s *Server) logoutFromProfile(ctx context.Context, profile *profilemanager.Profile) error {
activeProf, err := s.profileManager.GetActiveProfileState()
- if err == nil && activeProf.Name == profileName && s.connectClient != nil {
+ if err == nil && activeProf.ID == profile.ID && s.connectClient != nil {
return s.sendLogoutRequest(ctx)
}
- profileState := &profilemanager.ActiveProfileState{
- Name: profileName,
- Username: username,
- }
- profilePath, err := profileState.FilePath()
- if err != nil {
- return fmt.Errorf("get profile path: %w", err)
+ cfgPath := profile.Path
+ if cfgPath == "" {
+ cfgPath = profilemanager.DefaultConfigPath
}
- config, err := profilemanager.GetConfig(profilePath)
+ config, err := profilemanager.GetConfig(cfgPath)
if err != nil {
- return fmt.Errorf("profile '%s' not found", profileName)
+ return fmt.Errorf("profile '%s' not found", profile.ID)
}
return s.sendLogoutRequestWithConfig(ctx, config)
@@ -1074,10 +1185,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 {
@@ -1452,15 +1567,14 @@ func (s *Server) GetConfig(ctx context.Context, req *proto.GetConfigRequest) (*p
return nil, ctx.Err()
}
- prof := profilemanager.ActiveProfileState{
- Name: req.ProfileName,
- Username: req.Username,
- }
-
- cfgPath, err := prof.FilePath()
+ resolved, err := s.resolveProfileHandle(req.ProfileName, req.Username)
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)
+ log.Errorf("failed to resolve profile %q: %v", req.ProfileName, err)
+ return nil, err
+ }
+ cfgPath := resolved.Path
+ if cfgPath == "" {
+ cfgPath = profilemanager.DefaultConfigPath
}
cfg, err := profilemanager.GetConfig(cfgPath)
@@ -1548,6 +1662,7 @@ func (s *Server) GetConfig(ctx context.Context, req *proto.GetConfigRequest) (*p
EnableSSHRemotePortForwarding: enableSSHRemotePortForwarding,
DisableSSHAuth: disableSSHAuth,
SshJWTCacheTTL: sshJWTCacheTTL,
+ MDMManagedFields: cfg.Policy().ManagedKeys(),
}, nil
}
@@ -1564,12 +1679,39 @@ func (s *Server) AddProfile(ctx context.Context, msg *proto.AddProfileRequest) (
return nil, gstatus.Errorf(codes.InvalidArgument, "profile name and username must be provided")
}
- if err := s.profileManager.AddProfile(msg.ProfileName, msg.Username); err != nil {
+ created, err := s.profileManager.AddProfile(msg.ProfileName, msg.Username)
+ if err != nil {
log.Errorf("failed to create profile: %v", err)
return nil, fmt.Errorf("failed to create profile: %w", err)
}
- return &proto.AddProfileResponse{}, nil
+ return &proto.AddProfileResponse{Id: created.ID.String()}, nil
+}
+
+func (s *Server) RenameProfile(ctx context.Context, msg *proto.RenameProfileRequest) (*proto.RenameProfileResponse, error) {
+ s.mutex.Lock()
+ defer s.mutex.Unlock()
+
+ if s.checkProfilesDisabled() {
+ return nil, gstatus.Errorf(codes.Unavailable, errProfilesDisabled)
+ }
+
+ if msg.Handle == "" || msg.Username == "" || msg.NewProfileName == "" {
+ return nil, gstatus.Errorf(codes.InvalidArgument, "profile name, username and new profile name must be provided")
+ }
+
+ resolved, err := s.resolveProfileHandle(msg.Handle, msg.Username)
+ if err != nil {
+ return nil, err
+ }
+
+ err = s.profileManager.RenameProfile(resolved.ID, msg.Username, msg.NewProfileName)
+ if err != nil {
+ log.Errorf("failed to rename profile: %v", err)
+ return nil, fmt.Errorf("failed to rename profile: %w", err)
+ }
+
+ return &proto.RenameProfileResponse{OldProfileName: resolved.Name}, nil
}
// RemoveProfile removes a profile from the daemon.
@@ -1577,20 +1719,29 @@ func (s *Server) RemoveProfile(ctx context.Context, msg *proto.RemoveProfileRequ
s.mutex.Lock()
defer s.mutex.Unlock()
- if err := s.validateProfileOperation(msg.ProfileName, false); err != nil {
+ if s.checkProfilesDisabled() {
+ return nil, gstatus.Errorf(codes.Unavailable, errProfilesDisabled)
+ }
+
+ if msg.ProfileName == "" {
+ return nil, gstatus.Errorf(codes.InvalidArgument, "profile name must be provided")
+ }
+
+ resolved, err := s.resolveProfileHandle(msg.ProfileName, msg.Username)
+ if err != nil {
return nil, err
}
- if err := s.logoutFromProfile(ctx, msg.ProfileName, msg.Username); err != nil {
- log.Warnf("failed to logout from profile %s before removal: %v", msg.ProfileName, err)
+ if err := s.logoutFromProfile(ctx, resolved); err != nil {
+ log.Warnf("failed to logout from profile %s before removal: %v", resolved.ID, err)
}
- if err := s.profileManager.RemoveProfile(msg.ProfileName, msg.Username); err != nil {
+ if err := s.profileManager.RemoveProfile(resolved.ID, msg.Username); err != nil {
log.Errorf("failed to remove profile: %v", err)
return nil, fmt.Errorf("failed to remove profile: %w", err)
}
- return &proto.RemoveProfileResponse{}, nil
+ return &proto.RemoveProfileResponse{Id: resolved.ID.String()}, nil
}
// ListProfiles lists all profiles in the daemon.
@@ -1613,6 +1764,7 @@ func (s *Server) ListProfiles(ctx context.Context, msg *proto.ListProfilesReques
}
for i, profile := range profiles {
response.Profiles[i] = &proto.Profile{
+ Id: profile.ID.String(),
Name: profile.Name,
IsActive: profile.IsActive,
}
@@ -1621,7 +1773,9 @@ func (s *Server) ListProfiles(ctx context.Context, msg *proto.ListProfilesReques
return response, nil
}
-// GetActiveProfile returns the active profile in the daemon.
+// GetActiveProfile returns the active profile in the daemon. The ProfileName
+// field carries the display name for backwards compatibility with UI clients,
+// new callers should prefer Id.
func (s *Server) GetActiveProfile(ctx context.Context, msg *proto.GetActiveProfileRequest) (*proto.GetActiveProfileResponse, error) {
s.mutex.Lock()
defer s.mutex.Unlock()
@@ -1632,9 +1786,23 @@ func (s *Server) GetActiveProfile(ctx context.Context, msg *proto.GetActiveProfi
return nil, fmt.Errorf("failed to get active profile state: %w", err)
}
+ // Fallback to legacy name == ID
+ displayName := activeProfile.ID.String()
+ if activeProfile.ID != profilemanager.DefaultProfileName {
+ if profiles, lerr := s.profileManager.ListProfiles(activeProfile.Username); lerr == nil {
+ for _, p := range profiles {
+ if p.ID == activeProfile.ID {
+ displayName = p.Name
+ break
+ }
+ }
+ }
+ }
+
return &proto.GetActiveProfileResponse{
- ProfileName: activeProfile.Name,
+ ProfileName: displayName,
Username: activeProfile.Username,
+ Id: activeProfile.ID.String(),
}, nil
}
@@ -1646,7 +1814,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 +1836,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/server_privileged_test.go b/client/server/server_privileged_test.go
new file mode 100644
index 000000000..225cf6494
--- /dev/null
+++ b/client/server/server_privileged_test.go
@@ -0,0 +1,235 @@
+//go:build privileged
+
+package server
+
+import (
+ "context"
+ "net"
+ "os/user"
+ "testing"
+ "time"
+
+ "github.com/golang/mock/gomock"
+ "github.com/stretchr/testify/require"
+ "go.opentelemetry.io/otel"
+
+ "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"
+ "github.com/netbirdio/netbird/management/internals/modules/peers"
+ "github.com/netbirdio/netbird/management/internals/modules/peers/ephemeral/manager"
+ nbgrpc "github.com/netbirdio/netbird/management/internals/shared/grpc"
+ "github.com/netbirdio/netbird/management/server/job"
+
+ "github.com/netbirdio/netbird/management/internals/server/config"
+ "github.com/netbirdio/netbird/management/server/groups"
+
+ log "github.com/sirupsen/logrus"
+ "google.golang.org/grpc"
+ "google.golang.org/grpc/keepalive"
+
+ "github.com/netbirdio/netbird/client/internal"
+ "github.com/netbirdio/netbird/client/internal/peer"
+ "github.com/netbirdio/netbird/client/internal/profilemanager"
+ "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/integrations/port_forwarding"
+ "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"
+ mgmtProto "github.com/netbirdio/netbird/shared/management/proto"
+ "github.com/netbirdio/netbird/shared/signal/proto"
+ signalServer "github.com/netbirdio/netbird/signal/server"
+)
+
+var (
+ kaep = keepalive.EnforcementPolicy{
+ MinTime: 15 * time.Second,
+ PermitWithoutStream: true,
+ }
+
+ kasp = keepalive.ServerParameters{
+ MaxConnectionIdle: 15 * time.Second,
+ MaxConnectionAgeGrace: 5 * time.Second,
+ Time: 5 * time.Second,
+ Timeout: 2 * time.Second,
+ }
+)
+
+// TestConnectWithRetryRuns checks that the connectWithRetry function runs and runs the retries according to the times specified via environment variables
+// we will use a management server started via to simulate the server and capture the number of retries
+func TestConnectWithRetryRuns(t *testing.T) {
+ // start the signal server
+ _, signalAddr, err := startSignal(t)
+ if err != nil {
+ t.Fatalf("failed to start signal server: %v", err)
+ }
+
+ counter := 0
+ // start the management server
+ _, mgmtAddr, err := startManagement(t, signalAddr, &counter)
+ if err != nil {
+ t.Fatalf("failed to start management server: %v", err)
+ }
+
+ ctx := internal.CtxInitState(context.Background())
+
+ ctx, cancel := context.WithDeadline(ctx, time.Now().Add(30*time.Second))
+ defer cancel()
+ // create new server
+ ic := profilemanager.ConfigInput{
+ ManagementURL: "http://" + mgmtAddr,
+ ConfigPath: t.TempDir() + "/test-profile.json",
+ }
+
+ config, err := profilemanager.UpdateOrCreateConfig(ic)
+ if err != nil {
+ t.Fatalf("failed to create config: %v", err)
+ }
+
+ currUser, err := user.Current()
+ require.NoError(t, err)
+
+ pm := profilemanager.ServiceManager{}
+ err = pm.SetActiveProfileState(&profilemanager.ActiveProfileState{
+ ID: "test-profile",
+ Username: currUser.Username,
+ })
+ if err != nil {
+ t.Fatalf("failed to set active profile state: %v", err)
+ }
+
+ s := New(ctx, "debug", "", false, false, false, false)
+
+ s.config = config
+
+ s.statusRecorder = peer.NewRecorder(config.ManagementURL.String())
+ t.Setenv(retryInitialIntervalVar, "1s")
+ t.Setenv(maxRetryIntervalVar, "2s")
+ t.Setenv(maxRetryTimeVar, "5s")
+ t.Setenv(retryMultiplierVar, "1")
+
+ s.connectWithRetryRuns(ctx, config, s.statusRecorder, nil, nil)
+ if counter < 3 {
+ t.Fatalf("expected counter > 2, got %d", counter)
+ }
+}
+
+type mockServer struct {
+ mgmtProto.ManagementServiceServer
+ counter *int
+}
+
+func (m *mockServer) Login(ctx context.Context, req *mgmtProto.EncryptedMessage) (*mgmtProto.EncryptedMessage, error) {
+ *m.counter++
+ return m.ManagementServiceServer.Login(ctx, req)
+}
+
+func startManagement(t *testing.T, signalAddr string, counter *int) (*grpc.Server, string, error) {
+ t.Helper()
+ dataDir := t.TempDir()
+
+ config := &config.Config{
+ Stuns: []*config.Host{},
+ TURNConfig: &config.TURNConfig{},
+ Signal: &config.Host{
+ Proto: "http",
+ URI: signalAddr,
+ },
+ Datadir: dataDir,
+ HttpConfig: nil,
+ }
+
+ lis, err := net.Listen("tcp", "localhost:0")
+ if err != nil {
+ return nil, "", err
+ }
+ s := grpc.NewServer(grpc.KeepaliveEnforcementPolicy(kaep), grpc.KeepaliveParams(kasp))
+ store, cleanUp, err := store.NewTestStoreFromSQL(context.Background(), "", config.Datadir)
+ if err != nil {
+ return nil, "", err
+ }
+ t.Cleanup(cleanUp)
+
+ eventStore := &activity.InMemoryEventStore{}
+ if err != nil {
+ return nil, "", err
+ }
+
+ ctrl := gomock.NewController(t)
+ t.Cleanup(ctrl.Finish)
+
+ permissionsManagerMock := permissions.NewMockManager(ctrl)
+ peersManager := peers.NewManager(store, permissionsManagerMock)
+ settingsManagerMock := settings.NewMockManager(ctrl)
+
+ jobManager := job.NewJobManager(nil, store, peersManager)
+
+ cacheStore, err := nbcache.NewStore(context.Background(), 100*time.Millisecond, 300*time.Millisecond, 100)
+ if err != nil {
+ return nil, "", err
+ }
+
+ ia, _ := validator.NewIntegratedValidator(context.Background(), peersManager, settingsManagerMock, eventStore, cacheStore)
+
+ metrics, err := telemetry.NewDefaultAppMetrics(context.Background())
+ require.NoError(t, err)
+
+ settingsMockManager := settings.NewMockManager(ctrl)
+ groupsManager := groups.NewManagerMock()
+
+ requestBuffer := server.NewAccountRequestBuffer(context.Background(), store)
+ peersUpdateManager := update_channel.NewPeersUpdateManager(metrics)
+ networkMapController := controller.NewController(context.Background(), store, metrics, peersUpdateManager, requestBuffer, server.MockIntegratedValidator{}, settingsMockManager, "netbird.selfhosted", port_forwarding.NewControllerMock(), manager.NewEphemeralManager(store, peersManager), config)
+ accountManager, err := server.BuildManager(context.Background(), config, store, networkMapController, jobManager, nil, "", eventStore, nil, false, ia, metrics, port_forwarding.NewControllerMock(), settingsMockManager, permissionsManagerMock, false, cacheStore)
+ if err != nil {
+ return nil, "", err
+ }
+
+ secretsManager, err := nbgrpc.NewTimeBasedAuthSecretsManager(peersUpdateManager, config.TURNConfig, config.Relay, settingsMockManager, groupsManager)
+ if err != nil {
+ return nil, "", err
+ }
+ mgmtServer, err := nbgrpc.NewServer(config, accountManager, settingsMockManager, jobManager, secretsManager, nil, nil, &server.MockIntegratedValidator{}, networkMapController, nil, nil)
+ if err != nil {
+ return nil, "", err
+ }
+ mock := &mockServer{
+ ManagementServiceServer: mgmtServer,
+ counter: counter,
+ }
+ mgmtProto.RegisterManagementServiceServer(s, mock)
+ go func() {
+ if err = s.Serve(lis); err != nil {
+ log.Fatalf("failed to serve: %v", err)
+ }
+ }()
+
+ return s, lis.Addr().String(), nil
+}
+
+func startSignal(t *testing.T) (*grpc.Server, string, error) {
+ t.Helper()
+
+ s := grpc.NewServer(grpc.KeepaliveEnforcementPolicy(kaep), grpc.KeepaliveParams(kasp))
+
+ lis, err := net.Listen("tcp", "localhost:0")
+ if err != nil {
+ return nil, "", err
+ }
+
+ srv, err := signalServer.NewServer(context.Background(), otel.Meter(""))
+ require.NoError(t, err)
+ proto.RegisterSignalExchangeServer(s, srv)
+
+ go func() {
+ if err = s.Serve(lis); err != nil {
+ log.Fatalf("failed to serve: %v", err)
+ }
+ }()
+
+ return s, lis.Addr().String(), nil
+}
diff --git a/client/server/server_test.go b/client/server/server_test.go
index 66e0fcc4c..7717cfcf8 100644
--- a/client/server/server_test.go
+++ b/client/server/server_test.go
@@ -2,124 +2,22 @@ package server
import (
"context"
- "net"
"net/url"
"os/user"
"path/filepath"
"testing"
"time"
- "github.com/golang/mock/gomock"
- "github.com/stretchr/testify/require"
- "go.opentelemetry.io/otel"
-
- "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"
- "github.com/netbirdio/netbird/management/internals/modules/peers"
- "github.com/netbirdio/netbird/management/internals/modules/peers/ephemeral/manager"
- nbgrpc "github.com/netbirdio/netbird/management/internals/shared/grpc"
- "github.com/netbirdio/netbird/management/server/job"
-
- "github.com/netbirdio/netbird/management/internals/server/config"
- "github.com/netbirdio/netbird/management/server/groups"
-
log "github.com/sirupsen/logrus"
"github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
"google.golang.org/grpc"
- "google.golang.org/grpc/keepalive"
"github.com/netbirdio/netbird/client/internal"
- "github.com/netbirdio/netbird/client/internal/peer"
"github.com/netbirdio/netbird/client/internal/profilemanager"
daemonProto "github.com/netbirdio/netbird/client/proto"
- "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/integrations/port_forwarding"
- "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"
- mgmtProto "github.com/netbirdio/netbird/shared/management/proto"
- "github.com/netbirdio/netbird/shared/signal/proto"
- signalServer "github.com/netbirdio/netbird/signal/server"
)
-var (
- kaep = keepalive.EnforcementPolicy{
- MinTime: 15 * time.Second,
- PermitWithoutStream: true,
- }
-
- kasp = keepalive.ServerParameters{
- MaxConnectionIdle: 15 * time.Second,
- MaxConnectionAgeGrace: 5 * time.Second,
- Time: 5 * time.Second,
- Timeout: 2 * time.Second,
- }
-)
-
-// TestConnectWithRetryRuns checks that the connectWithRetry function runs and runs the retries according to the times specified via environment variables
-// we will use a management server started via to simulate the server and capture the number of retries
-func TestConnectWithRetryRuns(t *testing.T) {
- // start the signal server
- _, signalAddr, err := startSignal(t)
- if err != nil {
- t.Fatalf("failed to start signal server: %v", err)
- }
-
- counter := 0
- // start the management server
- _, mgmtAddr, err := startManagement(t, signalAddr, &counter)
- if err != nil {
- t.Fatalf("failed to start management server: %v", err)
- }
-
- ctx := internal.CtxInitState(context.Background())
-
- ctx, cancel := context.WithDeadline(ctx, time.Now().Add(30*time.Second))
- defer cancel()
- // create new server
- ic := profilemanager.ConfigInput{
- ManagementURL: "http://" + mgmtAddr,
- ConfigPath: t.TempDir() + "/test-profile.json",
- }
-
- config, err := profilemanager.UpdateOrCreateConfig(ic)
- if err != nil {
- t.Fatalf("failed to create config: %v", err)
- }
-
- currUser, err := user.Current()
- require.NoError(t, err)
-
- pm := profilemanager.ServiceManager{}
- err = pm.SetActiveProfileState(&profilemanager.ActiveProfileState{
- Name: "test-profile",
- Username: currUser.Username,
- })
- if err != nil {
- t.Fatalf("failed to set active profile state: %v", err)
- }
-
- s := New(ctx, "debug", "", false, false, false, false)
-
- s.config = config
-
- s.statusRecorder = peer.NewRecorder(config.ManagementURL.String())
- t.Setenv(retryInitialIntervalVar, "1s")
- t.Setenv(maxRetryIntervalVar, "2s")
- t.Setenv(maxRetryTimeVar, "5s")
- t.Setenv(retryMultiplierVar, "1")
-
- s.connectWithRetryRuns(ctx, config, s.statusRecorder, nil, nil)
- if counter < 3 {
- t.Fatalf("expected counter > 2, got %d", counter)
- }
-}
-
func TestServer_Up(t *testing.T) {
tempDir := t.TempDir()
origDefaultProfileDir := profilemanager.DefaultConfigPathDir
@@ -158,7 +56,7 @@ func TestServer_Up(t *testing.T) {
pm := profilemanager.ServiceManager{}
err = pm.SetActiveProfileState(&profilemanager.ActiveProfileState{
- Name: profName,
+ ID: profilemanager.ID(profName),
Username: currUser.Username,
})
if err != nil {
@@ -228,7 +126,7 @@ func TestServer_SubcribeEvents(t *testing.T) {
pm := profilemanager.ServiceManager{}
err = pm.SetActiveProfileState(&profilemanager.ActiveProfileState{
- Name: "default",
+ ID: "default",
Username: currUser.Username,
})
if err != nil {
@@ -259,119 +157,3 @@ func TestServer_SubcribeEvents(t *testing.T) {
assert.NoError(t, err)
}
-
-type mockServer struct {
- mgmtProto.ManagementServiceServer
- counter *int
-}
-
-func (m *mockServer) Login(ctx context.Context, req *mgmtProto.EncryptedMessage) (*mgmtProto.EncryptedMessage, error) {
- *m.counter++
- return m.ManagementServiceServer.Login(ctx, req)
-}
-
-func startManagement(t *testing.T, signalAddr string, counter *int) (*grpc.Server, string, error) {
- t.Helper()
- dataDir := t.TempDir()
-
- config := &config.Config{
- Stuns: []*config.Host{},
- TURNConfig: &config.TURNConfig{},
- Signal: &config.Host{
- Proto: "http",
- URI: signalAddr,
- },
- Datadir: dataDir,
- HttpConfig: nil,
- }
-
- lis, err := net.Listen("tcp", "localhost:0")
- if err != nil {
- return nil, "", err
- }
- s := grpc.NewServer(grpc.KeepaliveEnforcementPolicy(kaep), grpc.KeepaliveParams(kasp))
- store, cleanUp, err := store.NewTestStoreFromSQL(context.Background(), "", config.Datadir)
- if err != nil {
- return nil, "", err
- }
- t.Cleanup(cleanUp)
-
- eventStore := &activity.InMemoryEventStore{}
- if err != nil {
- return nil, "", err
- }
-
- ctrl := gomock.NewController(t)
- t.Cleanup(ctrl.Finish)
-
- permissionsManagerMock := permissions.NewMockManager(ctrl)
- peersManager := peers.NewManager(store, permissionsManagerMock)
- settingsManagerMock := settings.NewMockManager(ctrl)
-
- jobManager := job.NewJobManager(nil, store, peersManager)
-
- cacheStore, err := nbcache.NewStore(context.Background(), 100*time.Millisecond, 300*time.Millisecond, 100)
- if err != nil {
- return nil, "", err
- }
-
- ia, _ := validator.NewIntegratedValidator(context.Background(), peersManager, settingsManagerMock, eventStore, cacheStore)
-
- metrics, err := telemetry.NewDefaultAppMetrics(context.Background())
- require.NoError(t, err)
-
- settingsMockManager := settings.NewMockManager(ctrl)
- groupsManager := groups.NewManagerMock()
-
- requestBuffer := server.NewAccountRequestBuffer(context.Background(), store)
- peersUpdateManager := update_channel.NewPeersUpdateManager(metrics)
- networkMapController := controller.NewController(context.Background(), store, metrics, peersUpdateManager, requestBuffer, server.MockIntegratedValidator{}, settingsMockManager, "netbird.selfhosted", port_forwarding.NewControllerMock(), manager.NewEphemeralManager(store, peersManager), config)
- accountManager, err := server.BuildManager(context.Background(), config, store, networkMapController, jobManager, nil, "", eventStore, nil, false, ia, metrics, port_forwarding.NewControllerMock(), settingsMockManager, permissionsManagerMock, false, cacheStore)
- if err != nil {
- return nil, "", err
- }
-
- secretsManager, err := nbgrpc.NewTimeBasedAuthSecretsManager(peersUpdateManager, config.TURNConfig, config.Relay, settingsMockManager, groupsManager)
- if err != nil {
- return nil, "", err
- }
- mgmtServer, err := nbgrpc.NewServer(config, accountManager, settingsMockManager, jobManager, secretsManager, nil, nil, &server.MockIntegratedValidator{}, networkMapController, nil, nil)
- if err != nil {
- return nil, "", err
- }
- mock := &mockServer{
- ManagementServiceServer: mgmtServer,
- counter: counter,
- }
- mgmtProto.RegisterManagementServiceServer(s, mock)
- go func() {
- if err = s.Serve(lis); err != nil {
- log.Fatalf("failed to serve: %v", err)
- }
- }()
-
- return s, lis.Addr().String(), nil
-}
-
-func startSignal(t *testing.T) (*grpc.Server, string, error) {
- t.Helper()
-
- s := grpc.NewServer(grpc.KeepaliveEnforcementPolicy(kaep), grpc.KeepaliveParams(kasp))
-
- lis, err := net.Listen("tcp", "localhost:0")
- if err != nil {
- log.Fatalf("failed to listen: %v", err)
- }
-
- srv, err := signalServer.NewServer(context.Background(), otel.Meter(""))
- require.NoError(t, err)
- proto.RegisterSignalExchangeServer(s, srv)
-
- go func() {
- if err = s.Serve(lis); err != nil {
- log.Fatalf("failed to serve: %v", err)
- }
- }()
-
- return s, lis.Addr().String(), nil
-}
diff --git a/client/server/setconfig_mdm_test.go b/client/server/setconfig_mdm_test.go
new file mode 100644
index 000000000..9818f9fdf
--- /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{
+ ID: profilemanager.ID(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/server/setconfig_test.go b/client/server/setconfig_test.go
index 553d4ad71..7c85d16ce 100644
--- a/client/server/setconfig_test.go
+++ b/client/server/setconfig_test.go
@@ -47,7 +47,7 @@ func TestSetConfig_AllFieldsSaved(t *testing.T) {
pm := profilemanager.ServiceManager{}
err = pm.SetActiveProfileState(&profilemanager.ActiveProfileState{
- Name: profName,
+ ID: profilemanager.ID(profName),
Username: currUser.Username,
})
require.NoError(t, err)
@@ -96,7 +96,7 @@ func TestSetConfig_AllFieldsSaved(t *testing.T) {
DisableNotifications: &disableNotifications,
LazyConnectionEnabled: &lazyConnectionEnabled,
BlockInbound: &blockInbound,
- DisableIpv6: &disableIPv6,
+ DisableIpv6: &disableIPv6,
NatExternalIPs: []string{"1.2.3.4", "5.6.7.8"},
CleanNATExternalIPs: false,
CustomDNSAddress: []byte("1.1.1.1:53"),
@@ -112,7 +112,7 @@ func TestSetConfig_AllFieldsSaved(t *testing.T) {
require.NoError(t, err)
profState := profilemanager.ActiveProfileState{
- Name: profName,
+ ID: profilemanager.ID(profName),
Username: currUser.Username,
}
cfgPath, err := profState.FilePath()
diff --git a/client/ssh/client/client_privileged_test.go b/client/ssh/client/client_privileged_test.go
new file mode 100644
index 000000000..12edbbc06
--- /dev/null
+++ b/client/ssh/client/client_privileged_test.go
@@ -0,0 +1,118 @@
+//go:build privileged
+
+package client
+
+import (
+ "context"
+ "errors"
+ "runtime"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+ cryptossh "golang.org/x/crypto/ssh"
+
+ "github.com/netbirdio/netbird/client/ssh/testutil"
+)
+
+func TestSSHClient_CommandExecution(t *testing.T) {
+ if runtime.GOOS == "windows" && testutil.IsCI() {
+ t.Skip("Skipping Windows command execution tests in CI due to S4U authentication issues")
+ }
+
+ server, _, client := setupTestSSHServerAndClient(t)
+ defer func() {
+ err := server.Stop()
+ require.NoError(t, err)
+ }()
+ defer func() {
+ err := client.Close()
+ assert.NoError(t, err)
+ }()
+
+ ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
+ defer cancel()
+
+ t.Run("ExecuteCommand captures output", func(t *testing.T) {
+ output, err := client.ExecuteCommand(ctx, "echo hello")
+ assert.NoError(t, err)
+ assert.Contains(t, string(output), "hello")
+ })
+
+ t.Run("ExecuteCommandWithIO streams output", func(t *testing.T) {
+ err := client.ExecuteCommandWithIO(ctx, "echo world")
+ assert.NoError(t, err)
+ })
+
+ t.Run("commands with flags work", func(t *testing.T) {
+ output, err := client.ExecuteCommand(ctx, "echo -n test_flag")
+ assert.NoError(t, err)
+ assert.Equal(t, "test_flag", strings.TrimSpace(string(output)))
+ })
+
+ t.Run("non-zero exit codes don't return errors", func(t *testing.T) {
+ var testCmd string
+ if runtime.GOOS == "windows" {
+ testCmd = "echo hello | Select-String notfound"
+ } else {
+ testCmd = "echo 'hello' | grep 'notfound'"
+ }
+ _, err := client.ExecuteCommand(ctx, testCmd)
+ assert.NoError(t, err)
+ })
+}
+
+func TestSSHClient_ContextCancellation(t *testing.T) {
+ server, serverAddr, _ := setupTestSSHServerAndClient(t)
+ defer func() {
+ err := server.Stop()
+ require.NoError(t, err)
+ }()
+
+ t.Run("connection with short timeout", func(t *testing.T) {
+ ctx, cancel := context.WithTimeout(context.Background(), 1*time.Millisecond)
+ defer cancel()
+
+ currentUser := testutil.GetTestUsername(t)
+ _, err := Dial(ctx, serverAddr, currentUser, DialOptions{
+ InsecureSkipVerify: true,
+ })
+ if err != nil {
+ // Check for actual timeout-related errors rather than string matching
+ assert.True(t,
+ errors.Is(err, context.DeadlineExceeded) ||
+ errors.Is(err, context.Canceled) ||
+ strings.Contains(err.Error(), "timeout"),
+ "Expected timeout-related error, got: %v", err)
+ }
+ })
+
+ t.Run("command execution cancellation", func(t *testing.T) {
+ ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
+ defer cancel()
+ currentUser := testutil.GetTestUsername(t)
+ client, err := Dial(ctx, serverAddr, currentUser, DialOptions{
+ InsecureSkipVerify: true,
+ })
+ require.NoError(t, err)
+ defer func() {
+ if err := client.Close(); err != nil {
+ t.Logf("client close error: %v", err)
+ }
+ }()
+
+ cmdCtx, cmdCancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
+ defer cmdCancel()
+
+ err = client.ExecuteCommandWithPTY(cmdCtx, "sleep 10")
+ if err != nil {
+ var exitMissingErr *cryptossh.ExitMissingError
+ isValidCancellation := errors.Is(err, context.DeadlineExceeded) ||
+ errors.Is(err, context.Canceled) ||
+ errors.As(err, &exitMissingErr)
+ assert.True(t, isValidCancellation, "Should handle command cancellation properly")
+ }
+ })
+}
diff --git a/client/ssh/client/client_test.go b/client/ssh/client/client_test.go
index e38e02a86..191362940 100644
--- a/client/ssh/client/client_test.go
+++ b/client/ssh/client/client_test.go
@@ -15,7 +15,6 @@ import (
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
- cryptossh "golang.org/x/crypto/ssh"
"github.com/netbirdio/netbird/client/ssh"
sshserver "github.com/netbirdio/netbird/client/ssh/server"
@@ -78,53 +77,6 @@ func TestSSHClient_DialWithKey(t *testing.T) {
assert.NotNil(t, client.client)
}
-func TestSSHClient_CommandExecution(t *testing.T) {
- if runtime.GOOS == "windows" && testutil.IsCI() {
- t.Skip("Skipping Windows command execution tests in CI due to S4U authentication issues")
- }
-
- server, _, client := setupTestSSHServerAndClient(t)
- defer func() {
- err := server.Stop()
- require.NoError(t, err)
- }()
- defer func() {
- err := client.Close()
- assert.NoError(t, err)
- }()
-
- ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
- defer cancel()
-
- t.Run("ExecuteCommand captures output", func(t *testing.T) {
- output, err := client.ExecuteCommand(ctx, "echo hello")
- assert.NoError(t, err)
- assert.Contains(t, string(output), "hello")
- })
-
- t.Run("ExecuteCommandWithIO streams output", func(t *testing.T) {
- err := client.ExecuteCommandWithIO(ctx, "echo world")
- assert.NoError(t, err)
- })
-
- t.Run("commands with flags work", func(t *testing.T) {
- output, err := client.ExecuteCommand(ctx, "echo -n test_flag")
- assert.NoError(t, err)
- assert.Equal(t, "test_flag", strings.TrimSpace(string(output)))
- })
-
- t.Run("non-zero exit codes don't return errors", func(t *testing.T) {
- var testCmd string
- if runtime.GOOS == "windows" {
- testCmd = "echo hello | Select-String notfound"
- } else {
- testCmd = "echo 'hello' | grep 'notfound'"
- }
- _, err := client.ExecuteCommand(ctx, testCmd)
- assert.NoError(t, err)
- })
-}
-
func TestSSHClient_ConnectionHandling(t *testing.T) {
server, serverAddr, _ := setupTestSSHServerAndClient(t)
defer func() {
@@ -154,59 +106,6 @@ func TestSSHClient_ConnectionHandling(t *testing.T) {
}
}
-func TestSSHClient_ContextCancellation(t *testing.T) {
- server, serverAddr, _ := setupTestSSHServerAndClient(t)
- defer func() {
- err := server.Stop()
- require.NoError(t, err)
- }()
-
- t.Run("connection with short timeout", func(t *testing.T) {
- ctx, cancel := context.WithTimeout(context.Background(), 1*time.Millisecond)
- defer cancel()
-
- currentUser := testutil.GetTestUsername(t)
- _, err := Dial(ctx, serverAddr, currentUser, DialOptions{
- InsecureSkipVerify: true,
- })
- if err != nil {
- // Check for actual timeout-related errors rather than string matching
- assert.True(t,
- errors.Is(err, context.DeadlineExceeded) ||
- errors.Is(err, context.Canceled) ||
- strings.Contains(err.Error(), "timeout"),
- "Expected timeout-related error, got: %v", err)
- }
- })
-
- t.Run("command execution cancellation", func(t *testing.T) {
- ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
- defer cancel()
- currentUser := testutil.GetTestUsername(t)
- client, err := Dial(ctx, serverAddr, currentUser, DialOptions{
- InsecureSkipVerify: true,
- })
- require.NoError(t, err)
- defer func() {
- if err := client.Close(); err != nil {
- t.Logf("client close error: %v", err)
- }
- }()
-
- cmdCtx, cmdCancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
- defer cmdCancel()
-
- err = client.ExecuteCommandWithPTY(cmdCtx, "sleep 10")
- if err != nil {
- var exitMissingErr *cryptossh.ExitMissingError
- isValidCancellation := errors.Is(err, context.DeadlineExceeded) ||
- errors.Is(err, context.Canceled) ||
- errors.As(err, &exitMissingErr)
- assert.True(t, isValidCancellation, "Should handle command cancellation properly")
- }
- })
-}
-
func TestSSHClient_NoAuthMode(t *testing.T) {
hostKey, err := ssh.GeneratePrivateKey(ssh.ED25519)
require.NoError(t, err)
diff --git a/client/ssh/proxy/proxy_privileged_test.go b/client/ssh/proxy/proxy_privileged_test.go
new file mode 100644
index 000000000..94495a3ae
--- /dev/null
+++ b/client/ssh/proxy/proxy_privileged_test.go
@@ -0,0 +1,423 @@
+//go:build privileged
+
+package proxy
+
+import (
+ "bytes"
+ "context"
+ "crypto/rand"
+ "crypto/rsa"
+ "encoding/base64"
+ "encoding/json"
+ "io"
+ "math/big"
+ "net"
+ "net/http"
+ "net/http/httptest"
+ "os"
+ "runtime"
+ "strconv"
+ "testing"
+ "time"
+
+ "github.com/golang-jwt/jwt/v5"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+ cryptossh "golang.org/x/crypto/ssh"
+
+ nbssh "github.com/netbirdio/netbird/client/ssh"
+ sshauth "github.com/netbirdio/netbird/client/ssh/auth"
+ "github.com/netbirdio/netbird/client/ssh/server"
+ "github.com/netbirdio/netbird/client/ssh/testutil"
+ nbjwt "github.com/netbirdio/netbird/shared/auth/jwt"
+ sshuserhash "github.com/netbirdio/netbird/shared/sshauth"
+)
+
+func (m *mockDaemon) setJWTToken(token string) {
+ m.impl.jwtToken = token
+}
+
+func TestSSHProxy_Connect(t *testing.T) {
+ if testing.Short() {
+ t.Skip("Skipping integration test in short mode")
+ }
+
+ // TODO: Windows test times out - user switching and command execution tested on Linux
+ if runtime.GOOS == "windows" {
+ t.Skip("Skipping on Windows - covered by Linux tests")
+ }
+
+ const (
+ issuer = "https://test-issuer.example.com"
+ audience = "test-audience"
+ )
+
+ jwksServer, privateKey, jwksURL := setupJWKSServer(t)
+ defer jwksServer.Close()
+
+ hostKey, err := nbssh.GeneratePrivateKey(nbssh.ED25519)
+ require.NoError(t, err)
+ hostPubKey, err := nbssh.GeneratePublicKey(hostKey)
+ require.NoError(t, err)
+
+ serverConfig := &server.Config{
+ HostKeyPEM: hostKey,
+ JWT: &server.JWTConfig{
+ Issuer: issuer,
+ Audiences: []string{audience},
+ KeysLocation: jwksURL,
+ },
+ }
+ sshServer := server.New(serverConfig)
+ sshServer.SetAllowRootLogin(true)
+
+ // Configure SSH authorization for the test user
+ testUsername := testutil.GetTestUsername(t)
+ testJWTUser := "test-username"
+ testUserHash, err := sshuserhash.HashUserID(testJWTUser)
+ require.NoError(t, err)
+
+ authConfig := &sshauth.Config{
+ UserIDClaim: sshauth.DefaultUserIDClaim,
+ AuthorizedUsers: []sshuserhash.UserIDHash{testUserHash},
+ MachineUsers: map[string][]uint32{
+ testUsername: {0}, // Index 0 in AuthorizedUsers
+ },
+ }
+ sshServer.UpdateSSHAuth(authConfig)
+
+ sshServerAddr := server.StartTestServer(t, sshServer)
+ defer func() { _ = sshServer.Stop() }()
+
+ mockDaemon := startMockDaemon(t)
+ defer mockDaemon.stop()
+
+ host, portStr, err := net.SplitHostPort(sshServerAddr)
+ require.NoError(t, err)
+ port, err := strconv.Atoi(portStr)
+ require.NoError(t, err)
+
+ mockDaemon.setHostKey(host, hostPubKey)
+
+ validToken := generateValidJWT(t, privateKey, issuer, audience, testJWTUser)
+ mockDaemon.setJWTToken(validToken)
+
+ proxyInstance, err := New(mockDaemon.addr, host, port, io.Discard, nil)
+ require.NoError(t, err)
+
+ clientConn, proxyConn := net.Pipe()
+ defer func() { _ = clientConn.Close() }()
+
+ origStdin := os.Stdin
+ origStdout := os.Stdout
+ defer func() {
+ os.Stdin = origStdin
+ os.Stdout = origStdout
+ }()
+
+ stdinReader, stdinWriter, err := os.Pipe()
+ require.NoError(t, err)
+ stdoutReader, stdoutWriter, err := os.Pipe()
+ require.NoError(t, err)
+
+ os.Stdin = stdinReader
+ os.Stdout = stdoutWriter
+
+ go func() {
+ _, _ = io.Copy(stdinWriter, proxyConn)
+ }()
+ go func() {
+ _, _ = io.Copy(proxyConn, stdoutReader)
+ }()
+
+ ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
+ defer cancel()
+
+ connectErrCh := make(chan error, 1)
+ go func() {
+ connectErrCh <- proxyInstance.Connect(ctx)
+ }()
+
+ sshConfig := &cryptossh.ClientConfig{
+ User: testutil.GetTestUsername(t),
+ Auth: []cryptossh.AuthMethod{},
+ HostKeyCallback: cryptossh.InsecureIgnoreHostKey(),
+ Timeout: 3 * time.Second,
+ }
+
+ sshClientConn, chans, reqs, err := cryptossh.NewClientConn(clientConn, "test", sshConfig)
+ require.NoError(t, err, "Should connect to proxy server")
+ defer func() { _ = sshClientConn.Close() }()
+
+ sshClient := cryptossh.NewClient(sshClientConn, chans, reqs)
+
+ session, err := sshClient.NewSession()
+ require.NoError(t, err, "Should create session through full proxy to backend")
+
+ outputCh := make(chan []byte, 1)
+ errCh := make(chan error, 1)
+ go func() {
+ output, err := session.Output("echo hello-from-proxy")
+ outputCh <- output
+ errCh <- err
+ }()
+
+ select {
+ case output := <-outputCh:
+ err := <-errCh
+ require.NoError(t, err, "Command should execute successfully through proxy")
+ assert.Contains(t, string(output), "hello-from-proxy", "Should receive command output through proxy")
+ case <-time.After(3 * time.Second):
+ t.Fatal("Command execution timed out")
+ }
+
+ _ = session.Close()
+ _ = sshClient.Close()
+ _ = clientConn.Close()
+ cancel()
+}
+
+// TestSSHProxy_CommandQuoting verifies that the proxy preserves shell quoting
+// when forwarding commands to the backend. This is critical for tools like
+// Ansible that send commands such as:
+//
+// /bin/sh -c '( umask 77 && mkdir -p ... ) && sleep 0'
+//
+// The single quotes must be preserved so the backend shell receives the
+// subshell expression as a single argument to -c.
+func TestSSHProxy_CommandQuoting(t *testing.T) {
+ if testing.Short() {
+ t.Skip("Skipping integration test in short mode")
+ }
+
+ sshClient, cleanup := setupProxySSHClient(t)
+ defer cleanup()
+
+ // These commands simulate what the SSH protocol delivers as exec payloads.
+ // When a user types: ssh host '/bin/sh -c "( echo hello )"'
+ // the local shell strips the outer single quotes, and the SSH exec request
+ // contains the raw string: /bin/sh -c "( echo hello )"
+ //
+ // The proxy must forward this string verbatim. Using session.Command()
+ // (shlex.Split + strings.Join) strips the inner double quotes, breaking
+ // the command on the backend.
+ tests := []struct {
+ name string
+ command string
+ expect string
+ }{
+ {
+ name: "subshell_in_double_quotes",
+ command: `/bin/sh -c "( echo from-subshell ) && echo outer"`,
+ expect: "from-subshell\nouter\n",
+ },
+ {
+ name: "printf_with_special_chars",
+ command: `/bin/sh -c "printf '%s\n' 'hello world'"`,
+ expect: "hello world\n",
+ },
+ {
+ name: "nested_command_substitution",
+ command: `/bin/sh -c "echo $(echo nested)"`,
+ expect: "nested\n",
+ },
+ }
+
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ session, err := sshClient.NewSession()
+ require.NoError(t, err)
+ defer func() { _ = session.Close() }()
+
+ var stderrBuf bytes.Buffer
+ session.Stderr = &stderrBuf
+
+ outputCh := make(chan []byte, 1)
+ errCh := make(chan error, 1)
+ go func() {
+ output, err := session.Output(tc.command)
+ outputCh <- output
+ errCh <- err
+ }()
+
+ select {
+ case output := <-outputCh:
+ err := <-errCh
+ if stderrBuf.Len() > 0 {
+ t.Logf("stderr: %s", stderrBuf.String())
+ }
+ require.NoError(t, err, "command should succeed: %s", tc.command)
+ assert.Equal(t, tc.expect, string(output), "output mismatch for: %s", tc.command)
+ case <-time.After(5 * time.Second):
+ t.Fatalf("command timed out: %s", tc.command)
+ }
+ })
+ }
+}
+
+// setupProxySSHClient creates a full proxy test environment and returns
+// an SSH client connected through the proxy to a backend NetBird SSH server.
+func setupProxySSHClient(t *testing.T) (*cryptossh.Client, func()) {
+ t.Helper()
+
+ const (
+ issuer = "https://test-issuer.example.com"
+ audience = "test-audience"
+ )
+
+ jwksServer, privateKey, jwksURL := setupJWKSServer(t)
+
+ hostKey, err := nbssh.GeneratePrivateKey(nbssh.ED25519)
+ require.NoError(t, err)
+ hostPubKey, err := nbssh.GeneratePublicKey(hostKey)
+ require.NoError(t, err)
+
+ serverConfig := &server.Config{
+ HostKeyPEM: hostKey,
+ JWT: &server.JWTConfig{
+ Issuer: issuer,
+ Audiences: []string{audience},
+ KeysLocation: jwksURL,
+ },
+ }
+ sshServer := server.New(serverConfig)
+ sshServer.SetAllowRootLogin(true)
+
+ testUsername := testutil.GetTestUsername(t)
+ testJWTUser := "test-username"
+ testUserHash, err := sshuserhash.HashUserID(testJWTUser)
+ require.NoError(t, err)
+
+ authConfig := &sshauth.Config{
+ UserIDClaim: sshauth.DefaultUserIDClaim,
+ AuthorizedUsers: []sshuserhash.UserIDHash{testUserHash},
+ MachineUsers: map[string][]uint32{
+ testUsername: {0},
+ },
+ }
+ sshServer.UpdateSSHAuth(authConfig)
+
+ sshServerAddr := server.StartTestServer(t, sshServer)
+
+ mockDaemon := startMockDaemon(t)
+
+ host, portStr, err := net.SplitHostPort(sshServerAddr)
+ require.NoError(t, err)
+ port, err := strconv.Atoi(portStr)
+ require.NoError(t, err)
+
+ mockDaemon.setHostKey(host, hostPubKey)
+
+ validToken := generateValidJWT(t, privateKey, issuer, audience, testJWTUser)
+ mockDaemon.setJWTToken(validToken)
+
+ proxyInstance, err := New(mockDaemon.addr, host, port, io.Discard, nil)
+ require.NoError(t, err)
+
+ origStdin := os.Stdin
+ origStdout := os.Stdout
+
+ stdinReader, stdinWriter, err := os.Pipe()
+ require.NoError(t, err)
+ stdoutReader, stdoutWriter, err := os.Pipe()
+ require.NoError(t, err)
+
+ os.Stdin = stdinReader
+ os.Stdout = stdoutWriter
+
+ clientConn, proxyConn := net.Pipe()
+
+ go func() { _, _ = io.Copy(stdinWriter, proxyConn) }()
+ go func() { _, _ = io.Copy(proxyConn, stdoutReader) }()
+
+ ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
+
+ go func() {
+ _ = proxyInstance.Connect(ctx)
+ }()
+
+ sshConfig := &cryptossh.ClientConfig{
+ User: testutil.GetTestUsername(t),
+ Auth: []cryptossh.AuthMethod{},
+ HostKeyCallback: cryptossh.InsecureIgnoreHostKey(),
+ Timeout: 5 * time.Second,
+ }
+
+ sshClientConn, chans, reqs, err := cryptossh.NewClientConn(clientConn, "test", sshConfig)
+ require.NoError(t, err)
+
+ client := cryptossh.NewClient(sshClientConn, chans, reqs)
+
+ cleanupFn := func() {
+ _ = client.Close()
+ _ = clientConn.Close()
+ cancel()
+ os.Stdin = origStdin
+ os.Stdout = origStdout
+ _ = sshServer.Stop()
+ mockDaemon.stop()
+ jwksServer.Close()
+ }
+
+ return client, cleanupFn
+}
+
+func setupJWKSServer(t *testing.T) (*httptest.Server, *rsa.PrivateKey, string) {
+ t.Helper()
+ privateKey, jwksJSON := generateTestJWKS(t)
+
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "application/json")
+ if _, err := w.Write(jwksJSON); err != nil {
+ http.Error(w, err.Error(), http.StatusInternalServerError)
+ }
+ }))
+
+ return server, privateKey, server.URL
+}
+
+func generateTestJWKS(t *testing.T) (*rsa.PrivateKey, []byte) {
+ t.Helper()
+ privateKey, err := rsa.GenerateKey(rand.Reader, 2048)
+ require.NoError(t, err)
+
+ publicKey := &privateKey.PublicKey
+ n := publicKey.N.Bytes()
+ e := publicKey.E
+
+ jwk := nbjwt.JSONWebKey{
+ Kty: "RSA",
+ Kid: "test-key-id",
+ Use: "sig",
+ N: base64.RawURLEncoding.EncodeToString(n),
+ E: base64.RawURLEncoding.EncodeToString(big.NewInt(int64(e)).Bytes()),
+ }
+
+ jwks := nbjwt.Jwks{
+ Keys: []nbjwt.JSONWebKey{jwk},
+ }
+
+ jwksJSON, err := json.Marshal(jwks)
+ require.NoError(t, err)
+
+ return privateKey, jwksJSON
+}
+
+func generateValidJWT(t *testing.T, privateKey *rsa.PrivateKey, issuer, audience string, user string) string {
+ t.Helper()
+ claims := jwt.MapClaims{
+ "iss": issuer,
+ "aud": audience,
+ "sub": user,
+ "exp": time.Now().Add(time.Hour).Unix(),
+ "iat": time.Now().Unix(),
+ }
+
+ token := jwt.NewWithClaims(jwt.SigningMethodRS256, claims)
+ token.Header["kid"] = "test-key-id"
+
+ tokenString, err := token.SignedString(privateKey)
+ require.NoError(t, err)
+
+ return tokenString
+}
diff --git a/client/ssh/proxy/proxy_test.go b/client/ssh/proxy/proxy_test.go
index b33d5f8f4..2795c786b 100644
--- a/client/ssh/proxy/proxy_test.go
+++ b/client/ssh/proxy/proxy_test.go
@@ -1,25 +1,12 @@
package proxy
import (
- "bytes"
"context"
- "crypto/rand"
- "crypto/rsa"
- "encoding/base64"
- "encoding/json"
"fmt"
- "io"
- "math/big"
"net"
- "net/http"
- "net/http/httptest"
"os"
- "runtime"
- "strconv"
"testing"
- "time"
- "github.com/golang-jwt/jwt/v5"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
cryptossh "golang.org/x/crypto/ssh"
@@ -28,11 +15,7 @@ import (
"github.com/netbirdio/netbird/client/proto"
nbssh "github.com/netbirdio/netbird/client/ssh"
- sshauth "github.com/netbirdio/netbird/client/ssh/auth"
- "github.com/netbirdio/netbird/client/ssh/server"
"github.com/netbirdio/netbird/client/ssh/testutil"
- nbjwt "github.com/netbirdio/netbird/shared/auth/jwt"
- sshuserhash "github.com/netbirdio/netbird/shared/sshauth"
)
func TestMain(m *testing.M) {
@@ -106,331 +89,6 @@ func TestSSHProxy_verifyHostKey(t *testing.T) {
})
}
-func TestSSHProxy_Connect(t *testing.T) {
- if testing.Short() {
- t.Skip("Skipping integration test in short mode")
- }
-
- // TODO: Windows test times out - user switching and command execution tested on Linux
- if runtime.GOOS == "windows" {
- t.Skip("Skipping on Windows - covered by Linux tests")
- }
-
- const (
- issuer = "https://test-issuer.example.com"
- audience = "test-audience"
- )
-
- jwksServer, privateKey, jwksURL := setupJWKSServer(t)
- defer jwksServer.Close()
-
- hostKey, err := nbssh.GeneratePrivateKey(nbssh.ED25519)
- require.NoError(t, err)
- hostPubKey, err := nbssh.GeneratePublicKey(hostKey)
- require.NoError(t, err)
-
- serverConfig := &server.Config{
- HostKeyPEM: hostKey,
- JWT: &server.JWTConfig{
- Issuer: issuer,
- Audiences: []string{audience},
- KeysLocation: jwksURL,
- },
- }
- sshServer := server.New(serverConfig)
- sshServer.SetAllowRootLogin(true)
-
- // Configure SSH authorization for the test user
- testUsername := testutil.GetTestUsername(t)
- testJWTUser := "test-username"
- testUserHash, err := sshuserhash.HashUserID(testJWTUser)
- require.NoError(t, err)
-
- authConfig := &sshauth.Config{
- UserIDClaim: sshauth.DefaultUserIDClaim,
- AuthorizedUsers: []sshuserhash.UserIDHash{testUserHash},
- MachineUsers: map[string][]uint32{
- testUsername: {0}, // Index 0 in AuthorizedUsers
- },
- }
- sshServer.UpdateSSHAuth(authConfig)
-
- sshServerAddr := server.StartTestServer(t, sshServer)
- defer func() { _ = sshServer.Stop() }()
-
- mockDaemon := startMockDaemon(t)
- defer mockDaemon.stop()
-
- host, portStr, err := net.SplitHostPort(sshServerAddr)
- require.NoError(t, err)
- port, err := strconv.Atoi(portStr)
- require.NoError(t, err)
-
- mockDaemon.setHostKey(host, hostPubKey)
-
- validToken := generateValidJWT(t, privateKey, issuer, audience, testJWTUser)
- mockDaemon.setJWTToken(validToken)
-
- proxyInstance, err := New(mockDaemon.addr, host, port, io.Discard, nil)
- require.NoError(t, err)
-
- clientConn, proxyConn := net.Pipe()
- defer func() { _ = clientConn.Close() }()
-
- origStdin := os.Stdin
- origStdout := os.Stdout
- defer func() {
- os.Stdin = origStdin
- os.Stdout = origStdout
- }()
-
- stdinReader, stdinWriter, err := os.Pipe()
- require.NoError(t, err)
- stdoutReader, stdoutWriter, err := os.Pipe()
- require.NoError(t, err)
-
- os.Stdin = stdinReader
- os.Stdout = stdoutWriter
-
- go func() {
- _, _ = io.Copy(stdinWriter, proxyConn)
- }()
- go func() {
- _, _ = io.Copy(proxyConn, stdoutReader)
- }()
-
- ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
- defer cancel()
-
- connectErrCh := make(chan error, 1)
- go func() {
- connectErrCh <- proxyInstance.Connect(ctx)
- }()
-
- sshConfig := &cryptossh.ClientConfig{
- User: testutil.GetTestUsername(t),
- Auth: []cryptossh.AuthMethod{},
- HostKeyCallback: cryptossh.InsecureIgnoreHostKey(),
- Timeout: 3 * time.Second,
- }
-
- sshClientConn, chans, reqs, err := cryptossh.NewClientConn(clientConn, "test", sshConfig)
- require.NoError(t, err, "Should connect to proxy server")
- defer func() { _ = sshClientConn.Close() }()
-
- sshClient := cryptossh.NewClient(sshClientConn, chans, reqs)
-
- session, err := sshClient.NewSession()
- require.NoError(t, err, "Should create session through full proxy to backend")
-
- outputCh := make(chan []byte, 1)
- errCh := make(chan error, 1)
- go func() {
- output, err := session.Output("echo hello-from-proxy")
- outputCh <- output
- errCh <- err
- }()
-
- select {
- case output := <-outputCh:
- err := <-errCh
- require.NoError(t, err, "Command should execute successfully through proxy")
- assert.Contains(t, string(output), "hello-from-proxy", "Should receive command output through proxy")
- case <-time.After(3 * time.Second):
- t.Fatal("Command execution timed out")
- }
-
- _ = session.Close()
- _ = sshClient.Close()
- _ = clientConn.Close()
- cancel()
-}
-
-// TestSSHProxy_CommandQuoting verifies that the proxy preserves shell quoting
-// when forwarding commands to the backend. This is critical for tools like
-// Ansible that send commands such as:
-//
-// /bin/sh -c '( umask 77 && mkdir -p ... ) && sleep 0'
-//
-// The single quotes must be preserved so the backend shell receives the
-// subshell expression as a single argument to -c.
-func TestSSHProxy_CommandQuoting(t *testing.T) {
- if testing.Short() {
- t.Skip("Skipping integration test in short mode")
- }
-
- sshClient, cleanup := setupProxySSHClient(t)
- defer cleanup()
-
- // These commands simulate what the SSH protocol delivers as exec payloads.
- // When a user types: ssh host '/bin/sh -c "( echo hello )"'
- // the local shell strips the outer single quotes, and the SSH exec request
- // contains the raw string: /bin/sh -c "( echo hello )"
- //
- // The proxy must forward this string verbatim. Using session.Command()
- // (shlex.Split + strings.Join) strips the inner double quotes, breaking
- // the command on the backend.
- tests := []struct {
- name string
- command string
- expect string
- }{
- {
- name: "subshell_in_double_quotes",
- command: `/bin/sh -c "( echo from-subshell ) && echo outer"`,
- expect: "from-subshell\nouter\n",
- },
- {
- name: "printf_with_special_chars",
- command: `/bin/sh -c "printf '%s\n' 'hello world'"`,
- expect: "hello world\n",
- },
- {
- name: "nested_command_substitution",
- command: `/bin/sh -c "echo $(echo nested)"`,
- expect: "nested\n",
- },
- }
-
- for _, tc := range tests {
- t.Run(tc.name, func(t *testing.T) {
- session, err := sshClient.NewSession()
- require.NoError(t, err)
- defer func() { _ = session.Close() }()
-
- var stderrBuf bytes.Buffer
- session.Stderr = &stderrBuf
-
- outputCh := make(chan []byte, 1)
- errCh := make(chan error, 1)
- go func() {
- output, err := session.Output(tc.command)
- outputCh <- output
- errCh <- err
- }()
-
- select {
- case output := <-outputCh:
- err := <-errCh
- if stderrBuf.Len() > 0 {
- t.Logf("stderr: %s", stderrBuf.String())
- }
- require.NoError(t, err, "command should succeed: %s", tc.command)
- assert.Equal(t, tc.expect, string(output), "output mismatch for: %s", tc.command)
- case <-time.After(5 * time.Second):
- t.Fatalf("command timed out: %s", tc.command)
- }
- })
- }
-}
-
-// setupProxySSHClient creates a full proxy test environment and returns
-// an SSH client connected through the proxy to a backend NetBird SSH server.
-func setupProxySSHClient(t *testing.T) (*cryptossh.Client, func()) {
- t.Helper()
-
- const (
- issuer = "https://test-issuer.example.com"
- audience = "test-audience"
- )
-
- jwksServer, privateKey, jwksURL := setupJWKSServer(t)
-
- hostKey, err := nbssh.GeneratePrivateKey(nbssh.ED25519)
- require.NoError(t, err)
- hostPubKey, err := nbssh.GeneratePublicKey(hostKey)
- require.NoError(t, err)
-
- serverConfig := &server.Config{
- HostKeyPEM: hostKey,
- JWT: &server.JWTConfig{
- Issuer: issuer,
- Audiences: []string{audience},
- KeysLocation: jwksURL,
- },
- }
- sshServer := server.New(serverConfig)
- sshServer.SetAllowRootLogin(true)
-
- testUsername := testutil.GetTestUsername(t)
- testJWTUser := "test-username"
- testUserHash, err := sshuserhash.HashUserID(testJWTUser)
- require.NoError(t, err)
-
- authConfig := &sshauth.Config{
- UserIDClaim: sshauth.DefaultUserIDClaim,
- AuthorizedUsers: []sshuserhash.UserIDHash{testUserHash},
- MachineUsers: map[string][]uint32{
- testUsername: {0},
- },
- }
- sshServer.UpdateSSHAuth(authConfig)
-
- sshServerAddr := server.StartTestServer(t, sshServer)
-
- mockDaemon := startMockDaemon(t)
-
- host, portStr, err := net.SplitHostPort(sshServerAddr)
- require.NoError(t, err)
- port, err := strconv.Atoi(portStr)
- require.NoError(t, err)
-
- mockDaemon.setHostKey(host, hostPubKey)
-
- validToken := generateValidJWT(t, privateKey, issuer, audience, testJWTUser)
- mockDaemon.setJWTToken(validToken)
-
- proxyInstance, err := New(mockDaemon.addr, host, port, io.Discard, nil)
- require.NoError(t, err)
-
- origStdin := os.Stdin
- origStdout := os.Stdout
-
- stdinReader, stdinWriter, err := os.Pipe()
- require.NoError(t, err)
- stdoutReader, stdoutWriter, err := os.Pipe()
- require.NoError(t, err)
-
- os.Stdin = stdinReader
- os.Stdout = stdoutWriter
-
- clientConn, proxyConn := net.Pipe()
-
- go func() { _, _ = io.Copy(stdinWriter, proxyConn) }()
- go func() { _, _ = io.Copy(proxyConn, stdoutReader) }()
-
- ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
-
- go func() {
- _ = proxyInstance.Connect(ctx)
- }()
-
- sshConfig := &cryptossh.ClientConfig{
- User: testutil.GetTestUsername(t),
- Auth: []cryptossh.AuthMethod{},
- HostKeyCallback: cryptossh.InsecureIgnoreHostKey(),
- Timeout: 5 * time.Second,
- }
-
- sshClientConn, chans, reqs, err := cryptossh.NewClientConn(clientConn, "test", sshConfig)
- require.NoError(t, err)
-
- client := cryptossh.NewClient(sshClientConn, chans, reqs)
-
- cleanupFn := func() {
- _ = client.Close()
- _ = clientConn.Close()
- cancel()
- os.Stdin = origStdin
- os.Stdout = origStdout
- _ = sshServer.Stop()
- mockDaemon.stop()
- jwksServer.Close()
- }
-
- return client, cleanupFn
-}
-
type mockDaemonServer struct {
proto.UnimplementedDaemonServiceServer
hostKeys map[string][]byte
@@ -492,10 +150,6 @@ func (m *mockDaemon) setHostKey(addr string, pubKey []byte) {
m.impl.hostKeys[addr] = pubKey
}
-func (m *mockDaemon) setJWTToken(token string) {
- m.impl.jwtToken = token
-}
-
func (m *mockDaemon) stop() {
if m.server != nil {
m.server.Stop()
@@ -508,63 +162,3 @@ func mustParsePublicKey(t *testing.T, pubKeyBytes []byte) cryptossh.PublicKey {
require.NoError(t, err)
return pubKey
}
-
-func setupJWKSServer(t *testing.T) (*httptest.Server, *rsa.PrivateKey, string) {
- t.Helper()
- privateKey, jwksJSON := generateTestJWKS(t)
-
- server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- w.Header().Set("Content-Type", "application/json")
- if _, err := w.Write(jwksJSON); err != nil {
- http.Error(w, err.Error(), http.StatusInternalServerError)
- }
- }))
-
- return server, privateKey, server.URL
-}
-
-func generateTestJWKS(t *testing.T) (*rsa.PrivateKey, []byte) {
- t.Helper()
- privateKey, err := rsa.GenerateKey(rand.Reader, 2048)
- require.NoError(t, err)
-
- publicKey := &privateKey.PublicKey
- n := publicKey.N.Bytes()
- e := publicKey.E
-
- jwk := nbjwt.JSONWebKey{
- Kty: "RSA",
- Kid: "test-key-id",
- Use: "sig",
- N: base64.RawURLEncoding.EncodeToString(n),
- E: base64.RawURLEncoding.EncodeToString(big.NewInt(int64(e)).Bytes()),
- }
-
- jwks := nbjwt.Jwks{
- Keys: []nbjwt.JSONWebKey{jwk},
- }
-
- jwksJSON, err := json.Marshal(jwks)
- require.NoError(t, err)
-
- return privateKey, jwksJSON
-}
-
-func generateValidJWT(t *testing.T, privateKey *rsa.PrivateKey, issuer, audience string, user string) string {
- t.Helper()
- claims := jwt.MapClaims{
- "iss": issuer,
- "aud": audience,
- "sub": user,
- "exp": time.Now().Add(time.Hour).Unix(),
- "iat": time.Now().Unix(),
- }
-
- token := jwt.NewWithClaims(jwt.SigningMethodRS256, claims)
- token.Header["kid"] = "test-key-id"
-
- tokenString, err := token.SignedString(privateKey)
- require.NoError(t, err)
-
- return tokenString
-}
diff --git a/client/ssh/server/executor_unix_privileged_test.go b/client/ssh/server/executor_unix_privileged_test.go
new file mode 100644
index 000000000..f1b0805d9
--- /dev/null
+++ b/client/ssh/server/executor_unix_privileged_test.go
@@ -0,0 +1,66 @@
+//go:build unix && privileged
+
+package server
+
+import (
+ "context"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestPrivilegeDropper_CreateExecutorCommand(t *testing.T) {
+ pd := NewPrivilegeDropper()
+
+ config := ExecutorConfig{
+ UID: 1000,
+ GID: 1000,
+ Groups: []uint32{1000, 1001},
+ WorkingDir: "/home/testuser",
+ Shell: "/bin/bash",
+ Command: "ls -la",
+ }
+
+ cmd, err := pd.CreateExecutorCommand(context.Background(), config)
+ require.NoError(t, err)
+ require.NotNil(t, cmd)
+
+ // Verify the command is calling netbird ssh exec
+ assert.Contains(t, cmd.Args, "ssh")
+ assert.Contains(t, cmd.Args, "exec")
+ assert.Contains(t, cmd.Args, "--uid")
+ assert.Contains(t, cmd.Args, "1000")
+ assert.Contains(t, cmd.Args, "--gid")
+ assert.Contains(t, cmd.Args, "1000")
+ assert.Contains(t, cmd.Args, "--groups")
+ assert.Contains(t, cmd.Args, "1000")
+ assert.Contains(t, cmd.Args, "1001")
+ assert.Contains(t, cmd.Args, "--working-dir")
+ assert.Contains(t, cmd.Args, "/home/testuser")
+ assert.Contains(t, cmd.Args, "--shell")
+ assert.Contains(t, cmd.Args, "/bin/bash")
+ assert.Contains(t, cmd.Args, "--cmd")
+ assert.Contains(t, cmd.Args, "ls -la")
+}
+
+func TestPrivilegeDropper_CreateExecutorCommandInteractive(t *testing.T) {
+ pd := NewPrivilegeDropper()
+
+ config := ExecutorConfig{
+ UID: 1000,
+ GID: 1000,
+ Groups: []uint32{1000},
+ WorkingDir: "/home/testuser",
+ Shell: "/bin/bash",
+ Command: "",
+ }
+
+ cmd, err := pd.CreateExecutorCommand(context.Background(), config)
+ require.NoError(t, err)
+ require.NotNil(t, cmd)
+
+ // Verify no command mode (command is empty so no --cmd flag)
+ assert.NotContains(t, cmd.Args, "--cmd")
+ assert.NotContains(t, cmd.Args, "--interactive")
+}
diff --git a/client/ssh/server/executor_unix_test.go b/client/ssh/server/executor_unix_test.go
index 0c5108f57..171e78b83 100644
--- a/client/ssh/server/executor_unix_test.go
+++ b/client/ssh/server/executor_unix_test.go
@@ -73,61 +73,6 @@ func TestPrivilegeDropper_ValidatePrivileges(t *testing.T) {
}
}
-func TestPrivilegeDropper_CreateExecutorCommand(t *testing.T) {
- pd := NewPrivilegeDropper()
-
- config := ExecutorConfig{
- UID: 1000,
- GID: 1000,
- Groups: []uint32{1000, 1001},
- WorkingDir: "/home/testuser",
- Shell: "/bin/bash",
- Command: "ls -la",
- }
-
- cmd, err := pd.CreateExecutorCommand(context.Background(), config)
- require.NoError(t, err)
- require.NotNil(t, cmd)
-
- // Verify the command is calling netbird ssh exec
- assert.Contains(t, cmd.Args, "ssh")
- assert.Contains(t, cmd.Args, "exec")
- assert.Contains(t, cmd.Args, "--uid")
- assert.Contains(t, cmd.Args, "1000")
- assert.Contains(t, cmd.Args, "--gid")
- assert.Contains(t, cmd.Args, "1000")
- assert.Contains(t, cmd.Args, "--groups")
- assert.Contains(t, cmd.Args, "1000")
- assert.Contains(t, cmd.Args, "1001")
- assert.Contains(t, cmd.Args, "--working-dir")
- assert.Contains(t, cmd.Args, "/home/testuser")
- assert.Contains(t, cmd.Args, "--shell")
- assert.Contains(t, cmd.Args, "/bin/bash")
- assert.Contains(t, cmd.Args, "--cmd")
- assert.Contains(t, cmd.Args, "ls -la")
-}
-
-func TestPrivilegeDropper_CreateExecutorCommandInteractive(t *testing.T) {
- pd := NewPrivilegeDropper()
-
- config := ExecutorConfig{
- UID: 1000,
- GID: 1000,
- Groups: []uint32{1000},
- WorkingDir: "/home/testuser",
- Shell: "/bin/bash",
- Command: "",
- }
-
- cmd, err := pd.CreateExecutorCommand(context.Background(), config)
- require.NoError(t, err)
- require.NotNil(t, cmd)
-
- // Verify no command mode (command is empty so no --cmd flag)
- assert.NotContains(t, cmd.Args, "--cmd")
- assert.NotContains(t, cmd.Args, "--interactive")
-}
-
// TestPrivilegeDropper_ActualPrivilegeDrop tests actual privilege dropping
// This test requires root privileges and will be skipped if not running as root
func TestPrivilegeDropper_ActualPrivilegeDrop(t *testing.T) {
diff --git a/client/status/status.go b/client/status/status.go
index e7e8ee11c..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 {
@@ -219,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(),
},
)
@@ -235,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 {
@@ -441,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)
diff --git a/client/status/status_test.go b/client/status/status_test.go
index 1ae7157c0..44fc30baf 100644
--- a/client/status/status_test.go
+++ b/client/status/status_test.go
@@ -647,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/system/info.go b/client/system/info.go
index 477d5162b..27588859e 100644
--- a/client/system/info.go
+++ b/client/system/info.go
@@ -3,6 +3,7 @@ package system
import (
"context"
"net/netip"
+ "slices"
"strings"
log "github.com/sirupsen/logrus"
@@ -121,6 +122,23 @@ func (i *Info) SetFlags(
}
}
+// removeAddresses drops network addresses whose IP matches any of the given
+// addresses, regardless of prefix length. Used to exclude the NetBird overlay
+// address, which otherwise churns the meta as the interface comes and goes.
+func (i *Info) removeAddresses(ips ...netip.Addr) {
+ if len(ips) == 0 {
+ return
+ }
+ filtered := i.NetworkAddresses[:0]
+ for _, addr := range i.NetworkAddresses {
+ if slices.Contains(ips, addr.NetIP.Addr()) {
+ continue
+ }
+ filtered = append(filtered, addr)
+ }
+ i.NetworkAddresses = filtered
+}
+
// extractUserAgent extracts Netbird's agent (client) name and version from the outgoing context
func extractUserAgent(ctx context.Context) string {
md, hasMeta := metadata.FromOutgoingContext(ctx)
@@ -147,7 +165,9 @@ func extractDeviceName(ctx context.Context, defaultName string) string {
}
// GetInfoWithChecks retrieves and parses the system information with applied checks.
-func GetInfoWithChecks(ctx context.Context, checks []*proto.Checks) (*Info, error) {
+// excludeIPs are dropped from the reported network addresses (e.g. our own
+// WireGuard overlay address, which otherwise churns the peer meta).
+func GetInfoWithChecks(ctx context.Context, checks []*proto.Checks, excludeIPs ...netip.Addr) (*Info, error) {
log.Debugf("gathering system information with checks: %d", len(checks))
processCheckPaths := make([]string, 0)
for _, check := range checks {
@@ -162,6 +182,7 @@ func GetInfoWithChecks(ctx context.Context, checks []*proto.Checks) (*Info, erro
info := GetInfo(ctx)
info.Files = files
+ info.removeAddresses(excludeIPs...)
log.Debugf("all system information gathered successfully")
return info, nil
diff --git a/client/system/info_test.go b/client/system/info_test.go
index 27821f3c5..dcda18e61 100644
--- a/client/system/info_test.go
+++ b/client/system/info_test.go
@@ -2,6 +2,7 @@ package system
import (
"context"
+ "net/netip"
"testing"
"github.com/stretchr/testify/assert"
@@ -43,3 +44,42 @@ func Test_NetAddresses(t *testing.T) {
t.Errorf("no network addresses found")
}
}
+
+func TestInfo_RemoveAddresses(t *testing.T) {
+ addr := func(cidr string) NetworkAddress {
+ return NetworkAddress{NetIP: netip.MustParsePrefix(cidr)}
+ }
+
+ info := &Info{
+ NetworkAddresses: []NetworkAddress{
+ addr("192.168.1.7/24"),
+ addr("100.76.70.97/32"), // overlay v4 (host mask /32)
+ addr("2001:818:c51b:4800:845:a65d:ae6f:623f/64"), // real global v6
+ addr("fd00:1234::1/64"), // overlay v6
+ },
+ }
+
+ // Overlay addresses as the engine knows them, with a different mask (/16, /64).
+ info.removeAddresses(
+ netip.MustParseAddr("100.76.70.97"),
+ netip.MustParseAddr("fd00:1234::1"),
+ )
+
+ want := []string{"192.168.1.7/24", "2001:818:c51b:4800:845:a65d:ae6f:623f/64"}
+ if len(info.NetworkAddresses) != len(want) {
+ t.Fatalf("got %d addresses, want %d: %v", len(info.NetworkAddresses), len(want), info.NetworkAddresses)
+ }
+ for i, w := range want {
+ if got := info.NetworkAddresses[i].NetIP.String(); got != w {
+ t.Errorf("address[%d] = %s, want %s", i, got, w)
+ }
+ }
+}
+
+func TestInfo_RemoveAddresses_NoOp(t *testing.T) {
+ info := &Info{NetworkAddresses: []NetworkAddress{{NetIP: netip.MustParsePrefix("10.0.0.1/24")}}}
+ info.removeAddresses()
+ if len(info.NetworkAddresses) != 1 {
+ t.Errorf("expected no change with empty input, got %v", info.NetworkAddresses)
+ }
+}
diff --git a/client/system/network_addr.go b/client/system/network_addr.go
index 5423cf8ad..44260a938 100644
--- a/client/system/network_addr.go
+++ b/client/system/network_addr.go
@@ -46,7 +46,9 @@ func toNetworkAddress(address net.Addr, mac string) (NetworkAddress, bool) {
if !ok {
return NetworkAddress{}, false
}
- if ipNet.IP.IsLoopback() {
+ // Skip link-local and multicast: they carry no routable peer info and the
+ // IPv6 link-local of a flapping NIC churns the meta on every up/down.
+ if ipNet.IP.IsLoopback() || ipNet.IP.IsLinkLocalUnicast() || ipNet.IP.IsMulticast() {
return NetworkAddress{}, false
}
prefix, err := netip.ParsePrefix(ipNet.String())
diff --git a/client/system/network_addr_test.go b/client/system/network_addr_test.go
new file mode 100644
index 000000000..a5f9c4279
--- /dev/null
+++ b/client/system/network_addr_test.go
@@ -0,0 +1,45 @@
+//go:build !ios
+
+package system
+
+import (
+ "net"
+ "testing"
+)
+
+func mustIPNet(t *testing.T, cidr string) *net.IPNet {
+ t.Helper()
+ ip, ipNet, err := net.ParseCIDR(cidr)
+ if err != nil {
+ t.Fatalf("parse %q: %v", cidr, err)
+ }
+ ipNet.IP = ip
+ return ipNet
+}
+
+func TestToNetworkAddress_Filtering(t *testing.T) {
+ const mac = "c8:4b:d6:b6:04:ac"
+
+ tests := []struct {
+ name string
+ cidr string
+ want bool
+ }{
+ {"ipv4 global", "10.65.16.181/23", true},
+ {"ipv6 global", "2620:52:0:4110:102d:6a98:ee75:8b92/64", true},
+ {"ipv4 loopback", "127.0.0.1/8", false},
+ {"ipv6 loopback", "::1/128", false},
+ {"ipv6 link-local", "fe80::871:4c25:23d7:2529/64", false},
+ {"ipv4 link-local", "169.254.1.2/16", false},
+ {"ipv6 multicast", "ff02::1/128", false},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ _, got := toNetworkAddress(mustIPNet(t, tt.cidr), mac)
+ if got != tt.want {
+ t.Errorf("toNetworkAddress(%s) ok = %v, want %v", tt.cidr, got, tt.want)
+ }
+ })
+ }
+}
diff --git a/client/testutil/privileged/runner_test.go b/client/testutil/privileged/runner_test.go
new file mode 100644
index 000000000..d1945894d
--- /dev/null
+++ b/client/testutil/privileged/runner_test.go
@@ -0,0 +1,196 @@
+//go:build privileged && (linux || darwin)
+
+// Package privileged provides a self-hosting harness that runs the repo's
+// privileged-tagged test suite inside a --privileged --cap-add=NET_ADMIN
+// container, so developers can exercise the root/system-mutating tests on a
+// non-root host with a single `go test` invocation.
+package privileged
+
+import (
+ "bytes"
+ "context"
+ "fmt"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/moby/moby/api/types/container"
+ "github.com/ory/dockertest/v4"
+)
+
+// containerImage / containerTag match the image used by the CI privileged job
+// (.github/workflows/golang-test-linux.yml, test_client_on_docker).
+const (
+ containerImage = "golang"
+ containerTag = "1.25-alpine"
+)
+
+const (
+ containerWorkdir = "/app"
+ containerGoCache = "/root/.cache/go-build"
+ containerGoModCache = "/go/pkg/mod"
+)
+
+// alpinePackages are the build/runtime deps the privileged tests need, mirroring
+// the CI container setup.
+const alpinePackages = "ca-certificates iptables ip6tables dbus dbus-dev libpcap-dev build-base"
+
+// privilegedTestPackages is the package list the suite runs, excluding the
+// server-side trees and UI/upload helpers, matching the CI Docker job's filter.
+const privilegedTestPackages = `go list -buildvcs=false ./... | grep -v -e /management -e /signal -e /relay -e /proxy -e /combined -e /client/ui -e /upload-server`
+
+// testWriter forwards container output to the test log line by line.
+type testWriter struct{ t *testing.T }
+
+func (w testWriter) Write(p []byte) (int, error) {
+ for _, line := range strings.Split(strings.TrimRight(string(p), "\n"), "\n") {
+ w.t.Log(line)
+ }
+ return len(p), nil
+}
+
+// TestRunPrivilegedSuiteInDocker spins up a privileged container, mounts the repo,
+// and runs `go test -tags 'devcert privileged'` inside it. When already running
+// inside that container (DOCKER_CI=true) it returns immediately so the real
+// privileged tests in the suite execute in place instead of recursing.
+func TestRunPrivilegedSuiteInDocker(t *testing.T) {
+ if os.Getenv("DOCKER_CI") == "true" {
+ t.Skip("inside privileged container, skipping container spawn; privileged tests run in place")
+ }
+
+ repoRoot, err := findRepoRoot()
+ if err != nil {
+ t.Fatalf("locate repo root: %v", err)
+ }
+ goCache, goModCache := hostGoCaches(t)
+
+ // dockertest reads DOCKER_HOST; point it at the active context's socket when
+ // the default one is absent (macOS Docker Desktop, Colima, OrbStack).
+ if host := dockerHost(); host != "" {
+ t.Setenv("DOCKER_HOST", host)
+ }
+
+ // NewPoolT registers container cleanup via t.Cleanup automatically.
+ pool := dockertest.NewPoolT(t, "", dockertest.WithMaxWait(30*time.Minute))
+
+ // Keep the container alive so the suite runs via Exec, which yields a clean
+ // exit code (the v4 Resource API exposes no container wait/exit-code).
+ resource := pool.RunT(t, containerImage,
+ dockertest.WithTag(containerTag),
+ dockertest.WithWorkingDir(containerWorkdir),
+ dockertest.WithMounts([]string{
+ repoRoot + ":" + containerWorkdir,
+ goCache + ":" + containerGoCache,
+ goModCache + ":" + containerGoModCache,
+ }),
+ dockertest.WithEnv([]string{
+ "CGO_ENABLED=1",
+ "CI=true",
+ "DOCKER_CI=true",
+ "CONTAINER=true",
+ "GOCACHE=" + containerGoCache,
+ "GOMODCACHE=" + containerGoModCache,
+ }),
+ dockertest.WithCmd([]string{"sleep", "infinity"}),
+ dockertest.WithHostConfig(func(hc *container.HostConfig) {
+ hc.Privileged = true
+ hc.CapAdd = []string{"NET_ADMIN"}
+ }),
+ dockertest.WithoutReuse(),
+ )
+
+ ctx, cancel := context.WithTimeout(context.Background(), 30*time.Minute)
+ defer cancel()
+
+ result, err := resource.Exec(ctx, []string{"sh", "-c", buildTestScript()})
+ if err != nil {
+ t.Fatalf("run privileged suite in container: %v", err)
+ }
+
+ w := testWriter{t}
+ _, _ = w.Write([]byte(result.StdOut))
+ _, _ = w.Write([]byte(result.StdErr))
+
+ if result.ExitCode != 0 {
+ t.Fatalf("privileged test suite failed in container (exit code %d)", result.ExitCode)
+ }
+}
+
+// findRepoRoot walks up from the test's working directory to the module root.
+func findRepoRoot() (string, error) {
+ dir, err := os.Getwd()
+ if err != nil {
+ return "", err
+ }
+ for {
+ if _, statErr := os.Stat(filepath.Join(dir, "go.mod")); statErr == nil {
+ return dir, nil
+ }
+ parent := filepath.Dir(dir)
+ if parent == dir {
+ return "", fmt.Errorf("go.mod not found above %s", dir)
+ }
+ dir = parent
+ }
+}
+
+// dockerHost returns a DOCKER_HOST override when the default socket is missing.
+// An empty result means the caller should leave DOCKER_HOST untouched (it is
+// already set, or the default unix socket exists). When neither is present
+// (common on macOS Docker Desktop, Colima and OrbStack, which use a per-user
+// socket), it resolves the active docker context's endpoint.
+func dockerHost() string {
+ if os.Getenv("DOCKER_HOST") != "" {
+ return ""
+ }
+ if _, err := os.Stat("/var/run/docker.sock"); err == nil {
+ return ""
+ }
+
+ out, err := exec.Command("docker", "context", "inspect", "-f", "{{.Endpoints.docker.Host}}").Output()
+ if err != nil {
+ return ""
+ }
+ return strings.TrimSpace(string(out))
+}
+
+// hostGoCaches resolves the host GOCACHE/GOMODCACHE so the container reuses the
+// existing build/module cache for speed.
+func hostGoCaches(t *testing.T) (string, string) {
+ t.Helper()
+ return goEnv(t, "GOCACHE"), goEnv(t, "GOMODCACHE")
+}
+
+func goEnv(t *testing.T, key string) string {
+ t.Helper()
+ var out bytes.Buffer
+ cmd := exec.Command("go", "env", key)
+ cmd.Stdout = &out
+ if err := cmd.Run(); err != nil {
+ t.Fatalf("go env %s: %v", key, err)
+ }
+ return strings.TrimSpace(out.String())
+}
+
+// buildTestScript builds the in-container command. PRIV_PKGS overrides the package
+// list (default: the full filtered set); PRIV_RUN adds a -run test-name filter.
+// Both empty reproduces the full privileged suite.
+func buildTestScript() string {
+ pkgs := privilegedTestPackages + " | xargs"
+ if p := os.Getenv("PRIV_PKGS"); p != "" {
+ pkgs = "echo " + p + " | xargs"
+ }
+
+ runFilter := ""
+ if r := os.Getenv("PRIV_RUN"); r != "" {
+ runFilter = "-run '" + r + "' "
+ }
+
+ return fmt.Sprintf(
+ "apk update >/dev/null && apk add --no-cache %s >/dev/null && %s go test -buildvcs=false -tags 'devcert privileged' %s-v -timeout 20m -p 1",
+ alpinePackages, pkgs, runFilter,
+ )
+}
diff --git a/client/ui/client_ui.go b/client/ui/client_ui.go
index c4b644354..40fb4169d 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,9 +330,13 @@ type serviceClient struct {
isUpdateIconActive bool
isEnforcedUpdate bool
lastNotifiedVersion string
- settingsEnabled bool
profilesEnabled bool
networksEnabled bool
+ // 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
@@ -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 {
@@ -392,7 +418,14 @@ func newServiceClient(args *newServiceClientArgs) *serviceClient {
case args.showProfiles:
s.showProfilesUI()
case args.showQuickActions:
- s.showQuickActionsUI()
+ // Suppress the on-boot Quick Actions popup when the daemon
+ // reports DisableAutoConnect=true — that flag carries both the
+ // user's "Connect on Startup = off" preference AND any MDM-
+ // enforced override (applyMDMPolicy writes the policy value
+ // into the same Config field). See netbirdio/netbird#5744.
+ if !s.disableAutoConnectFromDaemon() {
+ s.showQuickActionsUI()
+ }
case args.showUpdate:
s.showUpdateProgress(ctx, args.showUpdateVersion)
}
@@ -441,15 +474,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")
@@ -532,7 +562,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,7 +584,7 @@ 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")
}
@@ -622,7 +652,7 @@ func (s *serviceClient) buildSetConfigRequest(iMngURL string, port, mtu int64) (
}
req := &proto.SetConfigRequest{
- ProfileName: activeProf.Name,
+ ProfileName: activeProf.ID.String(),
Username: currUser.Username,
}
@@ -663,7 +693,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
}
@@ -787,13 +825,15 @@ func (s *serviceClient) login(ctx context.Context, openURL bool) (*proto.LoginRe
return nil, fmt.Errorf("get current user: %w", err)
}
+ handle := activeProf.ID.String()
+
loginReq := &proto.LoginRequest{
IsUnixDesktopClient: runtime.GOOS == "linux" || runtime.GOOS == "freebsd",
- ProfileName: &activeProf.Name,
+ ProfileName: &handle,
Username: &currUser.Username,
}
- profileState, err := s.profileManager.GetProfileState(activeProf.Name)
+ profileState, err := s.profileManager.GetProfileState(activeProf.ID)
if err != nil {
log.Debugf("failed to get profile state for login hint: %v", err)
} else if profileState.Email != "" {
@@ -1036,6 +1076,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 +1102,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 +1147,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 +1204,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 +1284,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 +1295,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 +1310,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()
+ }
}
}
@@ -1278,6 +1345,40 @@ func (s *serviceClient) getFeatures() (*proto.GetFeaturesResponse, error) {
return features, nil
}
+// disableAutoConnectFromDaemon returns true when the daemon reports
+// the active profile has DisableAutoConnect=true. Used by the
+// --quick-actions startup path to suppress the on-boot popup when the
+// user (or an MDM admin) opted out of auto-connecting; both cases
+// converge on the same Config field because applyMDMPolicy writes the
+// policy value into it. Returns false on any RPC / lookup failure so a
+// daemon hiccup does not silently swallow the popup.
+func (s *serviceClient) disableAutoConnectFromDaemon() bool {
+ activeProf, err := s.profileManager.GetActiveProfile()
+ if err != nil {
+ log.Warnf("disableAutoConnectFromDaemon: get active profile: %v", err)
+ return false
+ }
+ currUser, err := user.Current()
+ if err != nil {
+ log.Warnf("disableAutoConnectFromDaemon: get current user: %v", err)
+ return false
+ }
+ conn, err := s.getSrvClient(failFastTimeout)
+ if err != nil {
+ log.Warnf("disableAutoConnectFromDaemon: get daemon client: %v", err)
+ return false
+ }
+ srvCfg, err := conn.GetConfig(s.ctx, &proto.GetConfigRequest{
+ ProfileName: activeProf.ID.String(),
+ Username: currUser.Username,
+ })
+ if err != nil {
+ log.Warnf("disableAutoConnectFromDaemon: GetConfig RPC: %v", err)
+ return false
+ }
+ return srvCfg.GetDisableAutoConnect()
+}
+
// getSrvConfig from the service to show it in the settings window.
func (s *serviceClient) getSrvConfig() {
s.managementURL = profilemanager.DefaultManagementURL
@@ -1309,7 +1410,7 @@ func (s *serviceClient) getSrvConfig() {
}
srvCfg, err := conn.GetConfig(s.ctx, &proto.GetConfigRequest{
- ProfileName: activeProf.Name,
+ ProfileName: activeProf.ID.String(),
Username: currUser.Username,
})
if err != nil {
@@ -1356,7 +1457,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 +1474,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 +1511,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
}
@@ -1533,7 +1656,7 @@ func (s *serviceClient) loadSettings() {
}
cfg, err := conn.GetConfig(s.ctx, &proto.GetConfigRequest{
- ProfileName: activeProf.Name,
+ ProfileName: activeProf.ID.String(),
Username: currUser.Username,
})
if err != nil {
@@ -1579,6 +1702,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
@@ -1610,7 +1856,7 @@ func (s *serviceClient) updateConfig() error {
}
req := proto.SetConfigRequest{
- ProfileName: activeProf.Name,
+ ProfileName: activeProf.ID.String(),
Username: currUser.Username,
DisableAutoConnect: &disableAutoStart,
ServerSSHAllowed: &sshAllowed,
diff --git a/client/ui/profile.go b/client/ui/profile.go
index 7ee89e631..83b0ec18b 100644
--- a/client/ui/profile.go
+++ b/client/ui/profile.go
@@ -66,7 +66,7 @@ func (s *serviceClient) showProfilesUI() {
} else {
indicator.SetText("")
}
- nameLabel.SetText(profile.Name)
+ nameLabel.SetText(formatProfileLabel(profile, profiles))
// Configure Select/Active button
selectBtn.SetText(func() string {
@@ -88,7 +88,7 @@ func (s *serviceClient) showProfilesUI() {
return
}
// switch
- err = s.switchProfile(profile.Name)
+ err = s.switchProfile(profile.ID)
if err != nil {
log.Errorf("failed to switch profile: %v", err)
dialog.ShowError(errors.New("failed to select profile"), s.wProfiles)
@@ -130,7 +130,7 @@ func (s *serviceClient) showProfilesUI() {
logoutBtn.Show()
logoutBtn.SetText("Deregister")
logoutBtn.OnTapped = func() {
- s.handleProfileLogout(profile.Name, refresh)
+ s.handleProfileLogout(profile, refresh)
}
// Remove profile
@@ -144,7 +144,7 @@ func (s *serviceClient) showProfilesUI() {
return
}
- err = s.removeProfile(profile.Name)
+ err = s.removeProfile(profile.ID)
if err != nil {
log.Errorf("failed to remove profile: %v", err)
dialog.ShowError(fmt.Errorf("failed to remove profile"), s.wProfiles)
@@ -250,7 +250,7 @@ func (s *serviceClient) addProfile(profileName string) error {
return nil
}
-func (s *serviceClient) switchProfile(profileName string) error {
+func (s *serviceClient) switchProfile(handle string) error {
conn, err := s.getSrvClient(defaultFailTimeout)
if err != nil {
return fmt.Errorf(getClientFMT, err)
@@ -261,15 +261,15 @@ func (s *serviceClient) switchProfile(profileName string) error {
return fmt.Errorf("get current user: %w", err)
}
- if _, err := conn.SwitchProfile(s.ctx, &proto.SwitchProfileRequest{
- ProfileName: &profileName,
+ resp, err := conn.SwitchProfile(s.ctx, &proto.SwitchProfileRequest{
+ ProfileName: &handle,
Username: &currUser.Username,
- }); err != nil {
+ })
+ if err != nil {
return fmt.Errorf("switch profile failed: %w", err)
}
- err = s.profileManager.SwitchProfile(profileName)
- if err != nil {
+ if err := s.profileManager.SwitchProfile(profilemanager.ID(resp.Id)); err != nil {
return fmt.Errorf("switch profile: %w", err)
}
@@ -299,10 +299,27 @@ func (s *serviceClient) removeProfile(profileName string) error {
}
type Profile struct {
+ ID string
Name string
IsActive bool
}
+// formatProfileLabel returns the display label for a profile. Profiles can
+// share the same Name, so when more than one profile in profiles carries this
+// Name, a short form of the ID is appended to disambiguate the entries.
+func formatProfileLabel(profile Profile, profiles []Profile) string {
+ count := 0
+ for _, p := range profiles {
+ if p.Name == profile.Name {
+ count++
+ }
+ }
+ if count <= 1 {
+ return profile.Name
+ }
+ return fmt.Sprintf("%s (%s)", profile.Name, profilemanager.ID(profile.ID).ShortID())
+}
+
func (s *serviceClient) getProfiles() ([]Profile, error) {
conn, err := s.getSrvClient(defaultFailTimeout)
if err != nil {
@@ -324,6 +341,7 @@ func (s *serviceClient) getProfiles() ([]Profile, error) {
for _, profile := range profilesResp.Profiles {
profiles = append(profiles, Profile{
+ ID: profile.Id,
Name: profile.Name,
IsActive: profile.IsActive,
})
@@ -332,10 +350,10 @@ func (s *serviceClient) getProfiles() ([]Profile, error) {
return profiles, nil
}
-func (s *serviceClient) handleProfileLogout(profileName string, refreshCallback func()) {
+func (s *serviceClient) handleProfileLogout(profile Profile, refreshCallback func()) {
dialog.ShowConfirm(
"Deregister",
- fmt.Sprintf("Are you sure you want to deregister from '%s'?", profileName),
+ fmt.Sprintf("Are you sure you want to deregister from '%s'?", profile.Name),
func(confirm bool) {
if !confirm {
return
@@ -356,8 +374,10 @@ func (s *serviceClient) handleProfileLogout(profileName string, refreshCallback
}
username := currUser.Username
+ // ProfileName is treated as a handle; send the ID so the
+ // daemon resolves to exactly this profile.
_, err = conn.Logout(s.ctx, &proto.LogoutRequest{
- ProfileName: &profileName,
+ ProfileName: &profile.ID,
Username: &username,
})
if err != nil {
@@ -368,7 +388,7 @@ func (s *serviceClient) handleProfileLogout(profileName string, refreshCallback
dialog.ShowInformation(
"Deregistered",
- fmt.Sprintf("Successfully deregistered from '%s'", profileName),
+ fmt.Sprintf("Successfully deregistered from '%s'", profile.Name),
s.wProfiles,
)
@@ -461,6 +481,7 @@ func (p *profileMenu) getProfiles() ([]Profile, error) {
for _, profile := range profilesResp.Profiles {
profiles = append(profiles, Profile{
+ ID: profile.Id,
Name: profile.Name,
IsActive: profile.IsActive,
})
@@ -501,7 +522,7 @@ func (p *profileMenu) refresh() {
}
if activeProf.ProfileName == "default" || activeProf.Username == currUser.Username {
- activeProfState, err := p.profileManager.GetProfileState(activeProf.ProfileName)
+ activeProfState, err := p.profileManager.GetProfileState(profilemanager.ID(activeProf.Id))
if err != nil {
log.Warnf("failed to get active profile state: %v", err)
p.emailMenuItem.Hide()
@@ -512,7 +533,7 @@ func (p *profileMenu) refresh() {
}
for _, profile := range profiles {
- item := p.profileMenuItem.AddSubMenuItem(profile.Name, "")
+ item := p.profileMenuItem.AddSubMenuItem(formatProfileLabel(profile, profiles), "")
if profile.IsActive {
item.Check()
}
@@ -541,8 +562,8 @@ func (p *profileMenu) refresh() {
return
}
- _, err = conn.SwitchProfile(ctx, &proto.SwitchProfileRequest{
- ProfileName: &profile.Name,
+ switchResp, err := conn.SwitchProfile(ctx, &proto.SwitchProfileRequest{
+ ProfileName: &profile.ID,
Username: &currUser.Username,
})
if err != nil {
@@ -552,7 +573,7 @@ func (p *profileMenu) refresh() {
return
}
- err = p.profileManager.SwitchProfile(profile.Name)
+ err = p.profileManager.SwitchProfile(profilemanager.ID(switchResp.Id))
if err != nil {
log.Errorf("failed to switch profile '%s': %v", profile.Name, err)
return
@@ -666,16 +687,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)
}
}
@@ -695,7 +748,10 @@ func (p *profileMenu) updateMenu() {
}
sort.Slice(profiles, func(i, j int) bool {
- return profiles[i].Name < profiles[j].Name
+ if profiles[i].Name != profiles[j].Name {
+ return profiles[i].Name < profiles[j].Name
+ }
+ return profiles[i].ID < profiles[j].ID
})
p.mu.Lock()
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/Dockerfile b/combined/Dockerfile
index 357e10cf8..ac88b8509 100644
--- a/combined/Dockerfile
+++ b/combined/Dockerfile
@@ -2,4 +2,5 @@ FROM ubuntu:24.04
RUN apt update && apt install -y ca-certificates && rm -fr /var/cache/apt
ENTRYPOINT [ "/go/bin/netbird-server" ]
CMD ["--config", "/etc/netbird/config.yaml"]
-COPY netbird-server /go/bin/netbird-server
\ No newline at end of file
+ARG TARGETPLATFORM
+COPY ${TARGETPLATFORM}/netbird-server /go/bin/netbird-server
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/docs/testing-privileged.md b/docs/testing-privileged.md
new file mode 100644
index 000000000..cf2f23171
--- /dev/null
+++ b/docs/testing-privileged.md
@@ -0,0 +1,78 @@
+# Privileged tests
+
+Some tests in this repo need `root` or mutate host network state: they create
+TUN/WireGuard interfaces, open netlink/raw sockets, run eBPF programs, or shell
+out to `ip`/`iptables`/`nft`/`ifconfig`/`route`. Running them on a developer
+machine would require `sudo` and could leave stray interfaces or routes behind.
+
+These tests are gated behind the **`privileged` build tag** so the default test
+run is host-safe.
+
+## Running tests
+
+```bash
+# Host-safe: excludes privileged tests. Runs as a normal user, no sudo.
+make test-unit
+# equivalently:
+go test -tags devcert ./...
+
+# Privileged suite: runs the privileged-tagged tests inside a
+# --privileged --cap-add=NET_ADMIN container (requires Docker).
+make test-privileged
+
+# Narrow the container run to a single test / package:
+PRIV_RUN=TestNftablesManager PRIV_PKGS=./client/firewall/nftables/... make test-privileged
+```
+
+`PRIV_RUN` adds a `-run` test-name filter and `PRIV_PKGS` overrides the package
+list; both are optional and default to the full privileged suite.
+
+`make test-privileged` invokes the `ory/dockertest` harness in
+`client/testutil/privileged/`. The harness:
+
+1. Skips immediately when it detects it is already inside the container
+ (`DOCKER_CI=true`), so the privileged tests run in place instead of recursing.
+2. Otherwise spins up a `golang:1.25-alpine` container (matching CI),
+ bind-mounts the repo and the host Go build/module caches, installs the
+ required packages, and runs `go test -tags 'devcert privileged'` over the
+ client packages.
+3. Streams the container's output to the test log and fails if the suite fails.
+
+## Adding a privileged test
+
+A test is privileged if it does any of:
+
+- creates a real interface via `iface.NewWGIFace(...).Create()`,
+- opens a netlink or raw socket that hard-fails without `CAP_NET_ADMIN`,
+- runs an eBPF program (`ebpf.*.Listen()`),
+- shells out to `ip`, `iptables`, `nft`, `ifconfig`, or `route` to change state.
+
+Add the tag to the **top** of the file, combined with any existing platform
+constraint:
+
+```go
+//go:build privileged && linux
+
+package foo
+```
+
+If a file mixes privileged and pure-logic tests, **split it**: keep the pure
+tests (and any shared data — type/var declarations, table-driven `testCases`,
+helper interfaces) in an untagged file, and move the privileged tests into a
+`*_privileged_test.go` file with the tag. Shared declarations must stay untagged,
+otherwise the unprivileged files in the package will not compile.
+
+Always verify both build modes compile on every target platform:
+
+```bash
+go vet -tags devcert ./...
+go vet -tags 'devcert privileged' ./...
+```
+
+## CI
+
+- The `Client / Unit` job runs `go test -tags devcert` with **no** `sudo` — only
+ host-safe tests.
+- The `Client (Docker) / Unit` job runs `go test -tags 'devcert privileged'`
+ inside a `--privileged --cap-add=NET_ADMIN` container, which is where the
+ privileged tests actually execute.
diff --git a/go.mod b/go.mod
index bafdeaf86..9a57de1c9 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
@@ -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
@@ -74,10 +78,12 @@ require (
github.com/mdp/qrterminal/v3 v3.2.1
github.com/miekg/dns v1.1.72
github.com/mitchellh/hashstructure/v2 v2.0.2
+ github.com/moby/moby/api v1.54.1
github.com/netbirdio/management-integrations/integrations v0.0.0-20260416123949-2355d972be42
github.com/netbirdio/signal-dispatcher/dispatcher v0.0.0-20250805121659-6b4ac470ca45
github.com/oapi-codegen/runtime v1.1.2
github.com/okta/okta-sdk-golang/v2 v2.18.0
+ github.com/ory/dockertest/v4 v4.0.0
github.com/oschwald/maxminddb-golang v1.12.0
github.com/patrickmn/go-cache v2.1.0+incompatible
github.com/petermattis/goid v0.0.0-20250303134427-723919f7f203
@@ -131,6 +137,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 (
@@ -140,7 +147,7 @@ require (
dario.cat/mergo v1.0.1 // indirect
filippo.io/edwards25519 v1.1.1 // indirect
github.com/AppsFlyer/go-sundheit v0.6.0 // indirect
- github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161 // indirect
+ github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c // indirect
github.com/Azure/go-ntlmssp v0.1.0 // indirect
github.com/BurntSushi/toml v1.5.0 // indirect
github.com/Masterminds/goutils v1.1.1 // indirect
@@ -172,6 +179,8 @@ require (
github.com/caddyserver/zerossl v0.1.3 // indirect
github.com/cenkalti/backoff/v5 v5.0.3 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
+ github.com/containerd/errdefs v1.0.0 // indirect
+ github.com/containerd/errdefs/pkg v0.3.0 // indirect
github.com/containerd/log v0.1.0 // indirect
github.com/containerd/platforms v0.2.1 // indirect
github.com/cpuguy83/dockercfg v0.3.2 // indirect
@@ -211,10 +220,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
@@ -265,11 +275,12 @@ require (
github.com/mitchellh/mapstructure v1.5.0 // indirect
github.com/mitchellh/reflectwalk v1.0.2 // indirect
github.com/moby/docker-image-spec v1.3.1 // indirect
+ github.com/moby/moby/client v0.4.0 // indirect
github.com/moby/patternmatcher v0.6.0 // indirect
github.com/moby/sys/sequential v0.5.0 // indirect
github.com/moby/sys/user v0.3.0 // indirect
github.com/moby/sys/userns v0.1.0 // indirect
- github.com/moby/term v0.5.0 // indirect
+ github.com/moby/term v0.5.2 // indirect
github.com/morikuni/aec v1.0.0 // indirect
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 // indirect
@@ -335,7 +346,7 @@ replace github.com/kardianos/service => github.com/netbirdio/service v0.0.0-2024
replace github.com/getlantern/systray => github.com/netbirdio/systray v0.0.0-20231030152038-ef1ed2a27949
-replace golang.zx2c4.com/wireguard => github.com/netbirdio/wireguard-go v0.0.0-20260523085312-4b4a4e36017f
+replace golang.zx2c4.com/wireguard => github.com/netbirdio/wireguard-go v0.0.0-20260628102922-2834bebf6c1a
replace github.com/cloudflare/circl => codeberg.org/cunicu/circl v0.0.0-20230801113412-fec58fc7b5f6
diff --git a/go.sum b/go.sum
index 2f42f96b1..7b29b7604 100644
--- a/go.sum
+++ b/go.sum
@@ -23,8 +23,8 @@ github.com/AdaLogics/go-fuzz-headers v0.0.0-20230811130428-ced1acdcaa24 h1:bvDV9
github.com/AdaLogics/go-fuzz-headers v0.0.0-20230811130428-ced1acdcaa24/go.mod h1:8o94RPi1/7XTJvwPpRSzSUedZrtlirdB3r9Z20bi2f8=
github.com/AppsFlyer/go-sundheit v0.6.0 h1:d2hBvCjBSb2lUsEWGfPigr4MCOt04sxB+Rppl0yUMSk=
github.com/AppsFlyer/go-sundheit v0.6.0/go.mod h1:LDdBHD6tQBtmHsdW+i1GwdTt6Wqc0qazf5ZEJVTbTME=
-github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161 h1:L/gRVlceqvL25UVaW/CKtUDjefjrs0SPonmDGUVOYP0=
-github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E=
+github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c h1:udKWzYgxTojEKWjV8V+WSxDXJ4NFATAsZjh8iIbsQIg=
+github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E=
github.com/Azure/go-ntlmssp v0.1.0 h1:DjFo6YtWzNqNvQdrwEyr/e4nhU3vRiwenz5QX7sFz+A=
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=
@@ -117,6 +117,10 @@ github.com/cilium/ebpf v0.19.0 h1:Ro/rE64RmFBeA9FGjcTc+KmCeY6jXmryu6FfnzPRIao=
github.com/cilium/ebpf v0.19.0/go.mod h1:fLCgMo3l8tZmAdM3B2XqdFzXBpwkcSTroaVqN08OWVY=
github.com/coder/websocket v1.8.14 h1:9L0p0iKiNOibykf283eHkKUHHrpG7f65OE3BhhO7v9g=
github.com/coder/websocket v1.8.14/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg=
+github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI=
+github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M=
+github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE=
+github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk=
github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I=
github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo=
github.com/containerd/platforms v0.2.1 h1:zvwtM3rz2YHPQsF2CHYM8+KtB5dvhISiXh5ZpSBQv6A=
@@ -249,6 +253,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=
@@ -275,8 +285,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=
@@ -380,6 +390,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=
@@ -473,6 +484,10 @@ github.com/mitchellh/reflectwalk v1.0.2 h1:G2LzWKi524PWgd3mLHV8Y5k7s6XUvT0Gef6zx
github.com/mitchellh/reflectwalk v1.0.2/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw=
github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0=
github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo=
+github.com/moby/moby/api v1.54.1 h1:TqVzuJkOLsgLDDwNLmYqACUuTehOHRGKiPhvH8V3Nn4=
+github.com/moby/moby/api v1.54.1/go.mod h1:+RQ6wluLwtYaTd1WnPLykIDPekkuyD/ROWQClE83pzs=
+github.com/moby/moby/client v0.4.0 h1:S+2XegzHQrrvTCvF6s5HFzcrywWQmuVnhOXe2kiWjIw=
+github.com/moby/moby/client v0.4.0/go.mod h1:QWPbvWchQbxBNdaLSpoKpCdf5E+WxFAgNHogCWDoa7g=
github.com/moby/patternmatcher v0.6.0 h1:GmP9lR19aU5GqSSFko+5pRqHi+Ohk1O69aFiKkVGiPk=
github.com/moby/patternmatcher v0.6.0/go.mod h1:hDPoyOpDY7OrrMDLaYoY3hf52gNCR/YOUYxkhApJIxc=
github.com/moby/sys/sequential v0.5.0 h1:OPvI35Lzn9K04PBbCLW0g4LcFAJgHsvXsRyewg5lXtc=
@@ -481,8 +496,8 @@ github.com/moby/sys/user v0.3.0 h1:9ni5DlcW5an3SvRSx4MouotOygvzaXbaSrc/wGDFWPo=
github.com/moby/sys/user v0.3.0/go.mod h1:bG+tYYYJgaMtRKgEmuueC0hJEAZWwtIbZTB+85uoHjs=
github.com/moby/sys/userns v0.1.0 h1:tVLXkFOxVu9A64/yh59slHVv9ahO9UIev4JZusOLG/g=
github.com/moby/sys/userns v0.1.0/go.mod h1:IHUYgu/kao6N8YZlp9Cf444ySSvCmDlmzUcYfDHOl28=
-github.com/moby/term v0.5.0 h1:xt8Q1nalod/v7BqbG21f8mQPqH+xAaC9C3N3wfWbVP0=
-github.com/moby/term v0.5.0/go.mod h1:8FzsFHVUBGZdbDsJw/ot+X+d5HLUbvklYLJ9uGfcI3Y=
+github.com/moby/term v0.5.2 h1:6qk3FJAFDs6i/q3W/pQ97SX192qKfZgGjCQqfCJkgzQ=
+github.com/moby/term v0.5.2/go.mod h1:d3djjFCrjnB+fl8NJux+EJzu0msscUP+f8it8hPkFLc=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0=
github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A=
@@ -503,8 +518,8 @@ github.com/netbirdio/service v0.0.0-20240911161631-f62744f42502 h1:3tHlFmhTdX9ax
github.com/netbirdio/service v0.0.0-20240911161631-f62744f42502/go.mod h1:CIMRFEJVL+0DS1a3Nx06NaMn4Dz63Ng6O7dl0qH0zVM=
github.com/netbirdio/signal-dispatcher/dispatcher v0.0.0-20250805121659-6b4ac470ca45 h1:ujgviVYmx243Ksy7NdSwrdGPSRNE3pb8kEDSpH0QuAQ=
github.com/netbirdio/signal-dispatcher/dispatcher v0.0.0-20250805121659-6b4ac470ca45/go.mod h1:5/sjFmLb8O96B5737VCqhHyGRzNFIaN/Bu7ZodXc3qQ=
-github.com/netbirdio/wireguard-go v0.0.0-20260523085312-4b4a4e36017f h1:ff2D57RBjWtyQ2wVwJOxOgXAXOe/J2lJWtSX0Bz/BRk=
-github.com/netbirdio/wireguard-go v0.0.0-20260523085312-4b4a4e36017f/go.mod h1:rpwXGsirqLqN2L0JDJQlwOboGHmptD5ZD6T2VmcqhTw=
+github.com/netbirdio/wireguard-go v0.0.0-20260628102922-2834bebf6c1a h1:3CWK+yTvRKOcC0Q8VCTGy4l60TEb27CQVS7LkMxwjmw=
+github.com/netbirdio/wireguard-go v0.0.0-20260628102922-2834bebf6c1a/go.mod h1:rpwXGsirqLqN2L0JDJQlwOboGHmptD5ZD6T2VmcqhTw=
github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 h1:zYyBkD/k9seD2A7fsi6Oo2LfFZAehjjQMERAvZLEDnQ=
github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646/go.mod h1:jpp1/29i3P1S/RLdc7JQKbRpFeM1dOBd8T9ki5s+AY8=
github.com/nicksnyder/go-i18n/v2 v2.5.1 h1:IxtPxYsR9Gp60cGXjfuR/llTqV8aYMsC472zD0D1vHk=
@@ -535,6 +550,8 @@ github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8
github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040=
github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M=
+github.com/ory/dockertest/v4 v4.0.0 h1:i19aFsO/VXE0VrMk4ifnKW4G/KIJ93PCjLOslxXoPME=
+github.com/ory/dockertest/v4 v4.0.0/go.mod h1:b5Ofu8VIxWNhXFvQcLu17pRNQdoUBKtXBW74G4Ygzx8=
github.com/oschwald/maxminddb-golang v1.12.0 h1:9FnTOD0YOhP7DGxGsq4glzpGy5+w7pq50AS6wALUMYs=
github.com/oschwald/maxminddb-golang v1.12.0/go.mod h1:q0Nob5lTCqyQ8WT6FYgS1L7PXKVVbgiymefNwIjPzgY=
github.com/patrickmn/go-cache v2.1.0+incompatible h1:HRMgzkcYKYpi3C8ajMPV8OFXaaRUnok+kx1WdO15EQc=
@@ -844,6 +861,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=
@@ -946,6 +964,7 @@ 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=
@@ -964,9 +983,13 @@ gorm.io/driver/sqlite v1.5.7/go.mod h1:U+J8craQU6Fzkcvu8oLeAQmi50TkwPEhHDEjQZXDa
gorm.io/gorm v1.25.7/go.mod h1:hbnx/Oo0ChWMn1BIhpy1oYozzpM15i4YPuHDmfYtwg8=
gorm.io/gorm v1.25.12 h1:I0u8i2hWQItBq1WfE0o2+WuL9+8L21K9e2HHSTE/0f8=
gorm.io/gorm v1.25.12/go.mod h1:xh7N7RHfYlNc5EmcI/El95gXusucDrQnHXe0+CgWcLQ=
-gotest.tools/v3 v3.5.1 h1:EENdUnS3pdur5nybKYIh2Vfgc8IUNBjxDPSjtiJcOzU=
-gotest.tools/v3 v3.5.1/go.mod h1:isy3WKz7GK6uNw/sbHzfKBLvlvXwUyV06n6brMxxopU=
+gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q=
+gotest.tools/v3 v3.5.2/go.mod h1:LtdLGcnqToBH83WByAAi/wiwSFCArdFIUV/xxN4pcjA=
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=
+pgregory.net/rapid v1.2.0 h1:keKAYRcjm+e1F0oAuU5F5+YPAWcyxNNRK2wud503Gnk=
+pgregory.net/rapid v1.2.0/go.mod h1:PY5XlDGj0+V1FCq0o192FdRhpKHGTRIWBgqjDBTrq04=
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 526d6a17a..67aeb995f 100644
--- a/idp/dex/provider.go
+++ b/idp/dex/provider.go
@@ -41,6 +41,8 @@ type Config struct {
GRPCAddr string
}
+const localConnectorID = "local"
+
// Provider wraps a Dex server
type Provider struct {
config *Config
@@ -544,7 +546,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
}
@@ -619,6 +621,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-enterprise.sh b/infrastructure_files/getting-started-enterprise.sh
new file mode 100755
index 000000000..5d2341cbe
--- /dev/null
+++ b/infrastructure_files/getting-started-enterprise.sh
@@ -0,0 +1,616 @@
+#!/bin/bash
+
+set -e
+set -o pipefail
+
+# NetBird Enterprise — Getting Started
+# Single-node bootstrap for a self-hosted NetBird Enterprise stack with the
+# embedded identity provider. Owner is created via first-login flow.
+
+SED_STRIP_PADDING='s/=//g'
+
+check_docker_compose() {
+ if command -v docker-compose &> /dev/null; then
+ echo "docker-compose"
+ return
+ fi
+ if docker compose --help &> /dev/null; then
+ echo "docker compose"
+ return
+ fi
+ echo "docker-compose is not installed or not in PATH. See https://docs.docker.com/engine/install/" > /dev/stderr
+ exit 1
+}
+
+check_openssl() {
+ if ! command -v openssl &> /dev/null; then
+ echo "openssl is not installed or not in PATH." > /dev/stderr
+ exit 1
+ fi
+}
+
+rand_secret() {
+ openssl rand -base64 32 | sed "$SED_STRIP_PADDING"
+}
+
+rand_b64_key() {
+ openssl rand -base64 32
+}
+
+check_nb_domain() {
+ local domain="$1"
+ if [[ -z "$domain" ]]; then
+ echo "The domain cannot be empty." > /dev/stderr
+ return 1
+ fi
+ if [[ "$domain" == "netbird.example.com" ]]; then
+ echo "The domain cannot be netbird.example.com" > /dev/stderr
+ return 1
+ fi
+ if [[ "$domain" =~ ^[0-9.]+$ ]]; then
+ echo "An IP address is not allowed. A real DNS-resolvable domain is required for TLS and the embedded IdP issuer." > /dev/stderr
+ return 1
+ fi
+ if [[ ! "$domain" =~ ^[A-Za-z0-9]([A-Za-z0-9-]*[A-Za-z0-9])?(\.[A-Za-z0-9]([A-Za-z0-9-]*[A-Za-z0-9])?)+$ ]]; then
+ echo "The value '$domain' is not a valid FQDN. A real DNS-resolvable domain is required for TLS and the embedded IdP issuer." > /dev/stderr
+ return 1
+ fi
+ return 0
+}
+
+check_domain_resolves() {
+ local domain="$1"
+ if command -v getent &> /dev/null && getent hosts "$domain" &> /dev/null; then return 0; fi
+ if command -v host &> /dev/null && host "$domain" &> /dev/null; then return 0; fi
+ if command -v dig &> /dev/null && [[ -n "$(dig +short "$domain" 2>/dev/null)" ]]; then return 0; fi
+ if command -v nslookup &> /dev/null && nslookup "$domain" &> /dev/null; then return 0; fi
+ return 1
+}
+
+read_nb_domain() {
+ local value=""
+ echo -n "Enter the FQDN for NetBird (must resolve via DNS, e.g. netbird.my-domain.com): " > /dev/stderr
+ read -r value < /dev/tty
+ if ! check_nb_domain "$value"; then
+ read_nb_domain
+ return
+ fi
+ if ! check_domain_resolves "$value"; then
+ echo "" > /dev/stderr
+ echo "Warning: '$value' does not resolve via DNS from this host." > /dev/stderr
+ echo "Caddy will not be able to issue TLS certificates until it does." > /dev/stderr
+ local confirm=""
+ echo -n "Continue anyway? [y/N]: " > /dev/stderr
+ read -r confirm < /dev/tty
+ if [[ ! "$confirm" =~ ^[Yy]$ ]]; then
+ read_nb_domain
+ return
+ fi
+ fi
+ echo "$value"
+}
+
+read_required() {
+ local prompt="$1"
+ local value=""
+ while [[ -z "$value" ]]; do
+ echo -n "$prompt: " > /dev/stderr
+ read -r value < /dev/tty
+ if [[ -z "$value" ]]; then
+ echo "Value cannot be empty." > /dev/stderr
+ fi
+ done
+ echo "$value"
+}
+
+read_secret() {
+ local prompt="$1"
+ local value=""
+ while [[ -z "$value" ]]; do
+ echo -n "$prompt: " > /dev/stderr
+ read -rs value < /dev/tty
+ echo "" > /dev/stderr
+ if [[ -z "$value" ]]; then
+ echo "Value cannot be empty." > /dev/stderr
+ fi
+ done
+ echo "$value"
+}
+
+# read_yes_no "" []
+read_yes_no() {
+ local prompt="$1"
+ local default="${2:-n}"
+ local hint
+ if [[ "$default" == "y" ]]; then
+ hint="[Y/n]"
+ else
+ hint="[y/N]"
+ fi
+ echo -n "${prompt} ${hint}: " > /dev/stderr
+ local ans=""
+ read -r ans < /dev/tty
+ if [[ -z "$ans" ]]; then
+ ans="$default"
+ fi
+ case "$ans" in
+ [Yy] | [Yy][Ee][Ss]) echo "yes" ;;
+ *) echo "no" ;;
+ esac
+}
+
+wait_postgres() {
+ set +e
+ echo -n "Waiting for postgres to become ready"
+ local counter=1
+ while true; do
+ if $DOCKER_COMPOSE_COMMAND exec -T postgres pg_isready -U "$POSTGRES_USER" -d "$POSTGRES_DB" &> /dev/null; then
+ break
+ fi
+ if [[ $counter -eq 60 ]]; then
+ echo ""
+ echo "Postgres is taking too long. Recent logs:"
+ $DOCKER_COMPOSE_COMMAND logs --tail=20 postgres
+ exit 1
+ fi
+ echo -n " ."
+ sleep 2
+ counter=$((counter + 1))
+ done
+ echo " done"
+ set -e
+}
+
+init_environment() {
+ check_openssl
+ DOCKER_COMPOSE_COMMAND=$(check_docker_compose)
+
+ if [[ -f .env ]] || [[ -f docker-compose.yml ]] || [[ -f config.yaml ]] || [[ -f Caddyfile ]]; then
+ echo "Generated files already exist in $(pwd)."
+ echo "If you want to reinitialize the environment, please remove them first:"
+ echo " $DOCKER_COMPOSE_COMMAND down --volumes # removes all containers and volumes"
+ echo " rm -f .env docker-compose.yml Caddyfile config.yaml"
+ echo "Be aware this will remove all data from the database."
+ exit 1
+ fi
+
+ echo "NetBird Enterprise bootstrap"
+ echo ""
+ echo "Traffic flow:"
+ echo " Enables traffic events logging on the management server."
+ echo " When enabled, the NetBird stack also runs NATS along with two"
+ echo " additional containers: netbird-receiver (the traffic log receiver"
+ echo " service) and netbird-enricher (the traffic log enricher service)."
+ echo " It still has to be turned on from the dashboard settings afterwards."
+ echo " See https://docs.netbird.io/manage/activity/traffic-events-logging"
+ NETBIRD_TRAFFIC_FLOW=$(read_yes_no "Enable traffic flow" "n")
+
+ echo ""
+ NETBIRD_DOMAIN=$(read_nb_domain)
+
+ echo ""
+
+ NETBIRD_LICENSE_KEY=$(read_secret "Enter license key (input hidden)")
+
+ GHCR_USERNAME="netbirdExtAccess1"
+ GHCR_TOKEN=$(read_secret "Enter GHCR token (input hidden)")
+
+ POSTGRES_USER="netbird"
+ POSTGRES_DB="netbird"
+ POSTGRES_PASSWORD=$(rand_secret)
+ NETBIRD_ENCRYPTION_KEY=$(rand_b64_key)
+ NETBIRD_RELAY_AUTH_SECRET=$(rand_secret)
+
+ POSTGRES_DSN="host=postgres user=${POSTGRES_USER} password=${POSTGRES_PASSWORD} dbname=${POSTGRES_DB} port=5432 sslmode=disable TimeZone=UTC"
+ NETBIRD_RELAY_ENDPOINT="rels://${NETBIRD_DOMAIN}:443"
+
+ echo ""
+ echo "Selected:"
+ echo " Traffic flow: ${NETBIRD_TRAFFIC_FLOW}"
+ echo " Domain: ${NETBIRD_DOMAIN}"
+ echo ""
+ echo "Rendering files into $(pwd) ..."
+ install -m 600 /dev/null .env
+ render_env >> .env
+ render_docker_compose > docker-compose.yml
+
+ if [[ -z "${NETBIRD_LICENSE_SERVER_BASE_URL:-}" ]]; then
+ sed -i.bak '/NETBIRD_LICENSE_SERVER_BASE_URL/d' docker-compose.yml && rm -f docker-compose.yml.bak
+ fi
+ render_caddyfile > Caddyfile
+ install -m 600 /dev/null config.yaml
+ render_config_yaml >> config.yaml
+
+ echo "Logging in to ghcr.io ..."
+ printf '%s' "$GHCR_TOKEN" | docker login ghcr.io -u "$GHCR_USERNAME" --password-stdin
+ unset GHCR_TOKEN
+
+ echo ""
+ echo "Pulling images ..."
+ $DOCKER_COMPOSE_COMMAND pull
+
+ echo ""
+ echo "Starting postgres ..."
+ $DOCKER_COMPOSE_COMMAND up -d postgres
+ sleep 2
+ wait_postgres
+
+ echo ""
+ echo "Starting remaining services ..."
+ $DOCKER_COMPOSE_COMMAND up -d
+
+ echo ""
+ echo "Done."
+ echo ""
+ echo "Dashboard: https://${NETBIRD_DOMAIN}"
+ echo ""
+ echo "Open the dashboard in a browser to complete the first-login owner setup."
+ echo "All configuration and secrets are stored (mode 600) in $(pwd)/.env"
+ echo ""
+ echo "Tail logs:"
+ echo " cd $(pwd) && $DOCKER_COMPOSE_COMMAND logs -f netbird-server caddy"
+}
+
+# ------------------------------------------------------------------
+# Renderers
+# ------------------------------------------------------------------
+
+render_env() {
+ cat < /dev/stderr
+ echo "Agent-network preset enabled (NETBIRD_AGENT_NETWORK=true):" > /dev/stderr
+ echo " - reverse proxy: built-in Traefik" > /dev/stderr
+ echo " - NetBird Proxy: enabled with NB_PROXY_PRIVATE=true" > /dev/stderr
+ echo " - server image: ${NETBIRD_SERVER_IMAGE}" > /dev/stderr
+ echo " - proxy image: ${NETBIRD_PROXY_IMAGE}" > /dev/stderr
+ echo " - dashboard: NETBIRD_AGENT_NETWORK_ONLY=true" > /dev/stderr
+ echo " - CrowdSec: disabled" > /dev/stderr
+ echo " - Let's Encrypt email: ${TRAEFIK_ACME_EMAIL}" > /dev/stderr
+ echo "" > /dev/stderr
+}
+
configure_reverse_proxy() {
+ # Short-circuit: agent-network preset locks every reverse-proxy /
+ # proxy / CrowdSec choice and bypasses the interactive prompts.
+ if [[ "${NETBIRD_AGENT_NETWORK}" == "true" ]]; then
+ apply_agent_network_preset
+ return 0
+ fi
+
# Prompt for reverse proxy type
REVERSE_PROXY_TYPE=$(read_reverse_proxy_type)
@@ -910,6 +961,15 @@ NGINX_SSL_PORT=443
# Letsencrypt
LETSENCRYPT_DOMAIN=none
EOF
+
+ if [[ "${NETBIRD_AGENT_NETWORK}" == "true" ]]; then
+ cat < /dev/null; then
+ echo "docker-compose"
+ return
+ fi
+ if docker compose --help &> /dev/null; then
+ echo "docker compose"
+ return
+ fi
+ echo "docker-compose is not installed or not in PATH." > /dev/stderr
+ exit 1
+}
+
+check_yq() {
+ if ! command -v yq &> /dev/null; then
+ cat > /dev/stderr <<'EOF'
+yq is required to parse and update YAML safely.
+
+ macOS: brew install yq
+ Linux: https://github.com/mikefarah/yq/releases (download binary into PATH)
+ Debian: apt-get install yq (Note: must be the mikefarah Go yq, not the Python wrapper.)
+
+EOF
+ exit 1
+ fi
+ if ! yq --version 2>&1 | grep -q "mikefarah"; then
+ echo "yq is present but appears to be the wrong implementation. The mikefarah Go-based yq is required (https://github.com/mikefarah/yq)." > /dev/stderr
+ exit 1
+ fi
+}
+
+check_openssl() {
+ if ! command -v openssl &> /dev/null; then
+ echo "openssl is not installed or not in PATH." > /dev/stderr
+ exit 1
+ fi
+}
+
+rand_password() {
+ openssl rand -hex 32
+}
+
+read_required() {
+ local prompt="$1"
+ local value=""
+ while [[ -z "$value" ]]; do
+ echo -n "$prompt: " > /dev/stderr
+ read -r value < /dev/tty
+ if [[ -z "$value" ]]; then
+ echo "Value cannot be empty." > /dev/stderr
+ fi
+ done
+ echo "$value"
+}
+
+read_secret() {
+ local prompt="$1"
+ local value=""
+ while [[ -z "$value" ]]; do
+ echo -n "$prompt: " > /dev/stderr
+ read -rs value < /dev/tty
+ echo "" > /dev/stderr
+ if [[ -z "$value" ]]; then
+ echo "Value cannot be empty." > /dev/stderr
+ fi
+ done
+ echo "$value"
+}
+
+read_yes_no() {
+ local prompt="$1"
+ local default="${2:-n}"
+ local hint
+ if [[ "$default" == "y" ]]; then
+ hint="[Y/n]"
+ else
+ hint="[y/N]"
+ fi
+ echo -n "${prompt} ${hint}: " > /dev/stderr
+ local ans=""
+ read -r ans < /dev/tty
+ if [[ -z "$ans" ]]; then
+ ans="$default"
+ fi
+ case "$ans" in
+ [Yy] | [Yy][Ee][Ss]) echo "yes" ;;
+ *) echo "no" ;;
+ esac
+}
+
+# ---------------------------------------------------------------------------
+# Detection — read the operator's existing compose to find service names and
+# paths we need to override. Bail loudly if shape isn't recognised.
+# ---------------------------------------------------------------------------
+
+detect_combined_service() {
+ yq eval '.services | to_entries | map(select(.value.image | test("^netbirdio/netbird-server"))) | .[0].key // ""' "$COMPOSE_FILE"
+}
+
+detect_dashboard_service() {
+ yq eval '.services | to_entries | map(select(.value.image | test("^netbirdio/dashboard"))) | .[0].key // ""' "$COMPOSE_FILE"
+}
+
+detect_config_yaml_host_path() {
+ yq eval ".services[\"$COMBINED_SERVICE\"].volumes[] | select(. | test(\":/etc/netbird/config.yaml\")) | sub(\":/etc/netbird/config.yaml.*\"; \"\") // \"\"" "$COMPOSE_FILE" | head -1
+}
+
+detect_data_volume() {
+ yq eval ".services[\"$COMBINED_SERVICE\"].volumes[] | select(. | test(\":/var/lib/netbird\")) | sub(\":/var/lib/netbird.*\"; \"\") // \"\"" "$COMPOSE_FILE" | head -1
+}
+
+detect_exposed_address() {
+ yq eval '.server.exposedAddress // ""' "$CONFIG_YAML_HOST"
+}
+
+detect_compose_network() {
+ local tag
+ tag=$(yq eval ".services[\"$COMBINED_SERVICE\"].networks | tag" "$COMPOSE_FILE" 2>/dev/null)
+ case "$tag" in
+ "!!seq")
+ yq eval ".services[\"$COMBINED_SERVICE\"].networks[0]" "$COMPOSE_FILE"
+ ;;
+ "!!map")
+ yq eval ".services[\"$COMBINED_SERVICE\"].networks | keys | .[0]" "$COMPOSE_FILE"
+ ;;
+ *)
+ echo "default"
+ ;;
+ esac
+}
+
+# ---------------------------------------------------------------------------
+# Renderers
+# ---------------------------------------------------------------------------
+
+# Build docker-compose.override.yml from the steps the operator selected.
+# Service names match what we detected on the operator's side.
+render_override() {
+ cat < "$ENTERPRISE_CONFIG_FILE"
+
+ if [[ "$ENABLE_FLOW" == "yes" ]]; then
+ local flow_addr="${NETBIRD_DOMAIN}"
+ yq eval -i "
+ .server.trafficFlow.enabled = true |
+ .server.trafficFlow.address = \"$flow_addr\" |
+ .server.trafficFlow.interval = \"60s\"
+ " "$ENTERPRISE_CONFIG_FILE"
+ fi
+}
+
+# ---------------------------------------------------------------------------
+# Execution steps
+# ---------------------------------------------------------------------------
+
+resolve_data_volume() {
+ local short="$1"
+ local actual
+ # Resolve project-prefixed volume name from Docker Compose config first.
+ actual=$($DOCKER_COMPOSE_COMMAND config 2>/dev/null | yq eval ".volumes.\"$short\".name" - 2>/dev/null)
+ if [[ -n "$actual" && "$actual" != "null" ]]; then
+ echo "$actual"
+ return
+ fi
+ # Relative bind mount: docker-compose resolves it against the compose
+ # file's directory, but `docker run -v` resolves it against the current
+ # working directory. Normalize to an absolute path so both interpretations
+ # agree (and the printed revert command works from any CWD).
+ if [[ "$short" == ./* || "$short" == ../* ]]; then
+ local compose_dir
+ compose_dir="$(cd "$(dirname "$COMPOSE_FILE")" && pwd)"
+ (
+ cd "$compose_dir"
+ cd "$(dirname "$short")"
+ printf '%s/%s\n' "$(pwd)" "$(basename "$short")"
+ )
+ return
+ fi
+ # Not a named volume (e.g. an absolute bind-mount path) — use it as-is.
+ echo "$short"
+}
+
+backup_sqlite() {
+ BACKUP_DIR="$(pwd)/backups/sqlite-pre-enterprise-$(date +%Y%m%d-%H%M%S)"
+ mkdir -p "$BACKUP_DIR"
+ local data_volume_actual
+ data_volume_actual=$(resolve_data_volume "$DATA_VOLUME")
+ echo "Backing up SQLite store from volume '$data_volume_actual' to $BACKUP_DIR ..."
+ docker run --rm \
+ -v "${data_volume_actual}:/var/lib/netbird:ro" \
+ -v "${BACKUP_DIR}:/backup" \
+ busybox \
+ sh -c 'cp -a /var/lib/netbird/. /backup/ 2>/dev/null || true'
+ local copied
+ copied=$(find "$BACKUP_DIR" -mindepth 1 | head -1)
+ if [[ -z "$copied" ]]; then
+ echo " ⚠ Backup directory is empty — the volume '$data_volume_actual' didn't contain data. Aborting." > /dev/stderr
+ exit 1
+ fi
+ echo " done"
+}
+
+run_migrate_store() {
+ echo "Running migrate-store (SQLite → Postgres) ..."
+ $DOCKER_COMPOSE_COMMAND run --rm "$COMBINED_SERVICE" migrate-store --config /etc/netbird/config.yaml.enterprise --verify
+ echo " done"
+}
+
+# ---------------------------------------------------------------------------
+# Main
+# ---------------------------------------------------------------------------
+
+init_migration() {
+ DOCKER_COMPOSE_COMMAND=$(check_docker_compose)
+ check_yq
+ check_openssl
+
+ COMPOSE_FILE="${COMPOSE_FILE:-docker-compose.yml}"
+
+ if [[ ! -f "$COMPOSE_FILE" ]]; then
+ echo "$COMPOSE_FILE not found in $(pwd)." > /dev/stderr
+ exit 1
+ fi
+ if [[ -f "$OVERRIDE_FILE" ]] || [[ -f "$ENTERPRISE_CONFIG_FILE" ]]; then
+ echo "Migration artifacts already exist in $(pwd):"
+ [[ -f "$OVERRIDE_FILE" ]] && echo " $OVERRIDE_FILE"
+ [[ -f "$ENTERPRISE_CONFIG_FILE" ]] && echo " $ENTERPRISE_CONFIG_FILE"
+ echo ""
+ echo "Either you've already migrated, or a previous run was interrupted."
+ echo "To re-run cleanly: rm -f $OVERRIDE_FILE $ENTERPRISE_CONFIG_FILE"
+ exit 1
+ fi
+
+ COMBINED_SERVICE=$(detect_combined_service)
+ DASHBOARD_SERVICE=$(detect_dashboard_service)
+ CONFIG_YAML_HOST=$(detect_config_yaml_host_path)
+ DATA_VOLUME=$(detect_data_volume)
+ COMPOSE_NETWORK=$(detect_compose_network)
+
+ if [[ -z "$COMBINED_SERVICE" ]]; then
+ echo "Could not find a service running netbirdio/netbird-server* in $COMPOSE_FILE." > /dev/stderr
+ echo "This script targets the community combined-server deployment." > /dev/stderr
+ exit 1
+ fi
+ if [[ -z "$DASHBOARD_SERVICE" ]]; then
+ echo "Could not find a service running netbirdio/dashboard* in $COMPOSE_FILE." > /dev/stderr
+ exit 1
+ fi
+ if [[ -z "$CONFIG_YAML_HOST" ]]; then
+ echo "Could not find a config.yaml mount on $COMBINED_SERVICE (expected to bind-mount to /etc/netbird/config.yaml)." > /dev/stderr
+ exit 1
+ fi
+ if [[ ! -f "$CONFIG_YAML_HOST" ]]; then
+ echo "config.yaml host file not found at $CONFIG_YAML_HOST." > /dev/stderr
+ exit 1
+ fi
+ if [[ -z "$DATA_VOLUME" ]]; then
+ echo "Could not find a volume mounted at /var/lib/netbird on $COMBINED_SERVICE." > /dev/stderr
+ exit 1
+ fi
+
+ echo "Detected existing deployment:"
+ echo " Combined service: $COMBINED_SERVICE"
+ echo " Dashboard: $DASHBOARD_SERVICE"
+ echo " config.yaml: $CONFIG_YAML_HOST"
+ echo " Data volume: $DATA_VOLUME"
+ echo " Network: $COMPOSE_NETWORK"
+ echo ""
+
+ local proceed
+ proceed=$(read_yes_no "Proceed with migration?" "y")
+ if [[ "$proceed" != "yes" ]]; then
+ echo "Aborted."
+ exit 0
+ fi
+
+ # Step 1 — always (this is the point of the script)
+ MIGRATE_IMAGES="yes"
+ echo ""
+ echo "Step 1: Image swap (community → Enterprise). License key required."
+ NB_LICENSE_KEY=$(read_secret " License key")
+ GHCR_USERNAME="netbirdExtAccess1"
+ GHCR_TOKEN=$(read_secret " GHCR token (input hidden)")
+
+ # Step 2 — optional
+ echo ""
+ MIGRATE_POSTGRES=$(read_yes_no "Step 2: Migrate storage from SQLite to Postgres? (recommended)" "n")
+ if [[ "$MIGRATE_POSTGRES" == "yes" ]]; then
+ echo ""
+ echo " ⚠ Data will be migrated from SQLite to Postgres. The SQLite store"
+ echo " will be backed up automatically. To fully revert later, restore"
+ echo " that backup and delete docker-compose.override.yml +"
+ echo " config.yaml.enterprise."
+ local confirm
+ confirm=$(read_yes_no " Continue?" "y")
+ if [[ "$confirm" != "yes" ]]; then
+ MIGRATE_POSTGRES="no"
+ echo " Skipping Postgres migration."
+ else
+ POSTGRES_PASSWORD=$(rand_password)
+ fi
+ fi
+
+ # Step 3 — optional, only if Postgres is on (flow requires Postgres)
+ echo ""
+ if [[ "$MIGRATE_POSTGRES" == "yes" ]]; then
+ ENABLE_FLOW=$(read_yes_no "Step 3: Enable traffic flow? (requires Postgres)" "n")
+ if [[ "$ENABLE_FLOW" == "yes" ]]; then
+ # Auth secret MUST match server.authSecret from config.yaml
+ NB_FLOW_AUTH_SECRET=$(yq eval '.server.authSecret // ""' "$CONFIG_YAML_HOST")
+ if [[ -z "$NB_FLOW_AUTH_SECRET" ]] || [[ "$NB_FLOW_AUTH_SECRET" == "null" ]]; then
+ echo "Could not read server.authSecret from $CONFIG_YAML_HOST." > /dev/stderr
+ echo "Flow receiver auth must match the combined server's authSecret." > /dev/stderr
+ exit 1
+ fi
+
+ NETBIRD_DOMAIN=$(detect_exposed_address)
+ if [[ -z "$NETBIRD_DOMAIN" ]] || [[ "$NETBIRD_DOMAIN" == "null" ]]; then
+ NETBIRD_DOMAIN=$(read_required " Public NetBird URL (e.g. https://netbird.example.com)")
+ fi
+ # Strip protocol + port to leave just the hostname for the Traefik Host() rule.
+ NETBIRD_HOSTNAME=$(echo "$NETBIRD_DOMAIN" | sed -E 's,^https?://,,' | sed 's,:.*,,' | sed 's,/.*,,')
+
+ # We need the encryption key from the existing config.yaml for the enricher
+ NETBIRD_ENCRYPTION_KEY=$(yq eval '.server.store.encryptionKey // ""' "$CONFIG_YAML_HOST")
+ if [[ -z "$NETBIRD_ENCRYPTION_KEY" ]] || [[ "$NETBIRD_ENCRYPTION_KEY" == "null" ]]; then
+ echo "Could not read server.store.encryptionKey from $CONFIG_YAML_HOST." > /dev/stderr
+ exit 1
+ fi
+ fi
+ else
+ ENABLE_FLOW="no"
+ echo "Step 3 (traffic flow) skipped — requires Postgres."
+ fi
+}
+
+apply_changes() {
+ echo ""
+ echo "Writing $OVERRIDE_FILE ..."
+ install -m 644 /dev/null "$OVERRIDE_FILE"
+ render_override > "$OVERRIDE_FILE"
+
+ if [[ -z "${NETBIRD_LICENSE_SERVER_BASE_URL:-}" ]]; then
+ sed -i.bak '/NETBIRD_LICENSE_SERVER_BASE_URL/d' "$OVERRIDE_FILE" && rm -f "$OVERRIDE_FILE.bak"
+ fi
+
+ if [[ "$MIGRATE_POSTGRES" == "yes" ]]; then
+ echo "Writing $ENTERPRISE_CONFIG_FILE ..."
+ install -m 600 /dev/null "$ENTERPRISE_CONFIG_FILE"
+ render_enterprise_config
+ fi
+
+ # Persist secrets that the override file references via env interpolation.
+ # We write them to a .env file in the current directory; docker compose
+ # picks it up automatically.
+ echo "Writing .env additions (mode 600) ..."
+ local ENV_FILE=".env"
+ touch "$ENV_FILE"
+ chmod 600 "$ENV_FILE"
+ {
+ echo ""
+ echo "# Added by migrate-to-enterprise.sh on $(date -u +%Y-%m-%dT%H:%M:%SZ)"
+ echo "NB_LICENSE_KEY=${NB_LICENSE_KEY}"
+ if [[ -n "${NETBIRD_LICENSE_SERVER_BASE_URL:-}" ]]; then
+ echo "NETBIRD_LICENSE_SERVER_BASE_URL=${NETBIRD_LICENSE_SERVER_BASE_URL}"
+ fi
+ if [[ "$MIGRATE_POSTGRES" == "yes" ]]; then
+ echo "POSTGRES_PASSWORD=${POSTGRES_PASSWORD}"
+ fi
+ if [[ "$ENABLE_FLOW" == "yes" ]]; then
+ echo "NB_FLOW_AUTH_SECRET=${NB_FLOW_AUTH_SECRET}"
+ echo "NETBIRD_ENCRYPTION_KEY=${NETBIRD_ENCRYPTION_KEY}"
+ fi
+ } >> "$ENV_FILE"
+
+ echo ""
+ echo "Logging in to ghcr.io ..."
+ printf '%s' "$GHCR_TOKEN" | docker login ghcr.io -u "$GHCR_USERNAME" --password-stdin
+ unset GHCR_TOKEN
+
+ echo ""
+ echo "Pulling enterprise images ..."
+ $DOCKER_COMPOSE_COMMAND pull
+
+ if [[ "$MIGRATE_POSTGRES" == "yes" ]]; then
+ echo ""
+ echo "Stopping existing services (volumes preserved) ..."
+ $DOCKER_COMPOSE_COMMAND down
+
+ backup_sqlite
+
+ echo ""
+ echo "Starting Postgres ..."
+ $DOCKER_COMPOSE_COMMAND up -d postgres
+
+ # Wait for healthy
+ local counter=0
+ echo -n "Waiting for Postgres to become ready"
+ while ! $DOCKER_COMPOSE_COMMAND exec -T postgres pg_isready -U netbird -d netbird &> /dev/null; do
+ echo -n " ."
+ sleep 2
+ counter=$((counter + 1))
+ if [[ $counter -ge 60 ]]; then
+ echo ""
+ echo "Postgres did not become ready in 120s. Recent logs:"
+ $DOCKER_COMPOSE_COMMAND logs --tail=20 postgres
+ exit 1
+ fi
+ done
+ echo " done"
+
+ run_migrate_store
+ fi
+
+ echo ""
+ echo "Bringing up all services ..."
+ $DOCKER_COMPOSE_COMMAND up -d
+
+ echo ""
+ echo "Migration complete."
+}
+
+print_summary() {
+ echo ""
+ echo "──────────────────────────────────────────────────────────────────────"
+ echo " Summary"
+ echo "──────────────────────────────────────────────────────────────────────"
+ echo " Images: swapped to enterprise"
+ [[ "$MIGRATE_POSTGRES" == "yes" ]] && echo " Storage: Postgres (data migrated from SQLite)"
+ [[ "$MIGRATE_POSTGRES" != "yes" ]] && echo " Storage: SQLite (unchanged)"
+ [[ "$ENABLE_FLOW" == "yes" ]] && echo " Traffic flow: enabled"
+ [[ "$ENABLE_FLOW" != "yes" ]] && echo " Traffic flow: disabled"
+ echo ""
+ echo " Generated files (next to your docker-compose.yml):"
+ echo " $OVERRIDE_FILE"
+ [[ "$MIGRATE_POSTGRES" == "yes" ]] && echo " $ENTERPRISE_CONFIG_FILE"
+ echo " .env (license key + secrets, mode 600)"
+ [[ "$MIGRATE_POSTGRES" == "yes" ]] && echo " backups/sqlite-pre-enterprise-*/ (SQLite backup)"
+ echo ""
+ echo " Tail logs:"
+ echo " $DOCKER_COMPOSE_COMMAND logs -f $COMBINED_SERVICE"
+ echo ""
+ echo "──────────────────────────────────────────────────────────────────────"
+ echo " To revert"
+ echo "──────────────────────────────────────────────────────────────────────"
+ echo " $DOCKER_COMPOSE_COMMAND down"
+ if [[ "$MIGRATE_POSTGRES" == "yes" ]]; then
+ # Resolve project-prefixed volume names now (before override is removed).
+ local pg_volume data_volume_actual
+ pg_volume=$(resolve_data_volume "netbird_postgres")
+ data_volume_actual=$(resolve_data_volume "$DATA_VOLUME")
+ echo " # Remove the Postgres volume FIRST, before deleting the override file:"
+ echo " docker volume rm $pg_volume"
+ echo " # Restore SQLite from the backup created during this run:"
+ echo " docker run --rm -v ${data_volume_actual}:/var/lib/netbird -v ${BACKUP_DIR}:/backup busybox sh -c 'cp -a /backup/. /var/lib/netbird/'"
+ fi
+ echo " rm -f $OVERRIDE_FILE $ENTERPRISE_CONFIG_FILE"
+ echo " # Remove migrate-to-enterprise.sh additions from .env (search for the timestamp marker)"
+ echo " $DOCKER_COMPOSE_COMMAND up -d"
+ echo "──────────────────────────────────────────────────────────────────────"
+}
+
+# ---------------------------------------------------------------------------
+# Run
+# ---------------------------------------------------------------------------
+
+init_migration
+apply_changes
+print_summary
diff --git a/management/Dockerfile b/management/Dockerfile
index 3b2df2623..fe414158c 100644
--- a/management/Dockerfile
+++ b/management/Dockerfile
@@ -2,4 +2,5 @@ FROM ubuntu:24.04
RUN apt update && apt install -y ca-certificates && rm -fr /var/cache/apt
ENTRYPOINT [ "/go/bin/netbird-mgmt","management"]
CMD ["--log-file", "console"]
-COPY netbird-mgmt /go/bin/netbird-mgmt
+ARG TARGETPLATFORM
+COPY ${TARGETPLATFORM}/netbird-mgmt /go/bin/netbird-mgmt
diff --git a/management/Dockerfile.debug b/management/Dockerfile.debug
deleted file mode 100644
index 4d9730bd7..000000000
--- a/management/Dockerfile.debug
+++ /dev/null
@@ -1,5 +0,0 @@
-FROM ubuntu:24.04
-RUN apt update && apt install -y ca-certificates && rm -fr /var/cache/apt
-ENTRYPOINT [ "/go/bin/netbird-mgmt","management","--log-level","debug"]
-CMD ["--log-file", "console"]
-COPY netbird-mgmt /go/bin/netbird-mgmt
diff --git a/management/internals/controllers/network_map/controller/controller.go b/management/internals/controllers/network_map/controller/controller.go
index 2b81cd6e5..0d8fb3c47 100644
--- a/management/internals/controllers/network_map/controller/controller.go
+++ b/management/internals/controllers/network_map/controller/controller.go
@@ -45,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
@@ -64,6 +64,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 {
@@ -201,7 +208,7 @@ func (c *Controller) sendUpdateAccountPeers(ctx context.Context, accountID strin
c.metrics.CountCalcPeerNetworkMapDuration(time.Since(start))
- proxyNetworkMap, ok := proxyNetworkMaps[peer.ID]
+ proxyNetworkMap, ok := proxyNetworkMaps[p.ID]
if ok {
remotePeerNetworkMap.Merge(proxyNetworkMap)
}
@@ -226,44 +233,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 {
@@ -273,6 +242,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)
@@ -381,66 +487,162 @@ func (c *Controller) BufferUpdateAccountPeers(ctx context.Context, accountID str
return 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 with reason %s/%s", len(peerIDs), accountID, util.GetCallerName(), reason.Operation, reason.Resource)
+
+ 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
@@ -578,21 +780,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
@@ -625,7 +830,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 44d8f7d72..14b12aba6 100644
--- a/management/internals/controllers/network_map/interface.go
+++ b/management/internals/controllers/network_map/interface.go
@@ -19,17 +19,19 @@ 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)
GetDNSDomain(settings *types.Settings) string
StartWarmup(context.Context)
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 073a75d3b..bfff32e6f 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)
}
// OnPeerConnected mocks base method.
@@ -158,45 +171,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.
@@ -250,3 +263,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 8f3253063..e22d1e6e0 100644
--- a/management/internals/modules/peers/manager.go
+++ b/management/internals/modules/peers/manager.go
@@ -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/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 c8ab4f955..365fbab40 100644
--- a/management/internals/modules/reverseproxy/service/manager/manager.go
+++ b/management/internals/modules/reverseproxy/service/manager/manager.go
@@ -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
}
@@ -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
@@ -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 0497415b7..29a117921 100644
--- a/management/internals/modules/reverseproxy/service/manager/manager_test.go
+++ b/management/internals/modules/reverseproxy/service/manager/manager_test.go
@@ -434,7 +434,7 @@ func TestDeletePeerService_SourcePeerValidation(t *testing.T) {
t.Helper()
tokenStore := nbgrpc.NewOneTimeTokenStore(context.Background(), testCacheStore(t))
pkceStore := nbgrpc.NewPKCEVerifierStore(context.Background(), testCacheStore(t))
- srv := nbgrpc.NewProxyServiceServer(nil, tokenStore, pkceStore, nbgrpc.ProxyOIDCConfig{}, nil, nil, nil, nil)
+ srv := nbgrpc.NewProxyServiceServer(nil, tokenStore, pkceStore, nbgrpc.ProxyOIDCConfig{}, nil, nil, nil, nil, nil)
return srv
}
@@ -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)
@@ -714,7 +723,7 @@ func setupIntegrationTest(t *testing.T) (*Manager, store.Store) {
tokenStore := nbgrpc.NewOneTimeTokenStore(ctx, testCacheStore(t))
pkceStore := nbgrpc.NewPKCEVerifierStore(ctx, testCacheStore(t))
- proxySrv := nbgrpc.NewProxyServiceServer(nil, tokenStore, pkceStore, nbgrpc.ProxyOIDCConfig{}, nil, nil, nil, nil)
+ proxySrv := nbgrpc.NewProxyServiceServer(nil, tokenStore, pkceStore, nbgrpc.ProxyOIDCConfig{}, nil, nil, nil, nil, nil)
proxyController, err := proxymanager.NewGRPCController(proxySrv, noop.NewMeterProvider().Meter(""))
require.NoError(t, err)
@@ -1138,7 +1147,7 @@ func TestDeleteService_DeletesTargets(t *testing.T) {
tokenStore := nbgrpc.NewOneTimeTokenStore(ctx, testCacheStore(t))
pkceStore := nbgrpc.NewPKCEVerifierStore(ctx, testCacheStore(t))
- proxySrv := nbgrpc.NewProxyServiceServer(nil, tokenStore, pkceStore, nbgrpc.ProxyOIDCConfig{}, nil, nil, nil, nil)
+ proxySrv := nbgrpc.NewProxyServiceServer(nil, tokenStore, pkceStore, nbgrpc.ProxyOIDCConfig{}, nil, nil, nil, nil, nil)
proxyController, err := proxymanager.NewGRPCController(proxySrv, noop.NewMeterProvider().Meter(""))
require.NoError(t, err)
@@ -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/server/boot.go b/management/internals/server/boot.go
index 46e475143..ae82b60fe 100644
--- a/management/internals/server/boot.go
+++ b/management/internals/server/boot.go
@@ -219,7 +219,7 @@ func (s *BaseServer) GRPCServer() *grpc.Server {
func (s *BaseServer) ReverseProxyGRPCServer() *nbgrpc.ProxyServiceServer {
return Create(s, func() *nbgrpc.ProxyServiceServer {
- proxyService := nbgrpc.NewProxyServiceServer(s.AccessLogsManager(), s.ProxyTokenStore(), s.PKCEVerifierStore(), s.proxyOIDCConfig(), s.PeersManager(), s.UsersManager(), s.ProxyManager(), s.Store())
+ proxyService := nbgrpc.NewProxyServiceServer(s.AccessLogsManager(), s.ProxyTokenStore(), s.PKCEVerifierStore(), s.proxyOIDCConfig(), s.PeersManager(), s.UsersManager(), s.IdpManager(), s.ProxyManager(), s.Store())
s.AfterInit(func(s *BaseServer) {
proxyService.SetServiceManager(s.ServiceManager())
proxyService.SetProxyController(s.ServiceProxyController())
diff --git a/management/internals/shared/grpc/conversion.go b/management/internals/shared/grpc/conversion.go
index b4a0d8b28..ced982a30 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"
log "github.com/sirupsen/logrus"
goproto "google.golang.org/protobuf/proto"
"google.golang.org/protobuf/types/known/timestamppb"
@@ -28,6 +30,23 @@ import (
"github.com/netbirdio/netbird/shared/sshauth"
)
+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
@@ -155,7 +174,11 @@ func ToSyncResponse(ctx context.Context, config *nbconfig.Config, httpConfig *nb
remotePeers := make([]*proto.RemotePeerConfig, 0, len(networkMap.Peers)+len(networkMap.OfflinePeers))
remotePeers = 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
@@ -246,6 +269,19 @@ func buildAuthorizedUsersProto(ctx context.Context, authorizedUsers map[string]m
return hashedUsers, machineUsers
}
+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 appendRemotePeerConfig(dst []*proto.RemotePeerConfig, peers []*nbpeer.Peer, dnsName string, includeIPv6 bool) []*proto.RemotePeerConfig {
for _, rPeer := range peers {
allowedIPs := []string{rPeer.IP.String() + "/32"}
@@ -363,7 +399,6 @@ func toProtocolFirewallRules(rules []*types.FirewallRule, includeIPv6, useSource
return result
}
-
// populateSourcePrefixes sets SourcePrefixes on fwRule and returns any
// additional rules needed (e.g. a v6 wildcard clone when the peer IP is unspecified).
func populateSourcePrefixes(fwRule *proto.FirewallRule, rule *types.FirewallRule, includeIPv6 bool) []*proto.FirewallRule {
diff --git a/management/internals/shared/grpc/conversion_test.go b/management/internals/shared/grpc/conversion_test.go
index 5efb24319..01a67e4fa 100644
--- a/management/internals/shared/grpc/conversion_test.go
+++ b/management/internals/shared/grpc/conversion_test.go
@@ -202,6 +202,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/loginfilter.go b/management/internals/shared/grpc/loginfilter.go
index 59f69dd90..cc69b7d6e 100644
--- a/management/internals/shared/grpc/loginfilter.go
+++ b/management/internals/shared/grpc/loginfilter.go
@@ -13,7 +13,7 @@ const (
reconnThreshold = 5 * time.Minute
baseBlockDuration = 10 * time.Minute // Duration for which a peer is banned after exceeding the reconnection limit
reconnLimitForBan = 30 // Number of reconnections within the reconnTreshold that triggers a ban
- metaChangeLimit = 3 // Number of reconnections with different metadata that triggers a ban of one peer
+ metaChangeLimit = 5 // Number of reconnections with different metadata that triggers a ban of one peer
)
type lfConfig struct {
@@ -139,7 +139,7 @@ func (l *loginFilter) addLogin(wgPubKey string, metaHash uint64) {
state.lastSeen = now
}
-func metaHash(meta nbpeer.PeerSystemMeta, pubip string) uint64 {
+func metaHash(meta nbpeer.PeerSystemMeta) uint64 {
h := fnv.New64a()
h.Write([]byte(meta.WtVersion))
@@ -147,14 +147,6 @@ func metaHash(meta nbpeer.PeerSystemMeta, pubip string) uint64 {
h.Write([]byte(meta.KernelVersion))
h.Write([]byte(meta.Hostname))
h.Write([]byte(meta.SystemSerialNumber))
- h.Write([]byte(pubip))
- macs := uint64(0)
- for _, na := range meta.NetworkAddresses {
- for _, r := range na.Mac {
- macs += uint64(r)
- }
- }
-
- return h.Sum64() + macs
+ return h.Sum64()
}
diff --git a/management/internals/shared/grpc/loginfilter_test.go b/management/internals/shared/grpc/loginfilter_test.go
index 797879ae7..d9df26420 100644
--- a/management/internals/shared/grpc/loginfilter_test.go
+++ b/management/internals/shared/grpc/loginfilter_test.go
@@ -164,9 +164,7 @@ func BenchmarkHashingMethods(b *testing.B) {
KernelVersion: "5.15.0-76-generic",
Hostname: "prod-server-database-01",
SystemSerialNumber: "PC-1234567890",
- NetworkAddresses: []nbpeer.NetworkAddress{{Mac: "00:1B:44:11:3A:B7"}, {Mac: "00:1B:44:11:3A:B8"}},
}
- pubip := "8.8.8.8"
var resultString string
var resultUint uint64
@@ -175,7 +173,7 @@ func BenchmarkHashingMethods(b *testing.B) {
b.ReportAllocs()
b.ResetTimer()
for i := 0; i < b.N; i++ {
- resultString = builderString(meta, pubip)
+ resultString = builderString(meta)
}
})
@@ -183,7 +181,7 @@ func BenchmarkHashingMethods(b *testing.B) {
b.ReportAllocs()
b.ResetTimer()
for i := 0; i < b.N; i++ {
- resultString = fnvHashToString(meta, pubip)
+ resultString = fnvHashToString(meta)
}
})
@@ -191,7 +189,7 @@ func BenchmarkHashingMethods(b *testing.B) {
b.ReportAllocs()
b.ResetTimer()
for i := 0; i < b.N; i++ {
- resultUint = metaHash(meta, pubip)
+ resultUint = metaHash(meta)
}
})
@@ -199,29 +197,20 @@ func BenchmarkHashingMethods(b *testing.B) {
_ = resultUint
}
-func fnvHashToString(meta nbpeer.PeerSystemMeta, pubip string) string {
+func fnvHashToString(meta nbpeer.PeerSystemMeta) string {
h := fnv.New64a()
- if len(meta.NetworkAddresses) != 0 {
- for _, na := range meta.NetworkAddresses {
- h.Write([]byte(na.Mac))
- }
- }
-
h.Write([]byte(meta.WtVersion))
h.Write([]byte(meta.OSVersion))
h.Write([]byte(meta.KernelVersion))
h.Write([]byte(meta.Hostname))
h.Write([]byte(meta.SystemSerialNumber))
- h.Write([]byte(pubip))
return strconv.FormatUint(h.Sum64(), 16)
}
-func builderString(meta nbpeer.PeerSystemMeta, pubip string) string {
- mac := getMacAddress(meta.NetworkAddresses)
- estimatedSize := len(meta.WtVersion) + len(meta.OSVersion) + len(meta.KernelVersion) + len(meta.Hostname) + len(meta.SystemSerialNumber) +
- len(pubip) + len(mac) + 6
+func builderString(meta nbpeer.PeerSystemMeta) string {
+ estimatedSize := len(meta.WtVersion) + len(meta.OSVersion) + len(meta.KernelVersion) + len(meta.Hostname) + len(meta.SystemSerialNumber) + 4
var b strings.Builder
b.Grow(estimatedSize)
@@ -235,23 +224,10 @@ func builderString(meta nbpeer.PeerSystemMeta, pubip string) string {
b.WriteString(meta.Hostname)
b.WriteByte('|')
b.WriteString(meta.SystemSerialNumber)
- b.WriteByte('|')
- b.WriteString(pubip)
return b.String()
}
-func getMacAddress(nas []nbpeer.NetworkAddress) string {
- if len(nas) == 0 {
- return ""
- }
- macs := make([]string, 0, len(nas))
- for _, na := range nas {
- macs = append(macs, na.Mac)
- }
- return strings.Join(macs, "/")
-}
-
func BenchmarkLoginFilter_ParallelLoad(b *testing.B) {
filter := newLoginFilterWithCfg(testAdvancedCfg())
numKeys := 100000
diff --git a/management/internals/shared/grpc/proxy.go b/management/internals/shared/grpc/proxy.go
index 0feb807f6..76663f898 100644
--- a/management/internals/shared/grpc/proxy.go
+++ b/management/internals/shared/grpc/proxy.go
@@ -33,6 +33,8 @@ import (
"github.com/netbirdio/netbird/management/internals/modules/reverseproxy/proxy"
rpservice "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/service"
"github.com/netbirdio/netbird/management/internals/modules/reverseproxy/sessionkey"
+ "github.com/netbirdio/netbird/management/server/idp"
+ "github.com/netbirdio/netbird/management/server/peer"
"github.com/netbirdio/netbird/management/server/types"
"github.com/netbirdio/netbird/management/server/users"
proxyauth "github.com/netbirdio/netbird/proxy/auth"
@@ -82,6 +84,9 @@ type ProxyServiceServer struct {
// Manager for users
usersManager users.Manager
+ // Manager for IdP-enriched user data (may be nil when no IdP is configured)
+ idpManager idp.Manager
+
// Store for one-time authentication tokens
tokenStore *OneTimeTokenStore
@@ -157,7 +162,7 @@ func enforceAccountScope(ctx context.Context, requestAccountID string) error {
}
// NewProxyServiceServer creates a new proxy service server.
-func NewProxyServiceServer(accessLogMgr accesslogs.Manager, tokenStore *OneTimeTokenStore, pkceStore *PKCEVerifierStore, oidcConfig ProxyOIDCConfig, peersManager peers.Manager, usersManager users.Manager, proxyMgr proxy.Manager, tokenChecker ProxyTokenChecker) *ProxyServiceServer {
+func NewProxyServiceServer(accessLogMgr accesslogs.Manager, tokenStore *OneTimeTokenStore, pkceStore *PKCEVerifierStore, oidcConfig ProxyOIDCConfig, peersManager peers.Manager, usersManager users.Manager, idpManager idp.Manager, proxyMgr proxy.Manager, tokenChecker ProxyTokenChecker) *ProxyServiceServer {
ctx, cancel := context.WithCancel(context.Background())
s := &ProxyServiceServer{
accessLogManager: accessLogMgr,
@@ -166,6 +171,7 @@ func NewProxyServiceServer(accessLogMgr accesslogs.Manager, tokenStore *OneTimeT
pkceVerifierStore: pkceStore,
peersManager: peersManager,
usersManager: usersManager,
+ idpManager: idpManager,
proxyManager: proxyMgr,
tokenChecker: tokenChecker,
snapshotBatchSize: snapshotBatchSizeFromEnv(),
@@ -1702,22 +1708,7 @@ func (s *ProxyServiceServer) ValidateTunnelPeer(ctx context.Context, req *proto.
}
groupIDs, groupNames := pairGroupIDsAndNames(peerGroups)
-
- // Resolve the principal: when the peer is linked to a user, the human
- // is the principal so multiple peers owned by the same user share a
- // single identity. Unlinked peers (machine agents) are their own
- // principal keyed on peer.ID. displayIdentity is what upstream gateways
- // tag spend with — user.Email when linked, peer.Name when not.
- principalID := peer.ID
- displayIdentity := peer.Name
- if peer.UserID != "" {
- if user, uerr := s.usersManager.GetUser(ctx, peer.UserID); uerr == nil && user != nil {
- principalID = user.Id
- if user.Email != "" {
- displayIdentity = user.Email
- }
- }
- }
+ principalID, displayIdentity := s.getTunnelPeerInfo(ctx, domain, service, peer)
if err := checkPeerGroupAccess(service, groupIDs); err != nil {
log.WithFields(log.Fields{"domain": domain, "peer_id": peer.ID, "error": err.Error()}).Debug("ValidateTunnelPeer: access denied")
@@ -1754,6 +1745,45 @@ func (s *ProxyServiceServer) ValidateTunnelPeer(ctx context.Context, req *proto.
}, nil
}
+// getTunnelPeerInfo returns the principal ID and display name for a peer, e.g. a
+// user or peer ID, and peer name or user email.
+func (s *ProxyServiceServer) getTunnelPeerInfo(ctx context.Context, domain string, service *rpservice.Service, peer *peer.Peer) (string, string) {
+ // Resolve the principal: when the peer is linked to a user, the human is the
+ // principal so multiple peers owned by the same user share a single
+ // identity. Unlinked peers (machine agents) are their own principal keyed on
+ // peer.ID. displayIdentity is what upstream gateways tag spend with —
+ // user.Email when linked, peer.Name when not.
+
+ // If the peer isn't associated with a user, return the peer info directly.
+ if peer.UserID == "" {
+ return peer.ID, peer.Name
+ }
+
+ // Otherwise, if the peer is linked to a user, the user is the principal and
+ // if an IdP is available, we gather details on the user from it.
+ principalID := peer.UserID
+ displayIdentity := peer.Name
+ // Stored column first (cheap, but often empty for OIDC-provisioned users).
+ if user, uerr := s.usersManager.GetUser(ctx, peer.UserID); uerr == nil && user != nil {
+ principalID = user.Id
+ if user.Email != "" {
+ displayIdentity = user.Email
+ }
+ }
+ // IdP enrichment wins when available — the stored email column is a
+ // best-effort cache and is frequently empty for OIDC users. Enrichment
+ // failures must never fail the RPC; we simply keep the stored/peer identity.
+ if s.idpManager != nil {
+ if ud, uerr := s.idpManager.GetUserDataByID(ctx, peer.UserID, idp.AppMetadata{WTAccountID: service.AccountID}); uerr == nil && ud != nil && ud.Email != "" {
+ displayIdentity = ud.Email
+ } else if uerr != nil {
+ log.WithFields(log.Fields{"domain": domain, "user_id": peer.UserID, "error": uerr.Error()}).Debug("ValidateTunnelPeer: IdP user enrichment failed; using stored/peer identity")
+ }
+ }
+
+ return principalID, displayIdentity
+}
+
// checkPeerGroupAccess gates ValidateTunnelPeer by the service's required
// groups. Private services authorise against AccessGroups (empty list fails
// closed — Validate() rejects that at save time but the RPC is the security
diff --git a/management/internals/shared/grpc/proxy_group_access_test.go b/management/internals/shared/grpc/proxy_group_access_test.go
index 76da7ddbc..532cb7cc3 100644
--- a/management/internals/shared/grpc/proxy_group_access_test.go
+++ b/management/internals/shared/grpc/proxy_group_access_test.go
@@ -3,14 +3,19 @@ package grpc
import (
"context"
"errors"
+ "net"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
+ "github.com/netbirdio/netbird/management/internals/modules/peers"
"github.com/netbirdio/netbird/management/internals/modules/reverseproxy/proxy"
"github.com/netbirdio/netbird/management/internals/modules/reverseproxy/service"
+ "github.com/netbirdio/netbird/management/server/idp"
+ "github.com/netbirdio/netbird/management/server/peer"
"github.com/netbirdio/netbird/management/server/types"
+ "github.com/netbirdio/netbird/shared/management/proto"
)
type mockReverseProxyManager struct {
@@ -137,6 +142,52 @@ func (m *mockUsersManager) GetUserWithGroups(ctx context.Context, userID string)
return user, nil, nil
}
+// mockTunnelPeersManager implements only the two peers.Manager methods that
+// ValidateTunnelPeer calls; the embedded interface satisfies the rest (and
+// panics if any unexpected method is invoked).
+type mockTunnelPeersManager struct {
+ peers.Manager
+ peer *peer.Peer
+ peerErr error
+ groups []*types.Group
+ groupsErr error
+}
+
+func (m *mockTunnelPeersManager) GetPeerByTunnelIP(_ context.Context, _ string, _ net.IP) (*peer.Peer, error) {
+ return m.peer, m.peerErr
+}
+
+func (m *mockTunnelPeersManager) GetPeerWithGroups(_ context.Context, _, _ string) (*peer.Peer, []*types.Group, error) {
+ return m.peer, m.groups, m.groupsErr
+}
+
+// mockTunnelIdpManager implements only GetUserDataByID; the embedded interface
+// satisfies the rest of idp.Manager. hasData==false returns (nil, nil) to model
+// an IdP that knows nothing about the user.
+type mockTunnelIdpManager struct {
+ idp.Manager
+ email string
+ hasData bool
+ err error
+ gotCalls int
+ gotMeta []idp.AppMetadata
+}
+
+func (m *mockTunnelIdpManager) GetUserDataByID(_ context.Context, userID string, meta idp.AppMetadata) (*idp.UserData, error) {
+ m.gotCalls++
+ m.gotMeta = append(m.gotMeta, meta)
+ if m.err != nil {
+ return nil, m.err
+ }
+ if !m.hasData {
+ // This might not be a thing any of the actual IDP implementations do,
+ // i.e. return a nil value with no error, but it seems valuable to test
+ // that behavior here.
+ return nil, nil //nolint:nilnil
+ }
+ return &idp.UserData{ID: userID, Email: m.email}, nil
+}
+
func TestValidateUserGroupAccess(t *testing.T) {
tests := []struct {
name string
@@ -354,6 +405,163 @@ func TestValidateUserGroupAccess(t *testing.T) {
}
}
+// TestValidateTunnelPeerUserEmailEnrichment verifies the UserEmail/UserId
+// resolution in ValidateTunnelPeer, including the IdP-enrichment fallback order
+// (IdP email -> stored User.Email -> peer.Name).
+func TestValidateTunnelPeerUserEmailEnrichment(t *testing.T) {
+ const (
+ domain = "app.example.com"
+ accountID = "account1"
+ peerID = "peer1"
+ peerName = "peer-display-name"
+ userID = "user1"
+ )
+
+ storedUser := map[string]*types.User{userID: {Id: userID, AccountID: accountID, Email: "stored@example.com"}}
+ storedUserNoEmail := map[string]*types.User{userID: {Id: userID, AccountID: accountID, Email: ""}}
+
+ tests := []struct {
+ name string
+ peerUserID string
+ storedUsers map[string]*types.User
+ storedErr error
+ noIdP bool
+ idpEmail string
+ idpHasData bool
+ idpErr error
+ expectEmail string
+ expectUserID string
+ expectIdPHit bool
+ }{
+ {
+ name: "idp email wins over stored email",
+ peerUserID: userID,
+ storedUsers: storedUser,
+ idpEmail: "idp@example.com",
+ idpHasData: true,
+ expectEmail: "idp@example.com",
+ expectUserID: userID,
+ expectIdPHit: true,
+ },
+ {
+ name: "stored email when idp returns empty email",
+ peerUserID: userID,
+ storedUsers: storedUser,
+ idpEmail: "",
+ idpHasData: true,
+ expectEmail: "stored@example.com",
+ expectUserID: userID,
+ expectIdPHit: true,
+ },
+ {
+ name: "stored email when idp has no data",
+ peerUserID: userID,
+ storedUsers: storedUser,
+ idpHasData: false,
+ expectEmail: "stored@example.com",
+ expectUserID: userID,
+ expectIdPHit: true,
+ },
+ {
+ name: "stored email when idp errors",
+ peerUserID: userID,
+ storedUsers: storedUser,
+ idpErr: errors.New("idp unreachable"),
+ expectEmail: "stored@example.com",
+ expectUserID: userID,
+ expectIdPHit: true,
+ },
+ {
+ name: "stored email when no idp manager",
+ peerUserID: userID,
+ storedUsers: storedUser,
+ noIdP: true,
+ expectEmail: "stored@example.com",
+ expectUserID: userID,
+ },
+ {
+ name: "idp email when stored email is empty",
+ peerUserID: userID,
+ storedUsers: storedUserNoEmail,
+ idpEmail: "idp@example.com",
+ idpHasData: true,
+ expectEmail: "idp@example.com",
+ expectUserID: userID,
+ expectIdPHit: true,
+ },
+ {
+ name: "idp email when stored user missing keeps peer.UserID as principal",
+ peerUserID: userID,
+ storedUsers: map[string]*types.User{},
+ idpEmail: "idp@example.com",
+ idpHasData: true,
+ expectEmail: "idp@example.com",
+ expectUserID: userID,
+ expectIdPHit: true,
+ },
+ {
+ name: "unlinked peer uses peer name and never consults idp",
+ peerUserID: "",
+ storedUsers: storedUser,
+ idpEmail: "idp@example.com",
+ idpHasData: true,
+ expectEmail: peerName,
+ expectUserID: peerID,
+ expectIdPHit: false,
+ },
+ {
+ name: "linked peer with empty stored email and no idp falls back to peer name",
+ peerUserID: userID,
+ storedUsers: storedUserNoEmail,
+ noIdP: true,
+ expectEmail: peerName,
+ expectUserID: userID,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ svc := &service.Service{Domain: domain, AccountID: accountID}
+ server := &ProxyServiceServer{
+ serviceManager: &mockReverseProxyManager{
+ proxiesByAccount: map[string][]*service.Service{accountID: {svc}},
+ },
+ peersManager: &mockTunnelPeersManager{
+ peer: &peer.Peer{ID: peerID, Name: peerName, UserID: tt.peerUserID},
+ },
+ usersManager: &mockUsersManager{users: tt.storedUsers, err: tt.storedErr},
+ }
+
+ var idpMock *mockTunnelIdpManager
+ if !tt.noIdP {
+ idpMock = &mockTunnelIdpManager{email: tt.idpEmail, hasData: tt.idpHasData, err: tt.idpErr}
+ server.idpManager = idpMock
+ }
+
+ resp, err := server.ValidateTunnelPeer(context.Background(), &proto.ValidateTunnelPeerRequest{
+ Domain: domain,
+ TunnelIp: "100.64.0.1",
+ })
+
+ require.NoError(t, err)
+ require.NotNil(t, resp)
+ assert.True(t, resp.GetValid(), "expected access granted")
+ assert.Equal(t, tt.expectEmail, resp.GetUserEmail())
+ assert.Equal(t, tt.expectUserID, resp.GetUserId())
+
+ if idpMock != nil {
+ if tt.expectIdPHit {
+ assert.Equal(t, 1, idpMock.gotCalls, "expected IdP to be consulted")
+ require.Len(t, idpMock.gotMeta, 1)
+ assert.Equal(t, accountID, idpMock.gotMeta[0].WTAccountID)
+ } else {
+ assert.Equal(t, 0, idpMock.gotCalls, "expected IdP to not be consulted")
+ }
+ }
+ })
+ }
+}
+
func TestGetAccountProxyByDomain(t *testing.T) {
tests := []struct {
name string
diff --git a/management/internals/shared/grpc/server.go b/management/internals/shared/grpc/server.go
index 2d19ca32b..476aaa9d6 100644
--- a/management/internals/shared/grpc/server.go
+++ b/management/internals/shared/grpc/server.go
@@ -254,7 +254,7 @@ func (s *Server) Sync(req *proto.EncryptedMessage, srv proto.ManagementService_S
return mapError(ctx, err)
}
- metahashed := metaHash(peerMeta, sRealIP)
+ metahashed := metaHash(peerMeta)
if userID == "" && !s.loginFilter.allowLogin(peerKey.String(), metahashed) {
if s.appMetrics != nil {
s.appMetrics.GRPCMetrics().CountSyncRequestBlocked()
@@ -306,7 +306,7 @@ func (s *Server) Sync(req *proto.EncryptedMessage, srv proto.ManagementService_S
log.WithContext(ctx).Tracef("peer system meta has to be provided on sync. Peer %s, remote addr %s", peerKey.String(), realIP)
}
- metahash := metaHash(peerMeta, realIP.String())
+ metahash := metaHash(peerMeta)
s.loginFilter.addLogin(peerKey.String(), metahash)
peer, netMap, postureChecks, dnsFwdPort, err := s.accountManager.SyncAndMarkPeer(ctx, accountID, peerKey.String(), peerMeta, realIP, syncStart)
@@ -732,7 +732,7 @@ func (s *Server) Login(ctx context.Context, req *proto.EncryptedMessage) (*proto
}
peerMeta := extractPeerMeta(ctx, loginReq.GetMeta())
- metahashed := metaHash(peerMeta, sRealIP)
+ metahashed := metaHash(peerMeta)
if !s.loginFilter.allowLogin(peerKey.String(), metahashed) {
if s.logBlockedPeers {
log.WithContext(ctx).Tracef("peer %s with meta hash %d is blocked from login", peerKey.String(), metahashed)
@@ -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,
@@ -788,11 +788,15 @@ func (s *Server) Login(ctx context.Context, req *proto.EncryptedMessage) (*proto
ExtraDNSLabels: loginReq.GetDnsLabels(),
})
if err != nil {
- log.WithContext(ctx).Warnf("failed logging in peer %s: %s", peerKey, err)
+ if errors.Is(err, internalStatus.ErrNoAuthMethodProvided) {
+ log.WithContext(ctx).Tracef("failed logging in peer %s: %s", peerKey, err)
+ } else {
+ log.WithContext(ctx).Warnf("failed logging in peer %s: %s", peerKey, err)
+ }
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 +899,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 +918,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),
}
@@ -1205,7 +1209,7 @@ func (s *Server) SyncMeta(ctx context.Context, req *proto.EncryptedMessage) (*pr
return nil, msg
}
- err = s.accountManager.SyncPeerMeta(ctx, peerKey.String(), extractPeerMeta(ctx, syncMetaReq.GetMeta()))
+ err = s.accountManager.SyncPeerMeta(ctx, peerKey.String(), extractPeerMeta(ctx, syncMetaReq.GetMeta()), realIP)
if err != nil {
return nil, mapError(ctx, err)
}
@@ -1254,7 +1258,10 @@ func (s *Server) Logout(ctx context.Context, req *proto.EncryptedMessage) (*prot
func toProtocolChecks(ctx context.Context, postureChecks []*posture.Checks) []*proto.Checks {
protoChecks := make([]*proto.Checks, 0, len(postureChecks))
for _, postureCheck := range postureChecks {
- protoChecks = append(protoChecks, toProtocolCheck(postureCheck))
+ check := toProtocolCheck(postureCheck)
+ if check != nil {
+ protoChecks = append(protoChecks, check)
+ }
}
return protoChecks
@@ -1278,5 +1285,9 @@ func toProtocolCheck(postureCheck *posture.Checks) *proto.Checks {
}
}
+ if len(protoCheck.Files) == 0 {
+ return nil
+ }
+
return protoCheck
}
diff --git a/management/internals/shared/grpc/validate_session_test.go b/management/internals/shared/grpc/validate_session_test.go
index 27d9a65e7..d649102a1 100644
--- a/management/internals/shared/grpc/validate_session_test.go
+++ b/management/internals/shared/grpc/validate_session_test.go
@@ -42,7 +42,7 @@ func setupValidateSessionTest(t *testing.T) *validateSessionTestSetup {
tokenStore := NewOneTimeTokenStore(ctx, testCacheStore(t))
pkceStore := NewPKCEVerifierStore(ctx, testCacheStore(t))
- proxyService := NewProxyServiceServer(nil, tokenStore, pkceStore, ProxyOIDCConfig{}, nil, usersManager, proxyManager, nil)
+ proxyService := NewProxyServiceServer(nil, tokenStore, pkceStore, ProxyOIDCConfig{}, nil, usersManager, nil, proxyManager, nil)
proxyService.SetServiceManager(serviceManager)
createTestProxies(t, ctx, testStore)
diff --git a/management/server/account.go b/management/server/account.go
index f16717857..34220ed3f 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"
@@ -1588,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
}
@@ -1885,12 +1889,12 @@ func domainIsUpToDate(domain string, domainCategory string, userAuth auth.UserAu
// concurrent stream that started earlier loses the optimistic-lock race
// in MarkPeerConnected and bails without writing.
func (am *DefaultAccountManager) SyncAndMarkPeer(ctx context.Context, accountID string, peerPubKey string, meta nbpeer.PeerSystemMeta, realIP net.IP, syncTime time.Time) (*nbpeer.Peer, *types.NetworkMap, []*posture.Checks, int64, error) {
- peer, netMap, postureChecks, dnsfwdPort, err := am.SyncPeer(ctx, types.PeerSync{WireGuardPubKey: peerPubKey, Meta: meta}, accountID)
+ peer, netMap, postureChecks, dnsfwdPort, err := am.SyncPeer(ctx, types.PeerSync{WireGuardPubKey: peerPubKey, Meta: meta, RealIP: realIP}, accountID)
if err != nil {
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, accountID, syncTime.UnixNano(), netMap); err != nil {
log.WithContext(ctx).Warnf("failed marking peer as connected %s %v", peerPubKey, err)
}
@@ -1910,13 +1914,13 @@ func (am *DefaultAccountManager) OnPeerDisconnected(ctx context.Context, account
return nil
}
-func (am *DefaultAccountManager) SyncPeerMeta(ctx context.Context, peerPubKey string, meta nbpeer.PeerSystemMeta) error {
+func (am *DefaultAccountManager) SyncPeerMeta(ctx context.Context, peerPubKey string, meta nbpeer.PeerSystemMeta, realIP net.IP) error {
accountID, err := am.Store.GetAccountIDByPeerPubKey(ctx, peerPubKey)
if err != nil {
return err
}
- _, _, _, _, err = am.SyncPeer(ctx, types.PeerSync{WireGuardPubKey: peerPubKey, Meta: meta, UpdateAccountPeers: true}, accountID)
+ _, _, _, _, err = am.SyncPeer(ctx, types.PeerSync{WireGuardPubKey: peerPubKey, Meta: meta, RealIP: realIP, UpdateAccountPeers: true}, accountID)
if err != nil {
return err
}
@@ -2573,7 +2577,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)
}
@@ -2664,7 +2670,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..1e738c274 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, 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)
@@ -122,12 +123,13 @@ type Manager interface {
GetValidatedPeers(ctx context.Context, accountID string) (map[string]struct{}, map[string]string, error)
SyncAndMarkPeer(ctx context.Context, accountID string, peerPubKey string, meta nbpeer.PeerSystemMeta, realIP net.IP, syncTime time.Time) (*nbpeer.Peer, *types.NetworkMap, []*posture.Checks, int64, error)
OnPeerDisconnected(ctx context.Context, accountID string, peerPubKey string, streamStartTime time.Time) error
- SyncPeerMeta(ctx context.Context, peerPubKey string, meta nbpeer.PeerSystemMeta) error
+ SyncPeerMeta(ctx context.Context, peerPubKey string, meta nbpeer.PeerSystemMeta, realIP net.IP) error
FindExistingPostureCheck(accountID string, checks *posture.ChecksDefinition) (*posture.Checks, error)
GetAccountIDForPeerKey(ctx context.Context, peerKey string) (string, error)
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..274e4c683 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, 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, 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, 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, accountID, sessionStartedAt, nmap)
}
// MarkPeerDisconnected mocks base method.
@@ -1583,17 +1586,17 @@ func (mr *MockManagerMockRecorder) SyncPeer(ctx, sync, accountID interface{}) *g
}
// SyncPeerMeta mocks base method.
-func (m *MockManager) SyncPeerMeta(ctx context.Context, peerPubKey string, meta peer.PeerSystemMeta) error {
+func (m *MockManager) SyncPeerMeta(ctx context.Context, peerPubKey string, meta peer.PeerSystemMeta, realIP net.IP) error {
m.ctrl.T.Helper()
- ret := m.ctrl.Call(m, "SyncPeerMeta", ctx, peerPubKey, meta)
+ ret := m.ctrl.Call(m, "SyncPeerMeta", ctx, peerPubKey, meta, realIP)
ret0, _ := ret[0].(error)
return ret0
}
// SyncPeerMeta indicates an expected call of SyncPeerMeta.
-func (mr *MockManagerMockRecorder) SyncPeerMeta(ctx, peerPubKey, meta interface{}) *gomock.Call {
+func (mr *MockManagerMockRecorder) SyncPeerMeta(ctx, peerPubKey, meta, realIP interface{}) *gomock.Call {
mr.mock.ctrl.T.Helper()
- return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SyncPeerMeta", reflect.TypeOf((*MockManager)(nil).SyncPeerMeta), ctx, peerPubKey, meta)
+ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SyncPeerMeta", reflect.TypeOf((*MockManager)(nil).SyncPeerMeta), ctx, peerPubKey, meta, realIP)
}
// SyncUserJWTGroups 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 ba621030c..585e267d5 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(), 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(), accountID, time.Now().UTC().UnixNano(), nil)
require.NoError(t, err, "unable to mark peer connected")
failed := waitTimeout(wg, time.Second)
@@ -1893,6 +1916,117 @@ func TestDefaultAccountManager_MarkPeerConnected_PeerLoginExpiration(t *testing.
}
}
+func TestDefaultAccountManager_MarkPeerDisconnected_SchedulesInactivityExpiration(t *testing.T) {
+ manager, _, err := createManager(t)
+ require.NoError(t, err, "unable to create account manager")
+
+ accountID, err := manager.GetAccountIDByUserID(context.Background(), auth.UserAuth{UserId: userID})
+ require.NoError(t, err, "unable to create an account")
+
+ key, err := wgtypes.GenerateKey()
+ require.NoError(t, err, "unable to generate WireGuard key")
+ peerPubKey := key.PublicKey().String()
+
+ _, _, _, _, err = manager.AddPeer(context.Background(), "", "", userID, &nbpeer.Peer{
+ Key: peerPubKey,
+ Meta: nbpeer.PeerSystemMeta{Hostname: "test-peer"},
+ InactivityExpirationEnabled: true,
+ }, false)
+ require.NoError(t, err, "unable to add peer")
+
+ _, err = manager.UpdateAccountSettings(context.Background(), accountID, userID, &types.Settings{
+ PeerLoginExpiration: time.Hour,
+ PeerLoginExpirationEnabled: true,
+ PeerInactivityExpiration: time.Hour,
+ PeerInactivityExpirationEnabled: true,
+ Extra: &types.ExtraSettings{},
+ })
+ require.NoError(t, err, "expecting to update account settings successfully but got error")
+
+ // Establish a session so the matching-token disconnect is actually applied.
+ streamStartTime := time.Now().UTC()
+ err = manager.MarkPeerConnected(context.Background(), peerPubKey, accountID, streamStartTime.UnixNano(), nil)
+ require.NoError(t, err, "unable to mark peer connected")
+
+ // Install the mock only now, so the assertion observes the disconnect, not
+ // the earlier connect.
+ scheduled := make(chan struct{}, 1)
+ manager.peerInactivityExpiry = &MockScheduler{
+ CancelFunc: func(ctx context.Context, IDs []string) {},
+ ScheduleFunc: func(ctx context.Context, in time.Duration, ID string, job func() (nextRunIn time.Duration, reschedule bool)) {
+ select {
+ case scheduled <- struct{}{}:
+ default:
+ }
+ },
+ }
+
+ err = manager.MarkPeerDisconnected(context.Background(), peerPubKey, accountID, streamStartTime.UnixNano())
+ require.NoError(t, err, "unable to mark peer disconnected")
+
+ select {
+ case <-scheduled:
+ // expected: disconnect re-armed the inactivity expiry timer
+ case <-time.After(time.Second):
+ t.Fatal("expected inactivity expiration to be rescheduled when an eligible peer disconnects")
+ }
+}
+
+func TestDefaultAccountManager_MarkPeerDisconnected_SkipsInactivityExpirationWhenDisabled(t *testing.T) {
+ manager, _, err := createManager(t)
+ require.NoError(t, err, "unable to create account manager")
+
+ accountID, err := manager.GetAccountIDByUserID(context.Background(), auth.UserAuth{UserId: userID})
+ require.NoError(t, err, "unable to create an account")
+
+ key, err := wgtypes.GenerateKey()
+ require.NoError(t, err, "unable to generate WireGuard key")
+ peerPubKey := key.PublicKey().String()
+
+ _, _, _, _, err = manager.AddPeer(context.Background(), "", "", userID, &nbpeer.Peer{
+ Key: peerPubKey,
+ Meta: nbpeer.PeerSystemMeta{Hostname: "test-peer"},
+ InactivityExpirationEnabled: true,
+ }, false)
+ require.NoError(t, err, "unable to add peer")
+
+ // Peer is eligible (SSO + inactivity enabled) but the account-level setting
+ // stays disabled, so disconnect must not schedule anything.
+ _, err = manager.UpdateAccountSettings(context.Background(), accountID, userID, &types.Settings{
+ PeerLoginExpiration: time.Hour,
+ PeerLoginExpirationEnabled: true,
+ PeerInactivityExpiration: time.Hour,
+ PeerInactivityExpirationEnabled: false,
+ Extra: &types.ExtraSettings{},
+ })
+ require.NoError(t, err, "expecting to update account settings successfully but got error")
+
+ streamStartTime := time.Now().UTC()
+ err = manager.MarkPeerConnected(context.Background(), peerPubKey, accountID, streamStartTime.UnixNano(), nil)
+ require.NoError(t, err, "unable to mark peer connected")
+
+ scheduled := make(chan struct{}, 1)
+ manager.peerInactivityExpiry = &MockScheduler{
+ CancelFunc: func(ctx context.Context, IDs []string) {},
+ ScheduleFunc: func(ctx context.Context, in time.Duration, ID string, job func() (nextRunIn time.Duration, reschedule bool)) {
+ select {
+ case scheduled <- struct{}{}:
+ default:
+ }
+ },
+ }
+
+ err = manager.MarkPeerDisconnected(context.Background(), peerPubKey, accountID, streamStartTime.UnixNano())
+ require.NoError(t, err, "unable to mark peer disconnected")
+
+ select {
+ case <-scheduled:
+ t.Fatal("inactivity expiration must not be scheduled while the account-level setting is disabled")
+ case <-time.After(200 * time.Millisecond):
+ // expected: nothing scheduled
+ }
+}
+
func TestDefaultAccountManager_OnPeerDisconnected_LastSeenCheck(t *testing.T) {
manager, _, err := createManager(t)
require.NoError(t, err, "unable to create account manager")
@@ -1904,7 +2038,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 +2046,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, 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 +2067,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, 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 +2091,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, 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 +2101,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, 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 +2128,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 +2163,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, accountID, token, nil)
}()
}
@@ -2057,7 +2191,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 +2204,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(), accountID, time.Now().UTC().UnixNano(), nil)
require.NoError(t, err, "unable to mark peer connected")
wg := &sync.WaitGroup{}
@@ -3192,7 +3326,7 @@ func createManager(t testing.TB) (*DefaultAccountManager, *update_channel.PeersU
return nil, nil, err
}
- proxyGrpcServer := nbgrpc.NewProxyServiceServer(nil, nil, nil, nbgrpc.ProxyOIDCConfig{}, peersManager, nil, proxyManager, nil)
+ proxyGrpcServer := nbgrpc.NewProxyServiceServer(nil, nil, nil, nbgrpc.ProxyOIDCConfig{}, peersManager, nil, nil, proxyManager, nil)
proxyController, err := proxymanager.NewGRPCController(proxyGrpcServer, noop.Meter{})
if err != nil {
return nil, nil, err
@@ -3253,7 +3387,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{
@@ -3282,6 +3416,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 {
@@ -3408,7 +3555,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)},
@@ -3477,7 +3624,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)},
@@ -3872,13 +4019,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..ae5b92f49
--- /dev/null
+++ b/management/server/affected_peers_coverage_test.go
@@ -0,0 +1,115 @@
+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, s.routerPeerID}, []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)
+
+ assert.ElementsMatch(t, affected, mustContain, "expected peer to be affected")
+ for _, peerID := range mustExclude {
+ assert.NotContains(t, affected, peerID, "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..5d83367fd
--- /dev/null
+++ b/management/server/affected_peers_router_paths_test.go
@@ -0,0 +1,340 @@
+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")
+ }
+}
+
+// A disabled sibling router routes to nobody, so updating a resource on its network
+// must NOT refresh its peer (the enabled router carries the bridge instead).
+func TestAffectedPeers_E2E_UpdateResource_DisabledSiblingRouterNotBridged(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)
+ enabledCh := s.updateManager.CreateChannel(ctx, s.routerPeerID)
+ t.Cleanup(func() {
+ s.updateManager.CloseChannel(ctx, disabledRouterPeer.ID)
+ s.updateManager.CloseChannel(ctx, s.routerPeerID)
+ })
+
+ settleAffectedUpdates(disabledCh, enabledCh)
+
+ done := make(chan struct{})
+ go func() {
+ peerShouldReceiveUpdate(t, enabledCh)
+ peerShouldNotReceiveUpdate(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")
+ }
+}
+
+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..cc9df0a6a
--- /dev/null
+++ b/management/server/affected_peers_router_test.go
@@ -0,0 +1,776 @@
+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")
+}
+
+// A disabled router in the snapshot routes to nobody, so it is skipped when the
+// walk scans existing account data: a policy edit still folds the literal source
+// group, but not the disabled router's peer.
+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 (literal policy source group) must be affected")
+ assert.NotContains(t, affected, s.routerPeerID,
+ "a disabled router routes to nobody, so its peer must not be folded from snapshot data")
+}
+
+// A disabled resource in the snapshot is skipped: the policy edit still folds the
+// literal source group, but the resource no longer bridges to its network's router.
+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 (literal policy source group) must be affected")
+ assert.NotContains(t, affected, s.routerPeerID,
+ "a disabled resource routes to nobody, so its network's router must not be folded from snapshot data")
+}
+
+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..235128693
--- /dev/null
+++ b/management/server/affected_peers_test.go
@@ -0,0 +1,1853 @@
+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, 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]},
+ SourceResource: types.Resource{ID: peerIDs[0], Type: types.ResourceTypePeer},
+ DestinationResource: types.Resource{ID: peerIDs[1], Type: types.ResourceTypePeer},
+ Bidirectional: true,
+ Action: types.PolicyTrafficActionAccept,
+ },
+ {
+ Enabled: true,
+ Sources: []string{groupIDs[0]},
+ Destinations: []string{groupIDs[1]},
+ SourceResource: types.Resource{ID: peerIDs[2], Type: types.ResourceTypeHost},
+ DestinationResource: types.Resource{ID: peerIDs[3], Type: types.ResourceTypeHost},
+ Bidirectional: true,
+ Action: types.PolicyTrafficActionAccept,
+ },
+ {
+ Enabled: true,
+ Sources: []string{groupIDs[0]},
+ Destinations: []string{groupIDs[1]},
+ SourceResource: types.Resource{ID: "", Type: types.ResourceTypePeer},
+ DestinationResource: types.Resource{ID: "", Type: types.ResourceTypePeer},
+ Bidirectional: true,
+ Action: types.PolicyTrafficActionAccept,
+ },
+ },
+ }, true)
+ require.NoError(t, err)
+
+ groups, directPeers := collectGroupChangeAffectedGroups(ctx, s, accountID, []string{groupIDs[0]})
+ assert.ElementsMatch(t, groups, []string{groupIDs[0], groupIDs[1]})
+ assert.ElementsMatch(t, directPeers, []string{peerIDs[1]})
+
+ groups, directPeers = collectGroupChangeAffectedGroups(ctx, s, accountID, []string{groupIDs[1]})
+ assert.ElementsMatch(t, groups, []string{groupIDs[0], groupIDs[1]})
+ assert.ElementsMatch(t, directPeers, []string{peerIDs[0]})
+
+ groups, directPeers = collectGroupChangeAffectedGroups(ctx, s, accountID, []string{groupIDs[2]})
+ assert.Empty(t, groups)
+ assert.Empty(t, directPeers)
+}
+
+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},
+ DestinationResource: types.Resource{ID: peerIDs[4], Type: types.ResourceTypePeer},
+ Destinations: []string{groupIDs[1]},
+ Action: types.PolicyTrafficActionAccept,
+ },
+ {
+ Enabled: true,
+ Sources: []string{groupIDs[0]},
+ SourceResource: types.Resource{ID: peerIDs[1], Type: types.ResourceTypeHost},
+ DestinationResource: types.Resource{ID: peerIDs[2], Type: types.ResourceTypeHost},
+ Destinations: []string{groupIDs[1]},
+ Action: types.PolicyTrafficActionAccept,
+ },
+ {
+ Enabled: true,
+ Sources: []string{groupIDs[0]},
+ SourceResource: types.Resource{ID: "", Type: types.ResourceTypePeer},
+ DestinationResource: types.Resource{ID: "", 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.ElementsMatch(t, groups, []string{groupIDs[0], groupIDs[1]})
+ assert.ElementsMatch(t, directPeers, []string{peerIDs[4]})
+
+ groups, directPeers = collectGroupChangeAffectedGroups(ctx, s, accountID, []string{groupIDs[1]})
+ assert.ElementsMatch(t, groups, []string{groupIDs[0], groupIDs[1]})
+ assert.ElementsMatch(t, directPeers, []string{peerIDs[3]})
+
+ groups, directPeers = collectGroupChangeAffectedGroups(ctx, s, accountID, []string{groupIDs[2]})
+ assert.Empty(t, groups)
+ assert.Empty(t, directPeers)
+}
+
+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.ElementsMatch(t, groups, []string{groupIDs[0], 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],
+ Enabled: true,
+ })
+ 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],
+ Enabled: true,
+ })
+ 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.ElementsMatch(t, groups, []string{groupIDs[0], groupIDs[1]})
+ assert.Empty(t, directPeers)
+
+ groups, directPeers = collectGroupChangeAffectedGroups(ctx, s, accountID, []string{groupIDs[3]})
+ assert.ElementsMatch(t, groups, []string{groupIDs[2], groupIDs[3]})
+ 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)
+
+ // peerIDs[2] is unrelated to the route; only its own map can change.
+ result = manager.resolveAffectedPeersForPeerChanges(ctx, s, accountID, []string{peerIDs[2]})
+ assert.ElementsMatch(t, []string{peerIDs[2]}, 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[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)
+
+ // peerIDs[2] is in no policy; only its own map can change, so it refreshes itself.
+ result = manager.resolveAffectedPeersForPeerChanges(ctx, s, accountID, []string{peerIDs[2]})
+ assert.ElementsMatch(t, []string{peerIDs[2]}, 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 to the route; only its own map can change.
+ result = manager.resolveAffectedPeersForPeerChanges(ctx, s, accountID, []string{peerIDs[3]})
+ assert.ElementsMatch(t, []string{peerIDs[3]}, 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],
+ Enabled: true,
+ })
+ 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. A peer change folds
+ // only the changed peer plus the opposite side of each rule: group2 (peer2) via
+ // the group0 policy and group3 (peer3) via the group1 policy. peer1, a co-member
+ // of group1, is a sibling of the changed peer and must NOT refresh.
+ result := manager.resolveAffectedPeersForPeerChanges(ctx, s, accountID, []string{peerIDs[0]})
+ assert.ElementsMatch(t, []string{peerIDs[0], peerIDs[2], peerIDs[3]}, result)
+ assert.NotContains(t, result, peerIDs[1], "co-member of the changed peer's group must not refresh")
+}
+
+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[2], peerIDs[1], 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])
+
+ // peerIDs[4] is in neither isolated policy; only its own map can change.
+ result = manager.resolveAffectedPeersForPeerChanges(ctx, s, accountID, []string{peerIDs[4]})
+ assert.ElementsMatch(t, []string{peerIDs[4]}, 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")
+ }
+ })
+}
+
+// A peer in no policy/route refreshes only itself — no other peer is affected.
+func TestAffectedPeers_UnlinkedPeerChange_RefreshesSelfOnly(t *testing.T) {
+ manager, s, accountID, peerIDs, _ := setupAffectedPeersTest(t)
+ ctx := context.Background()
+
+ result := manager.resolveAffectedPeersForPeerChanges(ctx, s, accountID, []string{peerIDs[0]})
+ assert.ElementsMatch(t, []string{peerIDs[0]}, 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"},
+ Enabled: 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("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")
+}
+
+// A disabled service in the snapshot proxies nothing, so it is skipped: a changed
+// target peer does not pull in the service's proxy peer.
+func TestCollectAffectedFromProxyServices_DisabledServiceSkipped(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.NotContains(t, directPeers, peerIDs[0], "a disabled service proxies nothing, so its proxy peer must not be folded")
+}
+
+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..94e24ced6
--- /dev/null
+++ b/management/server/affectedpeers/resolver.go
@@ -0,0 +1,1093 @@
+// 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 handling differs by source. Disabled objects in the SNAPSHOT (existing
+// account policies/resources/routers/routes/proxy services and their rules/targets)
+// route to nobody and are skipped — they cannot affect any peer's map. Objects in
+// the CHANGE itself are processed regardless of Enabled, so disabling one still
+// refreshes the peers that lose access (the toggle is the observable change, and the
+// update carries the old∪new state).
+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 {
+ // LinkGroups drive the same policy/route/dns walk as a changed group or peer.
+ hasGroupOrPeerChange := len(c.ChangedGroupIDs) > 0 || len(c.ChangedPeerIDs) > 0 || len(c.LinkGroups) > 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 || len(c.LinkGroups) > 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
+
+ // OutputPeerIDs are peers folded straight into the result without seeding their
+ // group memberships into the walk. Use for the peer whose group membership changed:
+ // the peer itself must refresh, but its OTHER groups did not change, so they must
+ // not be walked. Contrast ChangedPeerIDs, which seeds ALL of the peer's groups
+ // (correct when the peer's own attributes changed, e.g. IP/status).
+ OutputPeerIDs []string
+
+ // LinkGroups are groups used ONLY to match policies/routes/routers and walk to the
+ // OPPOSITE side — they are never expanded to their own members. Use this when a
+ // peer's group membership changed: pass the peer in ChangedPeerIDs and its
+ // group(s) here. The opposite side of the policies the group participates in
+ // refreshes, but the group's other members (siblings) do not — nothing changed for
+ // them. For an intra-group policy (A→A) the opposite side IS the group, so its
+ // members still refresh via the opposite-side fold, exactly when they genuinely
+ // gain/lose the changed peer. Unlike ChangedGroupIDs, a LinkGroup is not added to
+ // the output, so a one-sided membership change never wakes the whole group.
+ LinkGroups []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 &&
+ len(c.LinkGroups) == 0 &&
+ len(c.OutputPeerIDs) == 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 linkGroups=%v policies=%d routes=%d routers=%d resources=%d networks=%d postureChecks=%v distributionGroups=%v",
+ accountID, c.ChangedGroupIDs, c.ChangedPeerIDs, c.LinkGroups, 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.affectedGroups), setToSlice(r.affectedPeers)
+}
+
+func newResolver(ctx context.Context, snap *Snapshot, accountID string, c Change) *resolver {
+ r := &resolver{
+ ctx: ctx,
+ snap: snap,
+ accountID: accountID,
+ change: c,
+ linkGroups: toSet(c.ChangedGroupIDs),
+ outputGroups: toSet(c.ChangedGroupIDs),
+ changedPeers: toSet(c.ChangedPeerIDs),
+ affectedGroups: make(map[string]struct{}),
+ affectedPeers: make(map[string]struct{}),
+ }
+ // LinkGroups match policies/routes to find the opposite side but are NOT output:
+ // they go into linkGroups only, never outputGroups, so their members never fold in.
+ addAll(r.linkGroups, c.LinkGroups)
+ // Resolve each changed peer to its groups here so callers pass only ChangedPeerIDs.
+ r.seedChangedGroupsFromPeers()
+ return r
+}
+
+// seedChangedGroupsFromPeers adds each changed peer's groups to linkGroups so
+// the group-driven walkers fire for memberships, not just direct peer references.
+// These seeded groups are for MATCHING only — folding the changed entity's own
+// side is gated on outputGroups (the caller-reported groups), so a seeded group
+// never folds its whole membership; only the changed peer itself folds in.
+func (r *resolver) seedChangedGroupsFromPeers() {
+ if len(r.changedPeers) == 0 {
+ return
+ }
+ for groupID, members := range r.snap.groupPeers {
+ for pID := range r.changedPeers {
+ if _, ok := members[pID]; ok {
+ r.linkGroups[groupID] = struct{}{}
+ break
+ }
+ }
+ }
+}
+
+// policySide selects which side of a policy rule to walk.
+type policySide int
+
+const (
+ sideSource policySide = iota
+ sideDestination
+)
+
+func (s policySide) opposite() policySide {
+ if s == sideSource {
+ return sideDestination
+ }
+ return sideSource
+}
+
+// walk resolves affected peers in two buckets, by how far each change propagates.
+//
+// BOTH-SIDES — the rule itself changed (an explicit policy edit, or a policy whose
+// posture check changed). Source AND destination refresh, so each such policy is
+// walked on both sides.
+//
+// OPPOSITE-SIDE — an endpoint moved but no rule changed. For each policy the change
+// touches we fold only the side AWAY from the change:
+// - a changed peer/group sits ON a policy side -> fold the opposite side;
+// - a changed router/resource/network sits on a NETWORK -> fold the SOURCE side of
+// the policies whose destination reaches it (and the routers it implies).
+//
+// Routes, nameserver groups, DNS and embedded-proxy services distribute to their own
+// member peers, outside the policy graph, and are folded here too.
+func (r *resolver) walk() {
+ for _, policy := range r.bothSidesPolicies() {
+ r.foldPolicySide(policy, sideSource)
+ r.foldPolicySide(policy, sideDestination)
+ }
+
+ if len(r.linkGroups) > 0 || len(r.changedPeers) > 0 {
+ r.collectFromPolicies()
+ r.collectFromRoutes()
+ r.collectFromNameServers()
+ r.collectFromDNSSettings()
+ r.collectFromNetworkRouters()
+ r.collectFromProxyServices()
+ }
+
+ r.collectFromChangedRoutes(r.change.Routes)
+ r.collectFromChangedRouters(r.change.Routers)
+ r.collectFromChangedResources(r.change.Resources)
+ r.collectFromChangedNetworks(r.change.Networks)
+
+ // The explicitly changed peers always refresh their own maps. OnPeersUpdated only
+ // refreshes the resolver's output (it ignores the separately-passed changed peers),
+ // so the changed peer reaches its own new map only via here. An offline/deleted
+ // peer in the set is filtered downstream (filterConnectedAffectedPeers).
+ addAll(r.affectedPeers, setToSlice(r.changedPeers))
+ // OutputPeerIDs refresh themselves too, but unlike changedPeers their group
+ // memberships were not seeded into the walk (only the changed group was).
+ addAll(r.affectedPeers, r.change.OutputPeerIDs)
+
+ // Distribution groups (nameserver/DNS) affect only their member peers: fold them
+ // straight into affectedGroups so expand() maps them to members, without the
+ // policy/route walk that linkGroups would trigger.
+ addAll(r.affectedGroups, r.change.DistributionGroupIDs)
+}
+
+// bothSidesPolicies are the policies whose rule changed: the explicitly edited ones
+// plus those gated by a changed posture check. walk folds both their sides.
+func (r *resolver) bothSidesPolicies() []*types.Policy {
+ policies := append([]*types.Policy(nil), r.change.Policies...)
+ return r.appendPoliciesForPostureChecks(policies, r.change.PostureCheckIDs)
+}
+
+type resolver struct {
+ ctx context.Context
+ snap *Snapshot
+ accountID string
+ change Change
+
+ // Inputs — what changed. Set once at construction, read-only during the walk
+ // (except linkGroups, which collectFromExplicitResources also seeds).
+ //
+ // linkGroups is the MATCH set: caller-changed groups ∪ the groups of changed
+ // peers ∪ changed-resource groups. A rule/route/router matches the change when
+ // one of its groups is here — used only to find the opposite side to fold.
+ //
+ // outputGroups is the FOLD-WHOLE-GROUP set: ONLY Change.ChangedGroupIDs. When a
+ // matched group is here, its whole membership is affected. A peer-seeded group
+ // is in linkGroups but NOT outputGroups, so it folds only the changed peer
+ // (changedPeers), never its siblings.
+ linkGroups map[string]struct{}
+ outputGroups map[string]struct{}
+ changedPeers map[string]struct{}
+
+ // Outputs — the answer. The only sets the walk accumulates into. affectedGroups
+ // is expanded to its member peers in expand().
+ affectedGroups map[string]struct{}
+ affectedPeers map[string]struct{}
+}
+
+// policies returns the account's ENABLED policies from the snapshot. Disabled
+// policies grant no access, so the walk skips them when scanning existing account
+// data. Explicitly changed policies (Change.Policies, via bothSidesPolicies) are
+// processed regardless of Enabled, so disabling one still refreshes its peers.
+func (r *resolver) policies() []*types.Policy {
+ enabled := make([]*types.Policy, 0, len(r.snap.policies))
+ for _, policy := range r.snap.policies {
+ if policy != nil && policy.Enabled {
+ enabled = append(enabled, policy)
+ }
+ }
+ return enabled
+}
+
+// networkResources / networkRouters return the account's ENABLED resources/routers
+// from the snapshot. Disabled objects route to nobody, so the walk skips them when
+// it scans existing account data. The explicitly changed objects in the Change are
+// processed regardless of Enabled (collectFromChanged*), so disabling one still
+// refreshes the peers that lose access.
+func (r *resolver) networkResources() []*resourceTypes.NetworkResource {
+ enabled := make([]*resourceTypes.NetworkResource, 0, len(r.snap.resources))
+ for _, resource := range r.snap.resources {
+ if resource.Enabled {
+ enabled = append(enabled, resource)
+ }
+ }
+ return enabled
+}
+
+func (r *resolver) networkRouters() []*routerTypes.NetworkRouter {
+ enabled := make([]*routerTypes.NetworkRouter, 0, len(r.snap.routers))
+ for _, router := range r.snap.routers {
+ if router.Enabled {
+ enabled = append(enabled, router)
+ }
+ }
+ return enabled
+}
+
+// peerIDsForGroups maps a group set to its member peer IDs via the preloaded index.
+func (r *resolver) peerIDsForGroups(groups map[string]struct{}) []string {
+ seen := make(map[string]struct{})
+ var ids []string
+ for gID := range groups {
+ 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.affectedGroups)
+
+ log.WithContext(r.ctx).Tracef("affectedpeers expand: account=%s affectedGroups=%v -> %d group-member peers; direct peers=%v",
+ r.accountID, setToSlice(r.affectedGroups), len(peerIDs), setToSlice(r.affectedPeers))
+
+ seen := make(map[string]struct{}, len(peerIDs))
+ for _, id := range peerIDs {
+ seen[id] = struct{}{}
+ }
+ for id := range r.affectedPeers {
+ if _, ok := seen[id]; !ok {
+ peerIDs = append(peerIDs, id)
+ seen[id] = struct{}{}
+ }
+ }
+
+ // Fold in removed peers only when their group is linked (in affectedGroups).
+ for groupID, removed := range r.change.RemovedPeersByGroup {
+ if _, linked := r.affectedGroups[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
+}
+
+// ruleSideGroups / ruleSideResource return the groups and the resource on the given
+// side of a rule.
+func ruleSideGroups(rule *types.PolicyRule, side policySide) []string {
+ if side == sideDestination {
+ return rule.Destinations
+ }
+ return rule.Sources
+}
+
+func ruleSideResource(rule *types.PolicyRule, side policySide) types.Resource {
+ if side == sideDestination {
+ return rule.DestinationResource
+ }
+ return rule.SourceResource
+}
+
+// foldPolicySide folds one side of a policy down to affected peers: its groups
+// (resolved to members in expand) and its direct peer. When the side is the
+// DESTINATION and references a network resource (directly or via a destination
+// group's resources), it also folds the routers that serve that resource's network
+// — a destination resource is reached through its routers. A resource on the SOURCE
+// side routes to nobody (GetPoliciesForNetworkResource matches destinations only),
+// so the router hop is destination-only.
+func (r *resolver) foldPolicySide(policy *types.Policy, side policySide) {
+ if policy == nil {
+ return
+ }
+ for _, rule := range policy.Rules {
+ addAll(r.affectedGroups, ruleSideGroups(rule, side))
+ res := ruleSideResource(rule, side)
+ if res.Type == types.ResourceTypePeer && res.ID != "" {
+ r.affectedPeers[res.ID] = struct{}{}
+ }
+ }
+ if side == sideDestination {
+ r.foldRoutersForResources(r.policyDestinationResourceIDs(policy))
+ }
+}
+
+// appendPoliciesForPostureChecks appends every policy that references a changed
+// posture check (a rule change, so walk both sides).
+func (r *resolver) appendPoliciesForPostureChecks(policies []*types.Policy, postureCheckIDs []string) []*types.Policy {
+ if len(postureCheckIDs) == 0 {
+ return policies
+ }
+ ids := toSet(postureCheckIDs)
+ for _, policy := range r.policies() {
+ if !policyReferencesPostureChecks(policy, ids) || !policy.Enabled {
+ continue
+ }
+ log.WithContext(r.ctx).Tracef("appendPoliciesForPostureChecks: policy %s (%s) references changed posture checks %v -> both-sides policy",
+ policy.ID, policy.Name, postureCheckIDs)
+ policies = append(policies, policy)
+ }
+ return policies
+}
+
+// collectFromPolicies folds, for every policy whose rule a changed group or peer
+// touches, only the OPPOSITE side (down to peers, incl. destination routers), plus
+// the changed entity's own side: the changed group's whole membership when the
+// group itself changed (outputGroups), or the changed peer alone when matched via a
+// peer-seeded group (never its co-members).
+func (r *resolver) collectFromPolicies() {
+ for _, policy := range r.policies() {
+ for _, rule := range policy.Rules {
+ if !rule.Enabled {
+ continue // a disabled rule grants no access
+ }
+ r.foldRuleSideIfChanged(policy, rule, sideSource)
+ r.foldRuleSideIfChanged(policy, rule, sideDestination)
+ }
+ }
+}
+
+// foldRuleSideIfChanged: when a changed group or direct peer sits on `side` of the
+// rule, fold the opposite side fully (groups/peers + destination routers) and fold
+// the changed entity's own side (the whole changed group, or the changed peer alone).
+func (r *resolver) foldRuleSideIfChanged(policy *types.Policy, rule *types.PolicyRule, side policySide) {
+ nearGroups := ruleSideGroups(rule, side)
+ nearResource := ruleSideResource(rule, side)
+
+ matchedByGroup := anyInSet(nearGroups, r.linkGroups)
+ matchedByPeer := isDirectPeerInSet(nearResource, r.changedPeers)
+ if !matchedByGroup && !matchedByPeer {
+ return
+ }
+
+ // Opposite side, fully down to peers (a destination opposite also folds routers).
+ r.foldPolicySideForRule(policy, rule, side.opposite())
+
+ // Own side: fold the whole changed group's members only when the group itself
+ // changed (outputGroups). A peer-seeded or link-only group is not folded here —
+ // its siblings never refresh. The changed peers themselves are folded once, after
+ // the walk (see walk()).
+ for _, gID := range nearGroups {
+ if _, ok := r.outputGroups[gID]; ok {
+ r.affectedGroups[gID] = struct{}{}
+ }
+ }
+
+ // When the changed side IS a destination, the resources it targets are reached
+ // through their network's routers, so those routers refresh too (e.g. attaching a
+ // resource to a destination group, or a changed destination group/resource).
+ if side == sideDestination {
+ r.foldRoutersForResources(r.ruleDestinationResourceIDs(rule))
+ }
+}
+
+// foldPolicySideForRule folds one side of a single rule (groups + direct peer), and
+// for a destination side the routers of that rule's destination resources.
+func (r *resolver) foldPolicySideForRule(policy *types.Policy, rule *types.PolicyRule, side policySide) {
+ addAll(r.affectedGroups, ruleSideGroups(rule, side))
+ res := ruleSideResource(rule, side)
+ if res.Type == types.ResourceTypePeer && res.ID != "" {
+ r.affectedPeers[res.ID] = struct{}{}
+ }
+ if side == sideDestination {
+ r.foldRoutersForResources(r.ruleDestinationResourceIDs(rule))
+ }
+}
+
+// collectFromChangedRoutes folds an explicitly changed route's own groups and peer.
+func (r *resolver) collectFromChangedRoutes(routes []*route.Route) {
+ for _, rt := range routes {
+ if rt == nil {
+ continue
+ }
+ log.WithContext(r.ctx).Tracef("collectFromChangedRoutes: changed route %s -> folding groups=%v peerGroups=%v accessControlGroups=%v peer=%q",
+ rt.ID, rt.Groups, rt.PeerGroups, rt.AccessControlGroups, rt.Peer)
+ addAll(r.affectedGroups, rt.Groups, rt.PeerGroups, rt.AccessControlGroups)
+ if rt.Peer != "" {
+ r.affectedPeers[rt.Peer] = struct{}{}
+ }
+ }
+}
+
+// collectFromChangedRouters: a changed router refreshes its OWN backing peer/groups
+// (the changed entity) and the SOURCE side of every policy reaching a resource on
+// its network (the router serves the whole network). Sibling routers on the network
+// are independent and are NOT folded. Passing the old router state keeps a repointed
+// router's previous backing affected without a post-commit read.
+func (r *resolver) collectFromChangedRouters(routers []*routerTypes.NetworkRouter) {
+ for _, router := range routers {
+ if router == nil {
+ continue
+ }
+ log.WithContext(r.ctx).Tracef("collectFromChangedRouters: changed router %s on network %s -> folding its own peerGroups=%v peer=%q + sources reaching network resources",
+ router.ID, router.NetworkID, router.PeerGroups, router.Peer)
+ addAll(r.affectedGroups, router.PeerGroups)
+ if router.Peer != "" {
+ r.affectedPeers[router.Peer] = struct{}{}
+ }
+ if router.NetworkID != "" {
+ r.foldPolicySourcesForResources(r.networkResourceIDs(router.NetworkID))
+ }
+ }
+}
+
+// collectFromChangedResources: a changed resource refreshes the SOURCE side of the
+// policies targeting EXACTLY that resource — directly, or via one of the resource's
+// own groups (old∪new across the change, so a now-detached group's sources still
+// refresh) — plus the routers serving its network (the resource is reached through
+// them). It does not touch sibling resources on the same network.
+func (r *resolver) collectFromChangedResources(resources []*resourceTypes.NetworkResource) {
+ for _, resource := range resources {
+ if resource == nil {
+ continue
+ }
+ log.WithContext(r.ctx).Tracef("collectFromChangedResources: changed resource %s on network %s (groups %v) -> folding sources of policies targeting it + its network's routers",
+ resource.ID, resource.NetworkID, resource.GroupIDs)
+ r.foldPolicySourcesForResource(resource.ID, resource.GroupIDs)
+ if resource.NetworkID != "" {
+ r.foldRoutersOnNetworks(map[string]struct{}{resource.NetworkID: {}})
+ }
+ }
+}
+
+// foldPolicySourcesForResource folds the source side of every policy whose
+// destination is the given resource — referenced directly, or via any of the given
+// groups (the resource's own old∪new groups, which captures a detached group).
+func (r *resolver) foldPolicySourcesForResource(resourceID string, groupIDs []string) {
+ groups := toSet(groupIDs)
+ for _, policy := range r.policies() {
+ if !policyTargetsResourceOrGroups(policy, resourceID, groups) {
+ continue
+ }
+ log.WithContext(r.ctx).Tracef("foldPolicySourcesForResource: policy %s (%s) targets changed resource %s -> folding its source groups/peers", policy.ID, policy.Name, resourceID)
+ collectPolicySources(policy, r.affectedGroups, r.affectedPeers)
+ }
+}
+
+// policyTargetsResourceOrGroups reports whether a policy's destination is the given
+// resource directly, or one of the given destination groups.
+func policyTargetsResourceOrGroups(policy *types.Policy, resourceID string, groups map[string]struct{}) bool {
+ if policy == nil {
+ return false
+ }
+ for _, rule := range policy.Rules {
+ if !rule.Enabled {
+ continue
+ }
+ if rule.DestinationResource.Type != types.ResourceTypePeer && rule.DestinationResource.ID == resourceID && resourceID != "" {
+ return true
+ }
+ if anyInSet(rule.Destinations, groups) {
+ return true
+ }
+ }
+ return false
+}
+
+// collectFromChangedNetworks: a changed network refreshes the SOURCE side of the
+// policies reaching any of its resources, plus its routers. A network has no
+// groups/peers of its own.
+func (r *resolver) collectFromChangedNetworks(networks []*networkTypes.Network) {
+ for _, network := range networks {
+ if network == nil || network.ID == "" {
+ continue
+ }
+ log.WithContext(r.ctx).Tracef("collectFromChangedNetworks: changed network %s -> folding sources reaching its resources + its routers", network.ID)
+ resourceIDs := r.networkResourceIDs(network.ID)
+ r.foldPolicySourcesForResources(resourceIDs)
+ r.foldRoutersOnNetworks(map[string]struct{}{network.ID: {}})
+ }
+}
+
+// foldPolicySourcesForResources folds the source groups/peers of every policy whose
+// destination targets one of resourceIDs (directly or via a destination group).
+func (r *resolver) foldPolicySourcesForResources(resourceIDs map[string]struct{}) {
+ if len(resourceIDs) == 0 {
+ return
+ }
+ for _, policy := range r.policies() {
+ if r.policyTargetsResources(policy, resourceIDs) {
+ log.WithContext(r.ctx).Tracef("foldPolicySourcesForResources: policy %s (%s) targets a changed resource -> folding its source groups/peers", policy.ID, policy.Name)
+ collectPolicySources(policy, r.affectedGroups, r.affectedPeers)
+ }
+ }
+}
+
+// collectFromRoutes folds, per matched route, the OPPOSITE side(s) fully and the
+// matched side's own groups only on a whole-group change (outputGroups). A route has
+// three peer sides — routing (Peer/PeerGroups), consumer (Groups) and ACL
+// (AccessControlGroups) — that each refresh the others; the changed side's own group
+// folds its siblings only when the group itself changed, never on a one-peer move.
+func (r *resolver) collectFromRoutes() {
+ for _, rt := range r.snap.routes {
+ if !rt.Enabled {
+ continue // disabled routes route to nobody; skip existing account data
+ }
+ routing := anyInSet(rt.PeerGroups, r.linkGroups) || (rt.Peer != "" && isInSet(rt.Peer, r.changedPeers))
+ consumer := anyInSet(rt.Groups, r.linkGroups)
+ acl := anyInSet(rt.AccessControlGroups, r.linkGroups)
+ if !routing && !consumer && !acl {
+ continue
+ }
+ log.WithContext(r.ctx).Tracef("collectFromRoutes: route %s matched (routing=%t consumer=%t acl=%t) -> folding opposite sides; own side gated on outputGroups",
+ rt.ID, routing, consumer, acl)
+ r.foldRouteSide(rt.PeerGroups, routing)
+ r.foldRouteSide(rt.Groups, consumer)
+ r.foldRouteSide(rt.AccessControlGroups, acl)
+ // The single routing Peer folds when the routing side is the OPPOSITE of the
+ // match (consumer/acl need it), or when that very peer is the change.
+ if rt.Peer != "" && (consumer || acl || isInSet(rt.Peer, r.changedPeers)) {
+ r.affectedPeers[rt.Peer] = struct{}{}
+ }
+ }
+}
+
+// foldRouteSide folds a route side: when this side is the one that matched, fold its
+// groups only on a whole-group change (outputGroups) so siblings of a single moved
+// peer stay put; otherwise it is an opposite side and folds fully.
+func (r *resolver) foldRouteSide(groups []string, matchedHere bool) {
+ if matchedHere {
+ r.foldOutputGroups(groups)
+ return
+ }
+ addAll(r.affectedGroups, groups)
+}
+
+// foldOutputGroups folds only the groups that the caller reported as wholly changed
+// (outputGroups). Used for a matched object's OWN side, where a peer-seeded or
+// link-only group must not pull in its siblings.
+func (r *resolver) foldOutputGroups(groups ...[]string) {
+ for _, gs := range groups {
+ for _, gID := range gs {
+ if _, ok := r.outputGroups[gID]; ok {
+ r.affectedGroups[gID] = struct{}{}
+ }
+ }
+ }
+}
+
+func (r *resolver) collectFromNameServers() {
+ if len(r.linkGroups) == 0 {
+ return
+ }
+ for _, ns := range r.snap.nsGroups {
+ if anyInSet(ns.Groups, r.linkGroups) {
+ // A nameserver group has no opposite side: a peer's DNS config depends only
+ // on its own membership, so a one-peer move refreshes that peer alone (folded
+ // elsewhere). Fold the referenced groups only on a whole-group change.
+ log.WithContext(r.ctx).Tracef("collectFromNameServers: nameserver group %s references a linked group -> folding its groups %v (outputGroups only)", ns.ID, ns.Groups)
+ r.foldOutputGroups(ns.Groups)
+ }
+ }
+}
+
+func (r *resolver) collectFromDNSSettings() {
+ if len(r.linkGroups) == 0 || r.snap.dnsSettings == nil {
+ return
+ }
+ for _, gID := range r.snap.dnsSettings.DisabledManagementGroups {
+ if _, ok := r.linkGroups[gID]; ok {
+ log.WithContext(r.ctx).Tracef("collectFromDNSSettings: changed group %s is in DisabledManagementGroups -> folding it", gID)
+ r.affectedGroups[gID] = struct{}{}
+ }
+ }
+}
+
+// collectFromNetworkRouters handles a changed group/peer that BACKS a router (the
+// routing peer set moved): the router's own peers refresh and so do the sources of
+// the policies reaching its network's resources. Sibling routers on the network are
+// independent and are not folded.
+func (r *resolver) collectFromNetworkRouters() {
+ for _, router := range r.networkRouters() {
+ matchedByGroup := anyInSet(router.PeerGroups, r.linkGroups)
+ matchedByPeer := router.Peer != "" && len(r.changedPeers) > 0 && isInSet(router.Peer, r.changedPeers)
+ if !matchedByGroup && !matchedByPeer {
+ continue
+ }
+ log.WithContext(r.ctx).Tracef("collectFromNetworkRouters: router %s on network %s matched (byGroup=%t byPeer=%t) -> folding its peerGroups=%v peer=%q (own groups on outputGroups) + sources reaching network resources",
+ router.ID, router.NetworkID, matchedByGroup, matchedByPeer, router.PeerGroups, router.Peer)
+ // The backing PeerGroups are the matched (own) side: fold them only on a
+ // whole-group change so a one-peer move does not wake sibling backing peers. The
+ // opposite side (policy sources reaching the network) is folded below.
+ r.foldOutputGroups(router.PeerGroups)
+ if router.Peer != "" {
+ r.affectedPeers[router.Peer] = struct{}{}
+ }
+ if router.NetworkID != "" {
+ r.foldPolicySourcesForResources(r.networkResourceIDs(router.NetworkID))
+ }
+ }
+}
+
+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 || !svc.Enabled {
+ continue // a disabled service proxies nothing; skip existing account data
+ }
+ proxyPeers := proxyByCluster[svc.ProxyCluster]
+ if len(proxyPeers) == 0 {
+ continue
+ }
+ matchedByPeer := serviceMatchesChangedPeers(svc, proxyPeers, expanded)
+ matchedByAccessGroup := anyInSet(svc.AccessGroups, r.linkGroups)
+ 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; access groups %v on outputGroups only",
+ svc.ID, svc.ProxyCluster, matchedByPeer, matchedByAccessGroup, len(proxyPeers), svc.AccessGroups)
+ for _, pid := range proxyPeers {
+ r.affectedPeers[pid] = struct{}{}
+ }
+ for _, target := range svc.Targets {
+ if !target.Enabled {
+ continue // a disabled target forwards nothing
+ }
+ if target.TargetType == rpservice.TargetTypePeer && target.TargetId != "" {
+ r.affectedPeers[target.TargetId] = struct{}{}
+ }
+ }
+ // AccessGroups are the matched (own) side with no opposite to fold: a member's
+ // proxy access is self-contained, so a one-peer move refreshes that peer alone.
+ // Fold the groups only on a whole-group change.
+ r.foldOutputGroups(svc.AccessGroups)
+ }
+}
+
+func (r *resolver) expandChangedPeersWithGroups() map[string]struct{} {
+ if len(r.linkGroups) == 0 {
+ return r.changedPeers
+ }
+ ids := r.peerIDsForGroups(r.linkGroups)
+ if len(ids) == 0 {
+ return r.changedPeers
+ }
+ merged := make(map[string]struct{}, len(r.changedPeers)+len(ids))
+ for id := range r.changedPeers {
+ merged[id] = struct{}{}
+ }
+ for _, id := range ids {
+ merged[id] = struct{}{}
+ }
+ return merged
+}
+
+// foldRoutersForResources folds the routers serving the networks of the given
+// resources (a destination resource is reached through its network's routers). It is
+// the resource -> network -> router hop used by foldPolicySide for a destination.
+func (r *resolver) foldRoutersForResources(resourceIDs map[string]struct{}) {
+ if len(resourceIDs) == 0 {
+ return
+ }
+ r.foldRoutersOnNetworks(r.resourceNetworkIDs(resourceIDs))
+}
+
+// ruleDestinationResourceIDs returns the destination resource IDs of a single rule:
+// the direct DestinationResource plus the resources of its destination groups.
+func (r *resolver) ruleDestinationResourceIDs(rule *types.PolicyRule) map[string]struct{} {
+ resourceIDs := make(map[string]struct{})
+ if rule.DestinationResource.Type != types.ResourceTypePeer && rule.DestinationResource.ID != "" {
+ resourceIDs[rule.DestinationResource.ID] = struct{}{}
+ }
+ r.addGroupResourceIDs(toSet(rule.Destinations), resourceIDs)
+ return resourceIDs
+}
+
+// networkResourceIDs returns the IDs of all resources on the given network.
+func (r *resolver) networkResourceIDs(networkID string) map[string]struct{} {
+ resourceIDs := make(map[string]struct{})
+ for _, resource := range r.networkResources() {
+ if resource.NetworkID == networkID {
+ resourceIDs[resource.ID] = struct{}{}
+ }
+ }
+ return resourceIDs
+}
+
+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.affectedGroups, router.PeerGroups)
+ if router.Peer != "" {
+ r.affectedPeers[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.Enabled {
+ continue
+ }
+ 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{}{}
+ }
+ }
+ }
+}
+
+// collectPolicySources folds the source groups/peers of a snapshot policy's enabled
+// rules (a disabled rule grants no access).
+func collectPolicySources(policy *types.Policy, groups, peers map[string]struct{}) {
+ for _, rule := range policy.Rules {
+ if !rule.Enabled {
+ continue
+ }
+ addAll(groups, rule.Sources)
+ if rule.SourceResource.Type == types.ResourceTypePeer && rule.SourceResource.ID != "" {
+ peers[rule.SourceResource.ID] = struct{}{}
+ }
+ }
+}
+
+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.Enabled || 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..fe6ada347
--- /dev/null
+++ b/management/server/affectedpeers/resolver_test.go
@@ -0,0 +1,112 @@
+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 both-sides extraction (RuleGroups + direct peers)
+// the resolver folds in for a changed policy, 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()...)
+ for _, rule := range p.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{}{}
+ }
+ }
+ }
+ 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 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 TestCollectPolicySources(t *testing.T) {
+ policy := &types.Policy{Rules: []*types.PolicyRule{{
+ Enabled: true,
+ 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/dns.go b/management/server/dns.go
index dcc3f21c7..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"
@@ -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/group.go b/management/server/group.go
index 7e02af245..460b51274 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"
@@ -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
- }
-
if err := transaction.CreateGroup(ctx, newGroup); err != nil {
return status.Errorf(status.Internal, "failed to create group: %v", err)
}
@@ -106,6 +103,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 {
@@ -116,9 +118,7 @@ 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
}
@@ -134,7 +134,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 {
@@ -153,20 +154,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
}
@@ -178,6 +166,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 {
@@ -188,13 +187,26 @@ 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.
@@ -209,11 +221,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
@@ -230,35 +245,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
}
@@ -277,12 +288,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 {
@@ -292,27 +304,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
@@ -333,9 +340,12 @@ func (am *DefaultAccountManager) updateSingleGroup(ctx context.Context, accountI
}
events = am.prepareGroupEvents(ctx, transaction, accountID, userID, newGroup)
- return nil
+
+ var err error
+ 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.
@@ -438,6 +448,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 {
@@ -445,26 +457,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
}
@@ -483,25 +492,52 @@ 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
+ // A membership change affects only the peer itself and the opposite side of THIS
+ // group's policies — not the group's other members, and not the peer's other
+ // groups. LinkGroups walks only this group (matched, not expanded); OutputPeerIDs
+ // refreshes the peer without seeding its other group memberships. For an
+ // intra-group policy the opposite side is the group, so its members still refresh.
+ change := affectedpeers.Change{OutputPeerIDs: []string{peerID}, LinkGroups: []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
}
@@ -511,9 +547,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
}
@@ -521,8 +555,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)
@@ -534,12 +569,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
}
@@ -549,29 +583,31 @@ 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
+ // Same as GroupAddPeer: the removed peer and the opposite side of THIS group's
+ // policies refresh, not the group's other members or the peer's other groups. The
+ // peer is no longer in the group's index, but LinkGroups still drives the
+ // opposite-side walk, and OutputPeerIDs refreshes the removed peer itself.
+ change := affectedpeers.Change{OutputPeerIDs: []string{peerID}, LinkGroups: []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.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 {
+ var err error
+ if snap, err = affectedpeers.Load(ctx, transaction, accountID, change); err != nil {
return err
}
@@ -581,9 +617,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
}
@@ -591,8 +625,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)
@@ -604,8 +639,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
}
@@ -619,9 +655,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
}
@@ -832,49 +866,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/http/handlers/peers/peers_handler.go b/management/server/http/handlers/peers/peers_handler.go
index 1d4af95e9..310f90653 100644
--- a/management/server/http/handlers/peers/peers_handler.go
+++ b/management/server/http/handlers/peers/peers_handler.go
@@ -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/proxy/auth_callback_integration_test.go b/management/server/http/handlers/proxy/auth_callback_integration_test.go
index f08d5daf1..a24857066 100644
--- a/management/server/http/handlers/proxy/auth_callback_integration_test.go
+++ b/management/server/http/handlers/proxy/auth_callback_integration_test.go
@@ -217,6 +217,7 @@ func setupAuthCallbackTest(t *testing.T) *testSetup {
usersManager,
nil,
nil,
+ nil,
)
proxyService.SetServiceManager(&testServiceManager{store: testStore})
diff --git a/management/server/http/handlers/users/users_handler.go b/management/server/http/handlers/users/users_handler.go
index 40ad585d2..179d433f1 100644
--- a/management/server/http/handlers/users/users_handler.go
+++ b/management/server/http/handlers/users/users_handler.go
@@ -220,7 +220,7 @@ func (h *handler) getAllUsers(w http.ResponseWriter, r *http.Request) {
}
includeServiceUser, err := strconv.ParseBool(serviceUser)
- log.WithContext(r.Context()).Debugf("Should include service user: %v", includeServiceUser)
+ log.WithContext(r.Context()).Tracef("Should include service user: %v", includeServiceUser)
if err != nil {
util.WriteError(r.Context(), status.Errorf(status.InvalidArgument, "invalid service_user query parameter"), w)
return
diff --git a/management/server/http/testing/testing_tools/channel/channel.go b/management/server/http/testing/testing_tools/channel/channel.go
index 8da9c7ad4..61584a615 100644
--- a/management/server/http/testing/testing_tools/channel/channel.go
+++ b/management/server/http/testing/testing_tools/channel/channel.go
@@ -110,7 +110,7 @@ func BuildApiBlackBoxWithDBState(t testing_tools.TB, sqlFile string, expectedPee
if err != nil {
t.Fatalf("Failed to create proxy manager: %v", err)
}
- proxyServiceServer := nbgrpc.NewProxyServiceServer(accessLogsManager, proxyTokenStore, pkceverifierStore, nbgrpc.ProxyOIDCConfig{}, peersManager, userManager, proxyMgr, nil)
+ proxyServiceServer := nbgrpc.NewProxyServiceServer(accessLogsManager, proxyTokenStore, pkceverifierStore, nbgrpc.ProxyOIDCConfig{}, peersManager, userManager, nil, proxyMgr, nil)
domainManager := manager.NewManager(store, proxyMgr, permissionsManager, am)
serviceProxyController, err := proxymanager.NewGRPCController(proxyServiceServer, noopMeter)
if err != nil {
@@ -240,7 +240,7 @@ func BuildApiBlackBoxWithDBStateAndPeerChannel(t testing_tools.TB, sqlFile strin
if err != nil {
t.Fatalf("Failed to create proxy manager: %v", err)
}
- proxyServiceServer := nbgrpc.NewProxyServiceServer(accessLogsManager, proxyTokenStore, pkceverifierStore, nbgrpc.ProxyOIDCConfig{}, peersManager, userManager, proxyMgr, nil)
+ proxyServiceServer := nbgrpc.NewProxyServiceServer(accessLogsManager, proxyTokenStore, pkceverifierStore, nbgrpc.ProxyOIDCConfig{}, peersManager, userManager, nil, proxyMgr, nil)
domainManager := manager.NewManager(store, proxyMgr, permissionsManager, am)
serviceProxyController, err := proxymanager.NewGRPCController(proxyServiceServer, noopMeter)
if err != nil {
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..071e3771b 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, 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
@@ -113,7 +114,7 @@ type MockAccountManager struct {
GetIdpManagerFunc func() idp.Manager
UpdateIntegratedValidatorFunc func(ctx context.Context, accountID, userID, validator string, groups []string) error
GroupValidationFunc func(ctx context.Context, accountId string, groups []string) (bool, error)
- SyncPeerMetaFunc func(ctx context.Context, peerPubKey string, meta nbpeer.PeerSystemMeta) error
+ SyncPeerMetaFunc func(ctx context.Context, peerPubKey string, meta nbpeer.PeerSystemMeta, realIP net.IP) error
FindExistingPostureCheckFunc func(accountID string, checks *posture.ChecksDefinition) (*posture.Checks, error)
GetAccountIDForPeerKeyFunc func(ctx context.Context, peerKey string) (string, error)
GetAccountByIDFunc func(ctx context.Context, accountID string, userID string) (*types.Account, 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, 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, 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
@@ -967,9 +975,9 @@ func (am *MockAccountManager) GroupValidation(ctx context.Context, accountId str
}
// SyncPeerMeta mocks SyncPeerMeta of the AccountManager interface
-func (am *MockAccountManager) SyncPeerMeta(ctx context.Context, peerPubKey string, meta nbpeer.PeerSystemMeta) error {
+func (am *MockAccountManager) SyncPeerMeta(ctx context.Context, peerPubKey string, meta nbpeer.PeerSystemMeta, realIP net.IP) error {
if am.SyncPeerMetaFunc != nil {
- return am.SyncPeerMetaFunc(ctx, peerPubKey, meta)
+ return am.SyncPeerMetaFunc(ctx, peerPubKey, meta, realIP)
}
return status.Errorf(codes.Unimplemented, "method SyncPeerMeta is not implemented")
}
diff --git a/management/server/nameserver.go b/management/server/nameserver.go
index c836fefeb..b9cebf726 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"
@@ -57,19 +59,19 @@ 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 {
+ if err = transaction.SaveNameServerGroup(ctx, newNSGroup); err != nil {
return err
}
- if err = transaction.SaveNameServerGroup(ctx, newNSGroup); err != nil {
+ if snap, err = affectedpeers.Load(ctx, transaction, accountID, change); err != nil {
return err
}
@@ -81,9 +83,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
}
@@ -102,7 +102,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)
@@ -115,12 +116,12 @@ func (am *DefaultAccountManager) SaveNameServerGroup(ctx context.Context, accoun
return err
}
- updateAccountPeers, err = areNameServerGroupChangesAffectPeers(ctx, transaction, nsGroupToSave, oldNSGroup)
- if err != nil {
+ if err = transaction.SaveNameServerGroup(ctx, nsGroupToSave); err != nil {
return err
}
- if err = transaction.SaveNameServerGroup(ctx, nsGroupToSave); err != nil {
+ change = affectedpeers.Change{DistributionGroupIDs: slices.Concat(nsGroupToSave.Groups, oldNSGroup.Groups)}
+ if snap, err = affectedpeers.Load(ctx, transaction, accountID, change); err != nil {
return err
}
@@ -132,9 +133,7 @@ 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
}
@@ -150,7 +149,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)
@@ -158,8 +158,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
}
@@ -175,9 +176,7 @@ 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
}
@@ -224,24 +223,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 f825ae015..d572502fd 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"
@@ -15,7 +16,6 @@ import (
"github.com/netbirdio/netbird/management/server/permissions/modules"
"github.com/netbirdio/netbird/management/server/permissions/operations"
"github.com/netbirdio/netbird/management/server/store"
- serverTypes "github.com/netbirdio/netbird/management/server/types"
"github.com/netbirdio/netbird/shared/management/status"
)
@@ -127,30 +127,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)
}
@@ -178,7 +187,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 51a269163..6c427ce62 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 {
@@ -114,45 +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)
- }
-
- 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)
@@ -162,11 +130,55 @@ 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)
+ }
+
+ 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, ctx, err := m.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Networks, operations.Read)
if err != nil {
@@ -207,6 +219,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 {
@@ -232,6 +246,14 @@ func (m *managerImpl) UpdateResource(ctx context.Context, userID string, resourc
return fmt.Errorf("failed to get network resource: %w", err)
}
+ 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)
@@ -247,6 +269,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)
@@ -270,7 +297,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
}
@@ -331,8 +358,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)
}
@@ -352,51 +397,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 {
@@ -431,6 +478,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 9fa2b95f7..cff387a7c 100644
--- a/management/server/networks/routers/manager.go
+++ b/management/server/networks/routers/manager.go
@@ -9,13 +9,13 @@ 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"
"github.com/netbirdio/netbird/management/server/permissions/modules"
"github.com/netbirdio/netbird/management/server/permissions/operations"
"github.com/netbirdio/netbird/management/server/store"
- serverTypes "github.com/netbirdio/netbird/management/server/types"
"github.com/netbirdio/netbird/shared/management/status"
)
@@ -26,7 +26,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 {
@@ -90,6 +90,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 {
@@ -112,6 +114,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 {
@@ -120,7 +126,7 @@ 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
}
@@ -156,36 +162,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)
- }
-
- 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
@@ -193,11 +175,47 @@ 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)
+ }
+
+ 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, ctx, err := m.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Networks, operations.Delete)
if err != nil {
@@ -208,8 +226,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)
}
@@ -227,36 +256,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 {
@@ -287,6 +316,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 d4e3ebb49..440e90044 100644
--- a/management/server/peer.go
+++ b/management/server/peer.go
@@ -27,6 +27,7 @@ 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"
@@ -73,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, accountID string, sessionStartedAt int64, nmap *types.NetworkMap) error {
start := time.Now()
defer func() {
am.metrics.AccountManagerMetrics().RecordPeerStatusUpdateDuration(telemetry.PeerStatusConnect, time.Since(start))
@@ -101,39 +102,22 @@ func (am *DefaultAccountManager) MarkPeerConnected(ctx context.Context, peerPubK
}
am.metrics.AccountManagerMetrics().CountPeerStatusUpdate(telemetry.PeerStatusConnect, telemetry.PeerStatusApplied)
- if am.geo != nil && realIP != nil {
- am.updatePeerLocationIfChanged(ctx, accountID, peer, realIP)
+ if err = am.schedulePeerExpirations(ctx, accountID, peer); err != nil {
+ return err
}
- 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 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)
}
}
@@ -141,6 +125,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
@@ -175,36 +178,50 @@ 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)
}
}
+ if peer.AddedWithSSOLogin() && peer.InactivityExpirationEnabled {
+ settings, err := am.Store.GetAccountSettings(ctx, store.LockingStrengthNone, accountID)
+ if err != nil {
+ log.WithContext(ctx).Warnf("failed getting account settings to schedule inactivity expiration for peer %s: %v", peer.ID, err)
+ } else if settings.PeerInactivityExpirationEnabled {
+ am.checkAndSchedulePeerInactivityExpiration(ctx, accountID)
+ }
+ }
+
return nil
}
-// updatePeerLocationIfChanged refreshes the geolocation on a separate
-// row update, only when the connection IP actually changed. Geo lookups
-// are expensive so we skip same-IP reconnects.
-func (am *DefaultAccountManager) updatePeerLocationIfChanged(ctx context.Context, accountID string, peer *nbpeer.Peer, realIP net.IP) {
- if peer.Location.ConnectionIP != nil && peer.Location.ConnectionIP.Equal(realIP) {
- return
+// resolvePeerLocation looks up the geo location for realIP, returning nil when
+// there is nothing to apply: geo disabled, no real IP, the IP is unchanged from
+// what the peer already has, or the lookup failed. Geo lookups are skipped on
+// same-IP reconnects since they are comparatively expensive. The returned value
+// is applied by Peer.UpdateMetaIfNew so the change is persisted by its peer save.
+func (am *DefaultAccountManager) resolvePeerLocation(ctx context.Context, peer *nbpeer.Peer, realIP net.IP) *nbpeer.Location {
+ if am.geo == nil || realIP == nil {
+ return nil
}
location, err := am.geo.Lookup(realIP)
if err != nil {
log.WithContext(ctx).Warnf("failed to get location for peer %s realip: [%s]: %v", peer.ID, realIP.String(), err)
- return
+ return nil
}
- peer.Location.ConnectionIP = realIP
- peer.Location.CountryCode = location.Country.ISOCode
- peer.Location.CityName = location.City.Names.En
- peer.Location.GeoNameID = location.City.GeonameID
- if err := am.Store.SavePeerLocation(ctx, accountID, peer); err != nil {
- log.WithContext(ctx).Warnf("could not store location for peer %s: %s", peer.ID, err)
+ if peer.Location.ConnectionIP != nil && peer.Location.ConnectionIP.Equal(realIP) && peer.Location.GeoNameID == location.City.GeonameID {
+ return nil
+ }
+ return &nbpeer.Location{
+ ConnectionIP: realIP,
+ CountryCode: location.Country.ISOCode,
+ CityName: location.City.Names.En,
+ GeoNameID: location.City.GeonameID,
}
}
@@ -346,7 +363,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)
}
@@ -501,10 +521,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)
@@ -513,8 +529,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
}
@@ -528,8 +574,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)
}
@@ -539,23 +588,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)
@@ -694,10 +727,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.ErrNoAuthMethodProvided
}
upperKey := strings.ToUpper(setupKey)
@@ -713,7 +746,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{
@@ -724,7 +757,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
@@ -739,7 +772,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()
@@ -765,7 +798,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 {
@@ -783,30 +816,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
@@ -828,11 +861,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
}
@@ -905,10 +938,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
@@ -916,7 +949,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
}
@@ -924,12 +958,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) {
@@ -949,10 +989,9 @@ func getPeerIPDNSLabel(ip netip.Addr, peerHostName string) (string, error) {
// SyncPeer checks whether peer is eligible for receiving NetworkMap (authenticated) and returns its NetworkMap if eligible
func (am *DefaultAccountManager) SyncPeer(ctx context.Context, sync types.PeerSync, accountID string) (*nbpeer.Peer, *types.NetworkMap, []*posture.Checks, int64, error) {
var peer *nbpeer.Peer
- var updated, versionChanged, ipv6CapabilityChanged bool
+ var ipv6CapabilityChanged bool
+ var metaDiff nbpeer.MetaDiff
var err error
- var postureChecks []*posture.Checks
- var peerGroupIDs []string
settings, err := am.Store.GetAccountSettings(ctx, store.LockingStrengthNone, accountID)
if err != nil {
@@ -980,25 +1019,16 @@ func (am *DefaultAccountManager) SyncPeer(ctx context.Context, sync types.PeerSy
return status.NewPeerLoginExpiredError()
}
- peerGroupIDs, err = getPeerGroupIDs(ctx, transaction, accountID, peer.ID)
- if err != nil {
- return err
- }
-
oldHasIPv6Cap := peer.HasCapability(nbpeer.PeerCapabilityIPv6Overlay)
- updated, versionChanged = peer.UpdateMetaIfNew(sync.Meta)
+ newLocation := am.resolvePeerLocation(ctx, peer, sync.RealIP)
+ metaDiff = peer.UpdateMetaIfNew(ctx, sync.Meta, newLocation)
ipv6CapabilityChanged = oldHasIPv6Cap != peer.HasCapability(nbpeer.PeerCapabilityIPv6Overlay)
- if updated {
+ if metaDiff.Updated() {
am.metrics.AccountManagerMetrics().CountPeerMetUpdate()
log.WithContext(ctx).Tracef("peer %s metadata updated", peer.ID)
if err = transaction.SavePeer(ctx, accountID, peer); err != nil {
return err
}
-
- postureChecks, err = getPeerPostureChecks(ctx, transaction, accountID, peer.ID)
- if err != nil {
- return err
- }
}
return nil
})
@@ -1006,22 +1036,85 @@ func (am *DefaultAccountManager) SyncPeer(ctx context.Context, sync types.PeerSy
return nil, nil, nil, 0, err
}
+ peerGroupIDs, err := getPeerGroupIDs(ctx, am.Store, accountID, peer.ID)
+ if err != nil {
+ return nil, nil, nil, 0, err
+ }
+
peerNotValid, isStatusChanged, err := am.integratedPeerValidator.IsNotValidPeer(ctx, accountID, peer, peerGroupIDs, settings.Extra)
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 {
+ nmap, resPostureChecks, dnsFwdPort, err := am.networkMapController.GetValidatedPeerWithMap(ctx, peerNotValid, accountID, peer.ID)
+ if err != nil {
+ return nil, nil, nil, 0, err
+ }
+
+ metaDiffAffectsPosture := posture.AffectsPosture(ctx, &metaDiff, resPostureChecks)
+ if requiresPeerUpdate(ctx, isStatusChanged, sync.UpdateAccountPeers, ipv6CapabilityChanged, metaDiffAffectsPosture, metaDiff.VersionChanged(), metaDiff.HostnameChanged()) {
+ changedPeerIDs := []string{peer.ID}
+ affectedPeerIDs := am.syncPeerAffectedPeers(ctx, accountID, peer.ID, nmap, peerNotValid, metaDiffAffectsPosture)
+ 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) {
+func requiresPeerUpdate(ctx context.Context, isStatusChanged, updateAccountPeers, ipv6CapabilityChanged, metaDiffAffectsPosture, versionChanged, hostname bool) bool {
+ var reason string
+ switch {
+ case isStatusChanged:
+ reason = "status changed"
+ case updateAccountPeers:
+ reason = "update account peers"
+ case ipv6CapabilityChanged:
+ reason = "ipv6 capability changed"
+ case metaDiffAffectsPosture:
+ reason = "meta diff affects posture"
+ case versionChanged:
+ reason = "version changed"
+ case hostname:
+ reason = "hostname changed"
+ default:
+ return false
+ }
+
+ log.WithContext(ctx).Tracef("peer update required: %s", reason)
+ return true
+}
+
+// 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, metaChangeAffectedPosture bool) []string {
+ if peerNotValid || metaChangeAffectedPosture {
+ 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.
@@ -1037,12 +1130,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)
@@ -1054,20 +1147,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, shouldUpdatePeers 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 {
@@ -1076,9 +1166,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)
@@ -1092,32 +1179,13 @@ func (am *DefaultAccountManager) LoginPeer(ctx context.Context, login types.Peer
if changed {
shouldStorePeer = true
- updateRemotePeers = true
- }
- }
-
- peerGroupIDs, err = getPeerGroupIDs(ctx, transaction, accountID, peer.ID)
- if err != nil {
- 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
+ shouldUpdatePeers = true
}
}
if peer.SSHKey != login.SSHKey {
peer.SSHKey = login.SSHKey
shouldStorePeer = true
- updateRemotePeers = true
}
if !peer.AllowExtraDNSLabels && len(login.ExtraDNSLabels) > 0 {
@@ -1133,23 +1201,36 @@ 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)
+ // This is needed to keep in memory for the peer config. Otherwise browser client will end in a retry loop
+ peer.Meta = login.Meta
+
+ peerGroupIDs, err = getPeerGroupIDs(ctx, am.Store, accountID, peer.ID)
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)
+ isRequiresApproval, _, err := am.integratedPeerValidator.IsNotValidPeer(ctx, accountID, peer, peerGroupIDs, settings.Extra)
+ if err != nil {
+ return nil, nil, nil, false, err
+ }
+
+ network, postureChecks, enableSSH, err := getPeerLoginInfo(ctx, am.Store, accountID, peer, !isRequiresApproval)
+ if err != nil {
+ return nil, nil, nil, false, err
+ }
+
+ if shouldUpdatePeers {
+ 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
@@ -1225,13 +1306,51 @@ func (am *DefaultAccountManager) ExtendPeerSession(ctx context.Context, peerPubK
return refreshed.SessionExpiresAt(settings.PeerLoginExpirationEnabled, settings.PeerLoginExpiration), 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)
+// 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, err
+ return nil, nil, false, fmt.Errorf("get account network: %w", err)
}
+ if !isValid {
+ return network, nil, false, nil
+ }
+
+ policies, err := transaction.GetAccountPolicies(ctx, store.LockingStrengthNone, accountID)
+ if err != nil {
+ return nil, nil, false, err
+ }
+
+ peerGroupIDs, err := transaction.GetPeerGroupIDs(ctx, store.LockingStrengthNone, accountID, peer.ID)
+ if err != nil {
+ return nil, nil, false, err
+ }
+
+ postureChecks, err := getPeerPostureChecks(ctx, transaction, accountID, peerGroupIDs, policies)
+ if err != nil {
+ return nil, nil, false, err
+ }
+
+ enableSSH, err := isPeerSSHEnabled(ctx, peer, policies, peerGroupIDs)
+ if err != nil {
+ return nil, nil, false, err
+ }
+
+ return network, postureChecks, enableSSH, nil
+}
+
+func isPeerSSHEnabled(ctx context.Context, peer *nbpeer.Peer, policies []*types.Policy, peerGroupIDs []string) (bool, error) {
+ groupIDsMap := make(map[string]struct{}, len(peerGroupIDs))
+ for _, peerID := range peerGroupIDs {
+ groupIDsMap[peerID] = struct{}{}
+ }
+ return types.PeerSSHEnabledFromPolicies(policies, peer.ID, groupIDsMap, peer.SSHEnabled), nil
+}
+
+// getPeerPostureChecks returns the posture checks for the peer.
+func getPeerPostureChecks(ctx context.Context, transaction store.Store, accountID string, peerGroupIDs []string, policies []*types.Policy) ([]*posture.Checks, error) {
if len(policies) == 0 {
return nil, nil
}
@@ -1243,11 +1362,7 @@ func getPeerPostureChecks(ctx context.Context, transaction store.Store, accountI
continue
}
- postureChecksIDs, err := processPeerPostureChecks(ctx, transaction, policy, accountID, peerID)
- if err != nil {
- return nil, err
- }
-
+ postureChecksIDs := processPeerPostureChecks(policy, peerGroupIDs)
peerPostureChecksIDs = append(peerPostureChecksIDs, postureChecksIDs...)
}
@@ -1260,29 +1375,19 @@ func getPeerPostureChecks(ctx context.Context, transaction store.Store, accountI
}
// processPeerPostureChecks checks if the peer is in the source group of the policy and returns the posture checks.
-func processPeerPostureChecks(ctx context.Context, transaction store.Store, policy *types.Policy, accountID, peerID string) ([]string, error) {
+func processPeerPostureChecks(policy *types.Policy, peerGroupIDs []string) []string {
for _, rule := range policy.Rules {
if !rule.Enabled {
continue
}
- sourceGroups, err := transaction.GetGroupsByIDs(ctx, store.LockingStrengthNone, accountID, rule.Sources)
- if err != nil {
- return nil, err
- }
-
for _, sourceGroup := range rule.Sources {
- group, ok := sourceGroups[sourceGroup]
- if !ok {
- return nil, fmt.Errorf("failed to check peer in policy source group")
- }
-
- if slices.Contains(group.Peers, peerID) {
- return policy.SourcePostureChecks, nil
+ if slices.Contains(peerGroupIDs, sourceGroup) {
+ return policy.SourcePostureChecks
}
}
}
- return nil, nil
+ return nil
}
// checkIFPeerNeedsLoginWithoutLock checks if the peer needs login without acquiring the account lock. The check validate if the peer was not added via SSO
@@ -1407,6 +1512,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/peer.go b/management/server/peer/peer.go
index e5475c07d..3110cd9c1 100644
--- a/management/server/peer/peer.go
+++ b/management/server/peer/peer.go
@@ -1,12 +1,16 @@
package peer
import (
+ "context"
+ "fmt"
"net"
"net/netip"
"slices"
- "sort"
+ "strings"
"time"
+ log "github.com/sirupsen/logrus"
+
"github.com/netbirdio/netbird/management/server/util"
"github.com/netbirdio/netbird/shared/management/http/api"
)
@@ -103,6 +107,15 @@ type Location struct {
GeoNameID uint // city level geoname id
}
+// equal reports whether two locations match. ConnectionIP is a net.IP slice, so it uses
+// IP.Equal, not ==.
+func (l Location) equal(other Location) bool {
+ return l.CountryCode == other.CountryCode &&
+ l.CityName == other.CityName &&
+ l.GeoNameID == other.GeoNameID &&
+ l.ConnectionIP.Equal(other.ConnectionIP)
+}
+
// NetworkAddress is the IP address with network and MAC address of a network interface
type NetworkAddress struct {
NetIP netip.Prefix `gorm:"serializer:json"`
@@ -162,49 +175,7 @@ type PeerSystemMeta struct { //nolint:revive
}
func (p PeerSystemMeta) isEqual(other PeerSystemMeta) bool {
- sort.Slice(p.NetworkAddresses, func(i, j int) bool {
- return p.NetworkAddresses[i].Mac < p.NetworkAddresses[j].Mac
- })
- sort.Slice(other.NetworkAddresses, func(i, j int) bool {
- return other.NetworkAddresses[i].Mac < other.NetworkAddresses[j].Mac
- })
- equalNetworkAddresses := slices.EqualFunc(p.NetworkAddresses, other.NetworkAddresses, func(addr NetworkAddress, oAddr NetworkAddress) bool {
- return addr.Mac == oAddr.Mac && addr.NetIP == oAddr.NetIP
- })
- if !equalNetworkAddresses {
- return false
- }
-
- sort.Slice(p.Files, func(i, j int) bool {
- return p.Files[i].Path < p.Files[j].Path
- })
- sort.Slice(other.Files, func(i, j int) bool {
- return other.Files[i].Path < other.Files[j].Path
- })
- equalFiles := slices.EqualFunc(p.Files, other.Files, func(file File, oFile File) bool {
- return file.Path == oFile.Path && file.Exist == oFile.Exist && file.ProcessIsRunning == oFile.ProcessIsRunning
- })
- if !equalFiles {
- return false
- }
-
- return p.Hostname == other.Hostname &&
- p.GoOS == other.GoOS &&
- p.Kernel == other.Kernel &&
- p.KernelVersion == other.KernelVersion &&
- p.Core == other.Core &&
- p.Platform == other.Platform &&
- p.OS == other.OS &&
- p.OSVersion == other.OSVersion &&
- p.WtVersion == other.WtVersion &&
- p.UIVersion == other.UIVersion &&
- p.SystemSerialNumber == other.SystemSerialNumber &&
- p.SystemProductName == other.SystemProductName &&
- p.SystemManufacturer == other.SystemManufacturer &&
- p.Environment.Cloud == other.Environment.Cloud &&
- p.Environment.Platform == other.Environment.Platform &&
- p.Flags.isEqual(other.Flags) &&
- capabilitiesEqual(p.Capabilities, other.Capabilities)
+ return len(metaDiff(p, other)) == 0
}
func (p PeerSystemMeta) isEmpty() bool {
@@ -294,26 +265,173 @@ func (p *Peer) Copy() *Peer {
}
}
-// UpdateMetaIfNew updates peer's system metadata if new information is provided
-// returns true if meta was updated, false otherwise
-func (p *Peer) UpdateMetaIfNew(meta PeerSystemMeta) (updated, versionChanged bool) {
+// UpdateMetaIfNew updates peer's system metadata and connection geo location if
+// new information is provided. newLocation is the geo location resolved from the
+// peer's current connection IP, or nil when there is nothing to apply (geo
+// disabled, no real IP, or the IP is unchanged); the caller owns the expensive
+// lookup and the same-IP guard. It returns a MetaDiff describing what changed;
+// diff.Updated() reports whether the peer needs to be persisted.
+func (p *Peer) UpdateMetaIfNew(ctx context.Context, meta PeerSystemMeta, newLocation *Location) MetaDiff {
if meta.isEmpty() {
- return updated, versionChanged
+ return MetaDiff{}
}
- versionChanged = p.Meta.WtVersion != meta.WtVersion
-
// Avoid overwriting UIVersion if the update was triggered sole by the CLI client
if meta.UIVersion == "" {
meta.UIVersion = p.Meta.UIVersion
}
- if p.Meta.isEqual(meta) {
- return updated, versionChanged
+ effectiveLocation := p.Location
+ if newLocation != nil {
+ effectiveLocation = *newLocation
}
- p.Meta = meta
- updated = true
- return updated, versionChanged
+
+ diff := diffMeta(p.Meta, meta, p.Location, effectiveLocation)
+ if diff.Updated() {
+ p.Meta = meta
+ }
+ p.Location = effectiveLocation
+
+ if diff.Updated() {
+ log.WithContext(ctx).Debug(diff.LogSummary())
+ }
+
+ return diff
+}
+
+// MetaDiff holds a peer's full before/after state across a sync: both metas and both
+// connection locations (the location lives on Peer, not PeerSystemMeta, but posture
+// checks read it). Changed lists what moved, for logging and the persistence decision;
+// the snapshots let a posture check be replayed against old and new. Everything is derived
+// from these fields, so there are no parallel per-field flags to keep in sync.
+type MetaDiff struct {
+ OldMeta PeerSystemMeta
+ NewMeta PeerSystemMeta
+ OldLocation Location
+ NewLocation Location
+
+ Changed []string
+}
+
+// Updated reports whether anything changed and the peer must be persisted. diffMeta fills
+// Changed in the pass that builds the diff, so this is a length check, not a re-comparison.
+// Pointer receiver: MetaDiff embeds two metas, so copying it per call is wasteful.
+func (d *MetaDiff) Updated() bool {
+ return len(d.Changed) != 0
+}
+
+// VersionChanged reports whether the WireGuard client version changed (a client upgrade).
+func (d *MetaDiff) VersionChanged() bool {
+ return d.OldMeta.WtVersion != d.NewMeta.WtVersion
+}
+
+// HostnameChanged reports whether the peer's hostname changed.
+func (d *MetaDiff) HostnameChanged() bool {
+ return d.OldMeta.Hostname != d.NewMeta.Hostname
+}
+
+// LogSummary renders the changed fields as a single human-readable line.
+func (d *MetaDiff) LogSummary() string {
+ return fmt.Sprintf("peer meta updated, %d field(s) changed: %s",
+ len(d.Changed), strings.Join(d.Changed, ", "))
+}
+
+func metaDiff(oldMeta, newMeta PeerSystemMeta) []string {
+ return diffMeta(oldMeta, newMeta, Location{}, Location{}).Changed
+}
+
+// diffMeta snapshots a peer's old and new state and records a Changed entry per field that
+// moved. It is the single source of truth for the comparison: isEqual is an empty Changed
+// list, so the log line and the persistence decision can never disagree.
+func diffMeta(oldMeta, newMeta PeerSystemMeta, oldLocation, newLocation Location) MetaDiff {
+ d := MetaDiff{OldMeta: oldMeta, NewMeta: newMeta, OldLocation: oldLocation, NewLocation: newLocation}
+ add := func(field string, oldVal, newVal any) {
+ d.Changed = append(d.Changed, fmt.Sprintf("%s: %v -> %v", field, oldVal, newVal))
+ }
+
+ if oldMeta.Hostname != newMeta.Hostname {
+ add("hostname", oldMeta.Hostname, newMeta.Hostname)
+ }
+ if oldMeta.GoOS != newMeta.GoOS {
+ add("goos", oldMeta.GoOS, newMeta.GoOS)
+ }
+ if oldMeta.Kernel != newMeta.Kernel {
+ add("kernel", oldMeta.Kernel, newMeta.Kernel)
+ }
+ if oldMeta.KernelVersion != newMeta.KernelVersion {
+ add("kernel_version", oldMeta.KernelVersion, newMeta.KernelVersion)
+ }
+ if oldMeta.Core != newMeta.Core {
+ add("core", oldMeta.Core, newMeta.Core)
+ }
+ if oldMeta.Platform != newMeta.Platform {
+ add("platform", oldMeta.Platform, newMeta.Platform)
+ }
+ if oldMeta.OS != newMeta.OS {
+ add("os", oldMeta.OS, newMeta.OS)
+ }
+ if oldMeta.OSVersion != newMeta.OSVersion {
+ add("os_version", oldMeta.OSVersion, newMeta.OSVersion)
+ }
+ if oldMeta.WtVersion != newMeta.WtVersion {
+ add("wt_version", oldMeta.WtVersion, newMeta.WtVersion)
+ }
+ if oldMeta.UIVersion != newMeta.UIVersion {
+ add("ui_version", oldMeta.UIVersion, newMeta.UIVersion)
+ }
+ if oldMeta.SystemSerialNumber != newMeta.SystemSerialNumber {
+ add("system_serial_number", oldMeta.SystemSerialNumber, newMeta.SystemSerialNumber)
+ }
+ if oldMeta.SystemProductName != newMeta.SystemProductName {
+ add("system_product_name", oldMeta.SystemProductName, newMeta.SystemProductName)
+ }
+ if oldMeta.SystemManufacturer != newMeta.SystemManufacturer {
+ add("system_manufacturer", oldMeta.SystemManufacturer, newMeta.SystemManufacturer)
+ }
+ if oldMeta.Environment.Cloud != newMeta.Environment.Cloud {
+ add("environment_cloud", oldMeta.Environment.Cloud, newMeta.Environment.Cloud)
+ }
+ if oldMeta.Environment.Platform != newMeta.Environment.Platform {
+ add("environment_platform", oldMeta.Environment.Platform, newMeta.Environment.Platform)
+ }
+ if !oldMeta.Flags.isEqual(newMeta.Flags) {
+ add("flags", fmt.Sprintf("%+v", oldMeta.Flags), fmt.Sprintf("%+v", newMeta.Flags))
+ }
+ if !capabilitiesEqual(oldMeta.Capabilities, newMeta.Capabilities) {
+ add("capabilities", oldMeta.Capabilities, newMeta.Capabilities)
+ }
+ if !sameMultiset(oldMeta.NetworkAddresses, newMeta.NetworkAddresses) {
+ add("network_addresses", fmt.Sprintf("%v", oldMeta.NetworkAddresses), fmt.Sprintf("%v", newMeta.NetworkAddresses))
+ }
+ if !sameMultiset(oldMeta.Files, newMeta.Files) {
+ add("files", fmt.Sprintf("%v", oldMeta.Files), fmt.Sprintf("%v", newMeta.Files))
+ }
+
+ if !oldLocation.equal(newLocation) {
+ add("connection_ip", oldLocation.ConnectionIP, newLocation.ConnectionIP)
+ }
+
+ return d
+}
+
+// sameMultiset reports whether two slices contain the same elements with the
+// same multiplicity, ignoring order. The element type is the comparison key, so
+// every field participates in equality.
+func sameMultiset[T comparable](a, b []T) bool {
+ if len(a) != len(b) {
+ return false
+ }
+ counts := make(map[T]int, len(a))
+ for _, v := range a {
+ counts[v]++
+ }
+ for _, v := range b {
+ counts[v]--
+ if counts[v] == 0 {
+ delete(counts, v)
+ }
+ }
+ return len(counts) == 0
}
// GetLastLogin returns the last login time of the peer.
diff --git a/management/server/peer/peer_metadiff_test.go b/management/server/peer/peer_metadiff_test.go
new file mode 100644
index 000000000..1256cdb02
--- /dev/null
+++ b/management/server/peer/peer_metadiff_test.go
@@ -0,0 +1,113 @@
+package peer
+
+import (
+ "net/netip"
+ "reflect"
+ "testing"
+
+ "github.com/stretchr/testify/require"
+)
+
+// metaDiffExtraEntries accounts for PeerSystemMeta fields that metaDiff does not
+// map 1:1 to a single diff entry. Today the only such field is Environment, which
+// is exploded into two checks (Cloud, Platform) and therefore yields one extra
+// entry beyond its single struct field. If you teach metaDiff to explode another
+// field into N entries, bump this by N-1; if you collapse a field, lower it.
+const metaDiffExtraEntries = 1
+
+// TestMetaDiff_CoversAllFields fully populates a PeerSystemMeta with non-zero
+// values and diffs it against the zero value, then asserts metaDiff emits exactly
+// one entry per exported field (plus metaDiffExtraEntries for fields it explodes).
+//
+// The expected count is derived from the struct via reflection, so adding a field
+// to PeerSystemMeta raises the expectation automatically — but the actual diff
+// only grows if metaDiff was taught to compare the new field. A mismatch means
+// someone changed the struct without updating metaDiff (or this test's
+// extra-entry accounting), which is exactly what we want to catch.
+func TestMetaDiff_CoversAllFields(t *testing.T) {
+ var full PeerSystemMeta
+ exported := populateAll(t, reflect.ValueOf(&full).Elem())
+ require.NotZero(t, exported, "expected PeerSystemMeta to expose fields")
+
+ diff := metaDiff(PeerSystemMeta{}, full)
+
+ require.Len(t, diff, exported+metaDiffExtraEntries,
+ "metaDiff entry count no longer matches PeerSystemMeta's fields: a field was "+
+ "likely added or removed without updating metaDiff (or metaDiffExtraEntries). "+
+ "diff was: %v", diff)
+
+ require.False(t, full.isEqual(PeerSystemMeta{}),
+ "isEqual must report a fully-populated meta as different from the zero value")
+}
+
+// TestFlags_isEqualChecksEveryField guards the one field that the count-based
+// TestMetaDiff_CoversAllFields cannot: metaDiff collapses all of Flags into a
+// single "flags" diff entry, so a new Flags field that Flags.isEqual forgets to
+// compare would not change the diff count. This flips each Flags field on its own
+// and asserts Flags.isEqual notices, so adding a Flags field without comparing it
+// fails here.
+func TestFlags_isEqualChecksEveryField(t *testing.T) {
+ typ := reflect.TypeOf(Flags{})
+ for i := 0; i < typ.NumField(); i++ {
+ f := typ.Field(i)
+ require.Equal(t, reflect.Bool, f.Type.Kind(),
+ "Flags.%s is not a bool; extend this test to set it non-zero", f.Name)
+
+ var a, b Flags
+ reflect.ValueOf(&b).Elem().Field(i).SetBool(true)
+ require.False(t, a.isEqual(b), "Flags.isEqual ignores field %s", f.Name)
+ }
+}
+
+// populateAll sets every exported field of the struct to a deterministic non-zero
+// value, recursing into nested structs and the element type of struct slices so
+// that each leaf differs from zero. It returns the number of exported fields on
+// the top-level struct. netip.Prefix is treated as an opaque leaf (it has no
+// settable exported fields and is comparable with ==).
+func populateAll(t *testing.T, v reflect.Value) int {
+ t.Helper()
+
+ typ := v.Type()
+ exported := 0
+ for i := 0; i < typ.NumField(); i++ {
+ f := typ.Field(i)
+ if f.PkgPath != "" { // unexported
+ continue
+ }
+ exported++
+ setNonZero(t, v.Field(i))
+ }
+ return exported
+}
+
+// setNonZero assigns a deterministic non-zero value to a field based on its kind,
+// recursing into nested structs and populating one element of slice fields.
+func setNonZero(t *testing.T, field reflect.Value) {
+ t.Helper()
+
+ if field.Type() == reflect.TypeOf(netip.Prefix{}) {
+ field.Set(reflect.ValueOf(netip.MustParsePrefix("10.0.0.0/24")))
+ return
+ }
+
+ switch field.Kind() {
+ case reflect.String:
+ field.SetString("non-zero")
+ 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.Float32, reflect.Float64:
+ field.SetFloat(7)
+ case reflect.Struct:
+ populateAll(t, field)
+ case reflect.Slice:
+ s := reflect.MakeSlice(field.Type(), 1, 1)
+ setNonZero(t, s.Index(0))
+ field.Set(s)
+ default:
+ t.Fatalf("unhandled field kind %s; extend setNonZero", field.Kind())
+ }
+}
diff --git a/management/server/peer_test.go b/management/server/peer_test.go
index 9d6856740..6f139e43f 100644
--- a/management/server/peer_test.go
+++ b/management/server/peer_test.go
@@ -49,6 +49,7 @@ import (
nbdns "github.com/netbirdio/netbird/dns"
"github.com/netbirdio/netbird/management/server/activity"
+ "github.com/netbirdio/netbird/management/server/geolocation"
nbpeer "github.com/netbirdio/netbird/management/server/peer"
"github.com/netbirdio/netbird/management/server/posture"
"github.com/netbirdio/netbird/management/server/store"
@@ -205,7 +206,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 +220,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 +279,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 +293,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 +455,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 +469,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 +527,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 +543,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 +699,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 +708,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 +1333,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 +1466,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 +1578,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 +1724,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 +1740,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 +1758,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 +1856,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 +1864,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 +1881,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 +1987,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 +2019,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 +2046,7 @@ func TestPeerAccountPeersUpdate(t *testing.T) {
done := make(chan struct{})
go func() {
- peerShouldReceiveUpdate(t, updMsg)
+ peerShouldNotReceiveUpdate(t, updMsg)
close(done)
}()
@@ -2050,7 +2054,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 +2063,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 +2081,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 +2101,7 @@ func TestPeerAccountPeersUpdate(t *testing.T) {
done := make(chan struct{})
go func() {
- peerShouldReceiveUpdate(t, updMsg)
+ peerShouldNotReceiveUpdate(t, updMsg)
close(done)
}()
@@ -2105,7 +2109,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 +2118,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 +2136,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 +2287,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 +2367,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 +2402,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 +2445,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 +2464,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 +2501,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 +2514,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 +2838,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 +2848,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 +2872,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 +2882,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)
@@ -2890,3 +2894,141 @@ func TestUpdatePeer_DnsLabelUniqueName(t *testing.T) {
require.NoError(t, err, "renaming to unique FQDN should succeed")
assert.Equal(t, "api-server", updated.DNSLabel, "DNS label should be first label of FQDN")
}
+
+// fakeGeo is a configurable geolocation.Geolocation implementation for tests. It
+// returns a record built from the configured city geoname id, or an error when set.
+type fakeGeo struct {
+ geoNameID uint
+ isoCode string
+ cityName string
+ err error
+}
+
+func (g *fakeGeo) Lookup(net.IP) (*geolocation.Record, error) {
+ if g.err != nil {
+ return nil, g.err
+ }
+ record := &geolocation.Record{}
+ record.City.GeonameID = g.geoNameID
+ record.City.Names.En = g.cityName
+ record.Country.ISOCode = g.isoCode
+ return record, nil
+}
+
+func (g *fakeGeo) GetAllCountries() ([]geolocation.Country, error) { return nil, nil }
+
+func (g *fakeGeo) GetCitiesByCountry(string) ([]geolocation.City, error) { return nil, nil }
+
+func (g *fakeGeo) Stop() error { return nil }
+
+func TestResolvePeerLocation(t *testing.T) {
+ realIP := net.ParseIP("203.0.113.10")
+
+ tests := []struct {
+ name string
+ geo geolocation.Geolocation
+ peer *nbpeer.Peer
+ realIP net.IP
+ want *nbpeer.Location
+ wantNil bool
+ }{
+ {
+ name: "no geo configured returns nil",
+ geo: nil,
+ peer: &nbpeer.Peer{ID: "p1"},
+ realIP: realIP,
+ wantNil: true,
+ },
+ {
+ name: "nil real IP returns nil",
+ geo: &fakeGeo{geoNameID: 100},
+ peer: &nbpeer.Peer{ID: "p1"},
+ realIP: nil,
+ wantNil: true,
+ },
+ {
+ name: "lookup error returns nil",
+ geo: &fakeGeo{err: fmt.Errorf("lookup boom")},
+ peer: &nbpeer.Peer{ID: "p1"},
+ realIP: realIP,
+ wantNil: true,
+ },
+ {
+ name: "same IP and same geoname returns nil",
+ geo: &fakeGeo{geoNameID: 100, isoCode: "US", cityName: "City A"},
+ peer: &nbpeer.Peer{
+ ID: "p1",
+ Location: nbpeer.Location{
+ ConnectionIP: realIP,
+ GeoNameID: 100,
+ },
+ },
+ realIP: realIP,
+ wantNil: true,
+ },
+ {
+ name: "same IP but changed geoname returns location",
+ geo: &fakeGeo{geoNameID: 200, isoCode: "US", cityName: "City B"},
+ peer: &nbpeer.Peer{
+ ID: "p1",
+ Location: nbpeer.Location{
+ ConnectionIP: realIP,
+ GeoNameID: 100,
+ },
+ },
+ realIP: realIP,
+ want: &nbpeer.Location{
+ ConnectionIP: realIP,
+ CountryCode: "US",
+ CityName: "City B",
+ GeoNameID: 200,
+ },
+ },
+ {
+ name: "different IP returns location",
+ geo: &fakeGeo{geoNameID: 100, isoCode: "US", cityName: "City A"},
+ peer: &nbpeer.Peer{
+ ID: "p1",
+ Location: nbpeer.Location{
+ ConnectionIP: net.ParseIP("198.51.100.7"),
+ GeoNameID: 100,
+ },
+ },
+ realIP: realIP,
+ want: &nbpeer.Location{
+ ConnectionIP: realIP,
+ CountryCode: "US",
+ CityName: "City A",
+ GeoNameID: 100,
+ },
+ },
+ {
+ name: "no prior location returns location",
+ geo: &fakeGeo{geoNameID: 100, isoCode: "US", cityName: "City A"},
+ peer: &nbpeer.Peer{ID: "p1"},
+ realIP: realIP,
+ want: &nbpeer.Location{
+ ConnectionIP: realIP,
+ CountryCode: "US",
+ CityName: "City A",
+ GeoNameID: 100,
+ },
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ am := &DefaultAccountManager{geo: tt.geo}
+ got := am.resolvePeerLocation(context.Background(), tt.peer, tt.realIP)
+ if tt.wantNil {
+ assert.Nil(t, got, "resolved location should be nil")
+ return
+ }
+ require.NotNil(t, got, "resolved location should not be nil")
+ assert.True(t, tt.want.ConnectionIP.Equal(got.ConnectionIP), "connection IP should match")
+ assert.Equal(t, tt.want.CountryCode, got.CountryCode, "country code should match")
+ assert.Equal(t, tt.want.CityName, got.CityName, "city name should match")
+ assert.Equal(t, tt.want.GeoNameID, got.GeoNameID, "geoname id should match")
+ })
+ }
+}
diff --git a/management/server/policy.go b/management/server/policy.go
index d67b3206e..187c879cb 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,6 +13,7 @@ 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"
)
@@ -45,44 +46,47 @@ 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
- }
-
if err = transaction.SavePolicy(ctx, policy); err != nil {
return err
}
} else {
- updateAccountPeers, err = arePolicyChangesAffectPeers(ctx, transaction, policy)
- if err != nil {
- return err
- }
-
if err = transaction.CreatePolicy(ctx, policy); err != nil {
return err
}
}
+ // 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 {
@@ -95,13 +99,7 @@ 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
}
@@ -117,7 +115,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)
@@ -125,8 +124,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
}
@@ -142,9 +142,7 @@ 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
}
@@ -162,46 +160,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/affects_posture_test.go b/management/server/posture/affects_posture_test.go
new file mode 100644
index 000000000..6aa54d892
--- /dev/null
+++ b/management/server/posture/affects_posture_test.go
@@ -0,0 +1,202 @@
+package posture
+
+import (
+ "context"
+ "net"
+ "net/netip"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+
+ nbpeer "github.com/netbirdio/netbird/management/server/peer"
+)
+
+// diffFrom builds a MetaDiff from the old/new snapshots AffectsPosture replays against.
+func diffFrom(oldMeta, newMeta nbpeer.PeerSystemMeta, oldLoc, newLoc nbpeer.Location) *nbpeer.MetaDiff {
+ return &nbpeer.MetaDiff{
+ OldMeta: oldMeta,
+ NewMeta: newMeta,
+ OldLocation: oldLoc,
+ NewLocation: newLoc,
+ }
+}
+
+func checks(def ChecksDefinition) []*Checks {
+ return []*Checks{{Checks: def}}
+}
+
+func TestAffectsPosture_NilDiff(t *testing.T) {
+ assert.False(t, AffectsPosture(context.Background(), nil, checks(ChecksDefinition{
+ NBVersionCheck: &NBVersionCheck{MinVersion: "1.0.0"},
+ })))
+}
+
+func TestAffectsPosture_NBVersion(t *testing.T) {
+ c := checks(ChecksDefinition{NBVersionCheck: &NBVersionCheck{MinVersion: "1.2.0"}})
+
+ tests := []struct {
+ name string
+ oldVer, newVer string
+ want bool
+ }{
+ {"both above min, no flip", "1.3.0", "1.4.0", false},
+ {"both below min, no flip", "1.0.0", "1.1.0", false},
+ {"crosses up below->above", "1.1.0", "1.3.0", true},
+ {"crosses down above->below", "1.3.0", "1.1.0", true},
+ {"unparsable old only -> flip", "garbage", "1.3.0", true},
+ {"unparsable both -> no flip", "garbage", "junk", false},
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ diff := diffFrom(
+ nbpeer.PeerSystemMeta{WtVersion: tt.oldVer},
+ nbpeer.PeerSystemMeta{WtVersion: tt.newVer},
+ nbpeer.Location{}, nbpeer.Location{},
+ )
+ assert.Equal(t, tt.want, AffectsPosture(context.Background(), diff, c))
+ })
+ }
+}
+
+func TestAffectsPosture_OSVersion_KernelBumpWithinMin(t *testing.T) {
+ c := checks(ChecksDefinition{OSVersionCheck: &OSVersionCheck{
+ Linux: &MinKernelVersionCheck{MinKernelVersion: "5.0.0"},
+ }})
+
+ // Kernel moves but stays above the minimum: verdict stays pass -> not affected.
+ withinMin := diffFrom(
+ nbpeer.PeerSystemMeta{GoOS: "linux", KernelVersion: "5.10.0-arch1"},
+ nbpeer.PeerSystemMeta{GoOS: "linux", KernelVersion: "5.15.0-arch2"},
+ nbpeer.Location{}, nbpeer.Location{},
+ )
+ assert.False(t, AffectsPosture(context.Background(), withinMin, c))
+
+ // Kernel drops below the minimum: verdict flips pass -> fail -> affected.
+ crossesDown := diffFrom(
+ nbpeer.PeerSystemMeta{GoOS: "linux", KernelVersion: "5.10.0-arch1"},
+ nbpeer.PeerSystemMeta{GoOS: "linux", KernelVersion: "4.19.0-arch1"},
+ nbpeer.Location{}, nbpeer.Location{},
+ )
+ assert.True(t, AffectsPosture(context.Background(), crossesDown, c))
+}
+
+func TestAffectsPosture_OSVersion_GoOSSwitchFlipsVerdict(t *testing.T) {
+ // Only Linux is constrained. An OS outside the switch (freebsd) passes; switching to a
+ // failing linux kernel flips the verdict pass -> fail.
+ c := checks(ChecksDefinition{OSVersionCheck: &OSVersionCheck{
+ Linux: &MinKernelVersionCheck{MinKernelVersion: "6.0.0"},
+ }})
+
+ diff := diffFrom(
+ nbpeer.PeerSystemMeta{GoOS: "freebsd"},
+ nbpeer.PeerSystemMeta{GoOS: "linux", KernelVersion: "4.19.0"},
+ nbpeer.Location{}, nbpeer.Location{},
+ )
+ assert.True(t, AffectsPosture(context.Background(), diff, c))
+}
+
+func TestAffectsPosture_Process_GoOSSwitchFlipsVerdict(t *testing.T) {
+ // Process runs at a linux path. Switching GoOS to windows (no WindowsPath configured)
+ // flips the verdict.
+ c := checks(ChecksDefinition{ProcessCheck: &ProcessCheck{
+ Processes: []Process{{LinuxPath: "/usr/bin/foo"}},
+ }})
+
+ files := []nbpeer.File{{Path: "/usr/bin/foo", ProcessIsRunning: true}}
+ diff := diffFrom(
+ nbpeer.PeerSystemMeta{GoOS: "linux", Files: files},
+ nbpeer.PeerSystemMeta{GoOS: "windows", Files: files},
+ nbpeer.Location{}, nbpeer.Location{},
+ )
+ assert.True(t, AffectsPosture(context.Background(), diff, c))
+}
+
+func TestAffectsPosture_Process_UnrelatedFileChange(t *testing.T) {
+ // A tracked process stays running while an unrelated file is added: the verdict does
+ // not move, so posture is not affected.
+ c := checks(ChecksDefinition{ProcessCheck: &ProcessCheck{
+ Processes: []Process{{LinuxPath: "/usr/bin/foo"}},
+ }})
+
+ diff := diffFrom(
+ nbpeer.PeerSystemMeta{GoOS: "linux", Files: []nbpeer.File{
+ {Path: "/usr/bin/foo", ProcessIsRunning: true},
+ }},
+ nbpeer.PeerSystemMeta{GoOS: "linux", Files: []nbpeer.File{
+ {Path: "/usr/bin/foo", ProcessIsRunning: true},
+ {Path: "/usr/bin/bar", ProcessIsRunning: true},
+ }},
+ nbpeer.Location{}, nbpeer.Location{},
+ )
+ assert.False(t, AffectsPosture(context.Background(), diff, c))
+}
+
+func TestAffectsPosture_GeoLocation(t *testing.T) {
+ c := checks(ChecksDefinition{GeoLocationCheck: &GeoLocationCheck{
+ Action: CheckActionAllow,
+ Locations: []Location{{CountryCode: "DE"}},
+ }})
+
+ // Moving within allowed countries keeps the verdict; moving out flips it.
+ stayAllowed := diffFrom(
+ nbpeer.PeerSystemMeta{}, nbpeer.PeerSystemMeta{},
+ nbpeer.Location{CountryCode: "DE", CityName: "Berlin"},
+ nbpeer.Location{CountryCode: "DE", CityName: "Munich"},
+ )
+ assert.False(t, AffectsPosture(context.Background(), stayAllowed, c))
+
+ moveOut := diffFrom(
+ nbpeer.PeerSystemMeta{}, nbpeer.PeerSystemMeta{},
+ nbpeer.Location{CountryCode: "DE"},
+ nbpeer.Location{CountryCode: "FR"},
+ )
+ assert.True(t, AffectsPosture(context.Background(), moveOut, c))
+}
+
+func TestAffectsPosture_PeerNetworkRange_ConnectionIP(t *testing.T) {
+ // The check reads the connection IP. Moving out of the allowed range flips the verdict;
+ // moving within it does not.
+ _, allowed, _ := net.ParseCIDR("10.0.0.0/8")
+ c := checks(ChecksDefinition{PeerNetworkRangeCheck: &PeerNetworkRangeCheck{
+ Action: CheckActionAllow,
+ Ranges: []netip.Prefix{netip.MustParsePrefix(allowed.String())},
+ }})
+
+ movesOutOfRange := diffFrom(
+ nbpeer.PeerSystemMeta{}, nbpeer.PeerSystemMeta{},
+ nbpeer.Location{ConnectionIP: net.ParseIP("10.1.2.3")},
+ nbpeer.Location{ConnectionIP: net.ParseIP("8.8.8.8")},
+ )
+ assert.True(t, AffectsPosture(context.Background(), movesOutOfRange, c))
+
+ staysInRange := diffFrom(
+ nbpeer.PeerSystemMeta{}, nbpeer.PeerSystemMeta{},
+ nbpeer.Location{ConnectionIP: net.ParseIP("10.1.2.3")},
+ nbpeer.Location{ConnectionIP: net.ParseIP("10.9.9.9")},
+ )
+ assert.False(t, AffectsPosture(context.Background(), staysInRange, c))
+}
+
+func TestAffectsPosture_IrrelevantFieldChange(t *testing.T) {
+ // Hostname changes but no check reads it: not affected even with checks present.
+ c := checks(ChecksDefinition{
+ NBVersionCheck: &NBVersionCheck{MinVersion: "1.0.0"},
+ GeoLocationCheck: &GeoLocationCheck{Action: CheckActionAllow, Locations: []Location{{CountryCode: "DE"}}},
+ })
+
+ diff := diffFrom(
+ nbpeer.PeerSystemMeta{Hostname: "old", WtVersion: "1.5.0"},
+ nbpeer.PeerSystemMeta{Hostname: "new", WtVersion: "1.5.0"},
+ nbpeer.Location{CountryCode: "DE"}, nbpeer.Location{CountryCode: "DE"},
+ )
+ assert.False(t, AffectsPosture(context.Background(), diff, c))
+}
+
+func TestAffectsPosture_NoChecks(t *testing.T) {
+ diff := diffFrom(
+ nbpeer.PeerSystemMeta{WtVersion: "1.0.0"},
+ nbpeer.PeerSystemMeta{WtVersion: "2.0.0"},
+ nbpeer.Location{}, nbpeer.Location{},
+ )
+ assert.False(t, AffectsPosture(context.Background(), diff, nil))
+}
diff --git a/management/server/posture/checks.go b/management/server/posture/checks.go
index f0bbbc32e..23ae4efa9 100644
--- a/management/server/posture/checks.go
+++ b/management/server/posture/checks.go
@@ -7,6 +7,8 @@ import (
"regexp"
"github.com/hashicorp/go-version"
+ log "github.com/sirupsen/logrus"
+
nbpeer "github.com/netbirdio/netbird/management/server/peer"
"github.com/netbirdio/netbird/shared/management/http/api"
"github.com/netbirdio/netbird/shared/management/status"
@@ -51,6 +53,46 @@ type Checks struct {
Checks ChecksDefinition `gorm:"serializer:json"`
}
+// AffectsPosture reports whether the change in diff flips the verdict of any check. It
+// replays each check against the peer's old and new state and compares verdicts, so a
+// change that moves a field but stays the right side of a threshold (e.g. a kernel bump
+// still above the minimum) does not force a re-evaluation. See verdictChanged for how an
+// evaluation error counts.
+func AffectsPosture(ctx context.Context, diff *nbpeer.MetaDiff, checks []*Checks) bool {
+ if diff == nil {
+ return false
+ }
+
+ oldPeer := nbpeer.Peer{Meta: diff.OldMeta, Location: diff.OldLocation}
+ newPeer := nbpeer.Peer{Meta: diff.NewMeta, Location: diff.NewLocation}
+
+ for _, c := range checks {
+ for _, check := range c.GetChecks() {
+ if verdictChanged(ctx, check, oldPeer, newPeer) {
+ return true
+ }
+ }
+ }
+ return false
+}
+
+// verdictChanged replays check against old and new state and reports whether the verdict
+// differs. Like callers, it treats an evaluation error as deny: two errors are the same
+// verdict (no change), an error on one side only is a flip.
+func verdictChanged(ctx context.Context, check Check, oldPeer, newPeer nbpeer.Peer) bool {
+ oldPass, oldErr := check.Check(ctx, oldPeer)
+ newPass, newErr := check.Check(ctx, newPeer)
+
+ oldVerdict := oldPass && (oldErr == nil)
+ newVerdict := newPass && (newErr == nil)
+ changed := oldVerdict != newVerdict
+
+ log.WithContext(ctx).Tracef("posture check %s replay: verdict %t -> %t (changed=%t), errs: %v -> %v",
+ check.Name(), oldVerdict, newVerdict, changed, oldErr, newErr)
+
+ return changed
+}
+
// ChecksDefinition contains definition of actual check
type ChecksDefinition struct {
NBVersionCheck *NBVersionCheck `json:",omitempty"`
diff --git a/management/server/posture_checks.go b/management/server/posture_checks.go
index 56a732bf5..1d962438c 100644
--- a/management/server/posture_checks.go
+++ b/management/server/posture_checks.go
@@ -7,11 +7,11 @@ 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"
"github.com/netbirdio/netbird/management/server/store"
- "github.com/netbirdio/netbird/management/server/types"
"github.com/netbirdio/netbird/shared/management/status"
)
@@ -41,9 +41,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 {
@@ -51,11 +52,6 @@ func (am *DefaultAccountManager) SavePostureChecks(ctx context.Context, accountI
}
if isUpdate {
- updateAccountPeers, err = arePostureCheckChangesAffectPeers(ctx, transaction, accountID, postureChecks.ID)
- if err != nil {
- return err
- }
-
action = activity.PostureCheckUpdated
}
@@ -65,6 +61,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)
}
@@ -76,13 +77,7 @@ 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
}
@@ -137,29 +132,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 394f0d896..abf0b3237 100644
--- a/management/server/posture_checks_test.go
+++ b/management/server/posture_checks_test.go
@@ -489,6 +489,7 @@ func TestArePostureCheckChangesAffectPeers(t *testing.T) {
policy := &types.Policy{
AccountID: account.Id,
+ Enabled: true,
Rules: []*types.PolicyRule{
{
Enabled: true,
@@ -503,21 +504,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 +526,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 +536,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 +545,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,8 +556,10 @@ 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 8fd1cb02a..08e1489b2 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"
@@ -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,12 +175,12 @@ func (am *DefaultAccountManager) CreateRoute(ctx context.Context, accountID stri
return err
}
- updateAccountPeers, err = areRouteChangesAffectPeers(ctx, transaction, newRoute)
- if err != nil {
+ if err = transaction.SaveRoute(ctx, newRoute); err != nil {
return err
}
- if err = transaction.SaveRoute(ctx, newRoute); err != nil {
+ change = affectedpeers.Change{Routes: []*route.Route{newRoute}}
+ if snap, err = affectedpeers.Load(ctx, transaction, accountID, change); err != nil {
return err
}
@@ -190,9 +192,7 @@ 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
}
@@ -208,8 +208,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 {
@@ -221,21 +221,17 @@ 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
if err = transaction.SaveRoute(ctx, routeToSave); err != nil {
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 {
@@ -244,9 +240,7 @@ 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
}
@@ -261,17 +255,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
}
@@ -285,11 +281,9 @@ 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
}
@@ -377,25 +371,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/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 c6ced2642..8bc4bcd7d 100644
--- a/management/server/store/sql_store.go
+++ b/management/server/store/sql_store.go
@@ -265,7 +265,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() {
@@ -580,28 +581,6 @@ func (s *SqlStore) MarkPeerDisconnectedIfSameSession(ctx context.Context, accoun
return result.RowsAffected > 0, nil
}
-func (s *SqlStore) SavePeerLocation(ctx context.Context, accountID string, peerWithLocation *nbpeer.Peer) error {
- // To maintain data integrity, we create a copy of the peer's location to prevent unintended updates to other fields.
- var peerCopy nbpeer.Peer
- // Since the location field has been migrated to JSON serialization,
- // updating the struct ensures the correct data format is inserted into the database.
- peerCopy.Location = peerWithLocation.Location
-
- result := s.db.Model(&nbpeer.Peer{}).
- Where(accountAndIDQueryCondition, accountID, peerWithLocation.ID).
- Updates(peerCopy)
-
- if result.Error != nil {
- return status.Errorf(status.Internal, "failed to save peer locations to store: %v", result.Error)
- }
-
- if result.RowsAffected == 0 {
- return status.Errorf(status.NotFound, peerNotFoundFMT, peerWithLocation.ID)
- }
-
- return nil
-}
-
// ApproveAccountPeers marks all peers that currently require approval in the given account as approved.
func (s *SqlStore) ApproveAccountPeers(ctx context.Context, accountID string) (int, error) {
result := s.db.Model(&nbpeer.Peer{}).
@@ -4912,6 +4891,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 {
diff --git a/management/server/store/sql_store_test.go b/management/server/store/sql_store_test.go
index 0c90eaf5f..92784af83 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)
}
@@ -619,56 +618,6 @@ func TestSqlStore_SavePeerStatus(t *testing.T) {
assert.WithinDurationf(t, newStatus.LastSeen, actual.LastSeen.UTC(), time.Millisecond, "LastSeen should be equal")
}
-func TestSqlStore_SavePeerLocation(t *testing.T) {
- store, cleanUp, err := NewTestStoreFromSQL(context.Background(), "../testdata/store.sql", t.TempDir())
- t.Cleanup(cleanUp)
- assert.NoError(t, err)
-
- account, err := store.GetAccount(context.Background(), "bf1c8084-ba50-4ce7-9439-34653001fc3b")
- require.NoError(t, err)
-
- peer := &nbpeer.Peer{
- AccountID: account.Id,
- ID: "testpeer",
- Location: nbpeer.Location{
- ConnectionIP: net.ParseIP("0.0.0.0"),
- CountryCode: "YY",
- CityName: "City",
- GeoNameID: 1,
- },
- CreatedAt: time.Now().UTC(),
- Meta: nbpeer.PeerSystemMeta{},
- }
- // error is expected as peer is not in store yet
- err = store.SavePeerLocation(context.Background(), account.Id, peer)
- assert.Error(t, err)
-
- account.Peers[peer.ID] = peer
- err = store.SaveAccount(context.Background(), account)
- require.NoError(t, err)
-
- peer.Location.ConnectionIP = net.ParseIP("35.1.1.1")
- peer.Location.CountryCode = "DE"
- peer.Location.CityName = "Berlin"
- peer.Location.GeoNameID = 2950159
-
- err = store.SavePeerLocation(context.Background(), account.Id, account.Peers[peer.ID])
- assert.NoError(t, err)
-
- account, err = store.GetAccount(context.Background(), account.Id)
- require.NoError(t, err)
-
- actual := account.Peers[peer.ID].Location
- assert.Equal(t, peer.Location, actual)
-
- peer.ID = "non-existing-peer"
- err = store.SavePeerLocation(context.Background(), account.Id, peer)
- assert.Error(t, err)
- parsedErr, ok := status.FromError(err)
- require.True(t, ok)
- require.Equal(t, status.NotFound, parsedErr.Type(), "should return not found error")
-}
-
func Test_TestGetAccountByPrivateDomain(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("The SQLite store is not properly supported by Windows yet")
diff --git a/management/server/store/store.go b/management/server/store/store.go
index 746207f27..066ab285d 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)
@@ -182,7 +185,6 @@ type Store interface {
// recorded by the database. Returns true when the update happened,
// false when a newer session has taken over.
MarkPeerDisconnectedIfSameSession(ctx context.Context, accountID, peerID string, sessionStartedAt int64) (bool, error)
- SavePeerLocation(ctx context.Context, accountID string, peer *nbpeer.Peer) error
ApproveAccountPeers(ctx context.Context, accountID string) (int, error)
DeletePeer(ctx context.Context, accountID string, peerID string) error
diff --git a/management/server/store/store_mock.go b/management/server/store/store_mock.go
index dfd5af78d..fdd2d0900 100644
--- a/management/server/store/store_mock.go
+++ b/management/server/store/store_mock.go
@@ -1925,6 +1925,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()
@@ -2923,20 +2968,6 @@ func (mr *MockStoreMockRecorder) SavePeer(ctx, accountID, peer interface{}) *gom
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SavePeer", reflect.TypeOf((*MockStore)(nil).SavePeer), ctx, accountID, peer)
}
-// SavePeerLocation mocks base method.
-func (m *MockStore) SavePeerLocation(ctx context.Context, accountID string, peer *peer.Peer) error {
- m.ctrl.T.Helper()
- ret := m.ctrl.Call(m, "SavePeerLocation", ctx, accountID, peer)
- ret0, _ := ret[0].(error)
- return ret0
-}
-
-// SavePeerLocation indicates an expected call of SavePeerLocation.
-func (mr *MockStoreMockRecorder) SavePeerLocation(ctx, accountID, peer interface{}) *gomock.Call {
- mr.mock.ctrl.T.Helper()
- return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SavePeerLocation", reflect.TypeOf((*MockStore)(nil).SavePeerLocation), ctx, accountID, peer)
-}
-
// SavePeerStatus mocks base method.
func (m *MockStore) SavePeerStatus(ctx context.Context, accountID, peerID string, status peer.PeerStatus) error {
m.ctrl.T.Helper()
diff --git a/management/server/types/account.go b/management/server/types/account.go
index d658f605d..7a0a0054f 100644
--- a/management/server/types/account.go
+++ b/management/server/types/account.go
@@ -1156,6 +1156,47 @@ func policyRuleImpliesLegacySSH(rule *PolicyRule) bool {
return rule.Protocol == PolicyRuleProtocolALL || (rule.Protocol == PolicyRuleProtocolTCP && (portsIncludesSSH(rule.Ports) || portRangeIncludesSSH(rule.PortRanges)))
}
+// 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
+}
+
func portRangeIncludesSSH(portRanges []RulePortRange) bool {
for _, pr := range portRanges {
if (pr.Start <= defaultSSHPortNumber && pr.End >= defaultSSHPortNumber) || (pr.Start <= nativeSSHPortNumber && pr.End >= nativeSSHPortNumber) {
diff --git a/management/server/types/networkmap_components_correctness_test.go b/management/server/types/networkmap_components_correctness_test.go
index 3785a7399..1e3035300 100644
--- a/management/server/types/networkmap_components_correctness_test.go
+++ b/management/server/types/networkmap_components_correctness_test.go
@@ -1233,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/types/peer.go b/management/server/types/peer.go
index 15d343793..885d67bba 100644
--- a/management/server/types/peer.go
+++ b/management/server/types/peer.go
@@ -12,6 +12,9 @@ type PeerSync struct {
WireGuardPubKey string
// Meta is the system information passed by peer, must be always present
Meta nbpeer.PeerSystemMeta
+ // RealIP is the peer's connection IP, used to refresh its geo location.
+ // May be nil when the request has no associated connection IP.
+ RealIP net.IP
// UpdateAccountPeers indicate updating account peers,
// which occurs when the peer's metadata is updated
UpdateAccountPeers bool
diff --git a/management/server/user.go b/management/server/user.go
index 7cd955000..666d6d178 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"
@@ -1058,8 +1059,8 @@ func (am *DefaultAccountManager) BuildUserInfosForAccount(ctx context.Context, a
if err != nil {
return nil, err
}
- log.WithContext(ctx).Debugf("Got %d users from ExternalCache for account %s", len(usersFromIntegration), accountID)
- log.WithContext(ctx).Debugf("Got %d users from InternalCache for account %s", len(queriedUsers), accountID)
+ log.WithContext(ctx).Tracef("Got %d users from ExternalCache for account %s", len(usersFromIntegration), accountID)
+ log.WithContext(ctx).Tracef("Got %d users from InternalCache for account %s", len(queriedUsers), accountID)
queriedUsers = append(queriedUsers, usersFromIntegration...)
}
@@ -1157,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)
}
@@ -1273,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 {
@@ -1293,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)
@@ -1316,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)
}
diff --git a/management/server/user_test.go b/management/server/user_test.go
index 2a2d7857d..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()
diff --git a/proxy/Dockerfile b/proxy/Dockerfile
index e64680fd6..22c4cbfaa 100644
--- a/proxy/Dockerfile
+++ b/proxy/Dockerfile
@@ -7,7 +7,8 @@ RUN echo "netbird:x:1000:1000:netbird:/var/lib/netbird:/sbin/nologin" > /tmp/pas
mkdir -p /tmp/certs
FROM gcr.io/distroless/base:debug
-COPY netbird-proxy /go/bin/netbird-proxy
+ARG TARGETPLATFORM
+COPY ${TARGETPLATFORM}/netbird-proxy /go/bin/netbird-proxy
COPY --from=builder /tmp/passwd /etc/passwd
COPY --from=builder /tmp/group /etc/group
COPY --from=builder --chown=1000:1000 /tmp/var/lib/netbird /var/lib/netbird
diff --git a/proxy/management_byop_integration_test.go b/proxy/management_byop_integration_test.go
index c0fbe682a..d075e47ec 100644
--- a/proxy/management_byop_integration_test.go
+++ b/proxy/management_byop_integration_test.go
@@ -125,6 +125,7 @@ func setupBYOPIntegrationTest(t *testing.T) *byopTestSetup {
oidcConfig,
nil,
usersManager,
+ nil,
realProxyManager,
nil,
)
diff --git a/proxy/management_integration_test.go b/proxy/management_integration_test.go
index bf5067b85..cb82813b0 100644
--- a/proxy/management_integration_test.go
+++ b/proxy/management_integration_test.go
@@ -140,6 +140,7 @@ func setupIntegrationTest(t *testing.T) *integrationTestSetup {
oidcConfig,
nil,
usersManager,
+ nil,
proxyManager,
nil,
)
diff --git a/proxy/server.go b/proxy/server.go
index 6d5acfe46..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,29 +76,30 @@ type portRouter struct {
}
type Server struct {
- ctx context.Context
- 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
@@ -614,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 == "" {
@@ -792,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
}
@@ -1102,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,
@@ -1118,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.
@@ -1623,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,
@@ -1647,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())
@@ -1693,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
}
@@ -1941,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,
diff --git a/proxy/server_test.go b/proxy/server_test.go
index aa4892201..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"
)
@@ -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/Dockerfile b/relay/Dockerfile
index f750027c3..757ee7b59 100644
--- a/relay/Dockerfile
+++ b/relay/Dockerfile
@@ -1,4 +1,5 @@
FROM gcr.io/distroless/base:debug
ENTRYPOINT [ "/go/bin/netbird-relay" ]
ENV NB_LOG_FILE=console
-COPY netbird-relay /go/bin/netbird-relay
+ARG TARGETPLATFORM
+COPY ${TARGETPLATFORM}/netbird-relay /go/bin/netbird-relay
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/freebsd-port-diff.sh b/release_files/freebsd-port-diff.sh
index b030b9164..6ffa141be 100755
--- a/release_files/freebsd-port-diff.sh
+++ b/release_files/freebsd-port-diff.sh
@@ -21,7 +21,8 @@ AWK_FIRST_FIELD='{print $1}'
fetch_all_tags() {
curl -sL "https://github.com/${GITHUB_REPO}/tags" 2>/dev/null | \
- grep -oE '/releases/tag/v[0-9]+\.[0-9]+\.[0-9]+' | \
+ grep -oE '/releases/tag/v[0-9]+\.[0-9]+\.[0-9]+([^"]+)?' | \
+ grep -iv 'rc' | \
sed 's/.*\/v//' | \
sort -u -V
return 0
diff --git a/release_files/freebsd-port-issue-body.sh b/release_files/freebsd-port-issue-body.sh
index b7ad0f5b1..1c23dbbbe 100755
--- a/release_files/freebsd-port-issue-body.sh
+++ b/release_files/freebsd-port-issue-body.sh
@@ -32,7 +32,8 @@ fetch_current_ports_version() {
fetch_all_tags() {
# Fetch tags from GitHub tags page (no rate limiting, no auth needed)
curl -sL "https://github.com/${GITHUB_REPO}/tags" 2>/dev/null | \
- grep -oE '/releases/tag/v[0-9]+\.[0-9]+\.[0-9]+' | \
+ grep -oE '/releases/tag/v[0-9]+\.[0-9]+\.[0-9]+([^"]+)?' | \
+ grep -iv 'rc' | \
sed 's/.*\/v//' | \
sort -u -V
return 0
diff --git a/shared/management/client/client_test.go b/shared/management/client/client_test.go
index 53f3a262d..b62317775 100644
--- a/shared/management/client/client_test.go
+++ b/shared/management/client/client_test.go
@@ -322,15 +322,21 @@ func TestClient_Sync(t *testing.T) {
if resp.GetNetbirdConfig() == nil {
t.Error("expecting non nil NetbirdConfig got nil")
}
- if len(resp.GetRemotePeers()) != 1 {
- t.Errorf("expecting RemotePeers size %d got %d", 1, len(resp.GetRemotePeers()))
+ // we test network map peers from 0.29.3 and dev builds
+ if len(resp.GetRemotePeers()) != 0 {
+ t.Error("expecting top-level RemotePeers to be empty for v0.29.3+ clients")
+ }
+ networkMap := resp.GetNetworkMap()
+ if len(networkMap.GetRemotePeers()) != 1 {
+ t.Errorf("expecting RemotePeers size %d got %d", 1, len(networkMap.GetRemotePeers()))
return
}
- if resp.GetRemotePeersIsEmpty() == true {
+
+ if networkMap.GetRemotePeersIsEmpty() {
t.Error("expecting RemotePeers property to be false, got true")
}
- if resp.GetRemotePeers()[0].GetWgPubKey() != remoteKey.PublicKey().String() {
- t.Errorf("expecting RemotePeer public key %s got %s", remoteKey.PublicKey().String(), resp.GetRemotePeers()[0].GetWgPubKey())
+ if networkMap.GetRemotePeers()[0].GetWgPubKey() != remoteKey.PublicKey().String() {
+ t.Errorf("expecting RemotePeer public key %s got %s", remoteKey.PublicKey().String(), networkMap.GetRemotePeers()[0].GetWgPubKey())
}
case <-time.After(3 * time.Second):
t.Error("timeout waiting for test to finish")
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 f8c687b7b..196a0c6b1 100644
--- a/shared/management/http/api/openapi.yml
+++ b/shared/management/http/api/openapi.yml
@@ -3086,24 +3086,6 @@ components:
- enabled
- auth
- meta
- allOf:
- # When private=true, access_groups must be present and non-empty,
- # and the service mode must be "http". The bearer-auth mutex is
- # enforced at the service-validation layer
- # (validatePrivateRequirements) because it sits in a nested
- # ServiceAuthConfig and isn't cleanly expressible here.
- - if:
- required: [private]
- properties:
- private:
- const: true
- then:
- required: [access_groups]
- properties:
- access_groups:
- minItems: 1
- mode:
- const: http
ServiceMeta:
type: object
properties:
@@ -3191,23 +3173,6 @@ components:
- name
- domain
- enabled
- allOf:
- # Mirror of the Service conditional: when private=true the
- # request must carry a non-empty access_groups list and the
- # mode must be "http". The bearer-auth mutex is enforced at the
- # service-validation layer (validatePrivateRequirements).
- - if:
- required: [private]
- properties:
- private:
- const: true
- then:
- required: [access_groups]
- properties:
- access_groups:
- minItems: 1
- mode:
- const: http
ServiceTargetOptions:
type: object
properties:
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/status/error.go b/shared/management/status/error.go
index 78288aef3..1957c5591 100644
--- a/shared/management/status/error.go
+++ b/shared/management/status/error.go
@@ -48,6 +48,10 @@ type Type int32
var (
ErrExtraSettingsNotFound = errors.New("extra settings not found")
ErrPeerAlreadyLoggedIn = errors.New("peer with the same public key is already logged in")
+
+ // ErrNoAuthMethodProvided is returned when a peer login attempt carries neither a
+ // setup key nor an SSO token. Match it with errors.Is.
+ ErrNoAuthMethodProvided = Errorf(Unauthenticated, "no peer auth method provided, please use a setup key or interactive SSO login")
)
// Error is an internal error
@@ -66,6 +70,16 @@ func (e *Error) Error() string {
return e.Message
}
+// Is reports whether target is an *Error with the same type and message,
+// enabling matching with errors.Is against sentinel errors.
+func (e *Error) Is(target error) bool {
+ var t *Error
+ if !errors.As(target, &t) {
+ return false
+ }
+ return e.ErrorType == t.ErrorType && e.Message == t.Message
+}
+
// Errorf returns Error(ErrorType, fmt.Sprintf(format, a...)).
func Errorf(errorType Type, format string, a ...interface{}) error {
return &Error{
diff --git a/shared/relay/client/client.go b/shared/relay/client/client.go
index 002b8d134..8d4aa6020 100644
--- a/shared/relay/client/client.go
+++ b/shared/relay/client/client.go
@@ -145,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.
@@ -182,6 +187,18 @@ type Client struct {
// 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.
@@ -402,6 +419,9 @@ 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 {
@@ -792,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/quic/conn.go b/shared/relay/client/dialer/quic/conn.go
index a5c982551..e5ad77b29 100644
--- a/shared/relay/client/dialer/quic/conn.go
+++ b/shared/relay/client/dialer/quic/conn.go
@@ -57,6 +57,11 @@ func (c *Conn) Write(b []byte) (int, error) {
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()
}
diff --git a/shared/relay/client/dialer/quic/quic.go b/shared/relay/client/dialer/quic/quic.go
index 5e1758a1c..2e8de8af3 100644
--- a/shared/relay/client/dialer/quic/quic.go
+++ b/shared/relay/client/dialer/quic/quic.go
@@ -59,14 +59,12 @@ func (d Dialer) Dial(ctx context.Context, address, serverName string) (net.Conn,
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)
@@ -74,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
}
diff --git a/shared/relay/client/dialer/race_dialer.go b/shared/relay/client/dialer/race_dialer.go
index aef1ef464..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"
@@ -71,6 +72,7 @@ func (r *RaceDial) Dial(ctx context.Context) (net.Conn, error) {
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()
@@ -78,11 +80,11 @@ 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
}
@@ -90,6 +92,7 @@ func (r *RaceDial) Dial(ctx context.Context) (net.Conn, error) {
// 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
@@ -103,12 +106,13 @@ func (r *RaceDial) dialSequential(ctx context.Context) (net.Conn, error) {
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, errors.New("failed to dial to Relay server on any protocol")
+ return nil, dialErr(errs)
}
func (r *RaceDial) dial(dfn DialeFn, abortCtx context.Context, connChan chan dialResult) {
@@ -120,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 {
@@ -129,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
}
@@ -146,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/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_test.go b/shared/relay/client/dialers_generic_test.go
index c4ef9cc59..f6c885108 100644
--- a/shared/relay/client/dialers_generic_test.go
+++ b/shared/relay/client/dialers_generic_test.go
@@ -41,14 +41,14 @@ func TestGetDialers(t *testing.T) {
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: "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: "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"}},
}
@@ -91,11 +91,11 @@ func TestStickyFallbackAfterDatagramTooLarge(t *testing.T) {
}
// First dial races both transports.
- assert.Equal(t, []string{"quic", "WS"}, protocols(c.getDialers(transportModeFromEnv())))
+ 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())))
+ assert.Equal(t, []string{"ws"}, protocols(c.getDialers(transportModeFromEnv())))
}
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 f87da15de..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 {
@@ -130,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)
@@ -242,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 {
@@ -460,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 992e48114..bb721e4ad 100644
--- a/shared/relay/client/picker.go
+++ b/shared/relay/client/picker.go
@@ -40,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))
@@ -54,17 +55,17 @@ 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())
}
}
@@ -80,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)
@@ -101,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/signal/client/client.go b/shared/signal/client/client.go
index 9dc6ccd37..fb77cb90f 100644
--- a/shared/signal/client/client.go
+++ b/shared/signal/client/client.go
@@ -33,7 +33,7 @@ type Client interface {
Receive(ctx context.Context, msgHandler func(msg *proto.Message) error) error
Ready() bool
IsHealthy() bool
- WaitStreamConnected()
+ WaitStreamConnected(context.Context)
SendToStream(msg *proto.EncryptedMessage) error
Send(msg *proto.Message) error
SetOnReconnectedListener(func())
diff --git a/shared/signal/client/client_test.go b/shared/signal/client/client_test.go
index 1af34e37a..41def08a1 100644
--- a/shared/signal/client/client_test.go
+++ b/shared/signal/client/client_test.go
@@ -65,7 +65,10 @@ var _ = Describe("GrpcClient", func() {
return
}
}()
- clientA.WaitStreamConnected()
+ ctxA, cancelA := context.WithTimeout(context.Background(), 5*time.Second)
+ defer cancelA()
+ clientA.WaitStreamConnected(ctxA)
+ Expect(clientA.StreamConnected()).To(BeTrue())
// connect PeerB to Signal
keyB, _ := wgtypes.GenerateKey()
@@ -91,7 +94,10 @@ var _ = Describe("GrpcClient", func() {
}
}()
- clientB.WaitStreamConnected()
+ ctxB, cancelB := context.WithTimeout(context.Background(), 5*time.Second)
+ defer cancelB()
+ clientB.WaitStreamConnected(ctxB)
+ Expect(clientB.StreamConnected()).To(BeTrue())
// PeerA initiates ping-pong
err := clientA.Send(&sigProto.Message{
@@ -129,8 +135,10 @@ var _ = Describe("GrpcClient", func() {
return
}
}()
- client.WaitStreamConnected()
- Expect(client).NotTo(BeNil())
+ ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
+ defer cancel()
+ client.WaitStreamConnected(ctx)
+ Expect(client.StreamConnected()).To(BeTrue())
})
})
diff --git a/shared/signal/client/grpc.go b/shared/signal/client/grpc.go
index b245b2296..611ab0c45 100644
--- a/shared/signal/client/grpc.go
+++ b/shared/signal/client/grpc.go
@@ -2,9 +2,11 @@ package client
import (
"context"
+ "errors"
"fmt"
"io"
"sync"
+ "sync/atomic"
"time"
"github.com/cenkalti/backoff/v4"
@@ -23,7 +25,23 @@ import (
"github.com/netbirdio/netbird/util/wsproxy"
)
-const healthCheckTimeout = 5 * time.Second
+const (
+ // receiveInactivityThreshold is how long the receive stream may be silent
+ // before the watchdog actively probes it. The gRPC transport can stay
+ // healthy (keepalive satisfied) while the server stops delivering messages,
+ // which the transport layer cannot detect.
+ receiveInactivityThreshold = 30 * time.Second
+ // receiveProbeTimeout is how long the watchdog waits for its self-addressed
+ // probe to round-trip back on the stream before declaring the receive
+ // direction dead.
+ receiveProbeTimeout = 10 * time.Second
+ // receiveWatchdogInterval is how often the watchdog evaluates the stream.
+ receiveWatchdogInterval = 10 * time.Second
+)
+
+// errReceiveStreamStalled is reported when the receive stream is transport-alive
+// but no longer delivering messages, so the stream is torn down to reconnect.
+var errReceiveStreamStalled = errors.New("signal receive stream stalled")
// ConnStateNotifier is a wrapper interface of the status recorder
type ConnStateNotifier interface {
@@ -52,6 +70,21 @@ type GrpcClient struct {
decryptionWorker *Worker
decryptionWorkerCancel context.CancelFunc
decryptionWg sync.WaitGroup
+
+ // lastReceived holds the Unix-nano timestamp of the last message read from
+ // the receive stream, used by the receive watchdog.
+ lastReceived atomic.Int64
+ // receiveStalled is set by the receive watchdog when the stream is
+ // transport-alive but no longer delivering messages. It is the source of
+ // truth IsHealthy reads, and is cleared once any frame is received again.
+ receiveStalled atomic.Bool
+ // receiveHandoffBlocked is set while the receive loop is parked handing a
+ // message to a busy decryption worker. The loop stops calling Recv (and
+ // markReceived) in that window, so the stream looks silent though it is
+ // healthy. The watchdog reads this to avoid misreading self-inflicted
+ // receive backpressure as a dead stream: reconnecting cannot help, since the
+ // new stream feeds the same worker, and only triggers a reconnect storm.
+ receiveHandoffBlocked atomic.Bool
}
// NewClient creates a new Signal client
@@ -148,9 +181,9 @@ func (c *GrpcClient) Receive(ctx context.Context, msgHandler func(msg *proto.Mes
// connect to Signal stream identifying ourselves with a public WireGuard key
// todo once the key rotation logic has been implemented, consider changing to some other identifier (received from management)
- ctx, cancelStream := context.WithCancel(ctx)
+ streamCtx, cancelStream := context.WithCancel(ctx)
defer cancelStream()
- stream, err := c.connect(ctx, c.key.PublicKey().String())
+ stream, err := c.connect(streamCtx, c.key.PublicKey().String())
if err != nil {
log.Warnf("disconnected from the Signal Exchange due to an error: %v", err)
return err
@@ -164,9 +197,16 @@ func (c *GrpcClient) Receive(ctx context.Context, msgHandler func(msg *proto.Mes
// Start worker pool if not already started
c.startEncryptionWorker(msgHandler)
+ // Guard the receive direction: the transport can stay healthy while the
+ // server stops delivering messages. The watchdog reconnects via cancelStream.
+ c.markReceived()
+ go c.watchReceiveStream(streamCtx, cancelStream)
+
// start receiving messages from the Signal stream (from other peers through signal)
err = c.receive(stream)
if err != nil {
+ // Check the parent context, not streamCtx: a watchdog-triggered
+ // cancelStream must reconnect, only a parent cancel is shutdown.
if ctx.Err() != nil {
log.Debugf("signal connection context has been canceled, this usually indicates shutdown")
return nil
@@ -213,15 +253,6 @@ func (c *GrpcClient) notifyStreamConnected() {
}
}
-func (c *GrpcClient) getStreamStatusChan() <-chan struct{} {
- c.mux.Lock()
- defer c.mux.Unlock()
- if c.connectedCh == nil {
- c.connectedCh = make(chan struct{})
- }
- return c.connectedCh
-}
-
func (c *GrpcClient) connect(ctx context.Context, key string) (proto.SignalExchange_ConnectStreamClient, error) {
c.stream = nil
@@ -252,7 +283,10 @@ func (c *GrpcClient) Ready() bool {
return c.signalConn.GetState() == connectivity.Ready || c.signalConn.GetState() == connectivity.Idle
}
-// IsHealthy probes the gRPC connection and returns false on errors
+// IsHealthy reports whether the Signal connection is usable, based on the
+// transport state plus the receive watchdog's verdict, and updates the status
+// recorder accordingly. It does not actively probe: the watchdog
+// (watchReceiveStream) owns probing the receive path and reconnecting.
func (c *GrpcClient) IsHealthy() bool {
switch c.signalConn.GetState() {
case connectivity.TransientFailure:
@@ -265,16 +299,8 @@ func (c *GrpcClient) IsHealthy() bool {
case connectivity.Ready:
}
- ctx, cancel := context.WithTimeout(c.ctx, healthCheckTimeout)
- defer cancel()
- _, err := c.realClient.Send(ctx, &proto.EncryptedMessage{
- Key: c.key.PublicKey().String(),
- RemoteKey: "dummy",
- Body: nil,
- })
- if err != nil {
- c.notifyDisconnected(err)
- log.Warnf("health check returned: %s", err)
+ if c.receiveStalled.Load() {
+ c.notifyDisconnected(errReceiveStreamStalled)
return false
}
c.notifyConnected()
@@ -282,14 +308,24 @@ func (c *GrpcClient) IsHealthy() bool {
}
// WaitStreamConnected waits until the client is connected to the Signal stream
-func (c *GrpcClient) WaitStreamConnected() {
-
+func (c *GrpcClient) WaitStreamConnected(ctx context.Context) {
+ // Check the status and obtain the wait channel atomically: otherwise
+ // notifyStreamConnected could flip the status and close/clear the channel
+ // between the check and the channel creation, leaving us waiting forever on
+ // a stale channel.
+ c.mux.Lock()
if c.status == StreamConnected {
+ c.mux.Unlock()
return
}
+ if c.connectedCh == nil {
+ c.connectedCh = make(chan struct{})
+ }
+ ch := c.connectedCh
+ c.mux.Unlock()
- ch := c.getStreamStatusChan()
select {
+ case <-ctx.Done():
case <-c.ctx.Done():
case <-ch:
}
@@ -398,6 +434,78 @@ func (c *GrpcClient) Send(msg *proto.Message) error {
return err
}
+// markReceived records that a frame was just read from the receive stream and
+// clears the stalled flag.
+func (c *GrpcClient) markReceived() {
+ c.lastReceived.Store(time.Now().UnixNano())
+ c.receiveStalled.Store(false)
+}
+
+// idleSinceReceive returns how long the receive stream has been silent.
+func (c *GrpcClient) idleSinceReceive() time.Duration {
+ return time.Since(time.Unix(0, c.lastReceived.Load()))
+}
+
+// receiveAlive reports whether the receive stream shows liveness: it delivered a
+// frame within the inactivity threshold, or the receive loop is currently parked
+// handing a message to a busy decryption worker. In the latter case the loop has
+// stopped calling Recv, so the stream looks silent while being healthy, and
+// reconnecting would not help, so the watchdog must treat it as alive.
+func (c *GrpcClient) receiveAlive() bool {
+ return c.idleSinceReceive() < receiveInactivityThreshold ||
+ c.receiveHandoffBlocked.Load()
+}
+
+// watchReceiveStream guards against a receive stream that is transport-alive but
+// no longer delivering messages. While the stream is idle past
+// receiveInactivityThreshold it sends a self-addressed probe that the Signal
+// server routes back to this client. If the probe does not round-trip within
+// receiveProbeTimeout the receive direction is considered dead and cancelStream
+// is called so the retry loop reconnects.
+func (c *GrpcClient) watchReceiveStream(ctx context.Context, cancelStream context.CancelFunc) {
+ ticker := time.NewTicker(receiveWatchdogInterval)
+ defer ticker.Stop()
+
+ var probeSentAt time.Time
+ for {
+ select {
+ case <-ctx.Done():
+ return
+ case <-ticker.C:
+ if c.receiveAlive() {
+ probeSentAt = time.Time{}
+ continue
+ }
+
+ if !probeSentAt.IsZero() && time.Since(probeSentAt) >= receiveProbeTimeout {
+ log.Warnf("signal receive stream stalled: no messages for %s and probe did not return, reconnecting", c.idleSinceReceive().Round(time.Second))
+ c.receiveStalled.Store(true)
+ c.notifyDisconnected(errReceiveStreamStalled)
+ cancelStream()
+ return
+ }
+
+ if probeSentAt.IsZero() {
+ if err := c.sendReceiveProbe(); err != nil {
+ log.Debugf("failed to send signal receive probe: %v", err)
+ }
+ probeSentAt = time.Now()
+ }
+ }
+ }
+}
+
+// sendReceiveProbe sends a self-addressed heartbeat. The Signal server routes it
+// back to this client, exercising the exact receive path the watchdog guards.
+func (c *GrpcClient) sendReceiveProbe() error {
+ self := c.key.PublicKey().String()
+ return c.Send(&proto.Message{
+ Key: self,
+ RemoteKey: self,
+ Body: &proto.Body{Type: proto.Body_HEARTBEAT},
+ })
+}
+
// receive receives messages from other peers coming through the Signal Exchange
// and distributes them to worker threads for processing
func (c *GrpcClient) receive(stream proto.SignalExchange_ConnectStreamClient) error {
@@ -419,13 +527,21 @@ func (c *GrpcClient) receive(stream proto.SignalExchange_ConnectStreamClient) er
return err
}
+ // Any frame from the server proves the receive direction is alive.
+ c.markReceived()
+
if msg == nil {
continue
}
+ // The handoff blocks while the worker is busy, which parks this loop and
+ // stops Recv. Flag it so the watchdog does not read the resulting silence
+ // as a dead stream.
+ c.receiveHandoffBlocked.Store(true)
if err := c.decryptionWorker.AddMsg(c.ctx, msg); err != nil {
log.Errorf("failed to add message to decryption worker: %v", err)
}
+ c.receiveHandoffBlocked.Store(false)
}
}
diff --git a/shared/signal/client/mock.go b/shared/signal/client/mock.go
index 95381a5b0..0c8a083c5 100644
--- a/shared/signal/client/mock.go
+++ b/shared/signal/client/mock.go
@@ -55,7 +55,7 @@ func (sm *MockClient) Ready() bool {
return sm.ReadyFunc()
}
-func (sm *MockClient) WaitStreamConnected() {
+func (sm *MockClient) WaitStreamConnected(context.Context) {
if sm.WaitStreamConnectedFunc == nil {
return
}
diff --git a/shared/signal/client/watchdog_test.go b/shared/signal/client/watchdog_test.go
new file mode 100644
index 000000000..bc6b5520b
--- /dev/null
+++ b/shared/signal/client/watchdog_test.go
@@ -0,0 +1,108 @@
+package client
+
+import (
+ "context"
+ "net"
+ "testing"
+ "time"
+
+ "github.com/stretchr/testify/require"
+ "go.opentelemetry.io/otel"
+ "golang.zx2c4.com/wireguard/wgctrl/wgtypes"
+ "google.golang.org/grpc"
+
+ sigProto "github.com/netbirdio/netbird/shared/signal/proto"
+ "github.com/netbirdio/netbird/signal/server"
+)
+
+func startTestSignalServer(t *testing.T) string {
+ t.Helper()
+
+ lis, err := net.Listen("tcp", "127.0.0.1:0")
+ require.NoError(t, err)
+
+ s := grpc.NewServer()
+ srv, err := server.NewServer(context.Background(), otel.Meter(""))
+ require.NoError(t, err)
+ sigProto.RegisterSignalExchangeServer(s, srv)
+
+ go func() {
+ _ = s.Serve(lis)
+ }()
+ t.Cleanup(s.Stop)
+
+ return lis.Addr().String()
+}
+
+// TestReceiveProbeRoundTrips verifies that the watchdog's self-addressed heartbeat
+// is routed back to the same client through the signal server. This round-trip is
+// what lets the watchdog confirm the receive direction is still delivering.
+func TestReceiveProbeRoundTrips(t *testing.T) {
+ addr := startTestSignalServer(t)
+
+ key, err := wgtypes.GenerateKey()
+ require.NoError(t, err)
+
+ ctx, cancel := context.WithCancel(context.Background())
+ t.Cleanup(cancel)
+
+ client, err := NewClient(ctx, addr, key, false)
+ require.NoError(t, err)
+ t.Cleanup(func() { _ = client.Close() })
+
+ received := make(chan struct{}, 1)
+ go func() {
+ _ = client.Receive(ctx, func(msg *sigProto.Message) error {
+ if msg.GetBody().GetType() == sigProto.Body_HEARTBEAT && msg.GetKey() == key.PublicKey().String() {
+ select {
+ case received <- struct{}{}:
+ default:
+ }
+ }
+ return nil
+ })
+ }()
+
+ streamReady := make(chan struct{})
+ go func() {
+ client.WaitStreamConnected(ctx)
+ close(streamReady)
+ }()
+ select {
+ case <-streamReady:
+ case <-time.After(5 * time.Second):
+ t.Fatal("signal stream did not connect within timeout")
+ }
+
+ require.NoError(t, client.sendReceiveProbe())
+
+ select {
+ case <-received:
+ case <-time.After(3 * time.Second):
+ t.Fatal("self-addressed heartbeat did not round-trip back through the signal server")
+ }
+}
+
+// TestReceiveAliveTreatsHandoffBlockAsLiveness reproduces the false positive
+// where a busy decryption worker parks the receive loop on the worker handoff,
+// so Recv (and markReceived) stops firing even though the stream is healthy.
+// With the receive stream silent past the inactivity threshold but the loop
+// blocked on handoff, the watchdog must consider the stream alive rather than
+// tear it down (reconnecting feeds the same worker and would not help).
+func TestReceiveAliveTreatsHandoffBlockAsLiveness(t *testing.T) {
+ c := &GrpcClient{}
+
+ // Receive stream silent and the loop not blocked on handoff: genuinely stalled.
+ c.lastReceived.Store(time.Now().Add(-2 * receiveInactivityThreshold).UnixNano())
+ require.False(t, c.receiveAlive(), "silent stream with the receive loop idle must be treated as stalled")
+
+ // Receive stream silent but the loop is parked handing a message to a busy
+ // worker: self-inflicted backpressure, not a dead stream, must not tear down.
+ c.receiveHandoffBlocked.Store(true)
+ require.True(t, c.receiveAlive(), "a receive loop blocked on worker handoff must keep the stream alive")
+
+ // Handoff drained, loop back to reading, a frame just arrived: alive via the receive path.
+ c.receiveHandoffBlocked.Store(false)
+ c.markReceived()
+ require.True(t, c.receiveAlive(), "a freshly received frame must keep the stream alive")
+}
diff --git a/shared/signal/proto/signalexchange.pb.go b/shared/signal/proto/signalexchange.pb.go
index 0c80fb489..8e07977f0 100644
--- a/shared/signal/proto/signalexchange.pb.go
+++ b/shared/signal/proto/signalexchange.pb.go
@@ -30,6 +30,7 @@ const (
Body_CANDIDATE Body_Type = 2
Body_MODE Body_Type = 4
Body_GO_IDLE Body_Type = 5
+ Body_HEARTBEAT Body_Type = 6
)
// Enum value maps for Body_Type.
@@ -40,6 +41,7 @@ var (
2: "CANDIDATE",
4: "MODE",
5: "GO_IDLE",
+ 6: "HEARTBEAT",
}
Body_Type_value = map[string]int32{
"OFFER": 0,
@@ -47,6 +49,7 @@ var (
"CANDIDATE": 2,
"MODE": 4,
"GO_IDLE": 5,
+ "HEARTBEAT": 6,
}
)
@@ -463,7 +466,7 @@ var file_signalexchange_proto_rawDesc = []byte{
0x52, 0x09, 0x72, 0x65, 0x6d, 0x6f, 0x74, 0x65, 0x4b, 0x65, 0x79, 0x12, 0x28, 0x0a, 0x04, 0x62,
0x6f, 0x64, 0x79, 0x18, 0x04, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x14, 0x2e, 0x73, 0x69, 0x67, 0x6e,
0x61, 0x6c, 0x65, 0x78, 0x63, 0x68, 0x61, 0x6e, 0x67, 0x65, 0x2e, 0x42, 0x6f, 0x64, 0x79, 0x52,
- 0x04, 0x62, 0x6f, 0x64, 0x79, 0x22, 0xc3, 0x04, 0x0a, 0x04, 0x42, 0x6f, 0x64, 0x79, 0x12, 0x2d,
+ 0x04, 0x62, 0x6f, 0x64, 0x79, 0x22, 0xd2, 0x04, 0x0a, 0x04, 0x42, 0x6f, 0x64, 0x79, 0x12, 0x2d,
0x0a, 0x04, 0x74, 0x79, 0x70, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0e, 0x32, 0x19, 0x2e, 0x73,
0x69, 0x67, 0x6e, 0x61, 0x6c, 0x65, 0x78, 0x63, 0x68, 0x61, 0x6e, 0x67, 0x65, 0x2e, 0x42, 0x6f,
0x64, 0x79, 0x2e, 0x54, 0x79, 0x70, 0x65, 0x52, 0x04, 0x74, 0x79, 0x70, 0x65, 0x12, 0x18, 0x0a,
@@ -491,38 +494,39 @@ var file_signalexchange_proto_rawDesc = []byte{
0x52, 0x09, 0x73, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x49, 0x64, 0x88, 0x01, 0x01, 0x12, 0x29,
0x0a, 0x0d, 0x72, 0x65, 0x6c, 0x61, 0x79, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x49, 0x50, 0x18,
0x0b, 0x20, 0x01, 0x28, 0x0c, 0x48, 0x02, 0x52, 0x0d, 0x72, 0x65, 0x6c, 0x61, 0x79, 0x53, 0x65,
- 0x72, 0x76, 0x65, 0x72, 0x49, 0x50, 0x88, 0x01, 0x01, 0x22, 0x43, 0x0a, 0x04, 0x54, 0x79, 0x70,
+ 0x72, 0x76, 0x65, 0x72, 0x49, 0x50, 0x88, 0x01, 0x01, 0x22, 0x52, 0x0a, 0x04, 0x54, 0x79, 0x70,
0x65, 0x12, 0x09, 0x0a, 0x05, 0x4f, 0x46, 0x46, 0x45, 0x52, 0x10, 0x00, 0x12, 0x0a, 0x0a, 0x06,
0x41, 0x4e, 0x53, 0x57, 0x45, 0x52, 0x10, 0x01, 0x12, 0x0d, 0x0a, 0x09, 0x43, 0x41, 0x4e, 0x44,
0x49, 0x44, 0x41, 0x54, 0x45, 0x10, 0x02, 0x12, 0x08, 0x0a, 0x04, 0x4d, 0x4f, 0x44, 0x45, 0x10,
- 0x04, 0x12, 0x0b, 0x0a, 0x07, 0x47, 0x4f, 0x5f, 0x49, 0x44, 0x4c, 0x45, 0x10, 0x05, 0x42, 0x15,
- 0x0a, 0x13, 0x5f, 0x72, 0x65, 0x6c, 0x61, 0x79, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x41, 0x64,
- 0x64, 0x72, 0x65, 0x73, 0x73, 0x42, 0x0c, 0x0a, 0x0a, 0x5f, 0x73, 0x65, 0x73, 0x73, 0x69, 0x6f,
- 0x6e, 0x49, 0x64, 0x42, 0x10, 0x0a, 0x0e, 0x5f, 0x72, 0x65, 0x6c, 0x61, 0x79, 0x53, 0x65, 0x72,
- 0x76, 0x65, 0x72, 0x49, 0x50, 0x4a, 0x04, 0x08, 0x09, 0x10, 0x0a, 0x22, 0x2e, 0x0a, 0x04, 0x4d,
- 0x6f, 0x64, 0x65, 0x12, 0x1b, 0x0a, 0x06, 0x64, 0x69, 0x72, 0x65, 0x63, 0x74, 0x18, 0x01, 0x20,
- 0x01, 0x28, 0x08, 0x48, 0x00, 0x52, 0x06, 0x64, 0x69, 0x72, 0x65, 0x63, 0x74, 0x88, 0x01, 0x01,
- 0x42, 0x09, 0x0a, 0x07, 0x5f, 0x64, 0x69, 0x72, 0x65, 0x63, 0x74, 0x22, 0x6d, 0x0a, 0x0f, 0x52,
- 0x6f, 0x73, 0x65, 0x6e, 0x70, 0x61, 0x73, 0x73, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x12, 0x28,
- 0x0a, 0x0f, 0x72, 0x6f, 0x73, 0x65, 0x6e, 0x70, 0x61, 0x73, 0x73, 0x50, 0x75, 0x62, 0x4b, 0x65,
- 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x0f, 0x72, 0x6f, 0x73, 0x65, 0x6e, 0x70, 0x61,
- 0x73, 0x73, 0x50, 0x75, 0x62, 0x4b, 0x65, 0x79, 0x12, 0x30, 0x0a, 0x13, 0x72, 0x6f, 0x73, 0x65,
- 0x6e, 0x70, 0x61, 0x73, 0x73, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x41, 0x64, 0x64, 0x72, 0x18,
- 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x13, 0x72, 0x6f, 0x73, 0x65, 0x6e, 0x70, 0x61, 0x73, 0x73,
- 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x41, 0x64, 0x64, 0x72, 0x32, 0xb9, 0x01, 0x0a, 0x0e, 0x53,
- 0x69, 0x67, 0x6e, 0x61, 0x6c, 0x45, 0x78, 0x63, 0x68, 0x61, 0x6e, 0x67, 0x65, 0x12, 0x4c, 0x0a,
- 0x04, 0x53, 0x65, 0x6e, 0x64, 0x12, 0x20, 0x2e, 0x73, 0x69, 0x67, 0x6e, 0x61, 0x6c, 0x65, 0x78,
- 0x63, 0x68, 0x61, 0x6e, 0x67, 0x65, 0x2e, 0x45, 0x6e, 0x63, 0x72, 0x79, 0x70, 0x74, 0x65, 0x64,
- 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x1a, 0x20, 0x2e, 0x73, 0x69, 0x67, 0x6e, 0x61, 0x6c,
- 0x65, 0x78, 0x63, 0x68, 0x61, 0x6e, 0x67, 0x65, 0x2e, 0x45, 0x6e, 0x63, 0x72, 0x79, 0x70, 0x74,
- 0x65, 0x64, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x22, 0x00, 0x12, 0x59, 0x0a, 0x0d, 0x43,
- 0x6f, 0x6e, 0x6e, 0x65, 0x63, 0x74, 0x53, 0x74, 0x72, 0x65, 0x61, 0x6d, 0x12, 0x20, 0x2e, 0x73,
- 0x69, 0x67, 0x6e, 0x61, 0x6c, 0x65, 0x78, 0x63, 0x68, 0x61, 0x6e, 0x67, 0x65, 0x2e, 0x45, 0x6e,
- 0x63, 0x72, 0x79, 0x70, 0x74, 0x65, 0x64, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x1a, 0x20,
- 0x2e, 0x73, 0x69, 0x67, 0x6e, 0x61, 0x6c, 0x65, 0x78, 0x63, 0x68, 0x61, 0x6e, 0x67, 0x65, 0x2e,
- 0x45, 0x6e, 0x63, 0x72, 0x79, 0x70, 0x74, 0x65, 0x64, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65,
- 0x22, 0x00, 0x28, 0x01, 0x30, 0x01, 0x42, 0x08, 0x5a, 0x06, 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f,
- 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33,
+ 0x04, 0x12, 0x0b, 0x0a, 0x07, 0x47, 0x4f, 0x5f, 0x49, 0x44, 0x4c, 0x45, 0x10, 0x05, 0x12, 0x0d,
+ 0x0a, 0x09, 0x48, 0x45, 0x41, 0x52, 0x54, 0x42, 0x45, 0x41, 0x54, 0x10, 0x06, 0x42, 0x15, 0x0a,
+ 0x13, 0x5f, 0x72, 0x65, 0x6c, 0x61, 0x79, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x41, 0x64, 0x64,
+ 0x72, 0x65, 0x73, 0x73, 0x42, 0x0c, 0x0a, 0x0a, 0x5f, 0x73, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e,
+ 0x49, 0x64, 0x42, 0x10, 0x0a, 0x0e, 0x5f, 0x72, 0x65, 0x6c, 0x61, 0x79, 0x53, 0x65, 0x72, 0x76,
+ 0x65, 0x72, 0x49, 0x50, 0x4a, 0x04, 0x08, 0x09, 0x10, 0x0a, 0x22, 0x2e, 0x0a, 0x04, 0x4d, 0x6f,
+ 0x64, 0x65, 0x12, 0x1b, 0x0a, 0x06, 0x64, 0x69, 0x72, 0x65, 0x63, 0x74, 0x18, 0x01, 0x20, 0x01,
+ 0x28, 0x08, 0x48, 0x00, 0x52, 0x06, 0x64, 0x69, 0x72, 0x65, 0x63, 0x74, 0x88, 0x01, 0x01, 0x42,
+ 0x09, 0x0a, 0x07, 0x5f, 0x64, 0x69, 0x72, 0x65, 0x63, 0x74, 0x22, 0x6d, 0x0a, 0x0f, 0x52, 0x6f,
+ 0x73, 0x65, 0x6e, 0x70, 0x61, 0x73, 0x73, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x12, 0x28, 0x0a,
+ 0x0f, 0x72, 0x6f, 0x73, 0x65, 0x6e, 0x70, 0x61, 0x73, 0x73, 0x50, 0x75, 0x62, 0x4b, 0x65, 0x79,
+ 0x18, 0x01, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x0f, 0x72, 0x6f, 0x73, 0x65, 0x6e, 0x70, 0x61, 0x73,
+ 0x73, 0x50, 0x75, 0x62, 0x4b, 0x65, 0x79, 0x12, 0x30, 0x0a, 0x13, 0x72, 0x6f, 0x73, 0x65, 0x6e,
+ 0x70, 0x61, 0x73, 0x73, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x41, 0x64, 0x64, 0x72, 0x18, 0x02,
+ 0x20, 0x01, 0x28, 0x09, 0x52, 0x13, 0x72, 0x6f, 0x73, 0x65, 0x6e, 0x70, 0x61, 0x73, 0x73, 0x53,
+ 0x65, 0x72, 0x76, 0x65, 0x72, 0x41, 0x64, 0x64, 0x72, 0x32, 0xb9, 0x01, 0x0a, 0x0e, 0x53, 0x69,
+ 0x67, 0x6e, 0x61, 0x6c, 0x45, 0x78, 0x63, 0x68, 0x61, 0x6e, 0x67, 0x65, 0x12, 0x4c, 0x0a, 0x04,
+ 0x53, 0x65, 0x6e, 0x64, 0x12, 0x20, 0x2e, 0x73, 0x69, 0x67, 0x6e, 0x61, 0x6c, 0x65, 0x78, 0x63,
+ 0x68, 0x61, 0x6e, 0x67, 0x65, 0x2e, 0x45, 0x6e, 0x63, 0x72, 0x79, 0x70, 0x74, 0x65, 0x64, 0x4d,
+ 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x1a, 0x20, 0x2e, 0x73, 0x69, 0x67, 0x6e, 0x61, 0x6c, 0x65,
+ 0x78, 0x63, 0x68, 0x61, 0x6e, 0x67, 0x65, 0x2e, 0x45, 0x6e, 0x63, 0x72, 0x79, 0x70, 0x74, 0x65,
+ 0x64, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x22, 0x00, 0x12, 0x59, 0x0a, 0x0d, 0x43, 0x6f,
+ 0x6e, 0x6e, 0x65, 0x63, 0x74, 0x53, 0x74, 0x72, 0x65, 0x61, 0x6d, 0x12, 0x20, 0x2e, 0x73, 0x69,
+ 0x67, 0x6e, 0x61, 0x6c, 0x65, 0x78, 0x63, 0x68, 0x61, 0x6e, 0x67, 0x65, 0x2e, 0x45, 0x6e, 0x63,
+ 0x72, 0x79, 0x70, 0x74, 0x65, 0x64, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x1a, 0x20, 0x2e,
+ 0x73, 0x69, 0x67, 0x6e, 0x61, 0x6c, 0x65, 0x78, 0x63, 0x68, 0x61, 0x6e, 0x67, 0x65, 0x2e, 0x45,
+ 0x6e, 0x63, 0x72, 0x79, 0x70, 0x74, 0x65, 0x64, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x22,
+ 0x00, 0x28, 0x01, 0x30, 0x01, 0x42, 0x08, 0x5a, 0x06, 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62,
+ 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33,
}
var (
diff --git a/shared/signal/proto/signalexchange.proto b/shared/signal/proto/signalexchange.proto
index 96a4001e3..8c304e37c 100644
--- a/shared/signal/proto/signalexchange.proto
+++ b/shared/signal/proto/signalexchange.proto
@@ -48,6 +48,7 @@ message Body {
CANDIDATE = 2;
MODE = 4;
GO_IDLE = 5;
+ HEARTBEAT = 6;
}
Type type = 1;
string payload = 2;
diff --git a/sharedsock/sock_linux_test.go b/sharedsock/sock_linux_test.go
index a22af461a..0ed15e282 100644
--- a/sharedsock/sock_linux_test.go
+++ b/sharedsock/sock_linux_test.go
@@ -1,3 +1,5 @@
+//go:build privileged
+
package sharedsock
import (
diff --git a/signal/Dockerfile b/signal/Dockerfile
index 4fd5fe4a3..f6504dc74 100644
--- a/signal/Dockerfile
+++ b/signal/Dockerfile
@@ -1,4 +1,5 @@
FROM gcr.io/distroless/base:debug
ENTRYPOINT [ "/go/bin/netbird-signal","run" ]
CMD ["--log-file", "console"]
-COPY netbird-signal /go/bin/netbird-signal
+ARG TARGETPLATFORM
+COPY ${TARGETPLATFORM}/netbird-signal /go/bin/netbird-signal
diff --git a/signal/peer/peer.go b/signal/peer/peer.go
index c9dd60fc0..c04654b8b 100644
--- a/signal/peer/peer.go
+++ b/signal/peer/peer.go
@@ -26,6 +26,10 @@ type Peer struct {
// a gRpc connection stream to the Peer
Stream proto.SignalExchange_ConnectStreamServer
+ // sendMu serializes writes to Stream. gRPC forbids concurrent SendMsg on
+ // the same ServerStream, and a peer can be the target of many senders at
+ // once.
+ sendMu sync.Mutex
// registration time
RegisteredAt time.Time
@@ -33,6 +37,13 @@ type Peer struct {
Cancel context.CancelFunc
}
+// Send writes a message to the peer's stream, serializing concurrent senders.
+func (p *Peer) Send(msg *proto.EncryptedMessage) error {
+ p.sendMu.Lock()
+ defer p.sendMu.Unlock()
+ return p.Stream.Send(msg)
+}
+
// NewPeer creates a new instance of a connected Peer
func NewPeer(id string, stream proto.SignalExchange_ConnectStreamServer, cancel context.CancelFunc) *Peer {
return &Peer{
diff --git a/signal/server/concurrent_send_test.go b/signal/server/concurrent_send_test.go
new file mode 100644
index 000000000..b3830482d
--- /dev/null
+++ b/signal/server/concurrent_send_test.go
@@ -0,0 +1,67 @@
+package server
+
+import (
+ "context"
+ "sync"
+ "sync/atomic"
+ "testing"
+ "time"
+
+ "github.com/stretchr/testify/require"
+ "go.opentelemetry.io/otel"
+
+ "github.com/netbirdio/netbird/shared/signal/proto"
+ "github.com/netbirdio/netbird/signal/peer"
+)
+
+// concurrencyCheckStream records the maximum number of Send calls in flight at
+// once. gRPC forbids concurrent SendMsg on the same ServerStream, so a correct
+// server must never have more than one in flight per peer.
+type concurrencyCheckStream struct {
+ proto.SignalExchange_ConnectStreamServer
+ ctx context.Context
+ inflight atomic.Int32
+ maxSeen atomic.Int32
+}
+
+func (s *concurrencyCheckStream) Send(*proto.EncryptedMessage) error {
+ n := s.inflight.Add(1)
+ for {
+ old := s.maxSeen.Load()
+ if n <= old || s.maxSeen.CompareAndSwap(old, n) {
+ break
+ }
+ }
+ // Widen the window so overlapping callers are reliably observed.
+ time.Sleep(time.Millisecond)
+ s.inflight.Add(-1)
+ return nil
+}
+
+func (s *concurrencyCheckStream) Context() context.Context { return s.ctx }
+
+// TestForwardMessageToPeerSerializesSend verifies that concurrent forwards to the
+// same peer never call Stream.Send concurrently, which would violate the gRPC
+// ServerStream contract.
+func TestForwardMessageToPeerSerializesSend(t *testing.T) {
+ s, err := NewServer(context.Background(), otel.Meter(""))
+ require.NoError(t, err)
+
+ const peerID = "peerX"
+ stream := &concurrencyCheckStream{ctx: context.Background()}
+ _, cancel := context.WithCancel(context.Background())
+ t.Cleanup(cancel)
+ require.NoError(t, s.registry.Register(peer.NewPeer(peerID, stream, cancel)))
+
+ var wg sync.WaitGroup
+ for i := 0; i < 50; i++ {
+ wg.Add(1)
+ go func() {
+ defer wg.Done()
+ s.forwardMessageToPeer(context.Background(), &proto.EncryptedMessage{Key: "sender", RemoteKey: peerID})
+ }()
+ }
+ wg.Wait()
+
+ require.Equal(t, int32(1), stream.maxSeen.Load(), "Stream.Send must never run concurrently on the same peer stream")
+}
diff --git a/signal/server/signal.go b/signal/server/signal.go
index c46df56d2..7edbb4d34 100644
--- a/signal/server/signal.go
+++ b/signal/server/signal.go
@@ -179,7 +179,7 @@ func (s *Server) forwardMessageToPeer(ctx context.Context, msg *proto.EncryptedM
sendResultChan := make(chan error, 1)
go func() {
select {
- case sendResultChan <- dstPeer.Stream.Send(msg):
+ case sendResultChan <- dstPeer.Send(msg):
return
case <-dstPeer.Stream.Context().Done():
return
diff --git a/upload-server/Dockerfile b/upload-server/Dockerfile
index a38c6fbb8..3713d6f2a 100644
--- a/upload-server/Dockerfile
+++ b/upload-server/Dockerfile
@@ -1,3 +1,4 @@
FROM gcr.io/distroless/base:debug
ENTRYPOINT [ "/go/bin/netbird-upload" ]
-COPY netbird-upload /go/bin/netbird-upload
+ARG TARGETPLATFORM
+COPY ${TARGETPLATFORM}/netbird-upload /go/bin/netbird-upload
diff --git a/version/version.go b/version/version.go
index f33ff133c..074305bd6 100644
--- a/version/version.go
+++ b/version/version.go
@@ -13,6 +13,14 @@ import (
// 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 = DevelopmentVersion
@@ -69,8 +77,11 @@ func NetbirdCommit() string {
// 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",
-// while excluding tagged prereleases like "v0.31.1-dev".
+// 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)
+ 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
index 47b77b50d..cdba6b804 100644
--- a/version/version_test.go
+++ b/version/version_test.go
@@ -10,6 +10,8 @@ func TestIsDevelopmentVersion(t *testing.T) {
{"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},