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 c17f83222..34b215c60 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 -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
@@ -175,12 +175,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
@@ -192,7 +192,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: |
@@ -229,7 +229,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 -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:
@@ -246,12 +246,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
@@ -266,7 +266,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 }}
@@ -290,7 +290,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
@@ -306,12 +306,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
@@ -325,7 +325,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 }}
@@ -347,7 +347,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
@@ -363,12 +363,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
@@ -383,7 +383,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 }}
@@ -407,7 +407,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
@@ -424,12 +424,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
@@ -440,7 +440,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 }}
@@ -484,7 +484,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
@@ -529,12 +529,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
@@ -545,7 +545,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 }}
@@ -579,10 +579,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)"
@@ -623,12 +624,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
@@ -639,7 +640,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 }}
@@ -673,12 +674,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"
@@ -692,12 +694,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
@@ -708,7 +710,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 }}
@@ -734,7 +736,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 b335aad72..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
@@ -166,9 +186,9 @@ jobs:
- 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
@@ -201,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 }}
@@ -212,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 '
@@ -325,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
@@ -334,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
@@ -384,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 }}
@@ -395,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 '
@@ -427,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
@@ -451,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 }}
@@ -485,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
@@ -497,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..40c6b9ed5 100644
--- a/README.md
+++ b/README.md
@@ -33,10 +33,15 @@
- 🚀 We are hiring! Join us at careers.netbird.io
+ 🚀 We are hiring! Join us at https://netbird.io/careers
+> ### 🤖 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 bc7b0e98c..57e75f663 100644
--- a/client/cmd/debug.go
+++ b/client/cmd/debug.go
@@ -130,7 +130,7 @@ func debugConfigDump(cmd *cobra.Command, _ []string) error {
client := proto.NewDaemonServiceClient(conn)
resp, err := client.GetConfig(cmd.Context(), &proto.GetConfigRequest{
- ProfileName: activeProf.Name,
+ ProfileName: string(activeProf.ID),
Username: currUser.Username,
})
if err != nil {
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 b1d960bec..f3fde2f1c 100644
--- a/client/cmd/root.go
+++ b/client/cmd/root.go
@@ -190,6 +190,7 @@ func init() {
// 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 0e8991be2..d0d88b177 100644
--- a/client/embed/embed.go
+++ b/client/embed/embed.go
@@ -279,9 +279,11 @@ func (c *Client) Start(startCtx context.Context) error {
select {
case <-startCtx.Done():
- // Cancel the client context before stopping: Engine.Start blocks on the
- // signal stream while holding the engine mutex and only unblocks on
- // cancellation. Stopping first would deadlock on that mutex.
+ // 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())
diff --git a/client/firewall/iptables/manager_linux_test.go b/client/firewall/iptables/manager_linux_test.go
index cc4bda0e0..7b0989f6c 100644
--- a/client/firewall/iptables/manager_linux_test.go
+++ b/client/firewall/iptables/manager_linux_test.go
@@ -1,3 +1,5 @@
+//go:build privileged
+
package iptables
import (
diff --git a/client/firewall/iptables/router_linux_test.go b/client/firewall/iptables/router_linux_test.go
index 6707573be..9ca6b9f7e 100644
--- a/client/firewall/iptables/router_linux_test.go
+++ b/client/firewall/iptables/router_linux_test.go
@@ -1,4 +1,4 @@
-//go:build !android
+//go:build !android && privileged
package iptables
diff --git a/client/firewall/nftables/manager_linux_test.go b/client/firewall/nftables/manager_linux_test.go
index be4f65881..4eb466281 100644
--- a/client/firewall/nftables/manager_linux_test.go
+++ b/client/firewall/nftables/manager_linux_test.go
@@ -1,3 +1,5 @@
+//go:build privileged
+
package nftables
import (
diff --git a/client/firewall/nftables/router_linux_test.go b/client/firewall/nftables/router_linux_test.go
index c5d6729d9..2fc664d51 100644
--- a/client/firewall/nftables/router_linux_test.go
+++ b/client/firewall/nftables/router_linux_test.go
@@ -1,4 +1,4 @@
-//go:build !android
+//go:build !android && privileged
package nftables
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/wgproxy/proxy_linux_test.go b/client/iface/wgproxy/proxy_linux_test.go
index 7f7abcb4a..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
diff --git a/client/iface/wgproxy/proxy_seed_test.go b/client/iface/wgproxy/proxy_seed_test.go
index 9278029a5..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
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 935080bf9..d038a6ef2 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{},
@@ -135,6 +146,11 @@ func (c *ConnectClient) RunOniOS(
}
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
@@ -290,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)
@@ -414,14 +430,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()
@@ -437,12 +449,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
}
@@ -520,11 +532,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_test.go b/client/internal/debug/debug_test.go
index 76df588a5..ca7785d35 100644
--- a/client/internal/debug/debug_test.go
+++ b/client/internal/debug/debug_test.go
@@ -843,6 +843,7 @@ func TestAddConfig_AllFieldsCovered(t *testing.T) {
"PreSharedKey": "sensitive: WireGuard pre-shared key",
"SSHKey": "sensitive: SSH private key",
"ClientCertKeyPair": "non-config: parsed cert pair, not serialized",
+ "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",
}
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 07a70d6d1..931938755 100644
--- a/client/internal/dns/resutil/resolve.go
+++ b/client/internal/dns/resutil/resolve.go
@@ -8,6 +8,7 @@ import (
"errors"
"net"
"net/netip"
+ "slices"
"strings"
"github.com/miekg/dns"
@@ -167,7 +168,10 @@ func getRcodeForNotFound(ctx context.Context, r resolver, domain string, origina
case dns.TypeA:
alternativeNetwork = "ip6"
default:
- return dns.RcodeNameError
+ // Non-address types reach LookupIP only unexpectedly; without an
+ // address pair to probe we cannot prove the name is absent, so answer
+ // NODATA rather than a poisoning NXDOMAIN.
+ return dns.RcodeSuccess
}
if _, err := r.LookupNetIP(ctx, alternativeNetwork, domain); err != nil {
@@ -184,6 +188,230 @@ func getRcodeForNotFound(ctx context.Context, r resolver, domain string, origina
return dns.RcodeSuccess
}
+// RecordResolver is the host resolver surface used to forward non-address
+// record queries. net.DefaultResolver satisfies it.
+type RecordResolver interface {
+ LookupMX(ctx context.Context, name string) ([]*net.MX, error)
+ LookupTXT(ctx context.Context, name string) ([]string, error)
+ LookupNS(ctx context.Context, name string) ([]*net.NS, error)
+ LookupSRV(ctx context.Context, service, proto, name string) (string, []*net.SRV, error)
+ LookupCNAME(ctx context.Context, host string) (string, error)
+ LookupAddr(ctx context.Context, addr string) ([]string, error)
+}
+
+// LookupRecords resolves a non-address DNS record type through the host
+// resolver and returns the resource records and the DNS rcode. Types the host
+// resolver cannot answer (anything not covered by the net.Resolver Lookup*
+// methods) yield NODATA so that a routed name is never poisoned with NXDOMAIN
+// for an unsupported type.
+func LookupRecords(ctx context.Context, r RecordResolver, name string, qtype uint16, ttl uint32) ([]dns.RR, int) {
+ fqdn := dns.Fqdn(name)
+
+ switch qtype {
+ case dns.TypeMX:
+ return lookupMX(ctx, r, name, fqdn, ttl)
+ case dns.TypeTXT:
+ return lookupTXT(ctx, r, name, fqdn, ttl)
+ case dns.TypeNS:
+ return lookupNS(ctx, r, name, fqdn, ttl)
+ case dns.TypeSRV:
+ return lookupSRV(ctx, r, name, fqdn, ttl)
+ case dns.TypeCNAME:
+ return lookupCNAME(ctx, r, name, fqdn, ttl)
+ case dns.TypePTR:
+ return lookupPTR(ctx, r, name, fqdn, ttl)
+ default:
+ return nil, dns.RcodeSuccess
+ }
+}
+
+func recordHeader(fqdn string, rrtype uint16, ttl uint32) dns.RR_Header {
+ return dns.RR_Header{Name: fqdn, Rrtype: rrtype, Class: dns.ClassINET, Ttl: ttl}
+}
+
+func lookupMX(ctx context.Context, r RecordResolver, name, fqdn string, ttl uint32) ([]dns.RR, int) {
+ recs, err := r.LookupMX(ctx, name)
+ if err != nil {
+ return nil, rcodeForRecordError(err)
+ }
+ rrs := make([]dns.RR, 0, len(recs))
+ for _, mx := range recs {
+ rrs = append(rrs, &dns.MX{
+ Hdr: recordHeader(fqdn, dns.TypeMX, ttl),
+ Preference: mx.Pref,
+ Mx: dns.Fqdn(mx.Host),
+ })
+ }
+ return rrs, dns.RcodeSuccess
+}
+
+func lookupTXT(ctx context.Context, r RecordResolver, name, fqdn string, ttl uint32) ([]dns.RR, int) {
+ recs, err := r.LookupTXT(ctx, name)
+ if err != nil {
+ return nil, rcodeForRecordError(err)
+ }
+ rrs := make([]dns.RR, 0, len(recs))
+ for _, txt := range recs {
+ rrs = append(rrs, &dns.TXT{
+ Hdr: recordHeader(fqdn, dns.TypeTXT, ttl),
+ Txt: chunkTXT(txt),
+ })
+ }
+ return rrs, dns.RcodeSuccess
+}
+
+func lookupNS(ctx context.Context, r RecordResolver, name, fqdn string, ttl uint32) ([]dns.RR, int) {
+ recs, err := r.LookupNS(ctx, name)
+ if err != nil {
+ return nil, rcodeForRecordError(err)
+ }
+ rrs := make([]dns.RR, 0, len(recs))
+ for _, ns := range recs {
+ rrs = append(rrs, &dns.NS{
+ Hdr: recordHeader(fqdn, dns.TypeNS, ttl),
+ Ns: dns.Fqdn(ns.Host),
+ })
+ }
+ return rrs, dns.RcodeSuccess
+}
+
+func lookupSRV(ctx context.Context, r RecordResolver, name, fqdn string, ttl uint32) ([]dns.RR, int) {
+ _, recs, err := r.LookupSRV(ctx, "", "", name)
+ if err != nil {
+ return nil, rcodeForRecordError(err)
+ }
+ rrs := make([]dns.RR, 0, len(recs))
+ for _, srv := range recs {
+ rrs = append(rrs, &dns.SRV{
+ Hdr: recordHeader(fqdn, dns.TypeSRV, ttl),
+ Priority: srv.Priority,
+ Weight: srv.Weight,
+ Port: srv.Port,
+ Target: dns.Fqdn(srv.Target),
+ })
+ }
+ return rrs, dns.RcodeSuccess
+}
+
+func lookupCNAME(ctx context.Context, r RecordResolver, name, fqdn string, ttl uint32) ([]dns.RR, int) {
+ cname, err := r.LookupCNAME(ctx, name)
+ if err != nil {
+ return nil, rcodeForRecordError(err)
+ }
+ // LookupCNAME returns the queried name itself when the name resolves but
+ // has no CNAME record; that is a NODATA result, not a CNAME.
+ if strings.EqualFold(dns.Fqdn(cname), fqdn) {
+ return nil, dns.RcodeSuccess
+ }
+ return []dns.RR{&dns.CNAME{
+ Hdr: recordHeader(fqdn, dns.TypeCNAME, ttl),
+ Target: dns.Fqdn(cname),
+ }}, dns.RcodeSuccess
+}
+
+func lookupPTR(ctx context.Context, r RecordResolver, name, fqdn string, ttl uint32) ([]dns.RR, int) {
+ addr, ok := ptrQueryAddr(name)
+ if !ok {
+ return nil, dns.RcodeSuccess
+ }
+ names, err := r.LookupAddr(ctx, addr)
+ if err != nil {
+ return nil, rcodeForRecordError(err)
+ }
+ rrs := make([]dns.RR, 0, len(names))
+ for _, n := range names {
+ rrs = append(rrs, &dns.PTR{
+ Hdr: recordHeader(fqdn, dns.TypePTR, ttl),
+ Ptr: dns.Fqdn(n),
+ })
+ }
+ return rrs, dns.RcodeSuccess
+}
+
+// ptrQueryAddr converts a reverse-DNS query name (in-addr.arpa or ip6.arpa)
+// into the address string expected by net.Resolver.LookupAddr. It reports false
+// when the name is not a well-formed reverse name.
+func ptrQueryAddr(qname string) (string, bool) {
+ name := strings.TrimSuffix(strings.ToLower(dns.Fqdn(qname)), ".")
+
+ switch {
+ case strings.HasSuffix(name, ".in-addr.arpa"):
+ return parseInAddrArpa(strings.TrimSuffix(name, ".in-addr.arpa"))
+ case strings.HasSuffix(name, ".ip6.arpa"):
+ return parseIP6Arpa(strings.TrimSuffix(name, ".ip6.arpa"))
+ default:
+ return "", false
+ }
+}
+
+// parseInAddrArpa turns the label portion of an in-addr.arpa name into an IPv4
+// address string, reporting false when it is not a well-formed reverse name.
+func parseInAddrArpa(labelPart string) (string, bool) {
+ labels := strings.Split(labelPart, ".")
+ if len(labels) != 4 {
+ return "", false
+ }
+ slices.Reverse(labels)
+ addr, err := netip.ParseAddr(strings.Join(labels, "."))
+ if err != nil || !addr.Is4() {
+ return "", false
+ }
+ return addr.String(), true
+}
+
+// parseIP6Arpa turns the nibble portion of an ip6.arpa name into an IPv6
+// address string, reporting false when it is not a well-formed reverse name.
+func parseIP6Arpa(nibblePart string) (string, bool) {
+ nibbles := strings.Split(nibblePart, ".")
+ if len(nibbles) != 32 {
+ return "", false
+ }
+ slices.Reverse(nibbles)
+ var sb strings.Builder
+ for i, n := range nibbles {
+ if i > 0 && i%4 == 0 {
+ sb.WriteByte(':')
+ }
+ sb.WriteString(n)
+ }
+ addr, err := netip.ParseAddr(sb.String())
+ if err != nil || !addr.Is6() {
+ return "", false
+ }
+ return addr.String(), true
+}
+
+// rcodeForRecordError maps a non-address lookup error to a DNS rcode. A
+// not-found result becomes NODATA rather than NXDOMAIN: net.DNSError.IsNotFound
+// does not distinguish a missing name from a name that exists only with records
+// of other types, so the name cannot be proven absent and must not be poisoned.
+func rcodeForRecordError(err error) int {
+ var dnsErr *net.DNSError
+ if errors.As(err, &dnsErr) && dnsErr.IsNotFound {
+ return dns.RcodeSuccess
+ }
+ return dns.RcodeServerFailure
+}
+
+// chunkTXT splits a TXT string into character-strings no longer than 255 bytes
+// so the record can be packed. The chunks form one TXT resource record.
+func chunkTXT(s string) []string {
+ const maxLen = 255
+ if len(s) <= maxLen {
+ return []string{s}
+ }
+
+ var chunks []string
+ for len(s) > maxLen {
+ chunks = append(chunks, s[:maxLen])
+ s = s[maxLen:]
+ }
+ if len(s) > 0 {
+ chunks = append(chunks, s)
+ }
+ return chunks
+}
+
// FormatAnswers formats DNS resource records for logging.
func FormatAnswers(answers []dns.RR) string {
if len(answers) == 0 {
@@ -207,3 +435,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
index 432367c22..f51092a83 100644
--- a/client/internal/dns/resutil/resolve_test.go
+++ b/client/internal/dns/resutil/resolve_test.go
@@ -5,6 +5,7 @@ import (
"errors"
"net"
"net/netip"
+ "strings"
"testing"
"github.com/miekg/dns"
@@ -120,3 +121,200 @@ func TestLookupIP_DNSErrorNotIsNotFound(t *testing.T) {
assert.Equal(t, dns.RcodeServerFailure, result.Rcode, "upstream failure should map to SERVFAIL")
}
+
+func TestPtrQueryAddr(t *testing.T) {
+ tests := []struct {
+ name string
+ qname string
+ want string
+ wantOK bool
+ }{
+ {name: "ipv4", qname: "4.3.2.1.in-addr.arpa.", want: "1.2.3.4", wantOK: true},
+ {name: "ipv4 no trailing dot", qname: "1.0.0.127.in-addr.arpa", want: "127.0.0.1", wantOK: true},
+ {
+ name: "ipv6",
+ qname: "1.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.8.b.d.0.1.0.0.2.ip6.arpa.",
+ want: "2001:db8::1",
+ wantOK: true,
+ },
+ {name: "ipv4 wrong label count", qname: "2.1.in-addr.arpa.", wantOK: false},
+ {name: "ipv6 wrong nibble count", qname: "1.0.ip6.arpa.", wantOK: false},
+ {name: "not a reverse name", qname: "example.com.", wantOK: false},
+ {name: "ipv4 bad octet", qname: "4.3.2.999.in-addr.arpa.", wantOK: false},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ got, ok := ptrQueryAddr(tt.qname)
+ assert.Equal(t, tt.wantOK, ok, "parse success mismatch")
+ if tt.wantOK {
+ assert.Equal(t, tt.want, got, "parsed address mismatch")
+ }
+ })
+ }
+}
+
+type mockRecordResolver struct {
+ mx []*net.MX
+ txt []string
+ ns []*net.NS
+ srv []*net.SRV
+ cname string
+ ptr []string
+ err error
+}
+
+func (m *mockRecordResolver) LookupMX(context.Context, string) ([]*net.MX, error) {
+ return m.mx, m.err
+}
+func (m *mockRecordResolver) LookupTXT(context.Context, string) ([]string, error) {
+ return m.txt, m.err
+}
+func (m *mockRecordResolver) LookupNS(context.Context, string) ([]*net.NS, error) {
+ return m.ns, m.err
+}
+func (m *mockRecordResolver) LookupSRV(context.Context, string, string, string) (string, []*net.SRV, error) {
+ return "", m.srv, m.err
+}
+func (m *mockRecordResolver) LookupCNAME(context.Context, string) (string, error) {
+ return m.cname, m.err
+}
+func (m *mockRecordResolver) LookupAddr(context.Context, string) ([]string, error) {
+ return m.ptr, m.err
+}
+
+func TestLookupRecords(t *testing.T) {
+ notFound := &net.DNSError{IsNotFound: true, Name: "example.com."}
+
+ t.Run("MX success", func(t *testing.T) {
+ r := &mockRecordResolver{mx: []*net.MX{{Host: "mail.example.com.", Pref: 10}}}
+ rrs, rcode := LookupRecords(context.Background(), r, "example.com.", dns.TypeMX, 300)
+ assert.Equal(t, dns.RcodeSuccess, rcode)
+ require.Len(t, rrs, 1)
+ assert.Equal(t, "mail.example.com.", rrs[0].(*dns.MX).Mx)
+ })
+
+ t.Run("TXT short string is one character-string", func(t *testing.T) {
+ r := &mockRecordResolver{txt: []string{"v=spf1 -all"}}
+ rrs, rcode := LookupRecords(context.Background(), r, "example.com.", dns.TypeTXT, 300)
+ assert.Equal(t, dns.RcodeSuccess, rcode)
+ require.Len(t, rrs, 1)
+ assert.Equal(t, []string{"v=spf1 -all"}, rrs[0].(*dns.TXT).Txt)
+ })
+
+ t.Run("TXT chunks long strings", func(t *testing.T) {
+ long := strings.Repeat("a", 300)
+ r := &mockRecordResolver{txt: []string{long}}
+ rrs, rcode := LookupRecords(context.Background(), r, "example.com.", dns.TypeTXT, 300)
+ assert.Equal(t, dns.RcodeSuccess, rcode)
+ require.Len(t, rrs, 1)
+ txt := rrs[0].(*dns.TXT).Txt
+ require.Len(t, txt, 2, "300-byte string should split into two character-strings")
+ assert.Equal(t, 255, len(txt[0]))
+ assert.Equal(t, 45, len(txt[1]))
+ })
+
+ t.Run("NS success", func(t *testing.T) {
+ r := &mockRecordResolver{ns: []*net.NS{{Host: "ns1.example.com."}}}
+ rrs, rcode := LookupRecords(context.Background(), r, "example.com.", dns.TypeNS, 300)
+ assert.Equal(t, dns.RcodeSuccess, rcode)
+ require.Len(t, rrs, 1)
+ assert.Equal(t, "ns1.example.com.", rrs[0].(*dns.NS).Ns)
+ })
+
+ t.Run("SRV success", func(t *testing.T) {
+ r := &mockRecordResolver{srv: []*net.SRV{{Target: "sip.example.com.", Port: 5060}}}
+ rrs, rcode := LookupRecords(context.Background(), r, "_sip._tcp.example.com.", dns.TypeSRV, 300)
+ assert.Equal(t, dns.RcodeSuccess, rcode)
+ require.Len(t, rrs, 1)
+ assert.Equal(t, uint16(5060), rrs[0].(*dns.SRV).Port)
+ })
+
+ t.Run("CNAME success", func(t *testing.T) {
+ r := &mockRecordResolver{cname: "target.example.com."}
+ rrs, rcode := LookupRecords(context.Background(), r, "www.example.com.", dns.TypeCNAME, 300)
+ assert.Equal(t, dns.RcodeSuccess, rcode)
+ require.Len(t, rrs, 1)
+ assert.Equal(t, "target.example.com.", rrs[0].(*dns.CNAME).Target)
+ })
+
+ t.Run("CNAME equal to name is NODATA", func(t *testing.T) {
+ r := &mockRecordResolver{cname: "example.com."}
+ rrs, rcode := LookupRecords(context.Background(), r, "example.com.", dns.TypeCNAME, 300)
+ assert.Equal(t, dns.RcodeSuccess, rcode)
+ assert.Empty(t, rrs, "self-referential CNAME is NODATA")
+ })
+
+ t.Run("PTR success", func(t *testing.T) {
+ r := &mockRecordResolver{ptr: []string{"host.example.com."}}
+ rrs, rcode := LookupRecords(context.Background(), r, "4.3.2.1.in-addr.arpa.", dns.TypePTR, 300)
+ assert.Equal(t, dns.RcodeSuccess, rcode)
+ require.Len(t, rrs, 1)
+ assert.Equal(t, "host.example.com.", rrs[0].(*dns.PTR).Ptr)
+ })
+
+ t.Run("PTR malformed name is NODATA", func(t *testing.T) {
+ r := &mockRecordResolver{}
+ rrs, rcode := LookupRecords(context.Background(), r, "example.com.", dns.TypePTR, 300)
+ assert.Equal(t, dns.RcodeSuccess, rcode)
+ assert.Empty(t, rrs)
+ })
+
+ t.Run("not found is NODATA never NXDOMAIN", func(t *testing.T) {
+ r := &mockRecordResolver{err: notFound}
+ _, rcode := LookupRecords(context.Background(), r, "example.com.", dns.TypeMX, 300)
+ assert.Equal(t, dns.RcodeSuccess, rcode, "missing record must not poison the name")
+ })
+
+ t.Run("server failure maps to SERVFAIL", func(t *testing.T) {
+ r := &mockRecordResolver{err: &net.DNSError{Err: "server misbehaving", IsTemporary: true}}
+ _, rcode := LookupRecords(context.Background(), r, "example.com.", dns.TypeMX, 300)
+ assert.Equal(t, dns.RcodeServerFailure, rcode)
+ })
+
+ t.Run("unsupported type is NODATA", func(t *testing.T) {
+ r := &mockRecordResolver{}
+ rrs, rcode := LookupRecords(context.Background(), r, "example.com.", dns.TypeCAA, 300)
+ assert.Equal(t, dns.RcodeSuccess, rcode)
+ assert.Empty(t, rrs)
+ })
+}
+
+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 722c2abd7..96e55a354 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..b7e5a10e3 100644
--- a/client/internal/dnsfwd/forwarder.go
+++ b/client/internal/dnsfwd/forwarder.go
@@ -26,8 +26,23 @@ 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)
+ LookupMX(ctx context.Context, name string) ([]*net.MX, error)
+ LookupTXT(ctx context.Context, name string) ([]string, error)
+ LookupNS(ctx context.Context, name string) ([]*net.NS, error)
+ LookupSRV(ctx context.Context, service, proto, name string) (string, []*net.SRV, error)
+ LookupCNAME(ctx context.Context, host string) (string, error)
+ LookupAddr(ctx context.Context, addr string) ([]string, error)
}
type firewaller interface {
@@ -201,12 +216,6 @@ func (f *DNSForwarder) handleDNSQuery(logger *log.Entry, w dns.ResponseWriter, q
qname, dns.TypeToString[question.Qtype], dns.ClassToString[question.Qclass])
resp := query.SetReply(query)
- network := resutil.NetworkForQtype(question.Qtype)
- if network == "" {
- resp.Rcode = dns.RcodeNotImplemented
- f.writeResponse(logger, w, resp, qname, startTime)
- return
- }
mostSpecificResId, matchingEntries := f.getMatchingEntries(strings.TrimSuffix(qname, "."))
if mostSpecificResId == "" {
@@ -218,9 +227,46 @@ func (f *DNSForwarder) handleDNSQuery(logger *log.Entry, w dns.ResponseWriter, q
ctx, cancel := context.WithTimeout(context.Background(), upstreamTimeout)
defer cancel()
+ reqHasEdns := query.IsEdns0() != nil
+
+ switch question.Qtype {
+ case dns.TypeA, dns.TypeAAAA:
+ f.handleAddressQuery(ctx, logger, w, resp, mostSpecificResId, matchingEntries, reqHasEdns, startTime)
+ case dns.TypeMX, dns.TypeTXT, dns.TypeNS, dns.TypeSRV, dns.TypeCNAME, dns.TypePTR:
+ f.handleRecordQuery(ctx, logger, w, resp, startTime)
+ default:
+ // The domain is routed here, so any other type is answered NODATA
+ // (NOERROR, empty answer) rather than falling back to a resolver that
+ // would poison the name with NXDOMAIN. The Extended DNS Error lets a
+ // client tell this capability-driven NODATA apart from an
+ // authoritative one. The OPT pseudo-record must not appear unless the
+ // query advertised EDNS0.
+ if reqHasEdns {
+ attachEDE(resp, dns.ExtendedErrorCodeNotSupported, "netbird forwarder: unsupported query type")
+ }
+ f.writeResponse(logger, w, resp, qname, startTime)
+ }
+}
+
+// handleAddressQuery resolves A/AAAA queries, programs the firewall sets and
+// resolved-IP state, and caches the answer for resilience on upstream failure.
+func (f *DNSForwarder) handleAddressQuery(
+ ctx context.Context,
+ logger *log.Entry,
+ w dns.ResponseWriter,
+ resp *dns.Msg,
+ mostSpecificResId route.ResID,
+ matchingEntries []*ForwarderEntry,
+ reqHasEdns bool,
+ startTime time.Time,
+) {
+ question := resp.Question[0]
+ qname := strings.ToLower(question.Name)
+
+ network := resutil.NetworkForQtype(question.Qtype)
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, reqHasEdns, startTime)
return
}
@@ -231,6 +277,25 @@ func (f *DNSForwarder) handleDNSQuery(logger *log.Entry, w dns.ResponseWriter, q
f.writeResponse(logger, w, resp, qname, startTime)
}
+// handleRecordQuery resolves non-address record types (MX, TXT, NS, SRV,
+// CNAME, PTR) through the host resolver. Missing records are answered NODATA so
+// the routed name is never poisoned with NXDOMAIN.
+func (f *DNSForwarder) handleRecordQuery(
+ ctx context.Context,
+ logger *log.Entry,
+ w dns.ResponseWriter,
+ resp *dns.Msg,
+ startTime time.Time,
+) {
+ question := resp.Question[0]
+ qname := strings.ToLower(question.Name)
+
+ records, rcode := resutil.LookupRecords(ctx, f.resolver, qname, question.Qtype, f.ttl)
+ resp.Rcode = rcode
+ resp.Answer = append(resp.Answer, records...)
+ f.writeResponse(logger, w, resp, qname, startTime)
+}
+
func (f *DNSForwarder) writeResponse(logger *log.Entry, w dns.ResponseWriter, resp *dns.Msg, qname string, startTime time.Time) {
if err := w.WriteMsg(resp); err != nil {
logger.Errorf("failed to write DNS response: %v", err)
@@ -333,6 +398,7 @@ func (f *DNSForwarder) handleDNSError(
resp *dns.Msg,
domain string,
result resutil.LookupResult,
+ reqHasEdns bool,
startTime time.Time,
) {
qType := question.Qtype
@@ -374,6 +440,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 +484,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..c69a9166e 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"
@@ -132,6 +133,41 @@ func (m *MockResolver) LookupNetIP(ctx context.Context, network, host string) ([
return args.Get(0).([]netip.Addr), args.Error(1)
}
+func (m *MockResolver) LookupMX(ctx context.Context, name string) ([]*net.MX, error) {
+ args := m.Called(ctx, name)
+ recs, _ := args.Get(0).([]*net.MX)
+ return recs, args.Error(1)
+}
+
+func (m *MockResolver) LookupTXT(ctx context.Context, name string) ([]string, error) {
+ args := m.Called(ctx, name)
+ recs, _ := args.Get(0).([]string)
+ return recs, args.Error(1)
+}
+
+func (m *MockResolver) LookupNS(ctx context.Context, name string) ([]*net.NS, error) {
+ args := m.Called(ctx, name)
+ recs, _ := args.Get(0).([]*net.NS)
+ return recs, args.Error(1)
+}
+
+func (m *MockResolver) LookupSRV(ctx context.Context, service, proto, name string) (string, []*net.SRV, error) {
+ args := m.Called(ctx, service, proto, name)
+ recs, _ := args.Get(1).([]*net.SRV)
+ return args.String(0), recs, args.Error(2)
+}
+
+func (m *MockResolver) LookupCNAME(ctx context.Context, host string) (string, error) {
+ args := m.Called(ctx, host)
+ return args.String(0), args.Error(1)
+}
+
+func (m *MockResolver) LookupAddr(ctx context.Context, addr string) ([]string, error) {
+ args := m.Called(ctx, addr)
+ recs, _ := args.Get(0).([]string)
+ return recs, args.Error(1)
+}
+
func TestDNSForwarder_SubdomainAccessLogic(t *testing.T) {
tests := []struct {
name string
@@ -544,12 +580,15 @@ func TestDNSForwarder_MultipleIPsInSingleUpdate(t *testing.T) {
}
func TestDNSForwarder_ResponseCodes(t *testing.T) {
+ // A type with no net.Resolver Lookup method (CAA) must answer NODATA
+ // (NOERROR, empty) rather than NXDOMAIN/NOTIMP to avoid poisoning the name.
tests := []struct {
name string
queryType uint16
queryDomain string
configured string
expectedCode int
+ expectEDE bool
description string
}{
{
@@ -561,28 +600,13 @@ func TestDNSForwarder_ResponseCodes(t *testing.T) {
description: "RFC compliant REFUSED for unauthorized queries",
},
{
- name: "unsupported query type returns NOTIMP",
- queryType: dns.TypeMX,
+ name: "unsupported query type returns NODATA",
+ queryType: dns.TypeCAA,
queryDomain: "example.com",
configured: "example.com",
- expectedCode: dns.RcodeNotImplemented,
- description: "RFC compliant NOTIMP for unsupported types",
- },
- {
- name: "CNAME query returns NOTIMP",
- queryType: dns.TypeCNAME,
- queryDomain: "example.com",
- configured: "example.com",
- expectedCode: dns.RcodeNotImplemented,
- description: "CNAME queries not supported",
- },
- {
- name: "TXT query returns NOTIMP",
- queryType: dns.TypeTXT,
- queryDomain: "example.com",
- configured: "example.com",
- expectedCode: dns.RcodeNotImplemented,
- description: "TXT queries not supported",
+ expectedCode: dns.RcodeSuccess,
+ expectEDE: true,
+ description: "Unsupported types answer NODATA, not NXDOMAIN/NOTIMP",
},
}
@@ -598,6 +622,7 @@ func TestDNSForwarder_ResponseCodes(t *testing.T) {
query := &dns.Msg{}
query.SetQuestion(dns.Fqdn(tt.queryDomain), tt.queryType)
+ query.SetEdns0(dns.DefaultMsgSize, false)
// Capture the written response
var writtenResp *dns.Msg
@@ -613,6 +638,288 @@ func TestDNSForwarder_ResponseCodes(t *testing.T) {
// Check the response written to the writer
require.NotNil(t, writtenResp, "Expected response to be written")
assert.Equal(t, tt.expectedCode, writtenResp.Rcode, tt.description)
+ assert.Empty(t, writtenResp.Answer, "Non-address response should carry no answers")
+
+ if tt.expectEDE {
+ require.NotNil(t, writtenResp.IsEdns0(), "EDNS0 client should get an OPT in the reply")
+ assert.True(t, hasEDE(writtenResp, dns.ExtendedErrorCodeNotSupported),
+ "unsupported type NODATA should carry EDE Not Supported")
+ }
+ })
+ }
+}
+
+func hasEDE(m *dns.Msg, code uint16) bool {
+ opt := m.IsEdns0()
+ if opt == nil {
+ return false
+ }
+ for _, o := range opt.Option {
+ if ede, ok := o.(*dns.EDNS0_EDE); ok && ede.InfoCode == code {
+ return true
+ }
+ }
+ return false
+}
+
+func TestDNSForwarder_RecordQueries(t *testing.T) {
+ notFound := &net.DNSError{IsNotFound: true, Name: "example.com"}
+
+ t.Run("MX records are forwarded", func(t *testing.T) {
+ mockResolver := &MockResolver{}
+ forwarder := newRecordTestForwarder(t, mockResolver, "example.com")
+
+ mockResolver.On("LookupMX", mock.Anything, "example.com.").
+ Return([]*net.MX{{Host: "mail.example.com.", Pref: 10}}, nil).Once()
+
+ resp := runRecordQuery(t, forwarder, "example.com", dns.TypeMX)
+ require.Equal(t, dns.RcodeSuccess, resp.Rcode)
+ require.Len(t, resp.Answer, 1)
+ mx, ok := resp.Answer[0].(*dns.MX)
+ require.True(t, ok, "answer should be an MX record")
+ assert.Equal(t, uint16(10), mx.Preference)
+ assert.Equal(t, "mail.example.com.", mx.Mx)
+ mockResolver.AssertExpectations(t)
+ })
+
+ t.Run("missing MX is NODATA not NXDOMAIN", func(t *testing.T) {
+ mockResolver := &MockResolver{}
+ forwarder := newRecordTestForwarder(t, mockResolver, "example.com")
+
+ // A not-found cannot prove the name is absent (it may exist with only
+ // other record types), so it must answer NODATA, never NXDOMAIN.
+ mockResolver.On("LookupMX", mock.Anything, "example.com.").
+ Return(nil, notFound).Once()
+
+ resp := runRecordQuery(t, forwarder, "example.com", dns.TypeMX)
+ assert.Equal(t, dns.RcodeSuccess, resp.Rcode, "missing record must be NODATA")
+ assert.Empty(t, resp.Answer)
+ mockResolver.AssertExpectations(t)
+ })
+
+ t.Run("NS records are forwarded", func(t *testing.T) {
+ mockResolver := &MockResolver{}
+ forwarder := newRecordTestForwarder(t, mockResolver, "example.com")
+
+ mockResolver.On("LookupNS", mock.Anything, "example.com.").
+ Return([]*net.NS{{Host: "ns1.example.com."}}, nil).Once()
+
+ resp := runRecordQuery(t, forwarder, "example.com", dns.TypeNS)
+ require.Equal(t, dns.RcodeSuccess, resp.Rcode)
+ require.Len(t, resp.Answer, 1)
+ ns, ok := resp.Answer[0].(*dns.NS)
+ require.True(t, ok, "answer should be an NS record")
+ assert.Equal(t, "ns1.example.com.", ns.Ns)
+ mockResolver.AssertExpectations(t)
+ })
+
+ t.Run("missing NS is NODATA", func(t *testing.T) {
+ mockResolver := &MockResolver{}
+ forwarder := newRecordTestForwarder(t, mockResolver, "example.com")
+
+ mockResolver.On("LookupNS", mock.Anything, "example.com.").
+ Return(nil, notFound).Once()
+
+ resp := runRecordQuery(t, forwarder, "example.com", dns.TypeNS)
+ assert.Equal(t, dns.RcodeSuccess, resp.Rcode)
+ assert.Empty(t, resp.Answer)
+ mockResolver.AssertExpectations(t)
+ })
+
+ t.Run("SRV records are forwarded", func(t *testing.T) {
+ mockResolver := &MockResolver{}
+ forwarder := newRecordTestForwarder(t, mockResolver, "_sip._tcp.example.com")
+
+ mockResolver.On("LookupSRV", mock.Anything, "", "", "_sip._tcp.example.com.").
+ Return("", []*net.SRV{{Target: "sip.example.com.", Port: 5060, Priority: 10, Weight: 5}}, nil).Once()
+
+ resp := runRecordQuery(t, forwarder, "_sip._tcp.example.com", dns.TypeSRV)
+ require.Equal(t, dns.RcodeSuccess, resp.Rcode)
+ require.Len(t, resp.Answer, 1)
+ srv, ok := resp.Answer[0].(*dns.SRV)
+ require.True(t, ok, "answer should be an SRV record")
+ assert.Equal(t, "sip.example.com.", srv.Target)
+ assert.Equal(t, uint16(5060), srv.Port)
+ assert.Equal(t, uint16(10), srv.Priority)
+ mockResolver.AssertExpectations(t)
+ })
+
+ t.Run("missing SRV is NODATA", func(t *testing.T) {
+ mockResolver := &MockResolver{}
+ forwarder := newRecordTestForwarder(t, mockResolver, "_sip._tcp.example.com")
+
+ mockResolver.On("LookupSRV", mock.Anything, "", "", "_sip._tcp.example.com.").
+ Return("", nil, notFound).Once()
+
+ resp := runRecordQuery(t, forwarder, "_sip._tcp.example.com", dns.TypeSRV)
+ assert.Equal(t, dns.RcodeSuccess, resp.Rcode)
+ assert.Empty(t, resp.Answer)
+ mockResolver.AssertExpectations(t)
+ })
+
+ t.Run("TXT records are forwarded", func(t *testing.T) {
+ mockResolver := &MockResolver{}
+ forwarder := newRecordTestForwarder(t, mockResolver, "example.com")
+
+ mockResolver.On("LookupTXT", mock.Anything, "example.com.").
+ Return([]string{"v=spf1 -all"}, nil).Once()
+
+ resp := runRecordQuery(t, forwarder, "example.com", dns.TypeTXT)
+ require.Equal(t, dns.RcodeSuccess, resp.Rcode)
+ require.Len(t, resp.Answer, 1)
+ txt, ok := resp.Answer[0].(*dns.TXT)
+ require.True(t, ok, "answer should be a TXT record")
+ assert.Equal(t, []string{"v=spf1 -all"}, txt.Txt)
+ mockResolver.AssertExpectations(t)
+ })
+
+ t.Run("CNAME record is forwarded", func(t *testing.T) {
+ mockResolver := &MockResolver{}
+ forwarder := newRecordTestForwarder(t, mockResolver, "www.example.com")
+
+ mockResolver.On("LookupCNAME", mock.Anything, "www.example.com.").
+ Return("target.example.com.", nil).Once()
+
+ resp := runRecordQuery(t, forwarder, "www.example.com", dns.TypeCNAME)
+ require.Equal(t, dns.RcodeSuccess, resp.Rcode)
+ require.Len(t, resp.Answer, 1)
+ cname, ok := resp.Answer[0].(*dns.CNAME)
+ require.True(t, ok, "answer should be a CNAME record")
+ assert.Equal(t, "target.example.com.", cname.Target)
+ mockResolver.AssertExpectations(t)
+ })
+
+ t.Run("CNAME equal to the name is NODATA", func(t *testing.T) {
+ mockResolver := &MockResolver{}
+ forwarder := newRecordTestForwarder(t, mockResolver, "example.com")
+
+ // No CNAME exists: LookupCNAME echoes the queried name back.
+ mockResolver.On("LookupCNAME", mock.Anything, "example.com.").
+ Return("example.com.", nil).Once()
+
+ resp := runRecordQuery(t, forwarder, "example.com", dns.TypeCNAME)
+ assert.Equal(t, dns.RcodeSuccess, resp.Rcode)
+ assert.Empty(t, resp.Answer, "self-referential CNAME means no CNAME record")
+ mockResolver.AssertExpectations(t)
+ })
+
+ t.Run("PTR record is forwarded", func(t *testing.T) {
+ mockResolver := &MockResolver{}
+ forwarder := newRecordTestForwarder(t, mockResolver, "*.in-addr.arpa")
+
+ // The reverse name is parsed back to the address LookupAddr expects.
+ mockResolver.On("LookupAddr", mock.Anything, "1.2.3.4").
+ Return([]string{"host.example.com."}, nil).Once()
+
+ resp := runRecordQuery(t, forwarder, "4.3.2.1.in-addr.arpa", dns.TypePTR)
+ require.Equal(t, dns.RcodeSuccess, resp.Rcode)
+ require.Len(t, resp.Answer, 1)
+ ptr, ok := resp.Answer[0].(*dns.PTR)
+ require.True(t, ok, "answer should be a PTR record")
+ assert.Equal(t, "host.example.com.", ptr.Ptr)
+ mockResolver.AssertExpectations(t)
+ })
+}
+
+func newRecordTestForwarder(t *testing.T, r resolver, configured string) *DNSForwarder {
+ t.Helper()
+ forwarder := NewDNSForwarder(netip.MustParseAddrPort("127.0.0.1:0"), 300, nil, &peer.Status{}, nil)
+ forwarder.resolver = r
+
+ d, err := domain.FromString(configured)
+ require.NoError(t, err)
+ forwarder.UpdateDomains([]*ForwarderEntry{{Domain: d, ResID: "test-res"}})
+ return forwarder
+}
+
+func runRecordQuery(t *testing.T, forwarder *DNSForwarder, qname string, qtype uint16) *dns.Msg {
+ t.Helper()
+ query := &dns.Msg{}
+ query.SetQuestion(dns.Fqdn(qname), qtype)
+
+ mockWriter := &test.MockResponseWriter{}
+ forwarder.handleDNSQuery(log.NewEntry(log.StandardLogger()), mockWriter, query, time.Now())
+
+ resp := mockWriter.GetLastResponse()
+ require.NotNil(t, resp, "expected response to be written")
+ return resp
+}
+
+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")
})
}
}
diff --git a/client/internal/engine.go b/client/internal/engine.go
index 60e0eba74..6bc063369 100644
--- a/client/internal/engine.go
+++ b/client/internal/engine.go
@@ -86,6 +86,8 @@ const (
var ErrResetConnection = fmt.Errorf("reset connection")
+var ErrEngineAlreadyStarted = errors.New("engine already started")
+
type EngineConfig struct {
WgPort int
WgIfaceName string
@@ -199,6 +201,8 @@ type Engine struct {
ctx context.Context
cancel context.CancelFunc
+ started bool
+
wgInterface WGIface
udpMux *udpmux.UniversalUDPMuxDefault
@@ -279,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,
@@ -314,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()
}
@@ -366,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()
@@ -388,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.
@@ -440,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()
@@ -485,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
@@ -526,7 +561,6 @@ 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)
}
@@ -535,7 +569,6 @@ func (e *Engine) Start(netbirdConfig *mgmProto.NetbirdConfig, mgmtURL *url.URL)
}
if err := e.createFirewall(); err != nil {
- e.close()
return err
}
@@ -547,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)
}
@@ -572,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)
}
@@ -586,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()
@@ -638,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)
}
@@ -864,6 +895,16 @@ func (e *Engine) handleAutoUpdateVersion(autoUpdateSettings *mgmProto.AutoUpdate
e.updateManager.SetVersion(autoUpdateSettings.Version, autoUpdateSettings.AlwaysUpdate)
}
+// phase times a sync sub-phase: it returns a function that records the elapsed
+// duration when called. Starting the timer at the call site keeps inter-phase
+// glue code out of the measurement.
+func (e *Engine) phase(name string) func() {
+ start := time.Now()
+ return func() {
+ e.clientMetrics.RecordSyncPhase(e.ctx, name, time.Since(start))
+ }
+}
+
func (e *Engine) handleSync(update *mgmProto.SyncResponse) error {
started := time.Now()
defer func() {
@@ -883,7 +924,10 @@ func (e *Engine) handleSync(update *mgmProto.SyncResponse) error {
e.handleAutoUpdateVersion(update.NetworkMap.PeerConfig.AutoUpdate)
}
- if err := e.updateNetbirdConfig(update.GetNetbirdConfig()); err != nil {
+ done := e.phase("netbird_config")
+ err := e.updateNetbirdConfig(update.GetNetbirdConfig())
+ done()
+ if err != nil {
return err
}
@@ -897,11 +941,16 @@ func (e *Engine) handleSync(update *mgmProto.SyncResponse) error {
return nil
}
- if err := e.updateChecksIfNew(update.Checks); err != nil {
+ done = e.phase("checks")
+ err = e.updateChecksIfNew(update.Checks)
+ done()
+ if err != nil {
return err
}
+ done = e.phase("persist")
e.persistSyncResponse(update)
+ done()
// only apply new changes and ignore old ones
if err := e.updateNetworkMap(nm); err != nil {
@@ -1045,7 +1094,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)
@@ -1076,6 +1125,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")
@@ -1219,7 +1282,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)
@@ -1336,13 +1399,16 @@ func (e *Engine) updateNetworkMap(networkMap *mgmProto.NetworkMap) error {
dnsConfig := toDNSConfig(protoDNSConfig, e.wgInterface.Address())
+ done := e.phase("dns_server")
if err := e.dnsServer.UpdateDNSServer(serial, dnsConfig); err != nil {
log.Errorf("failed to update dns server, err: %v", err)
}
+ done()
e.routeManager.SetDNSForwarderPort(dnsConfig.ForwarderPort)
// apply routes first, route related actions might depend on routing being enabled
+ done = e.phase("routes_classify")
routes := toRoutes(networkMap.GetRoutes())
serverRoutes, clientRoutes := e.routeManager.ClassifyRoutes(routes)
@@ -1351,29 +1417,60 @@ func (e *Engine) updateNetworkMap(networkMap *mgmProto.NetworkMap) error {
e.connMgr.UpdateRouteHAMap(clientRoutes)
log.Debugf("updated lazy connection manager with %d HA groups", len(clientRoutes))
}
+ done()
+ done = e.phase("routes_apply")
dnsRouteFeatureFlag := toDNSFeatureFlag(networkMap)
if err := e.routeManager.UpdateRoutes(serial, serverRoutes, clientRoutes, dnsRouteFeatureFlag); err != nil {
log.Errorf("failed to update routes: %v", err)
}
+ done()
+ done = e.phase("filtering")
if e.acl != nil {
e.acl.ApplyFiltering(networkMap, dnsRouteFeatureFlag)
}
+ done()
+ done = e.phase("dns_forwarder")
fwdEntries := toRouteDomains(e.config.WgPrivateKey.PublicKey().String(), routes)
e.updateDNSForwarder(dnsRouteFeatureFlag, fwdEntries)
+ done()
// Ingress forward rules
+ done = e.phase("forward_rules")
forwardingRules, err := e.updateForwardRules(networkMap.GetForwardingRules())
if err != nil {
log.Errorf("failed to update forward rules, err: %v", err)
}
+ done()
log.Debugf("got peers update from Management Service, total peers to connect to = %d", len(networkMap.GetRemotePeers()))
+ done = e.phase("offline_peers")
e.updateOfflinePeers(networkMap.GetOfflinePeers())
+ done()
+ remotePeers, err := e.reconcilePeers(networkMap)
+ if err != nil {
+ return err
+ }
+
+ // must set the exclude list after the peers are added. Without it the manager can not figure out the peers parameters from the store
+ done = e.phase("lazy_exclude")
+ excludedLazyPeers := e.toExcludedLazyPeers(forwardingRules, remotePeers)
+ e.connMgr.SetExcludeList(e.ctx, excludedLazyPeers)
+ done()
+
+ e.networkSerial = serial
+
+ return nil
+}
+
+// reconcilePeers applies the remote peer list from the network map (removing,
+// modifying and adding peers, then updating SSH config) and returns the remote
+// peers with our own peer filtered out, for use by later sync steps.
+func (e *Engine) reconcilePeers(networkMap *mgmProto.NetworkMap) ([]*mgmProto.RemotePeerConfig, error) {
// Filter out own peer from the remote peers list
localPubKey := e.config.WgPrivateKey.PublicKey().String()
remotePeers := make([]*mgmProto.RemotePeerConfig, 0, len(networkMap.GetRemotePeers()))
@@ -1388,42 +1485,43 @@ func (e *Engine) updateNetworkMap(networkMap *mgmProto.NetworkMap) error {
err := e.removeAllPeers()
e.statusRecorder.FinishPeerListModifications()
if err != nil {
- return err
+ return nil, err
}
- } else {
- err := e.removePeers(remotePeers)
- if err != nil {
- return err
- }
-
- err = e.modifyPeers(remotePeers)
- if err != nil {
- return err
- }
-
- err = e.addNewPeers(remotePeers)
- if err != nil {
- return err
- }
-
- e.statusRecorder.FinishPeerListModifications()
-
- e.updatePeerSSHHostKeys(remotePeers)
-
- if err := e.updateSSHClientConfig(remotePeers); err != nil {
- log.Warnf("failed to update SSH client config: %v", err)
- }
-
- e.updateSSHServerAuth(networkMap.GetSshAuth())
+ return remotePeers, nil
}
- // must set the exclude list after the peers are added. Without it the manager can not figure out the peers parameters from the store
- excludedLazyPeers := e.toExcludedLazyPeers(forwardingRules, remotePeers)
- e.connMgr.SetExcludeList(e.ctx, excludedLazyPeers)
+ done := e.phase("removed_peers")
+ err := e.removePeers(remotePeers)
+ done()
+ if err != nil {
+ return nil, err
+ }
- e.networkSerial = serial
+ done = e.phase("modified_peers")
+ err = e.modifyPeers(remotePeers)
+ done()
+ if err != nil {
+ return nil, err
+ }
- return nil
+ done = e.phase("added_peers")
+ err = e.addNewPeers(remotePeers)
+ done()
+ if err != nil {
+ return nil, err
+ }
+
+ e.statusRecorder.FinishPeerListModifications()
+
+ e.updatePeerSSHHostKeys(remotePeers)
+
+ if err := e.updateSSHClientConfig(remotePeers); err != nil {
+ log.Warnf("failed to update SSH client config: %v", err)
+ }
+
+ e.updateSSHServerAuth(networkMap.GetSshAuth())
+
+ return remotePeers, nil
}
func toDNSFeatureFlag(networkMap *mgmProto.NetworkMap) bool {
@@ -1708,7 +1806,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()
@@ -1724,6 +1822,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)
@@ -1772,7 +1877,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 {
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/metrics/influxdb.go b/client/internal/metrics/influxdb.go
index 531f6a986..4ba14bf44 100644
--- a/client/internal/metrics/influxdb.go
+++ b/client/internal/metrics/influxdb.go
@@ -120,6 +120,30 @@ func (m *influxDBMetrics) RecordSyncDuration(_ context.Context, agentInfo AgentI
m.trimLocked()
}
+func (m *influxDBMetrics) RecordSyncPhase(_ context.Context, agentInfo AgentInfo, phase string, duration time.Duration) {
+ tags := fmt.Sprintf("deployment_type=%s,version=%s,os=%s,arch=%s,peer_id=%s,phase=%s",
+ agentInfo.DeploymentType.String(),
+ agentInfo.Version,
+ agentInfo.OS,
+ agentInfo.Arch,
+ agentInfo.peerID,
+ phase,
+ )
+
+ m.mu.Lock()
+ defer m.mu.Unlock()
+
+ m.samples = append(m.samples, influxSample{
+ measurement: "netbird_sync_phase",
+ tags: tags,
+ fields: map[string]float64{
+ "duration_seconds": duration.Seconds(),
+ },
+ timestamp: time.Now(),
+ })
+ m.trimLocked()
+}
+
func (m *influxDBMetrics) RecordLoginDuration(_ context.Context, agentInfo AgentInfo, duration time.Duration, success bool) {
result := "success"
if !success {
diff --git a/client/internal/metrics/infra/README.md b/client/internal/metrics/infra/README.md
index 5a93dbd87..7941a30cf 100644
--- a/client/internal/metrics/infra/README.md
+++ b/client/internal/metrics/infra/README.md
@@ -78,6 +78,25 @@ Tags:
- `os`: Operating system (linux, darwin, windows, android, ios, etc.)
- `arch`: CPU architecture (amd64, arm64, etc.)
+### Sync Phase Timing
+
+Measurement: `netbird_sync_phase`
+
+Breaks down where time goes inside a single sync, so the total `netbird_sync` duration can be attributed to the sub-step that dominates.
+
+| Field | Description |
+|-------|-------------|
+| `duration_seconds` | Time spent in one sub-phase of sync processing |
+
+Tags:
+- `phase`: the sub-phase — `netbird_config`, `checks`, `persist`, `dns_server`, `routes_classify`, `routes_apply`, `filtering`, `dns_forwarder`, `forward_rules`, `offline_peers`, `removed_peers`, `modified_peers`, `added_peers`, `lazy_exclude`
+- `deployment_type`: "cloud" | "selfhosted" | "unknown"
+- `version`: NetBird version string
+- `os`: Operating system (linux, darwin, windows, android, ios, etc.)
+- `arch`: CPU architecture (amd64, arm64, etc.)
+
+**Note:** this is wall-time per phase — it includes both CPU work and time spent waiting on locks. A slow phase points to *where* the time goes, not *why*; pair it with lock-wait metrics to tell contention apart from real work.
+
### Login Duration
Measurement: `netbird_login`
@@ -191,4 +210,52 @@ docker compose exec influxdb influx query \
# Check ingest server health
curl http://localhost:8087/health
-```
\ No newline at end of file
+```
+
+## Analyzing a Debug Bundle
+
+Metrics collection is always on, so every debug bundle ships a `metrics.txt` in InfluxDB line protocol — a timestamped time series of all recorded events (sync durations, sync phases, connection stages, login). You can replay it into the local stack and graph it, without a running client.
+
+The bundle's `metrics.txt` is a rolling window (capped at 5 days / ~20k samples, see [Buffer Limits](#buffer-limits)). For a connection incident the relevant window is short (connection setup is seconds), so a bundle captured during the issue is enough.
+
+### 1. Start the stack
+
+```bash
+# From this directory (client/internal/metrics/infra)
+INFLUXDB_ADMIN_TOKEN=admin123 INFLUXDB_ADMIN_PASSWORD=admin123 GRAFANA_ADMIN_PASSWORD=admin123 \
+ docker compose up -d
+```
+
+(`admin123` are throwaway local credentials — fine for offline analysis.)
+
+### 2. Clear any previous data
+
+So you only see this bundle:
+
+```bash
+docker exec influxdb influx delete --org netbird --bucket metrics --token admin123 \
+ --start 1970-01-01T00:00:00Z --stop 2100-01-01T00:00:00Z
+```
+
+### 3. Import the bundle's metrics.txt
+
+InfluxDB is not exposed on the host, so import inside the container:
+
+```bash
+docker cp /path/to/bundle/metrics.txt influxdb:/tmp/m.txt
+docker exec influxdb influx write --org netbird --bucket metrics --precision ns \
+ --token admin123 --file /tmp/m.txt
+```
+
+Re-importing the same file is idempotent (same measurement+tags+timestamp overwrites).
+
+### 4. View the dashboards
+
+Grafana on http://localhost:3001 (login `admin` / `admin123`), datasource pre-provisioned:
+
+- **Where sync time goes:** http://localhost:3001/d/netbird-sync-phases/netbird-sync-phases-where-time-goes
+- **General client metrics:** http://localhost:3001/d/netbird-influxdb-metrics
+
+**Set the time range** to cover the bundle's timestamps (e.g. "Last 7 days" or an absolute range matching when the bundle was taken) — with the default short range the panels look empty.
+
+Bundles are distinguishable by the `version` tag; add a tag at import time (e.g. `sed 's/^netbird_\([a-z_]*\),/netbird_\1,bundle=mycase,/' metrics.txt`) if you want to compare several side by side.
\ No newline at end of file
diff --git a/client/internal/metrics/infra/grafana/provisioning/dashboards/json/netbird-sync-phases.json b/client/internal/metrics/infra/grafana/provisioning/dashboards/json/netbird-sync-phases.json
new file mode 100644
index 000000000..69dbac0ae
--- /dev/null
+++ b/client/internal/metrics/infra/grafana/provisioning/dashboards/json/netbird-sync-phases.json
@@ -0,0 +1,259 @@
+{
+ "annotations": {
+ "list": []
+ },
+ "editable": true,
+ "fiscalYearStartMonth": 0,
+ "graphTooltip": 1,
+ "links": [],
+ "refresh": "",
+ "schemaVersion": 39,
+ "tags": [
+ "netbird",
+ "sync"
+ ],
+ "templating": {
+ "list": [
+ {
+ "current": {
+ "text": "All",
+ "value": "$__all"
+ },
+ "datasource": {
+ "type": "influxdb",
+ "uid": "influxdb"
+ },
+ "definition": "import \"influxdata/influxdb/schema\"\nschema.tagValues(bucket: \"metrics\", tag: \"version\")",
+ "includeAll": true,
+ "label": "version",
+ "multi": true,
+ "name": "version",
+ "query": "import \"influxdata/influxdb/schema\"\nschema.tagValues(bucket: \"metrics\", tag: \"version\")",
+ "refresh": 2,
+ "type": "query",
+ "allValue": ".*"
+ }
+ ]
+ },
+ "time": {
+ "from": "now-2d",
+ "to": "now"
+ },
+ "timepicker": {},
+ "timezone": "",
+ "title": "NetBird Sync Phases (where time goes)",
+ "uid": "netbird-sync-phases",
+ "version": 1,
+ "panels": [
+ {
+ "id": 1,
+ "title": "Time per phase over time (stacked, ms)",
+ "type": "timeseries",
+ "datasource": {
+ "type": "influxdb",
+ "uid": "influxdb"
+ },
+ "gridPos": {
+ "h": 10,
+ "w": 24,
+ "x": 0,
+ "y": 0
+ },
+ "fieldConfig": {
+ "defaults": {
+ "unit": "ms",
+ "custom": {
+ "drawStyle": "bars",
+ "stacking": {
+ "mode": "normal",
+ "group": "A"
+ },
+ "fillOpacity": 80,
+ "lineWidth": 0
+ }
+ },
+ "overrides": []
+ },
+ "options": {
+ "legend": {
+ "displayMode": "table",
+ "placement": "right",
+ "calcs": [
+ "max",
+ "mean"
+ ]
+ },
+ "tooltip": {
+ "mode": "multi",
+ "sort": "desc"
+ }
+ },
+ "targets": [
+ {
+ "refId": "A",
+ "datasource": {
+ "type": "influxdb",
+ "uid": "influxdb"
+ },
+ "query": "from(bucket: \"metrics\")\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\n |> filter(fn: (r) => r._measurement == \"netbird_sync_phase\" and r._field == \"duration_seconds\")\n |> filter(fn: (r) => r.version =~ /${version:regex}/)\n |> map(fn: (r) => ({ r with _value: r._value * 1000.0 }))\n |> keep(columns: [\"_time\", \"_value\", \"phase\"])\n |> group(columns: [\"phase\"])"
+ }
+ ]
+ },
+ {
+ "id": 2,
+ "title": "p95 per phase (ms)",
+ "type": "bargauge",
+ "datasource": {
+ "type": "influxdb",
+ "uid": "influxdb"
+ },
+ "gridPos": {
+ "h": 11,
+ "w": 12,
+ "x": 0,
+ "y": 10
+ },
+ "fieldConfig": {
+ "defaults": {
+ "unit": "ms",
+ "color": {
+ "mode": "continuous-GrYlRd"
+ }
+ },
+ "overrides": []
+ },
+ "options": {
+ "displayMode": "gradient",
+ "orientation": "horizontal",
+ "reduceOptions": {
+ "calcs": [
+ "lastNotNull"
+ ],
+ "fields": "",
+ "values": false
+ },
+ "showUnfilled": true
+ },
+ "targets": [
+ {
+ "refId": "A",
+ "datasource": {
+ "type": "influxdb",
+ "uid": "influxdb"
+ },
+ "query": "from(bucket: \"metrics\")\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\n |> filter(fn: (r) => r._measurement == \"netbird_sync_phase\" and r._field == \"duration_seconds\")\n |> filter(fn: (r) => r.version =~ /${version:regex}/)\n |> map(fn: (r) => ({ r with _value: r._value * 1000.0 }))\n |> group(columns: [\"phase\"])\n |> quantile(q: 0.95)\n |> group()\n |> sort(columns: [\"_value\"], desc: true)"
+ }
+ ]
+ },
+ {
+ "id": 3,
+ "title": "Per-phase stats (ms): mean / p95 / max",
+ "type": "table",
+ "datasource": {
+ "type": "influxdb",
+ "uid": "influxdb"
+ },
+ "gridPos": {
+ "h": 11,
+ "w": 12,
+ "x": 12,
+ "y": 10
+ },
+ "fieldConfig": {
+ "defaults": {
+ "unit": "ms"
+ },
+ "overrides": []
+ },
+ "options": {
+ "showHeader": true,
+ "sortBy": [
+ {
+ "displayName": "max",
+ "desc": true
+ }
+ ]
+ },
+ "transformations": [
+ {
+ "id": "merge",
+ "options": {}
+ }
+ ],
+ "targets": [
+ {
+ "refId": "mean",
+ "datasource": {
+ "type": "influxdb",
+ "uid": "influxdb"
+ },
+ "query": "from(bucket: \"metrics\")\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\n |> filter(fn: (r) => r._measurement == \"netbird_sync_phase\" and r._field == \"duration_seconds\")\n |> filter(fn: (r) => r.version =~ /${version:regex}/)\n |> map(fn: (r) => ({ r with _value: r._value * 1000.0 }))\n |> group(columns: [\"phase\"])\n |> mean()\n |> group()\n |> keep(columns: [\"phase\", \"_value\"])\n |> rename(columns: {_value: \"mean\"})"
+ },
+ {
+ "refId": "p95",
+ "datasource": {
+ "type": "influxdb",
+ "uid": "influxdb"
+ },
+ "query": "from(bucket: \"metrics\")\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\n |> filter(fn: (r) => r._measurement == \"netbird_sync_phase\" and r._field == \"duration_seconds\")\n |> filter(fn: (r) => r.version =~ /${version:regex}/)\n |> map(fn: (r) => ({ r with _value: r._value * 1000.0 }))\n |> group(columns: [\"phase\"])\n |> quantile(q: 0.95)\n |> group()\n |> keep(columns: [\"phase\", \"_value\"])\n |> rename(columns: {_value: \"p95\"})"
+ },
+ {
+ "refId": "max",
+ "datasource": {
+ "type": "influxdb",
+ "uid": "influxdb"
+ },
+ "query": "from(bucket: \"metrics\")\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\n |> filter(fn: (r) => r._measurement == \"netbird_sync_phase\" and r._field == \"duration_seconds\")\n |> filter(fn: (r) => r.version =~ /${version:regex}/)\n |> map(fn: (r) => ({ r with _value: r._value * 1000.0 }))\n |> group(columns: [\"phase\"])\n |> max()\n |> group()\n |> keep(columns: [\"phase\", \"_value\"])\n |> rename(columns: {_value: \"max\"})"
+ }
+ ]
+ },
+ {
+ "id": 4,
+ "title": "Total sync duration (netbird_sync, ms) \u2014 reference",
+ "type": "timeseries",
+ "datasource": {
+ "type": "influxdb",
+ "uid": "influxdb"
+ },
+ "gridPos": {
+ "h": 8,
+ "w": 24,
+ "x": 0,
+ "y": 21
+ },
+ "fieldConfig": {
+ "defaults": {
+ "unit": "ms",
+ "custom": {
+ "drawStyle": "points",
+ "pointSize": 5
+ }
+ },
+ "overrides": []
+ },
+ "options": {
+ "legend": {
+ "displayMode": "table",
+ "placement": "right",
+ "calcs": [
+ "max",
+ "mean"
+ ]
+ },
+ "tooltip": {
+ "mode": "single"
+ }
+ },
+ "targets": [
+ {
+ "refId": "A",
+ "datasource": {
+ "type": "influxdb",
+ "uid": "influxdb"
+ },
+ "query": "from(bucket: \"metrics\")\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\n |> filter(fn: (r) => r._measurement == \"netbird_sync\" and r._field == \"duration_seconds\")\n |> filter(fn: (r) => r.version =~ /${version:regex}/)\n |> map(fn: (r) => ({ r with _value: r._value * 1000.0 }))\n |> keep(columns: [\"_time\", \"_value\", \"version\"])\n |> group(columns: [\"version\"])"
+ }
+ ]
+ }
+ ]
+}
\ No newline at end of file
diff --git a/client/internal/metrics/infra/ingest/main.go b/client/internal/metrics/infra/ingest/main.go
index a5031a873..623a17e4d 100644
--- a/client/internal/metrics/infra/ingest/main.go
+++ b/client/internal/metrics/infra/ingest/main.go
@@ -59,6 +59,19 @@ var allowedMeasurements = map[string]measurementSpec{
"peer_id": true,
},
},
+ "netbird_sync_phase": {
+ allowedFields: map[string]bool{
+ "duration_seconds": true,
+ },
+ allowedTags: map[string]bool{
+ "deployment_type": true,
+ "version": true,
+ "os": true,
+ "arch": true,
+ "peer_id": true,
+ "phase": true,
+ },
+ },
"netbird_login": {
allowedFields: map[string]bool{
"duration_seconds": true,
diff --git a/client/internal/metrics/metrics.go b/client/internal/metrics/metrics.go
index 1c30b1089..d07a99a2d 100644
--- a/client/internal/metrics/metrics.go
+++ b/client/internal/metrics/metrics.go
@@ -56,6 +56,9 @@ type metricsImplementation interface {
// RecordSyncDuration records how long it took to process a sync message
RecordSyncDuration(ctx context.Context, agentInfo AgentInfo, duration time.Duration)
+ // RecordSyncPhase records how long a single sub-phase of sync processing took
+ RecordSyncPhase(ctx context.Context, agentInfo AgentInfo, phase string, duration time.Duration)
+
// RecordLoginDuration records how long the login to management took
RecordLoginDuration(ctx context.Context, agentInfo AgentInfo, duration time.Duration, success bool)
@@ -127,6 +130,18 @@ func (c *ClientMetrics) RecordSyncDuration(ctx context.Context, duration time.Du
c.impl.RecordSyncDuration(ctx, agentInfo, duration)
}
+// RecordSyncPhase records the duration of a single sub-phase of sync processing
+func (c *ClientMetrics) RecordSyncPhase(ctx context.Context, phase string, duration time.Duration) {
+ if c == nil {
+ return
+ }
+ c.mu.RLock()
+ agentInfo := c.agentInfo
+ c.mu.RUnlock()
+
+ c.impl.RecordSyncPhase(ctx, agentInfo, phase, duration)
+}
+
// RecordLoginDuration records how long the login to management server took
func (c *ClientMetrics) RecordLoginDuration(ctx context.Context, duration time.Duration, success bool) {
if c == nil {
diff --git a/client/internal/metrics/push_test.go b/client/internal/metrics/push_test.go
index 20a509da1..43c1b2c06 100644
--- a/client/internal/metrics/push_test.go
+++ b/client/internal/metrics/push_test.go
@@ -70,6 +70,9 @@ func (m *mockMetrics) RecordConnectionStages(_ context.Context, _ AgentInfo, _ s
func (m *mockMetrics) RecordSyncDuration(_ context.Context, _ AgentInfo, _ time.Duration) {
}
+func (m *mockMetrics) RecordSyncPhase(_ context.Context, _ AgentInfo, _ string, _ time.Duration) {
+}
+
func (m *mockMetrics) RecordLoginDuration(_ context.Context, _ AgentInfo, _ time.Duration, _ bool) {
}
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 3e5c56dd2..e48ac333c 100644
--- a/client/internal/peer/status.go
+++ b/client/internal/peer/status.go
@@ -192,6 +192,7 @@ 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
@@ -244,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
}
@@ -906,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
}
@@ -1018,24 +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)
}
+ relayMgr := d.relayMgr
// extend the list of stun, turn servers with the relay server connections
relayStates := slices.Clone(d.relayStates)
+ d.muxRelays.RUnlock()
- states := d.relayMgr.RelayStates()
+ 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 := d.relayMgr.RelayConnectError(); connErr != nil {
+ if connErr := relayMgr.RelayConnectError(); connErr != nil {
err = connErr
}
- for _, r := range d.relayMgr.ServerURLs() {
+ for _, r := range relayMgr.ServerURLs() {
relayStates = append(relayStates, relay.ProbeResult{
URI: r,
Err: err,
diff --git a/client/internal/profilemanager/config.go b/client/internal/profilemanager/config.go
index b0c7fd470..5a71a981e 100644
--- a/client/internal/profilemanager/config.go
+++ b/client/internal/profilemanager/config.go
@@ -108,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
@@ -270,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)
@@ -419,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 {
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/routemanager/dnsinterceptor/handler.go b/client/internal/routemanager/dnsinterceptor/handler.go
index e25cc2a5c..b784cc274 100644
--- a/client/internal/routemanager/dnsinterceptor/handler.go
+++ b/client/internal/routemanager/dnsinterceptor/handler.go
@@ -226,12 +226,11 @@ func (d *DnsInterceptor) ServeDNS(w dns.ResponseWriter, r *dns.Msg) {
return
}
- // pass if non A/AAAA query
- if r.Question[0].Qtype != dns.TypeA && r.Question[0].Qtype != dns.TypeAAAA {
- d.continueToNextHandler(w, r, logger, "non A/AAAA query")
- return
- }
-
+ // All query types for an intercepted domain are forwarded to the peer's
+ // DNS forwarder, which owns the name. Falling through to the system
+ // resolver would let it answer NXDOMAIN for a name it isn't authoritative
+ // for, poisoning the whole name (including the A/AAAA records the route
+ // does serve). The forwarder answers NODATA for types it cannot resolve.
d.mu.RLock()
peerKey := d.currentPeerKey
d.mu.RUnlock()
@@ -251,6 +250,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 +267,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
@@ -278,19 +292,6 @@ func (d *DnsInterceptor) writeDNSError(w dns.ResponseWriter, r *dns.Msg, logger
}
}
-// continueToNextHandler signals the handler chain to try the next handler
-func (d *DnsInterceptor) continueToNextHandler(w dns.ResponseWriter, r *dns.Msg, logger *log.Entry, reason string) {
- logger.Tracef("continuing to next handler for domain=%s reason=%s", r.Question[0].Name, reason)
-
- resp := new(dns.Msg)
- resp.SetRcode(r, dns.RcodeNameError)
- // Set Zero bit to signal handler chain to continue
- resp.MsgHdr.Zero = true
- if err := w.WriteMsg(resp); err != nil {
- logger.Errorf("failed writing DNS continue response: %v", err)
- }
-}
-
func (d *DnsInterceptor) getUpstreamIP(peerKey string) (netip.Addr, error) {
peerAllowedIP, exists := d.peerStore.AllowedIP(peerKey)
if !exists {
diff --git a/client/internal/routemanager/manager.go b/client/internal/routemanager/manager.go
index 0edf4607f..22458d575 100644
--- a/client/internal/routemanager/manager.go
+++ b/client/internal/routemanager/manager.go
@@ -333,6 +333,8 @@ func (m *DefaultManager) Stop(stateManager *statemanager.Manager) {
}
}
+ m.notifier.Close()
+
m.mux.Lock()
defer m.mux.Unlock()
m.clientRoutes = nil
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/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_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/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/proto/daemon.pb.go b/client/proto/daemon.pb.go
index 6b5a37658..488b0186c 100644
--- a/client/proto/daemon.pb.go
+++ b/client/proto/daemon.pb.go
@@ -3954,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
}
@@ -4006,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
}
@@ -4041,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"`
@@ -4397,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
}
@@ -4449,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
}
@@ -4484,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)
}
@@ -4506,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 {
@@ -4519,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 {
@@ -4537,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)
}
@@ -4556,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 {
@@ -4569,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 {
@@ -4581,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)
}
@@ -4593,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 {
@@ -4606,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 {
@@ -4625,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)
}
@@ -4637,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 {
@@ -4650,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 {
@@ -4664,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)
}
@@ -4682,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 {
@@ -4695,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 {
@@ -4712,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
@@ -4720,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)
}
@@ -4732,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 {
@@ -4745,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)
}
@@ -4770,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 {
@@ -4783,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 {
@@ -4800,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"`
@@ -4810,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)
}
@@ -4822,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 {
@@ -4835,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 {
@@ -4860,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)
}
@@ -4872,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 {
@@ -4885,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 {
@@ -4896,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)
}
@@ -4908,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 {
@@ -4921,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 {
@@ -4935,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)
}
@@ -4947,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 {
@@ -4960,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 {
@@ -4998,7 +5158,7 @@ type MDMManagedFieldsViolation struct {
func (x *MDMManagedFieldsViolation) Reset() {
*x = MDMManagedFieldsViolation{}
- mi := &file_daemon_proto_msgTypes[71]
+ mi := &file_daemon_proto_msgTypes[73]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -5010,7 +5170,7 @@ func (x *MDMManagedFieldsViolation) String() string {
func (*MDMManagedFieldsViolation) ProtoMessage() {}
func (x *MDMManagedFieldsViolation) ProtoReflect() protoreflect.Message {
- mi := &file_daemon_proto_msgTypes[71]
+ mi := &file_daemon_proto_msgTypes[73]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -5023,7 +5183,7 @@ func (x *MDMManagedFieldsViolation) ProtoReflect() protoreflect.Message {
// Deprecated: Use MDMManagedFieldsViolation.ProtoReflect.Descriptor instead.
func (*MDMManagedFieldsViolation) Descriptor() ([]byte, []int) {
- return file_daemon_proto_rawDescGZIP(), []int{71}
+ return file_daemon_proto_rawDescGZIP(), []int{73}
}
func (x *MDMManagedFieldsViolation) GetFields() []string {
@@ -5041,7 +5201,7 @@ type TriggerUpdateRequest struct {
func (x *TriggerUpdateRequest) Reset() {
*x = TriggerUpdateRequest{}
- mi := &file_daemon_proto_msgTypes[72]
+ mi := &file_daemon_proto_msgTypes[74]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -5053,7 +5213,7 @@ func (x *TriggerUpdateRequest) String() string {
func (*TriggerUpdateRequest) ProtoMessage() {}
func (x *TriggerUpdateRequest) ProtoReflect() protoreflect.Message {
- mi := &file_daemon_proto_msgTypes[72]
+ mi := &file_daemon_proto_msgTypes[74]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -5066,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{72}
+ return file_daemon_proto_rawDescGZIP(), []int{74}
}
type TriggerUpdateResponse struct {
@@ -5079,7 +5239,7 @@ type TriggerUpdateResponse struct {
func (x *TriggerUpdateResponse) Reset() {
*x = TriggerUpdateResponse{}
- mi := &file_daemon_proto_msgTypes[73]
+ mi := &file_daemon_proto_msgTypes[75]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -5091,7 +5251,7 @@ func (x *TriggerUpdateResponse) String() string {
func (*TriggerUpdateResponse) ProtoMessage() {}
func (x *TriggerUpdateResponse) ProtoReflect() protoreflect.Message {
- mi := &file_daemon_proto_msgTypes[73]
+ mi := &file_daemon_proto_msgTypes[75]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -5104,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{73}
+ return file_daemon_proto_rawDescGZIP(), []int{75}
}
func (x *TriggerUpdateResponse) GetSuccess() bool {
@@ -5132,7 +5292,7 @@ type GetPeerSSHHostKeyRequest struct {
func (x *GetPeerSSHHostKeyRequest) Reset() {
*x = GetPeerSSHHostKeyRequest{}
- mi := &file_daemon_proto_msgTypes[74]
+ mi := &file_daemon_proto_msgTypes[76]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -5144,7 +5304,7 @@ func (x *GetPeerSSHHostKeyRequest) String() string {
func (*GetPeerSSHHostKeyRequest) ProtoMessage() {}
func (x *GetPeerSSHHostKeyRequest) ProtoReflect() protoreflect.Message {
- mi := &file_daemon_proto_msgTypes[74]
+ mi := &file_daemon_proto_msgTypes[76]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -5157,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{74}
+ return file_daemon_proto_rawDescGZIP(), []int{76}
}
func (x *GetPeerSSHHostKeyRequest) GetPeerAddress() string {
@@ -5184,7 +5344,7 @@ type GetPeerSSHHostKeyResponse struct {
func (x *GetPeerSSHHostKeyResponse) Reset() {
*x = GetPeerSSHHostKeyResponse{}
- mi := &file_daemon_proto_msgTypes[75]
+ mi := &file_daemon_proto_msgTypes[77]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -5196,7 +5356,7 @@ func (x *GetPeerSSHHostKeyResponse) String() string {
func (*GetPeerSSHHostKeyResponse) ProtoMessage() {}
func (x *GetPeerSSHHostKeyResponse) ProtoReflect() protoreflect.Message {
- mi := &file_daemon_proto_msgTypes[75]
+ mi := &file_daemon_proto_msgTypes[77]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -5209,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{75}
+ return file_daemon_proto_rawDescGZIP(), []int{77}
}
func (x *GetPeerSSHHostKeyResponse) GetSshHostKey() []byte {
@@ -5251,7 +5411,7 @@ type RequestJWTAuthRequest struct {
func (x *RequestJWTAuthRequest) Reset() {
*x = RequestJWTAuthRequest{}
- mi := &file_daemon_proto_msgTypes[76]
+ mi := &file_daemon_proto_msgTypes[78]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -5263,7 +5423,7 @@ func (x *RequestJWTAuthRequest) String() string {
func (*RequestJWTAuthRequest) ProtoMessage() {}
func (x *RequestJWTAuthRequest) ProtoReflect() protoreflect.Message {
- mi := &file_daemon_proto_msgTypes[76]
+ mi := &file_daemon_proto_msgTypes[78]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -5276,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{76}
+ return file_daemon_proto_rawDescGZIP(), []int{78}
}
func (x *RequestJWTAuthRequest) GetHint() string {
@@ -5309,7 +5469,7 @@ type RequestJWTAuthResponse struct {
func (x *RequestJWTAuthResponse) Reset() {
*x = RequestJWTAuthResponse{}
- mi := &file_daemon_proto_msgTypes[77]
+ mi := &file_daemon_proto_msgTypes[79]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -5321,7 +5481,7 @@ func (x *RequestJWTAuthResponse) String() string {
func (*RequestJWTAuthResponse) ProtoMessage() {}
func (x *RequestJWTAuthResponse) ProtoReflect() protoreflect.Message {
- mi := &file_daemon_proto_msgTypes[77]
+ mi := &file_daemon_proto_msgTypes[79]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -5334,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{77}
+ return file_daemon_proto_rawDescGZIP(), []int{79}
}
func (x *RequestJWTAuthResponse) GetVerificationURI() string {
@@ -5399,7 +5559,7 @@ type WaitJWTTokenRequest struct {
func (x *WaitJWTTokenRequest) Reset() {
*x = WaitJWTTokenRequest{}
- mi := &file_daemon_proto_msgTypes[78]
+ mi := &file_daemon_proto_msgTypes[80]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -5411,7 +5571,7 @@ func (x *WaitJWTTokenRequest) String() string {
func (*WaitJWTTokenRequest) ProtoMessage() {}
func (x *WaitJWTTokenRequest) ProtoReflect() protoreflect.Message {
- mi := &file_daemon_proto_msgTypes[78]
+ mi := &file_daemon_proto_msgTypes[80]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -5424,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{78}
+ return file_daemon_proto_rawDescGZIP(), []int{80}
}
func (x *WaitJWTTokenRequest) GetDeviceCode() string {
@@ -5456,7 +5616,7 @@ type WaitJWTTokenResponse struct {
func (x *WaitJWTTokenResponse) Reset() {
*x = WaitJWTTokenResponse{}
- mi := &file_daemon_proto_msgTypes[79]
+ mi := &file_daemon_proto_msgTypes[81]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -5468,7 +5628,7 @@ func (x *WaitJWTTokenResponse) String() string {
func (*WaitJWTTokenResponse) ProtoMessage() {}
func (x *WaitJWTTokenResponse) ProtoReflect() protoreflect.Message {
- mi := &file_daemon_proto_msgTypes[79]
+ mi := &file_daemon_proto_msgTypes[81]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -5481,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{79}
+ return file_daemon_proto_rawDescGZIP(), []int{81}
}
func (x *WaitJWTTokenResponse) GetToken() string {
@@ -5514,7 +5674,7 @@ type StartCPUProfileRequest struct {
func (x *StartCPUProfileRequest) Reset() {
*x = StartCPUProfileRequest{}
- mi := &file_daemon_proto_msgTypes[80]
+ mi := &file_daemon_proto_msgTypes[82]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -5526,7 +5686,7 @@ func (x *StartCPUProfileRequest) String() string {
func (*StartCPUProfileRequest) ProtoMessage() {}
func (x *StartCPUProfileRequest) ProtoReflect() protoreflect.Message {
- mi := &file_daemon_proto_msgTypes[80]
+ mi := &file_daemon_proto_msgTypes[82]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -5539,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{80}
+ return file_daemon_proto_rawDescGZIP(), []int{82}
}
// StartCPUProfileResponse confirms CPU profiling has started
@@ -5551,7 +5711,7 @@ type StartCPUProfileResponse struct {
func (x *StartCPUProfileResponse) Reset() {
*x = StartCPUProfileResponse{}
- mi := &file_daemon_proto_msgTypes[81]
+ mi := &file_daemon_proto_msgTypes[83]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -5563,7 +5723,7 @@ func (x *StartCPUProfileResponse) String() string {
func (*StartCPUProfileResponse) ProtoMessage() {}
func (x *StartCPUProfileResponse) ProtoReflect() protoreflect.Message {
- mi := &file_daemon_proto_msgTypes[81]
+ mi := &file_daemon_proto_msgTypes[83]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -5576,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{81}
+ return file_daemon_proto_rawDescGZIP(), []int{83}
}
// StopCPUProfileRequest for stopping CPU profiling
@@ -5588,7 +5748,7 @@ type StopCPUProfileRequest struct {
func (x *StopCPUProfileRequest) Reset() {
*x = StopCPUProfileRequest{}
- mi := &file_daemon_proto_msgTypes[82]
+ mi := &file_daemon_proto_msgTypes[84]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -5600,7 +5760,7 @@ func (x *StopCPUProfileRequest) String() string {
func (*StopCPUProfileRequest) ProtoMessage() {}
func (x *StopCPUProfileRequest) ProtoReflect() protoreflect.Message {
- mi := &file_daemon_proto_msgTypes[82]
+ mi := &file_daemon_proto_msgTypes[84]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -5613,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{82}
+ return file_daemon_proto_rawDescGZIP(), []int{84}
}
// StopCPUProfileResponse confirms CPU profiling has stopped
@@ -5625,7 +5785,7 @@ type StopCPUProfileResponse struct {
func (x *StopCPUProfileResponse) Reset() {
*x = StopCPUProfileResponse{}
- mi := &file_daemon_proto_msgTypes[83]
+ mi := &file_daemon_proto_msgTypes[85]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -5637,7 +5797,7 @@ func (x *StopCPUProfileResponse) String() string {
func (*StopCPUProfileResponse) ProtoMessage() {}
func (x *StopCPUProfileResponse) ProtoReflect() protoreflect.Message {
- mi := &file_daemon_proto_msgTypes[83]
+ mi := &file_daemon_proto_msgTypes[85]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -5650,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{83}
+ return file_daemon_proto_rawDescGZIP(), []int{85}
}
type InstallerResultRequest struct {
@@ -5661,7 +5821,7 @@ type InstallerResultRequest struct {
func (x *InstallerResultRequest) Reset() {
*x = InstallerResultRequest{}
- mi := &file_daemon_proto_msgTypes[84]
+ mi := &file_daemon_proto_msgTypes[86]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -5673,7 +5833,7 @@ func (x *InstallerResultRequest) String() string {
func (*InstallerResultRequest) ProtoMessage() {}
func (x *InstallerResultRequest) ProtoReflect() protoreflect.Message {
- mi := &file_daemon_proto_msgTypes[84]
+ mi := &file_daemon_proto_msgTypes[86]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -5686,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{84}
+ return file_daemon_proto_rawDescGZIP(), []int{86}
}
type InstallerResultResponse struct {
@@ -5699,7 +5859,7 @@ type InstallerResultResponse struct {
func (x *InstallerResultResponse) Reset() {
*x = InstallerResultResponse{}
- mi := &file_daemon_proto_msgTypes[85]
+ mi := &file_daemon_proto_msgTypes[87]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -5711,7 +5871,7 @@ func (x *InstallerResultResponse) String() string {
func (*InstallerResultResponse) ProtoMessage() {}
func (x *InstallerResultResponse) ProtoReflect() protoreflect.Message {
- mi := &file_daemon_proto_msgTypes[85]
+ mi := &file_daemon_proto_msgTypes[87]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -5724,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{85}
+ return file_daemon_proto_rawDescGZIP(), []int{87}
}
func (x *InstallerResultResponse) GetSuccess() bool {
@@ -5757,7 +5917,7 @@ type ExposeServiceRequest struct {
func (x *ExposeServiceRequest) Reset() {
*x = ExposeServiceRequest{}
- mi := &file_daemon_proto_msgTypes[86]
+ mi := &file_daemon_proto_msgTypes[88]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -5769,7 +5929,7 @@ func (x *ExposeServiceRequest) String() string {
func (*ExposeServiceRequest) ProtoMessage() {}
func (x *ExposeServiceRequest) ProtoReflect() protoreflect.Message {
- mi := &file_daemon_proto_msgTypes[86]
+ mi := &file_daemon_proto_msgTypes[88]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -5782,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{86}
+ return file_daemon_proto_rawDescGZIP(), []int{88}
}
func (x *ExposeServiceRequest) GetPort() uint32 {
@@ -5853,7 +6013,7 @@ type ExposeServiceEvent struct {
func (x *ExposeServiceEvent) Reset() {
*x = ExposeServiceEvent{}
- mi := &file_daemon_proto_msgTypes[87]
+ mi := &file_daemon_proto_msgTypes[89]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -5865,7 +6025,7 @@ func (x *ExposeServiceEvent) String() string {
func (*ExposeServiceEvent) ProtoMessage() {}
func (x *ExposeServiceEvent) ProtoReflect() protoreflect.Message {
- mi := &file_daemon_proto_msgTypes[87]
+ mi := &file_daemon_proto_msgTypes[89]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -5878,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{87}
+ return file_daemon_proto_rawDescGZIP(), []int{89}
}
func (x *ExposeServiceEvent) GetEvent() isExposeServiceEvent_Event {
@@ -5919,7 +6079,7 @@ type ExposeServiceReady struct {
func (x *ExposeServiceReady) Reset() {
*x = ExposeServiceReady{}
- mi := &file_daemon_proto_msgTypes[88]
+ mi := &file_daemon_proto_msgTypes[90]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -5931,7 +6091,7 @@ func (x *ExposeServiceReady) String() string {
func (*ExposeServiceReady) ProtoMessage() {}
func (x *ExposeServiceReady) ProtoReflect() protoreflect.Message {
- mi := &file_daemon_proto_msgTypes[88]
+ mi := &file_daemon_proto_msgTypes[90]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -5944,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{88}
+ return file_daemon_proto_rawDescGZIP(), []int{90}
}
func (x *ExposeServiceReady) GetServiceName() string {
@@ -5989,7 +6149,7 @@ type StartCaptureRequest struct {
func (x *StartCaptureRequest) Reset() {
*x = StartCaptureRequest{}
- mi := &file_daemon_proto_msgTypes[89]
+ mi := &file_daemon_proto_msgTypes[91]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -6001,7 +6161,7 @@ func (x *StartCaptureRequest) String() string {
func (*StartCaptureRequest) ProtoMessage() {}
func (x *StartCaptureRequest) ProtoReflect() protoreflect.Message {
- mi := &file_daemon_proto_msgTypes[89]
+ mi := &file_daemon_proto_msgTypes[91]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -6014,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{89}
+ return file_daemon_proto_rawDescGZIP(), []int{91}
}
func (x *StartCaptureRequest) GetTextOutput() bool {
@@ -6068,7 +6228,7 @@ type CapturePacket struct {
func (x *CapturePacket) Reset() {
*x = CapturePacket{}
- mi := &file_daemon_proto_msgTypes[90]
+ mi := &file_daemon_proto_msgTypes[92]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -6080,7 +6240,7 @@ func (x *CapturePacket) String() string {
func (*CapturePacket) ProtoMessage() {}
func (x *CapturePacket) ProtoReflect() protoreflect.Message {
- mi := &file_daemon_proto_msgTypes[90]
+ mi := &file_daemon_proto_msgTypes[92]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -6093,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{90}
+ return file_daemon_proto_rawDescGZIP(), []int{92}
}
func (x *CapturePacket) GetData() []byte {
@@ -6114,7 +6274,7 @@ type StartBundleCaptureRequest struct {
func (x *StartBundleCaptureRequest) Reset() {
*x = StartBundleCaptureRequest{}
- mi := &file_daemon_proto_msgTypes[91]
+ mi := &file_daemon_proto_msgTypes[93]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -6126,7 +6286,7 @@ func (x *StartBundleCaptureRequest) String() string {
func (*StartBundleCaptureRequest) ProtoMessage() {}
func (x *StartBundleCaptureRequest) ProtoReflect() protoreflect.Message {
- mi := &file_daemon_proto_msgTypes[91]
+ mi := &file_daemon_proto_msgTypes[93]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -6139,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{91}
+ return file_daemon_proto_rawDescGZIP(), []int{93}
}
func (x *StartBundleCaptureRequest) GetTimeout() *durationpb.Duration {
@@ -6157,7 +6317,7 @@ type StartBundleCaptureResponse struct {
func (x *StartBundleCaptureResponse) Reset() {
*x = StartBundleCaptureResponse{}
- mi := &file_daemon_proto_msgTypes[92]
+ mi := &file_daemon_proto_msgTypes[94]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -6169,7 +6329,7 @@ func (x *StartBundleCaptureResponse) String() string {
func (*StartBundleCaptureResponse) ProtoMessage() {}
func (x *StartBundleCaptureResponse) ProtoReflect() protoreflect.Message {
- mi := &file_daemon_proto_msgTypes[92]
+ mi := &file_daemon_proto_msgTypes[94]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -6182,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{92}
+ return file_daemon_proto_rawDescGZIP(), []int{94}
}
type StopBundleCaptureRequest struct {
@@ -6193,7 +6353,7 @@ type StopBundleCaptureRequest struct {
func (x *StopBundleCaptureRequest) Reset() {
*x = StopBundleCaptureRequest{}
- mi := &file_daemon_proto_msgTypes[93]
+ mi := &file_daemon_proto_msgTypes[95]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -6205,7 +6365,7 @@ func (x *StopBundleCaptureRequest) String() string {
func (*StopBundleCaptureRequest) ProtoMessage() {}
func (x *StopBundleCaptureRequest) ProtoReflect() protoreflect.Message {
- mi := &file_daemon_proto_msgTypes[93]
+ mi := &file_daemon_proto_msgTypes[95]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -6218,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{93}
+ return file_daemon_proto_rawDescGZIP(), []int{95}
}
type StopBundleCaptureResponse struct {
@@ -6229,7 +6389,7 @@ type StopBundleCaptureResponse struct {
func (x *StopBundleCaptureResponse) Reset() {
*x = StopBundleCaptureResponse{}
- mi := &file_daemon_proto_msgTypes[94]
+ mi := &file_daemon_proto_msgTypes[96]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -6241,7 +6401,7 @@ func (x *StopBundleCaptureResponse) String() string {
func (*StopBundleCaptureResponse) ProtoMessage() {}
func (x *StopBundleCaptureResponse) ProtoReflect() protoreflect.Message {
- mi := &file_daemon_proto_msgTypes[94]
+ mi := &file_daemon_proto_msgTypes[96]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -6254,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{94}
+ return file_daemon_proto_rawDescGZIP(), []int{96}
}
type PortInfo_Range struct {
@@ -6267,7 +6427,7 @@ type PortInfo_Range struct {
func (x *PortInfo_Range) Reset() {
*x = PortInfo_Range{}
- mi := &file_daemon_proto_msgTypes[96]
+ mi := &file_daemon_proto_msgTypes[98]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -6279,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[96]
+ mi := &file_daemon_proto_msgTypes[98]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -6672,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" +
@@ -6742,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" +
@@ -6869,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" +
@@ -6900,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" +
@@ -6927,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, 98)
+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
@@ -6993,53 +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
- (*MDMManagedFieldsViolation)(nil), // 75: daemon.MDMManagedFieldsViolation
- (*TriggerUpdateRequest)(nil), // 76: daemon.TriggerUpdateRequest
- (*TriggerUpdateResponse)(nil), // 77: daemon.TriggerUpdateResponse
- (*GetPeerSSHHostKeyRequest)(nil), // 78: daemon.GetPeerSSHHostKeyRequest
- (*GetPeerSSHHostKeyResponse)(nil), // 79: daemon.GetPeerSSHHostKeyResponse
- (*RequestJWTAuthRequest)(nil), // 80: daemon.RequestJWTAuthRequest
- (*RequestJWTAuthResponse)(nil), // 81: daemon.RequestJWTAuthResponse
- (*WaitJWTTokenRequest)(nil), // 82: daemon.WaitJWTTokenRequest
- (*WaitJWTTokenResponse)(nil), // 83: daemon.WaitJWTTokenResponse
- (*StartCPUProfileRequest)(nil), // 84: daemon.StartCPUProfileRequest
- (*StartCPUProfileResponse)(nil), // 85: daemon.StartCPUProfileResponse
- (*StopCPUProfileRequest)(nil), // 86: daemon.StopCPUProfileRequest
- (*StopCPUProfileResponse)(nil), // 87: daemon.StopCPUProfileResponse
- (*InstallerResultRequest)(nil), // 88: daemon.InstallerResultRequest
- (*InstallerResultResponse)(nil), // 89: daemon.InstallerResultResponse
- (*ExposeServiceRequest)(nil), // 90: daemon.ExposeServiceRequest
- (*ExposeServiceEvent)(nil), // 91: daemon.ExposeServiceEvent
- (*ExposeServiceReady)(nil), // 92: daemon.ExposeServiceReady
- (*StartCaptureRequest)(nil), // 93: daemon.StartCaptureRequest
- (*CapturePacket)(nil), // 94: daemon.CapturePacket
- (*StartBundleCaptureRequest)(nil), // 95: daemon.StartBundleCaptureRequest
- (*StartBundleCaptureResponse)(nil), // 96: daemon.StartBundleCaptureResponse
- (*StopBundleCaptureRequest)(nil), // 97: daemon.StopBundleCaptureRequest
- (*StopBundleCaptureResponse)(nil), // 98: daemon.StopBundleCaptureResponse
- nil, // 99: daemon.Network.ResolvedIPsEntry
- (*PortInfo_Range)(nil), // 100: daemon.PortInfo.Range
- nil, // 101: daemon.SystemEvent.MetadataEntry
- (*durationpb.Duration)(nil), // 102: google.protobuf.Duration
- (*timestamppb.Timestamp)(nil), // 103: google.protobuf.Timestamp
+ (*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{
- 102, // 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
- 103, // 2: daemon.PeerState.connStatusUpdate:type_name -> google.protobuf.Timestamp
- 103, // 3: daemon.PeerState.lastWireguardHandshake:type_name -> google.protobuf.Timestamp
- 102, // 4: daemon.PeerState.latency:type_name -> google.protobuf.Duration
+ 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
@@ -7050,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
- 99, // 15: daemon.Network.resolvedIPs:type_name -> daemon.Network.ResolvedIPsEntry
- 100, // 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
@@ -7062,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
- 103, // 27: daemon.SystemEvent.timestamp:type_name -> google.protobuf.Timestamp
- 101, // 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
- 102, // 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
- 92, // 33: daemon.ExposeServiceEvent.ready:type_name -> daemon.ExposeServiceReady
- 102, // 34: daemon.StartCaptureRequest.duration:type_name -> google.protobuf.Duration
- 102, // 35: daemon.StartBundleCaptureRequest.timeout:type_name -> google.protobuf.Duration
+ 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
@@ -7090,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
- 93, // 55: daemon.DaemonService.StartCapture:input_type -> daemon.StartCaptureRequest
- 95, // 56: daemon.DaemonService.StartBundleCapture:input_type -> daemon.StartBundleCaptureRequest
- 97, // 57: daemon.DaemonService.StopBundleCapture:input_type -> daemon.StopBundleCaptureRequest
+ 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
- 76, // 68: daemon.DaemonService.TriggerUpdate:input_type -> daemon.TriggerUpdateRequest
- 78, // 69: daemon.DaemonService.GetPeerSSHHostKey:input_type -> daemon.GetPeerSSHHostKeyRequest
- 80, // 70: daemon.DaemonService.RequestJWTAuth:input_type -> daemon.RequestJWTAuthRequest
- 82, // 71: daemon.DaemonService.WaitJWTToken:input_type -> daemon.WaitJWTTokenRequest
- 84, // 72: daemon.DaemonService.StartCPUProfile:input_type -> daemon.StartCPUProfileRequest
- 86, // 73: daemon.DaemonService.StopCPUProfile:input_type -> daemon.StopCPUProfileRequest
- 88, // 74: daemon.DaemonService.GetInstallerResult:input_type -> daemon.InstallerResultRequest
- 90, // 75: daemon.DaemonService.ExposeService:input_type -> daemon.ExposeServiceRequest
- 6, // 76: daemon.DaemonService.Login:output_type -> daemon.LoginResponse
- 8, // 77: daemon.DaemonService.WaitSSOLogin:output_type -> daemon.WaitSSOLoginResponse
- 10, // 78: daemon.DaemonService.Up:output_type -> daemon.UpResponse
- 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
- 94, // 94: daemon.DaemonService.StartCapture:output_type -> daemon.CapturePacket
- 96, // 95: daemon.DaemonService.StartBundleCapture:output_type -> daemon.StartBundleCaptureResponse
- 98, // 96: daemon.DaemonService.StopBundleCapture:output_type -> daemon.StopBundleCaptureResponse
- 55, // 97: daemon.DaemonService.SubscribeEvents:output_type -> daemon.SystemEvent
- 57, // 98: daemon.DaemonService.GetEvents:output_type -> daemon.GetEventsResponse
- 59, // 99: daemon.DaemonService.SwitchProfile:output_type -> daemon.SwitchProfileResponse
- 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
- 77, // 107: daemon.DaemonService.TriggerUpdate:output_type -> daemon.TriggerUpdateResponse
- 79, // 108: daemon.DaemonService.GetPeerSSHHostKey:output_type -> daemon.GetPeerSSHHostKeyResponse
- 81, // 109: daemon.DaemonService.RequestJWTAuth:output_type -> daemon.RequestJWTAuthResponse
- 83, // 110: daemon.DaemonService.WaitJWTToken:output_type -> daemon.WaitJWTTokenResponse
- 85, // 111: daemon.DaemonService.StartCPUProfile:output_type -> daemon.StartCPUProfileResponse
- 87, // 112: daemon.DaemonService.StopCPUProfile:output_type -> daemon.StopCPUProfileResponse
- 89, // 113: daemon.DaemonService.GetInstallerResult:output_type -> daemon.InstallerResultResponse
- 91, // 114: daemon.DaemonService.ExposeService:output_type -> daemon.ExposeServiceEvent
- 76, // [76:115] is the sub-list for method output_type
- 37, // [37:76] is the sub-list for method input_type
+ 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
@@ -7173,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[76].OneofWrappers = []any{}
- file_daemon_proto_msgTypes[87].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{}
@@ -7184,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: 98,
+ NumMessages: 100,
NumExtensions: 0,
NumServices: 1,
},
diff --git a/client/proto/daemon.proto b/client/proto/daemon.proto
index ea668f629..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) {}
@@ -625,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;
@@ -696,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;
@@ -719,6 +753,7 @@ message ListProfilesResponse {
message Profile {
string name = 1;
bool is_active = 2;
+ string id = 3;
}
message GetActiveProfileRequest {}
@@ -726,6 +761,7 @@ message GetActiveProfileRequest {}
message GetActiveProfileResponse {
string profileName = 1;
string username = 2;
+ string id = 3;
}
message LogoutRequest {
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/server.go b/client/server/server.go
index 32daf7718..3f6dabc56 100644
--- a/client/server/server.go
+++ b/client/server/server.go
@@ -78,7 +78,7 @@ type Server struct {
// 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
+ clientRunning bool
clientRunningChan chan struct{}
clientGiveUpChan chan struct{} // closed when connectWithRetryRuns goroutine exits
@@ -375,7 +375,7 @@ func (s *Server) SetConfig(callerCtx context.Context, msg *proto.SetConfigReques
return nil, err
}
- config, err := setConfigInputFromRequest(msg)
+ config, err := s.setConfigInputFromRequest(msg)
if err != nil {
return nil, err
}
@@ -398,17 +398,17 @@ func (s *Server) SetConfig(callerCtx context.Context, msg *proto.SetConfigReques
// field is its own optional case. Returns the resolved ConfigInput
// and a non-nil error only when the active profile file path cannot
// be determined.
-func setConfigInputFromRequest(msg *proto.SetConfigRequest) (profilemanager.ConfigInput, error) {
+func (s *Server) setConfigInputFromRequest(msg *proto.SetConfigRequest) (profilemanager.ConfigInput, error) {
var config profilemanager.ConfigInput
- profState := profilemanager.ActiveProfileState{
- Name: msg.ProfileName,
- Username: msg.Username,
- }
- profPath, err := profState.FilePath()
+ resolved, err := s.resolveProfileHandle(msg.ProfileName, msg.Username)
if err != nil {
- log.Errorf("failed to get active profile file path: %v", err)
- return config, fmt.Errorf("failed to get active profile file path: %w", err)
+ 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
@@ -535,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
}
}
@@ -568,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()
@@ -806,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
}
}
@@ -820,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 {
@@ -864,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.
@@ -906,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()
@@ -924,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.
@@ -988,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
@@ -1014,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)
}
@@ -1091,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)
}
}
@@ -1122,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)
@@ -1558,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)
@@ -1671,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.
@@ -1684,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.
@@ -1720,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,
}
@@ -1728,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()
@@ -1739,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
}
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
index 53232c70d..9818f9fdf 100644
--- a/client/server/setconfig_mdm_test.go
+++ b/client/server/setconfig_mdm_test.go
@@ -62,7 +62,7 @@ func setupServerWithProfile(t *testing.T) (s *Server, ctx context.Context, profN
pm := profilemanager.ServiceManager{}
require.NoError(t, pm.SetActiveProfileState(&profilemanager.ActiveProfileState{
- Name: profName,
+ ID: profilemanager.ID(profName),
Username: currUser.Username,
}))
@@ -107,9 +107,9 @@ func TestSetConfig_MDMReject_SingleField(t *testing.T) {
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,
+ mdm.KeyManagementURL: "https://mdm.example.com:443",
+ mdm.KeyBlockInbound: true,
+ mdm.KeyRosenpassEnabled: true,
}))
s, ctx, profName, username, _ := setupServerWithProfile(t)
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/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 5814ad9b4..40fb4169d 100644
--- a/client/ui/client_ui.go
+++ b/client/ui/client_ui.go
@@ -418,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)
}
@@ -645,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,
}
@@ -818,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 != "" {
@@ -1336,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
@@ -1367,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 {
@@ -1613,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 {
@@ -1813,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 d3db17855..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
@@ -727,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/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/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 2858d2044..9a57de1c9 100644
--- a/go.mod
+++ b/go.mod
@@ -78,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
@@ -145,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
@@ -177,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
@@ -271,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
@@ -341,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 1768ee069..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=
@@ -480,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=
@@ -488,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=
@@ -510,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=
@@ -542,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=
@@ -973,11 +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/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 9adf594cd..0d8fb3c47 100644
--- a/management/internals/controllers/network_map/controller/controller.go
+++ b/management/internals/controllers/network_map/controller/controller.go
@@ -497,7 +497,7 @@ func (c *Controller) BufferUpdateAffectedPeers(ctx context.Context, accountID st
c.accountManagerMetrics.CountUpdateAccountPeersTriggered(string(reason.Resource), string(reason.Operation))
}
- log.WithContext(ctx).Tracef("buffer updating %d affected peers for account %s from %s", len(peerIDs), accountID, util.GetCallerName())
+ 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{}),
@@ -585,66 +585,64 @@ func (b *bufferAffectedUpdate) setTimer(d time.Duration, f func()) {
b.next.Reset(d)
}
-func (c *Controller) GetValidatedPeerWithMap(ctx context.Context, isRequiresApproval bool, accountID string, peer *nbpeer.Peer) (*nbpeer.Peer, *types.NetworkMap, []*posture.Checks, int64, error) {
+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
diff --git a/management/internals/controllers/network_map/interface.go b/management/internals/controllers/network_map/interface.go
index dbdd87708..14b12aba6 100644
--- a/management/internals/controllers/network_map/interface.go
+++ b/management/internals/controllers/network_map/interface.go
@@ -23,7 +23,7 @@ type Controller interface {
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)
diff --git a/management/internals/controllers/network_map/interface_mock.go b/management/internals/controllers/network_map/interface_mock.go
index a67156719..bfff32e6f 100644
--- a/management/internals/controllers/network_map/interface_mock.go
+++ b/management/internals/controllers/network_map/interface_mock.go
@@ -127,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.
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/manager_test.go b/management/internals/modules/reverseproxy/service/manager/manager_test.go
index ace105b31..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
}
@@ -723,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)
@@ -1147,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)
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/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 3803866f4..fa06687d0 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, settings),
- 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 66a802312..9968427a4 100644
--- a/management/server/account.go
+++ b/management/server/account.go
@@ -1901,12 +1901,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(), netMap); 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)
}
@@ -1926,13 +1926,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
}
diff --git a/management/server/account/manager.go b/management/server/account/manager.go
index 2fdfdba5a..1e738c274 100644
--- a/management/server/account/manager.go
+++ b/management/server/account/manager.go
@@ -62,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, nmap *types.NetworkMap) 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)
@@ -70,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)
@@ -109,7 +109,7 @@ 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
+ 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
@@ -123,7 +123,7 @@ 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)
diff --git a/management/server/account/manager_mock.go b/management/server/account/manager_mock.go
index 0e06ebf91..274e4c683 100644
--- a/management/server/account/manager_mock.go
+++ b/management/server/account/manager_mock.go
@@ -80,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.
@@ -1289,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.
@@ -1321,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, nmap *types.NetworkMap) 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, nmap)
+ 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, nmap 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, nmap)
+ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "MarkPeerConnected", reflect.TypeOf((*MockManager)(nil).MarkPeerConnected), ctx, peerKey, accountID, sessionStartedAt, nmap)
}
// MarkPeerDisconnected mocks base method.
@@ -1584,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.
diff --git a/management/server/account_test.go b/management/server/account_test.go
index 51f079a57..585e267d5 100644
--- a/management/server/account_test.go
+++ b/management/server/account_test.go
@@ -84,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)
}
@@ -1092,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)
@@ -1156,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)
@@ -1504,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)
@@ -1826,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,
@@ -1836,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(), nil)
+ 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{
@@ -1882,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,
@@ -1907,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(), nil)
+ 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)
@@ -1916,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")
@@ -1927,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)
@@ -1935,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(), nil)
+ 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)
@@ -1956,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(), nil)
+ 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)
@@ -1980,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(), nil)
+ 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)
@@ -1990,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(), nil)
+ 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)
@@ -2017,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)
@@ -2052,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, nil)
+ errs <- manager.MarkPeerConnected(context.Background(), peerPubKey, accountID, token, nil)
}()
}
@@ -2080,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,
@@ -2093,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(), nil)
+ 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{}
@@ -3215,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
@@ -3276,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{
@@ -3444,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)},
@@ -3513,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)},
@@ -3908,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
index 56917905f..ae5b92f49 100644
--- a/management/server/affected_peers_coverage_test.go
+++ b/management/server/affected_peers_coverage_test.go
@@ -41,7 +41,7 @@ func TestAffectedPeers_DependencyCoverageMatrix(t *testing.T) {
_, err := s.manager.SavePolicy(ctx, s.accountID, userID, peerToResourcePolicyByGroup(s.sourceGroupID, s.resourceGroupID), true)
require.NoError(t, err)
return affectedpeers.Change{ChangedPeerIDs: []string{s.routerPeerID}},
- []string{s.sourcePeerID}, []string{s.unrelatedPeerID}
+ []string{s.sourcePeerID, s.routerPeerID}, []string{s.unrelatedPeerID}
},
},
{
@@ -106,11 +106,9 @@ func TestAffectedPeers_DependencyCoverageMatrix(t *testing.T) {
change, mustContain, mustExclude := r.build(t, s, ctx)
affected := resolveAffected(t, s.manager.Store, s.accountID, change)
- for _, id := range mustContain {
- assert.Contains(t, affected, id, "expected peer to be affected")
- }
- for _, id := range mustExclude {
- assert.NotContains(t, affected, id, "peer must not be affected")
+ 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_router_paths_test.go b/management/server/affected_peers_router_paths_test.go
index 11313c387..5d83367fd 100644
--- a/management/server/affected_peers_router_paths_test.go
+++ b/management/server/affected_peers_router_paths_test.go
@@ -251,7 +251,9 @@ func TestAffectedPeers_E2E_UpdateResource_DestinationResourcePolicy_RefreshesSou
}
}
-func TestAffectedPeers_E2E_UpdateResource_DisabledSiblingRouter_StillBridged(t *testing.T) {
+// 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()
@@ -274,13 +276,18 @@ func TestAffectedPeers_E2E_UpdateResource_DisabledSiblingRouter_StillBridged(t *
require.NoError(t, err)
disabledCh := s.updateManager.CreateChannel(ctx, disabledRouterPeer.ID)
- t.Cleanup(func() { s.updateManager.CloseChannel(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)
+ settleAffectedUpdates(disabledCh, enabledCh)
done := make(chan struct{})
go func() {
- peerShouldReceiveUpdate(t, disabledCh)
+ peerShouldReceiveUpdate(t, enabledCh)
+ peerShouldNotReceiveUpdate(t, disabledCh)
close(done)
}()
@@ -298,7 +305,7 @@ func TestAffectedPeers_E2E_UpdateResource_DisabledSiblingRouter_StillBridged(t *
select {
case <-done:
case <-time.After(peerUpdateTimeout):
- t.Error("timeout: resource update did not refresh the disabled sibling router's peer")
+ t.Error("timeout")
}
}
diff --git a/management/server/affected_peers_router_test.go b/management/server/affected_peers_router_test.go
index dc064e787..cc9df0a6a 100644
--- a/management/server/affected_peers_router_test.go
+++ b/management/server/affected_peers_router_test.go
@@ -682,6 +682,9 @@ func TestAffectedPeers_AllRoutingPeers_Network(t *testing.T) {
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()
@@ -694,11 +697,13 @@ func TestAffectedPeers_DisabledRouter(t *testing.T) {
affected := s.resolvePolicyAffected(ctx, peerToResourcePolicyByGroup(s.sourceGroupID, s.resourceGroupID))
- assert.Contains(t, affected, s.sourcePeerID, "source peer must be affected")
- assert.Contains(t, affected, s.routerPeerID,
- "disabled router's peer must still be affected: Enabled must not gate affected-peers")
+ 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()
@@ -710,9 +715,9 @@ func TestAffectedPeers_DisabledResource(t *testing.T) {
affected := s.resolvePolicyAffected(ctx, peerToResourcePolicyByGroup(s.sourceGroupID, s.resourceGroupID))
- assert.Contains(t, affected, s.sourcePeerID, "source peer must be affected")
- assert.Contains(t, affected, s.routerPeerID,
- "disabled resource must still resolve the routing peer: Enabled must not gate affected-peers")
+ 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) {
diff --git a/management/server/affected_peers_test.go b/management/server/affected_peers_test.go
index b66eeb3b5..235128693 100644
--- a/management/server/affected_peers_test.go
+++ b/management/server/affected_peers_test.go
@@ -96,33 +96,54 @@ func affectedGroupID(i int) string { return fmt.Sprintf("affected-grp-%d", i)
func affectedGroupName(i int) string { return fmt.Sprintf("AffectedGroup%d", i) }
func TestCollectGroupChange_PolicyLinked(t *testing.T) {
- manager, s, accountID, _, groupIDs := setupAffectedPeersTest(t)
+ 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,
+ 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, _ := collectGroupChangeAffectedGroups(ctx, s, accountID, []string{groupIDs[0]})
- assert.Contains(t, groups, groupIDs[0])
- assert.Contains(t, groups, groupIDs[1])
+ 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, _ = collectGroupChangeAffectedGroups(ctx, s, accountID, []string{groupIDs[1]})
- assert.Contains(t, groups, groupIDs[0])
- assert.Contains(t, groups, groupIDs[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, _ = collectGroupChangeAffectedGroups(ctx, s, accountID, []string{groupIDs[2]})
+ groups, directPeers = collectGroupChangeAffectedGroups(ctx, s, accountID, []string{groupIDs[2]})
assert.Empty(t, groups)
+ assert.Empty(t, directPeers)
}
func TestCollectGroupChange_PolicyWithDirectPeerResource(t *testing.T) {
@@ -133,20 +154,44 @@ func TestCollectGroupChange_PolicyWithDirectPeerResource(t *testing.T) {
Enabled: true,
Rules: []*types.PolicyRule{
{
- Enabled: true,
- Sources: []string{groupIDs[0]},
- SourceResource: types.Resource{ID: peerIDs[3], Type: types.ResourceTypePeer},
- Destinations: []string{groupIDs[1]},
- Action: types.PolicyTrafficActionAccept,
+ 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.Contains(t, groups, groupIDs[0])
- assert.Contains(t, groups, groupIDs[1])
- assert.Contains(t, directPeers, peerIDs[3])
+ 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) {
@@ -168,8 +213,7 @@ func TestCollectGroupChange_PolicyWithNonPeerResource_NoDirectPeers(t *testing.T
require.NoError(t, err)
groups, directPeers := collectGroupChangeAffectedGroups(ctx, s, accountID, []string{groupIDs[0]})
- assert.Contains(t, groups, groupIDs[0])
- assert.Contains(t, groups, groupIDs[1])
+ assert.ElementsMatch(t, groups, []string{groupIDs[0], groupIDs[1]})
assert.Empty(t, directPeers, "non-peer resources should not produce direct peer IDs")
}
@@ -294,6 +338,7 @@ func TestCollectGroupChange_NetworkRouterLinked(t *testing.T) {
AccountID: accountID,
PeerGroups: []string{groupIDs[0]},
Peer: peerIDs[3],
+ Enabled: true,
})
require.NoError(t, err)
@@ -324,6 +369,7 @@ func TestCollectGroupChange_NetworkRouterPeerOnlyNoGroups(t *testing.T) {
NetworkID: net1.ID,
AccountID: accountID,
Peer: peerIDs[4],
+ Enabled: true,
})
require.NoError(t, err)
@@ -373,17 +419,11 @@ func TestCollectGroupChange_MultipleEntities(t *testing.T) {
require.NoError(t, err)
groups, directPeers := collectGroupChangeAffectedGroups(ctx, s, accountID, []string{groupIDs[0]})
- assert.Contains(t, groups, groupIDs[0])
- assert.Contains(t, groups, groupIDs[1])
- assert.NotContains(t, groups, groupIDs[2])
- assert.NotContains(t, groups, groupIDs[3])
+ assert.ElementsMatch(t, groups, []string{groupIDs[0], groupIDs[1]})
assert.Empty(t, directPeers)
groups, directPeers = collectGroupChangeAffectedGroups(ctx, s, accountID, []string{groupIDs[3]})
- assert.Contains(t, groups, groupIDs[2])
- assert.Contains(t, groups, groupIDs[3])
- assert.NotContains(t, groups, groupIDs[0])
- assert.NotContains(t, groups, groupIDs[1])
+ assert.ElementsMatch(t, groups, []string{groupIDs[2], groupIDs[3]})
assert.Empty(t, directPeers)
}
@@ -452,8 +492,9 @@ func TestResolveAffectedPeers_PolicyBetweenTwoGroups(t *testing.T) {
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.Empty(t, result)
+ assert.ElementsMatch(t, []string{peerIDs[2]}, result)
}
func TestResolveAffectedPeers_PolicyThreeGroups(t *testing.T) {
@@ -474,7 +515,7 @@ func TestResolveAffectedPeers_PolicyThreeGroups(t *testing.T) {
require.NoError(t, err)
result := manager.resolveAffectedPeersForPeerChanges(ctx, s, accountID, []string{peerIDs[0]})
- assert.ElementsMatch(t, []string{peerIDs[0], peerIDs[1], peerIDs[2]}, result)
+ assert.ElementsMatch(t, []string{peerIDs[0], peerIDs[2]}, result)
}
func TestResolveAffectedPeers_RoutePeerGroups(t *testing.T) {
@@ -506,8 +547,9 @@ func TestResolveAffectedPeers_RoutePeerGroups(t *testing.T) {
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.Empty(t, result)
+ assert.ElementsMatch(t, []string{peerIDs[2]}, result)
}
func TestResolveAffectedPeers_RouteWithDirectPeer(t *testing.T) {
@@ -564,9 +606,9 @@ func TestResolveAffectedPeers_RouteWithAccessControlGroups(t *testing.T) {
result := manager.resolveAffectedPeersForPeerChanges(ctx, s, accountID, []string{peerIDs[2]})
assert.ElementsMatch(t, []string{peerIDs[0], peerIDs[1], peerIDs[2]}, result)
- // peer3 is unrelated
+ // peer3 is unrelated to the route; only its own map can change.
result = manager.resolveAffectedPeersForPeerChanges(ctx, s, accountID, []string{peerIDs[3]})
- assert.Empty(t, result)
+ assert.ElementsMatch(t, []string{peerIDs[3]}, result)
}
func TestResolveAffectedPeers_NetworkRouter(t *testing.T) {
@@ -587,6 +629,7 @@ func TestResolveAffectedPeers_NetworkRouter(t *testing.T) {
AccountID: accountID,
PeerGroups: []string{groupIDs[0]},
Peer: peerIDs[3],
+ Enabled: true,
})
require.NoError(t, err)
@@ -659,9 +702,13 @@ func TestResolveAffectedPeers_PeerInMultipleGroups(t *testing.T) {
}, true)
require.NoError(t, err)
- // peer0 is in group0 AND group1, so both policies apply
+ // 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[1], peerIDs[2], peerIDs[3]}, result)
+ 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) {
@@ -697,7 +744,7 @@ func TestResolveAffectedPeers_MultipleChangedPeers(t *testing.T) {
require.NoError(t, err)
result := manager.resolveAffectedPeersForPeerChanges(ctx, s, accountID, []string{peerIDs[0], peerIDs[2]})
- assert.ElementsMatch(t, []string{peerIDs[0], peerIDs[1], peerIDs[2], peerIDs[3]}, result)
+ assert.ElementsMatch(t, []string{peerIDs[0], peerIDs[2], peerIDs[1], peerIDs[3]}, result)
}
func TestResolveAffectedPeers_SharedGroupAcrossPolicyAndRoute(t *testing.T) {
@@ -854,8 +901,9 @@ func TestAffectedPeers_IsolatedPolicies(t *testing.T) {
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.Empty(t, result)
+ assert.ElementsMatch(t, []string{peerIDs[4]}, result)
}
func TestAffectedPeers_IsolatedRouteAndPolicy(t *testing.T) {
@@ -977,12 +1025,13 @@ func TestAffectedPeers_GroupUpdateOnlyAffectsLinkedPeers(t *testing.T) {
})
}
-func TestAffectedPeers_UnlinkedGroupChange_NoUpdates(t *testing.T) {
+// 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.Empty(t, result)
+ assert.ElementsMatch(t, []string{peerIDs[0]}, result)
}
// TestAffectedPeers_PolicyChange_UnrelatedPeerNoUpdate verifies that creating/deleting a
@@ -1332,6 +1381,7 @@ func TestAffectedPeers_NetworkRouterUnlinkedPeerNoUpdate(t *testing.T) {
NetworkID: net1.ID,
AccountID: accountID,
PeerGroups: []string{"nr-grpA"},
+ Enabled: true,
})
require.NoError(t, err)
@@ -1663,7 +1713,7 @@ func addPeerToAccount(t *testing.T, manager *DefaultAccountManager, _, setupKeyK
key, err := wgtypes.GeneratePrivateKey()
require.NoError(t, err)
- peer, _, _, err := manager.AddPeer(context.Background(), "", setupKeyKey, "", &nbpeer.Peer{
+ peer, _, _, _, err := manager.AddPeer(context.Background(), "", setupKeyKey, "", &nbpeer.Peer{
Key: key.PublicKey().String(),
Meta: nbpeer.PeerSystemMeta{Hostname: key.PublicKey().String()},
}, false)
@@ -1755,7 +1805,9 @@ func TestCollectAffectedFromProxyServices_GroupContainingTargetPeerChanged(t *te
assert.Contains(t, directPeers, peerIDs[1], "target peer must be refreshed")
}
-func TestCollectAffectedFromProxyServices_DisabledServiceStillMatches(t *testing.T) {
+// 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()
@@ -1781,8 +1833,7 @@ func TestCollectAffectedFromProxyServices_DisabledServiceStillMatches(t *testing
require.NoError(t, s.CreateService(ctx, svc))
_, directPeers := collectPeerChangeAffectedGroups(ctx, manager.Store, accountID, nil, []string{peerIDs[1]})
- assert.Contains(t, directPeers, peerIDs[0], "disabled service should still trigger a refresh so peers are ready when re-enabled")
- assert.Contains(t, directPeers, peerIDs[1], "disabled target should still trigger a refresh")
+ 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) {
diff --git a/management/server/affectedpeers/resolver.go b/management/server/affectedpeers/resolver.go
index 4ef986345..94e24ced6 100644
--- a/management/server/affectedpeers/resolver.go
+++ b/management/server/affectedpeers/resolver.go
@@ -6,7 +6,12 @@
// and before a delete/removal severs the old state).
// - Snapshot.Expand: in-memory walk, no store access. Run AFTER the tx commits.
//
-// Enabled is never consulted: toggling it is itself an observable change.
+// 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 (
@@ -61,7 +66,8 @@ func Load(ctx context.Context, s store.Store, accountID string, c Change) (*Snap
// loadCollections reads the policy/route/nameserver/dns/router/resource/proxy
// collections a Change can touch, gated to what the walk needs.
func (snap *Snapshot) loadCollections(ctx context.Context, s store.Store, accountID string, c Change) error {
- hasGroupOrPeerChange := len(c.ChangedGroupIDs) > 0 || len(c.ChangedPeerIDs) > 0 || len(c.Resources) > 0
+ // 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
@@ -76,7 +82,7 @@ func (snap *Snapshot) loadCollections(ctx context.Context, s store.Store, accoun
return err
}
}
- if len(c.ChangedGroupIDs) > 0 || len(c.ChangedPeerIDs) > 0 {
+ if len(c.ChangedGroupIDs) > 0 || len(c.ChangedPeerIDs) > 0 || len(c.LinkGroups) > 0 {
if err := snap.loadDNS(ctx, s, accountID); err != nil {
return err
}
@@ -174,6 +180,24 @@ type Change struct {
// 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 {
@@ -186,7 +210,9 @@ func (c Change) isEmpty() bool {
len(c.Networks) == 0 &&
len(c.PostureCheckIDs) == 0 &&
len(c.DistributionGroupIDs) == 0 &&
- len(c.RemovedPeersByGroup) == 0
+ len(c.RemovedPeersByGroup) == 0 &&
+ len(c.LinkGroups) == 0 &&
+ len(c.OutputPeerIDs) == 0
}
// Expand returns the deduplicated affected peer IDs from the preloaded Snapshot,
@@ -197,8 +223,8 @@ func (snap *Snapshot) Expand(ctx context.Context, accountID string, c Change) []
return nil
}
r := newResolver(ctx, snap, accountID, c)
- log.WithContext(ctx).Tracef("affectedpeers expand start: account=%s changedGroups=%v changedPeers=%v policies=%d routes=%d routers=%d resources=%d networks=%d postureChecks=%v distributionGroups=%v",
- accountID, c.ChangedGroupIDs, c.ChangedPeerIDs, len(c.Policies), len(c.Routes), len(c.Routers), len(c.Resources), len(c.Networks), c.PostureCheckIDs, c.DistributionGroupIDs)
+ 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()
}
@@ -216,57 +242,84 @@ func Collect(ctx context.Context, s store.Store, accountID string, c Change) (gr
}
r := newResolver(ctx, snap, accountID, c)
r.walk()
- return setToSlice(r.groupSet), setToSlice(r.peerSet)
+ 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,
- changedGroupSet: toSet(c.ChangedGroupIDs),
- changedPeerSet: toSet(c.ChangedPeerIDs),
- groupSet: make(map[string]struct{}),
- peerSet: make(map[string]struct{}),
- networkIDs: make(map[string]struct{}),
+ 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()
- r.matchedPolicies = append(r.matchedPolicies, c.Policies...)
return r
}
-// seedChangedGroupsFromPeers adds each changed peer's groups to changedGroupSet so
+// 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.changedPeerSet) == 0 {
+ if len(r.changedPeers) == 0 {
return
}
for groupID, members := range r.snap.groupPeers {
- for pID := range r.changedPeerSet {
+ for pID := range r.changedPeers {
if _, ok := members[pID]; ok {
- r.changedGroupSet[groupID] = struct{}{}
+ 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() {
- r.collectFromExplicitPolicies()
- r.collectFromExplicitRoutes(r.change.Routes)
- r.collectFromExplicitRouters(r.change.Routers)
- r.collectFromExplicitResources(r.change.Resources)
- r.collectFromExplicitNetworks(r.change.Networks)
- r.collectFromPostureChecks(r.change.PostureCheckIDs)
+ for _, policy := range r.bothSidesPolicies() {
+ r.foldPolicySide(policy, sideSource)
+ r.foldPolicySide(policy, sideDestination)
+ }
- // Distribution groups (nameserver/DNS) affect only their member peers: fold them
- // straight into groupSet so expand() maps them to members, without the policy/
- // route walk that changedGroupSet would trigger.
- addAll(r.groupSet, r.change.DistributionGroupIDs)
-
- if len(r.changedGroupSet) > 0 || len(r.changedPeerSet) > 0 {
+ if len(r.linkGroups) > 0 || len(r.changedPeers) > 0 {
r.collectFromPolicies()
r.collectFromRoutes()
r.collectFromNameServers()
@@ -275,7 +328,31 @@ func (r *resolver) walk() {
r.collectFromProxyServices()
}
- r.collectResourceRouterBridge()
+ 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 {
@@ -284,27 +361,71 @@ type resolver struct {
accountID string
change Change
- changedGroupSet map[string]struct{}
- changedPeerSet map[string]struct{}
+ // 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{}
- groupSet map[string]struct{}
- peerSet map[string]struct{}
-
- matchedPolicies []*types.Policy
- networkIDs 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{}
}
-func (r *resolver) policies() []*types.Policy { return r.snap.policies }
+// 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
+}
-func (r *resolver) networkResources() []*resourceTypes.NetworkResource { return r.snap.resources }
+// 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 { return r.snap.routers }
+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(groupSet map[string]struct{}) []string {
+func (r *resolver) peerIDsForGroups(groups map[string]struct{}) []string {
seen := make(map[string]struct{})
var ids []string
- for gID := range groupSet {
+ for gID := range groups {
for pID := range r.snap.groupPeers[gID] {
if _, ok := seen[pID]; ok {
continue
@@ -317,25 +438,25 @@ func (r *resolver) peerIDsForGroups(groupSet map[string]struct{}) []string {
}
func (r *resolver) expand() []string {
- peerIDs := r.peerIDsForGroups(r.groupSet)
+ 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.groupSet), len(peerIDs), setToSlice(r.peerSet))
+ 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.peerSet {
+ 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 groupSet).
+ // Fold in removed peers only when their group is linked (in affectedGroups).
for groupID, removed := range r.change.RemovedPeersByGroup {
- if _, linked := r.groupSet[groupID]; !linked {
+ if _, linked := r.affectedGroups[groupID]; !linked {
continue
}
for _, id := range removed {
@@ -351,169 +472,349 @@ func (r *resolver) expand() []string {
return peerIDs
}
-func (r *resolver) collectFromExplicitPolicies() {
- for _, policy := range r.matchedPolicies {
- if policy == nil {
- continue
+// 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{}{}
}
- log.WithContext(r.ctx).Tracef("collectFromExplicitPolicies: changed policy %s (%s) -> folding rule groups %v + direct peers",
- policy.ID, policy.Name, policy.RuleGroups())
- addAll(r.groupSet, policy.RuleGroups())
- collectPolicyDirectPeers(policy, r.peerSet)
+ }
+ if side == sideDestination {
+ r.foldRoutersForResources(r.policyDestinationResourceIDs(policy))
}
}
-func (r *resolver) collectFromExplicitRoutes(routes []*route.Route) {
+// 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("collectFromExplicitRoutes: changed route %s -> folding groups=%v peerGroups=%v accessControlGroups=%v peer=%q",
+ 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.groupSet, rt.Groups, rt.PeerGroups, rt.AccessControlGroups)
+ addAll(r.affectedGroups, rt.Groups, rt.PeerGroups, rt.AccessControlGroups)
if rt.Peer != "" {
- r.peerSet[rt.Peer] = struct{}{}
+ r.affectedPeers[rt.Peer] = struct{}{}
}
}
}
-// collectFromExplicitRouters folds changed routers' peers and marks their networks
-// for the bridge. Passing the old router keeps a repointed router's previous peers
-// affected without a post-commit read.
-func (r *resolver) collectFromExplicitRouters(routers []*routerTypes.NetworkRouter) {
+// 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("collectFromExplicitRouters: changed router %s on network %s -> folding peerGroups=%v peer=%q and marking network for source bridge",
+ 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.groupSet, router.PeerGroups)
+ addAll(r.affectedGroups, router.PeerGroups)
if router.Peer != "" {
- r.peerSet[router.Peer] = struct{}{}
+ r.affectedPeers[router.Peer] = struct{}{}
}
if router.NetworkID != "" {
- r.networkIDs[router.NetworkID] = struct{}{}
+ r.foldPolicySourcesForResources(r.networkResourceIDs(router.NetworkID))
}
}
}
-// collectFromExplicitResources marks changed resources' networks for the bridge and
-// treats their group IDs as changed, so policies targeting the resource via a
-// now-detached (old) group still refresh.
-func (r *resolver) collectFromExplicitResources(resources []*resourceTypes.NetworkResource) {
+// 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("collectFromExplicitResources: changed resource %s on network %s -> marking network for bridge and treating groups %v as changed",
+ 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)
- addAll(r.changedGroupSet, resource.GroupIDs)
+ r.foldPolicySourcesForResource(resource.ID, resource.GroupIDs)
if resource.NetworkID != "" {
- r.networkIDs[resource.NetworkID] = struct{}{}
+ r.foldRoutersOnNetworks(map[string]struct{}{resource.NetworkID: {}})
}
}
}
-// collectFromExplicitNetworks marks changed networks for the bridge. A network has
-// no groups/peers of its own.
-func (r *resolver) collectFromExplicitNetworks(networks []*networkTypes.Network) {
- for _, network := range networks {
- if network == nil {
+// 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("collectFromExplicitNetworks: changed network %s -> marking for bridge", network.ID)
- if network.ID != "" {
- r.networkIDs[network.ID] = struct{}{}
- }
+ 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)
}
}
-func (r *resolver) collectFromPostureChecks(postureCheckIDs []string) {
- if len(postureCheckIDs) == 0 {
+// 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
}
- ids := toSet(postureCheckIDs)
for _, policy := range r.policies() {
- if !policyReferencesPostureChecks(policy, ids) {
- continue
+ 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)
}
- log.WithContext(r.ctx).Tracef("collectFromPostureChecks: policy %s (%s) references changed posture checks %v -> folding rule groups %v + direct peers",
- policy.ID, policy.Name, postureCheckIDs, policy.RuleGroups())
- addAll(r.groupSet, policy.RuleGroups())
- collectPolicyDirectPeers(policy, r.peerSet)
- r.matchedPolicies = append(r.matchedPolicies, policy)
- }
-}
-
-func (r *resolver) collectFromPolicies() {
- for _, policy := range r.policies() {
- matchedByGroup := policyReferencesGroups(policy, r.changedGroupSet)
- matchedByPeer := len(r.changedPeerSet) > 0 && policyReferencesDirectPeers(policy, r.changedPeerSet)
- if !matchedByGroup && !matchedByPeer {
- continue
- }
- log.WithContext(r.ctx).Tracef("collectFromPolicies: policy %s (%s) matched (byGroup=%t byPeer=%t) -> folding rule groups %v + direct peers",
- policy.ID, policy.Name, matchedByGroup, matchedByPeer, policy.RuleGroups())
- addAll(r.groupSet, policy.RuleGroups())
- collectPolicyDirectPeers(policy, r.peerSet)
- r.matchedPolicies = append(r.matchedPolicies, policy)
}
}
+// 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 {
- matchedByGroup := anyInSet(rt.Groups, r.changedGroupSet) || anyInSet(rt.PeerGroups, r.changedGroupSet) || anyInSet(rt.AccessControlGroups, r.changedGroupSet)
- matchedByPeer := rt.Peer != "" && len(r.changedPeerSet) > 0 && isInSet(rt.Peer, r.changedPeerSet)
- if !matchedByGroup && !matchedByPeer {
+ 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 (byGroup=%t byPeer=%t) -> folding groups=%v peerGroups=%v accessControlGroups=%v peer=%q",
- rt.ID, matchedByGroup, matchedByPeer, rt.Groups, rt.PeerGroups, rt.AccessControlGroups, rt.Peer)
- addAll(r.groupSet, rt.Groups, rt.PeerGroups, rt.AccessControlGroups)
- if rt.Peer != "" {
- r.peerSet[rt.Peer] = struct{}{}
+ 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.changedGroupSet) == 0 {
+ if len(r.linkGroups) == 0 {
return
}
for _, ns := range r.snap.nsGroups {
- if anyInSet(ns.Groups, r.changedGroupSet) {
- log.WithContext(r.ctx).Tracef("collectFromNameServers: nameserver group %s references a changed group -> folding its groups %v", ns.ID, ns.Groups)
- addAll(r.groupSet, ns.Groups)
+ 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.changedGroupSet) == 0 || r.snap.dnsSettings == nil {
+ if len(r.linkGroups) == 0 || r.snap.dnsSettings == nil {
return
}
for _, gID := range r.snap.dnsSettings.DisabledManagementGroups {
- if _, ok := r.changedGroupSet[gID]; ok {
+ if _, ok := r.linkGroups[gID]; ok {
log.WithContext(r.ctx).Tracef("collectFromDNSSettings: changed group %s is in DisabledManagementGroups -> folding it", gID)
- r.groupSet[gID] = struct{}{}
+ 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.changedGroupSet)
- matchedByPeer := router.Peer != "" && len(r.changedPeerSet) > 0 && isInSet(router.Peer, r.changedPeerSet)
+ 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 peerGroups=%v peer=%q and marking network for source bridge",
+ 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)
- addAll(r.groupSet, router.PeerGroups)
+ // 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.peerSet[router.Peer] = struct{}{}
+ r.affectedPeers[router.Peer] = struct{}{}
+ }
+ if router.NetworkID != "" {
+ r.foldPolicySourcesForResources(r.networkResourceIDs(router.NetworkID))
}
- r.networkIDs[router.NetworkID] = struct{}{}
}
}
@@ -526,42 +827,48 @@ func (r *resolver) collectFromProxyServices() {
expanded := r.expandChangedPeersWithGroups()
for _, svc := range services {
- if svc == nil {
- continue
+ 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.changedGroupSet)
+ 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 and access groups %v",
+ 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.peerSet[pid] = struct{}{}
+ 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.peerSet[target.TargetId] = struct{}{}
+ r.affectedPeers[target.TargetId] = struct{}{}
}
}
- addAll(r.groupSet, svc.AccessGroups)
+ // 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.changedGroupSet) == 0 {
- return r.changedPeerSet
+ if len(r.linkGroups) == 0 {
+ return r.changedPeers
}
- ids := r.peerIDsForGroups(r.changedGroupSet)
+ ids := r.peerIDsForGroups(r.linkGroups)
if len(ids) == 0 {
- return r.changedPeerSet
+ return r.changedPeers
}
- merged := make(map[string]struct{}, len(r.changedPeerSet)+len(ids))
- for id := range r.changedPeerSet {
+ merged := make(map[string]struct{}, len(r.changedPeers)+len(ids))
+ for id := range r.changedPeers {
merged[id] = struct{}{}
}
for _, id := range ids {
@@ -570,54 +877,36 @@ func (r *resolver) expandChangedPeersWithGroups() map[string]struct{} {
return merged
}
-// collectResourceRouterBridge crosses between source peers and routing peers, which
-// are reachable only via resource -> network -> router, not through the policy's own
-// groups: source -> router (targeted resources' networks), then router -> source.
-func (r *resolver) collectResourceRouterBridge() {
- r.bridgeSourceToRouters()
- r.bridgeRoutersToSources()
-}
-
-func (r *resolver) bridgeSourceToRouters() {
- resourceIDs := r.policyDestinationResourceIDs(r.matchedPolicies...)
+// 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
}
-
- networkIDs := r.resourceNetworkIDs(resourceIDs)
- log.WithContext(r.ctx).Tracef("bridgeSourceToRouters: targeted resources %v -> networks %v (their routers become affected via the router->source pass)",
- setToSlice(resourceIDs), setToSlice(networkIDs))
- for id := range networkIDs {
- r.networkIDs[id] = struct{}{}
- }
+ r.foldRoutersOnNetworks(r.resourceNetworkIDs(resourceIDs))
}
-func (r *resolver) bridgeRoutersToSources() {
- if len(r.networkIDs) == 0 {
- return
+// 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
+}
- log.WithContext(r.ctx).Tracef("bridgeRoutersToSources: affected networks %v -> folding their routing peers and the source peers of policies targeting their resources",
- setToSlice(r.networkIDs))
-
- r.foldRoutersOnNetworks(r.networkIDs)
-
+// 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 _, ok := r.networkIDs[resource.NetworkID]; ok {
+ if resource.NetworkID == networkID {
resourceIDs[resource.ID] = struct{}{}
}
}
- if len(resourceIDs) == 0 {
- return
- }
-
- for _, policy := range r.policies() {
- if r.policyTargetsResources(policy, resourceIDs) {
- log.WithContext(r.ctx).Tracef("bridgeRoutersToSources: policy %s (%s) targets an affected-network resource -> folding its source groups/peers", policy.ID, policy.Name)
- collectPolicySources(policy, r.groupSet, r.peerSet)
- }
- }
+ return resourceIDs
}
func (r *resolver) foldRoutersOnNetworks(networkIDs map[string]struct{}) {
@@ -627,9 +916,9 @@ func (r *resolver) foldRoutersOnNetworks(networkIDs map[string]struct{}) {
}
log.WithContext(r.ctx).Tracef("bridgeRoutersToSources: router %s serves affected network %s -> folding peerGroups=%v peer=%q",
router.ID, router.NetworkID, router.PeerGroups, router.Peer)
- addAll(r.groupSet, router.PeerGroups)
+ addAll(r.affectedGroups, router.PeerGroups)
if router.Peer != "" {
- r.peerSet[router.Peer] = struct{}{}
+ r.affectedPeers[router.Peer] = struct{}{}
}
}
}
@@ -650,6 +939,9 @@ func (r *resolver) policyTargetsResources(policy *types.Policy, resourceIDs map[
}
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
}
@@ -714,44 +1006,20 @@ func (r *resolver) addGroupResourceIDs(groupIDs map[string]struct{}, resourceIDs
}
}
-func collectPolicyDirectPeers(policy *types.Policy, peerSet map[string]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 != "" {
- peerSet[rule.SourceResource.ID] = struct{}{}
- }
- if rule.DestinationResource.Type == types.ResourceTypePeer && rule.DestinationResource.ID != "" {
- peerSet[rule.DestinationResource.ID] = struct{}{}
+ peers[rule.SourceResource.ID] = struct{}{}
}
}
}
-func collectPolicySources(policy *types.Policy, groupSet, peerSet map[string]struct{}) {
- for _, rule := range policy.Rules {
- addAll(groupSet, rule.Sources)
- if rule.SourceResource.Type == types.ResourceTypePeer && rule.SourceResource.ID != "" {
- peerSet[rule.SourceResource.ID] = struct{}{}
- }
- }
-}
-
-func policyReferencesGroups(policy *types.Policy, groupSet map[string]struct{}) bool {
- for _, rule := range policy.Rules {
- if anyInSet(rule.Sources, groupSet) || anyInSet(rule.Destinations, groupSet) {
- return true
- }
- }
- return false
-}
-
-func policyReferencesDirectPeers(policy *types.Policy, changedSet map[string]struct{}) bool {
- for _, rule := range policy.Rules {
- if isDirectPeerInSet(rule.SourceResource, changedSet) || isDirectPeerInSet(rule.DestinationResource, changedSet) {
- return true
- }
- }
- return false
-}
-
func policyReferencesPostureChecks(policy *types.Policy, ids map[string]struct{}) bool {
for _, id := range policy.SourcePostureChecks {
if _, ok := ids[id]; ok {
@@ -776,7 +1044,7 @@ func serviceMatchesChangedPeers(svc *rpservice.Service, proxyPeers []string, cha
}
}
for _, target := range svc.Targets {
- if target.TargetType != rpservice.TargetTypePeer || target.TargetId == "" {
+ if !target.Enabled || target.TargetType != rpservice.TargetTypePeer || target.TargetId == "" {
continue
}
if _, ok := changedPeers[target.TargetId]; ok {
diff --git a/management/server/affectedpeers/resolver_test.go b/management/server/affectedpeers/resolver_test.go
index dcd304a56..fe6ada347 100644
--- a/management/server/affectedpeers/resolver_test.go
+++ b/management/server/affectedpeers/resolver_test.go
@@ -10,8 +10,8 @@ import (
"github.com/netbirdio/netbird/management/server/types"
)
-// policyGroupsAndPeers mirrors the explicit-policy extraction (RuleGroups +
-// direct peers) the resolver folds in, for asserting the pure logic.
+// 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 {
@@ -19,7 +19,14 @@ func policyGroupsAndPeers(policies ...*types.Policy) (groups []string, peers []s
continue
}
groups = append(groups, p.RuleGroups()...)
- collectPolicyDirectPeers(p, peerSet)
+ 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)
@@ -80,26 +87,6 @@ func TestChangeIsEmpty(t *testing.T) {
assert.False(t, Change{PostureCheckIDs: []string{"pc"}}.isEmpty())
}
-func TestPolicyReferencesGroups(t *testing.T) {
- policy := &types.Policy{Rules: []*types.PolicyRule{{Sources: []string{"g1", "g2"}, Destinations: []string{"g3"}}}}
-
- assert.True(t, policyReferencesGroups(policy, map[string]struct{}{"g1": {}}))
- assert.True(t, policyReferencesGroups(policy, map[string]struct{}{"g3": {}}))
- assert.False(t, policyReferencesGroups(policy, map[string]struct{}{"g4": {}}))
- assert.False(t, policyReferencesGroups(policy, map[string]struct{}{}))
-}
-
-func TestPolicyReferencesDirectPeers(t *testing.T) {
- policy := &types.Policy{Rules: []*types.PolicyRule{{
- SourceResource: types.Resource{Type: types.ResourceTypePeer, ID: "p1"},
- DestinationResource: types.Resource{Type: types.ResourceTypeHost, ID: "r1"},
- }}}
-
- assert.True(t, policyReferencesDirectPeers(policy, map[string]struct{}{"p1": {}}))
- assert.False(t, policyReferencesDirectPeers(policy, map[string]struct{}{"r1": {}}))
- assert.False(t, policyReferencesDirectPeers(policy, map[string]struct{}{"p2": {}}))
-}
-
func TestPolicyReferencesPostureChecks(t *testing.T) {
policy := &types.Policy{SourcePostureChecks: []string{"pc1", "pc2"}}
@@ -107,24 +94,9 @@ func TestPolicyReferencesPostureChecks(t *testing.T) {
assert.False(t, policyReferencesPostureChecks(policy, map[string]struct{}{"pc3": {}}))
}
-func TestCollectPolicyDirectPeers(t *testing.T) {
- policy := &types.Policy{Rules: []*types.PolicyRule{{
- SourceResource: types.Resource{Type: types.ResourceTypePeer, ID: "p1"},
- DestinationResource: types.Resource{Type: types.ResourceTypePeer, ID: "p2"},
- }, {
- DestinationResource: types.Resource{Type: types.ResourceTypeHost, ID: "r1"},
- }}}
-
- peerSet := map[string]struct{}{}
- collectPolicyDirectPeers(policy, peerSet)
-
- assert.Contains(t, peerSet, "p1")
- assert.Contains(t, peerSet, "p2")
- assert.NotContains(t, peerSet, "r1")
-}
-
func TestCollectPolicySources(t *testing.T) {
policy := &types.Policy{Rules: []*types.PolicyRule{{
+ Enabled: true,
Sources: []string{"g1"},
SourceResource: types.Resource{Type: types.ResourceTypePeer, ID: "p1"},
Destinations: []string{"g2"},
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 070344c61..460b51274 100644
--- a/management/server/group.go
+++ b/management/server/group.go
@@ -520,7 +520,12 @@ func collectDeletableGroups(ctx context.Context, transaction store.Store, accoun
// GroupAddPeer appends peer to the group
func (am *DefaultAccountManager) GroupAddPeer(ctx context.Context, accountID, groupID, peerID string) error {
var snap *affectedpeers.Snapshot
- change := affectedpeers.Change{ChangedGroupIDs: []string{groupID}}
+ // 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 {
if err := transaction.AddPeerToGroup(ctx, accountID, peerID, groupID); err != nil {
@@ -586,10 +591,11 @@ func (am *DefaultAccountManager) GroupAddResource(ctx context.Context, accountID
// GroupDeletePeer removes peer from the group
func (am *DefaultAccountManager) GroupDeletePeer(ctx context.Context, accountID, groupID, peerID string) error {
var snap *affectedpeers.Snapshot
- change := affectedpeers.Change{
- ChangedGroupIDs: []string{groupID},
- RemovedPeersByGroup: map[string][]string{groupID: {peerID}},
- }
+ // 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 {
if err := transaction.RemovePeerFromGroup(ctx, peerID, groupID); err != nil {
@@ -600,8 +606,6 @@ func (am *DefaultAccountManager) GroupDeletePeer(ctx context.Context, accountID,
return err
}
- // The removed peer is carried in change.RemovedPeersByGroup and folded in
- // only when the group is linked, so loading post-removal is correct.
var err error
if snap, err = affectedpeers.Load(ctx, transaction, accountID, change); err != nil {
return err
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 15eb9b190..071e3771b 100644
--- a/management/server/mock_server/account_mock.go
+++ b/management/server/mock_server/account_mock.go
@@ -39,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, nmap *types.NetworkMap) 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)
@@ -98,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
@@ -114,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)
@@ -345,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, nmap *types.NetworkMap) 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, nmap)
+ return am.MarkPeerConnectedFunc(ctx, peerKey, accountID, sessionStartedAt, nmap)
}
return status.Errorf(codes.Unimplemented, "method MarkPeerConnected is not implemented")
}
@@ -424,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
@@ -862,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
@@ -975,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_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/peer.go b/management/server/peer.go
index baf62a7eb..440e90044 100644
--- a/management/server/peer.go
+++ b/management/server/peer.go
@@ -74,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, nmap *types.NetworkMap) 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))
@@ -102,10 +102,6 @@ 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
}
@@ -192,27 +188,40 @@ func (am *DefaultAccountManager) MarkPeerDisconnected(ctx context.Context, peerP
}
}
+ 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,
}
}
@@ -718,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)
@@ -737,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{
@@ -748,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
@@ -763,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()
@@ -789,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 {
@@ -807,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
@@ -852,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
}
@@ -929,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
@@ -940,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
}
@@ -948,18 +958,18 @@ func (am *DefaultAccountManager) AddPeer(ctx context.Context, accountID, setupKe
am.StoreEvent(ctx, opEvent.InitiatorID, opEvent.TargetID, opEvent.AccountID, opEvent.Activity, opEvent.Meta)
}
- p, nmap, pc, _, err := am.networkMapController.GetValidatedPeerWithMap(ctx, false, accountID, newPeer)
+ network, postureChecks, enableSSH, err := getPeerLoginInfo(ctx, am.Store, accountID, newPeer, !requiresApproval)
if err != nil {
- return p, nmap, pc, err
+ return nil, nil, nil, false, err
}
changedPeerIDs := []string{newPeer.ID}
- affectedPeerIDs := affectedPeerIDsFromNetworkMap(nmap, 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)
}
- return p, nmap, pc, nil
+ return newPeer, network, postureChecks, enableSSH, nil
}
func getPeerIPDNSLabel(ip netip.Addr, peerHostName string) (string, error) {
@@ -979,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 {
@@ -1010,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
})
@@ -1036,25 +1036,54 @@ 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
}
- resPeer, nmap, resPostureChecks, dnsFwdPort, err := am.networkMapController.GetValidatedPeerWithMap(ctx, peerNotValid, accountID, peer)
+ nmap, resPostureChecks, dnsFwdPort, err := am.networkMapController.GetValidatedPeerWithMap(ctx, peerNotValid, accountID, peer.ID)
if err != nil {
return nil, nil, nil, 0, err
}
- if isStatusChanged || sync.UpdateAccountPeers || ipv6CapabilityChanged || (updated && (len(postureChecks) > 0 || versionChanged)) {
+ 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, updated, len(postureChecks) > 0)
+ 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 resPeer, nmap, resPostureChecks, dnsFwdPort, nil
+ return peer, nmap, resPostureChecks, dnsFwdPort, nil
+}
+
+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
@@ -1065,8 +1094,8 @@ func (am *DefaultAccountManager) SyncPeer(ctx context.Context, sync types.PeerSy
// metadata change that flips a posture result removes this peer from others'
// maps asymmetrically; that case (and an invalid peer, whose map is empty) falls
// back to the resolver.
-func (am *DefaultAccountManager) syncPeerAffectedPeers(ctx context.Context, accountID, peerID string, nmap *types.NetworkMap, peerNotValid, metaUpdated, hasPostureChecks bool) []string {
- if peerNotValid || (metaUpdated && hasPostureChecks) {
+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)
@@ -1085,7 +1114,7 @@ func (am *DefaultAccountManager) markConnectedAffectedPeers(ctx context.Context,
return affectedPeerIDsFromNetworkMap(nmap, peerID)
}
-func (am *DefaultAccountManager) handlePeerLoginNotFound(ctx context.Context, login types.PeerLogin, err error) (*nbpeer.Peer, *types.NetworkMap, []*posture.Checks, error) {
+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.
@@ -1101,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)
@@ -1118,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 {
@@ -1140,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)
@@ -1156,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 {
@@ -1197,28 +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
}
- p, nmap, pc, _, err := am.networkMapController.GetValidatedPeerWithMap(ctx, isRequiresApproval, accountID, peer)
+ isRequiresApproval, _, err := am.integratedPeerValidator.IsNotValidPeer(ctx, accountID, peer, peerGroupIDs, settings.Extra)
if err != nil {
- return nil, nil, nil, err
+ return nil, nil, nil, false, err
}
- if updateRemotePeers || isStatusChanged || ipv6CapabilityChanged || (isPeerUpdated && len(postureChecks) > 0) {
+ 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.syncPeerAffectedPeers(ctx, accountID, peer.ID, nmap, isRequiresApproval, isPeerUpdated, len(postureChecks) > 0)
+ affectedPeerIDs := am.resolveAffectedPeersForPeerChanges(ctx, am.Store, accountID, changedPeerIDs)
if err = am.networkMapController.OnPeersUpdated(ctx, accountID, changedPeerIDs, affectedPeerIDs); err != nil {
- return nil, nil, nil, fmt.Errorf("notify network map controller of peer update: %w", err)
+ return nil, nil, nil, false, fmt.Errorf("notify network map controller of peer update: %w", err)
}
}
- return p, nmap, pc, nil
+ return peer, network, postureChecks, enableSSH, nil
}
// ExtendPeerSession refreshes the peer's SSO session deadline by updating
@@ -1294,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
}
@@ -1312,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...)
}
@@ -1329,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
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 5a67aa9ae..6c243c4c7 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)
@@ -1336,7 +1337,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)
@@ -1469,7 +1470,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)
@@ -1581,7 +1582,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)
@@ -1727,7 +1728,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{
@@ -1743,12 +1744,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
}
@@ -1761,7 +1762,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")
@@ -1867,7 +1868,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)
@@ -1990,7 +1991,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},
@@ -2057,7 +2058,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},
@@ -2112,7 +2113,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},
@@ -2290,7 +2291,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
@@ -2370,7 +2371,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")
}
@@ -2405,7 +2406,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")
}
@@ -2448,7 +2449,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
@@ -2467,7 +2468,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")
@@ -2504,7 +2505,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
@@ -2517,7 +2518,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")
}
@@ -2841,7 +2842,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)
@@ -2851,7 +2852,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)
@@ -2875,7 +2876,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)
@@ -2885,7 +2886,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)
@@ -2897,3 +2898,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/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_test.go b/management/server/posture_checks_test.go
index 14bc2c45a..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,
diff --git a/management/server/store/sql_store.go b/management/server/store/sql_store.go
index 7d22905dd..8bc4bcd7d 100644
--- a/management/server/store/sql_store.go
+++ b/management/server/store/sql_store.go
@@ -581,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{}).
diff --git a/management/server/store/sql_store_test.go b/management/server/store/sql_store_test.go
index ac136987e..92784af83 100644
--- a/management/server/store/sql_store_test.go
+++ b/management/server/store/sql_store_test.go
@@ -618,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 31f1fea86..066ab285d 100644
--- a/management/server/store/store.go
+++ b/management/server/store/store.go
@@ -185,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 706c03f1b..fdd2d0900 100644
--- a/management/server/store/store_mock.go
+++ b/management/server/store/store_mock.go
@@ -2968,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 412f15ce7..666d6d178 100644
--- a/management/server/user.go
+++ b/management/server/user.go
@@ -1059,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...)
}
diff --git a/management/server/user_test.go b/management/server/user_test.go
index d46519396..f32a6b3a1 100644
--- a/management/server/user_test.go
+++ b/management/server/user_test.go
@@ -1565,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)
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/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/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/grpc.go b/shared/management/client/grpc.go
index 016cde68a..6f5172376 100644
--- a/shared/management/client/grpc.go
+++ b/shared/management/client/grpc.go
@@ -55,6 +55,14 @@ type GrpcClient struct {
connStateCallback ConnStateNotifier
connStateCallbackLock sync.RWMutex
serverURL string
+
+ // syncStreamErr holds the last Sync stream error, or nil while the stream
+ // is established and healthy. GetServerKey succeeds even when the peer
+ // cannot sync (e.g. the server returns "settings not found"), so the
+ // health probe must consult this to avoid reporting a healthy management
+ // connection while the Sync stream keeps failing.
+ syncStreamMu sync.RWMutex
+ syncStreamErr error
}
type ExposeRequest struct {
@@ -364,6 +372,8 @@ func (c *GrpcClient) handleSyncStream(ctx context.Context, serverPubKey wgtypes.
stream, err := c.connectToSyncStream(ctx, serverPubKey, sysInfo)
if err != nil {
log.Debugf("failed to open Management Service stream: %s", err)
+ c.notifyDisconnected(err)
+ c.setSyncStreamDisconnected(err)
if s, ok := gstatus.FromError(err); ok && s.Code() == codes.PermissionDenied {
return backoff.Permanent(err) // unrecoverable error, propagate to the upper layer
}
@@ -372,11 +382,13 @@ func (c *GrpcClient) handleSyncStream(ctx context.Context, serverPubKey wgtypes.
log.Infof("connected to the Management Service stream")
c.notifyConnected()
+ c.setSyncStreamConnected()
// blocking until error
err = c.receiveUpdatesEvents(stream, serverPubKey, msgHandler)
if err != nil {
c.notifyDisconnected(err)
+ c.setSyncStreamDisconnected(err)
if ctx.Err() != nil {
log.Debugf("management connection context has been canceled, this usually indicates shutdown")
return nil
@@ -530,6 +542,13 @@ func (c *GrpcClient) IsHealthy() bool {
log.Warnf("health check returned: %s", err)
return false
}
+
+ if syncErr := c.syncStreamError(); syncErr != nil {
+ c.notifyDisconnected(syncErr)
+ log.Warnf("management transport is up but the Sync stream is unhealthy: %s", syncErr)
+ return false
+ }
+
c.notifyConnected()
return true
}
@@ -771,6 +790,24 @@ func (c *GrpcClient) SyncMeta(sysInfo *system.Info) error {
return err
}
+func (c *GrpcClient) setSyncStreamConnected() {
+ c.syncStreamMu.Lock()
+ defer c.syncStreamMu.Unlock()
+ c.syncStreamErr = nil
+}
+
+func (c *GrpcClient) setSyncStreamDisconnected(err error) {
+ c.syncStreamMu.Lock()
+ defer c.syncStreamMu.Unlock()
+ c.syncStreamErr = err
+}
+
+func (c *GrpcClient) syncStreamError() error {
+ c.syncStreamMu.RLock()
+ defer c.syncStreamMu.RUnlock()
+ return c.syncStreamErr
+}
+
func (c *GrpcClient) notifyDisconnected(err error) {
c.connStateCallbackLock.RLock()
defer c.connStateCallbackLock.RUnlock()
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/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..a07867263 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,22 @@ 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
+ watchdogWg sync.WaitGroup
}
// NewClient creates a new Signal client
@@ -148,9 +182,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 +198,24 @@ 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()
+ c.watchdogWg.Add(1)
+ go func() {
+ defer c.watchdogWg.Done()
+ c.watchReceiveStream(streamCtx, cancelStream)
+ }()
+
// start receiving messages from the Signal stream (from other peers through signal)
err = c.receive(stream)
+
+ cancelStream()
+ c.watchdogWg.Wait()
+
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 +262,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 +292,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 +308,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 +317,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:
}
@@ -364,7 +409,12 @@ func (c *GrpcClient) encryptMessage(msg *proto.Message) (*proto.EncryptedMessage
// Send sends a message to the remote Peer through the Signal Exchange.
func (c *GrpcClient) Send(msg *proto.Message) error {
+ return c.send(c.ctx, msg)
+}
+// send delivers a message deriving per-attempt timeouts from parentCtx, so a
+// caller can abort an in-flight send by cancelling that context.
+func (c *GrpcClient) send(parentCtx context.Context, msg *proto.Message) error {
if !c.Ready() {
return fmt.Errorf("no connection to signal")
}
@@ -380,7 +430,7 @@ func (c *GrpcClient) Send(msg *proto.Message) error {
if attempt > 1 {
attemptTimeout = time.Duration(attempt) * 5 * time.Second
}
- ctx, cancel := context.WithTimeout(c.ctx, attemptTimeout)
+ ctx, cancel := context.WithTimeout(parentCtx, attemptTimeout)
_, err = c.realClient.Send(ctx, encryptedMessage)
@@ -398,6 +448,80 @@ 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(ctx); err != nil {
+ log.Debugf("failed to send signal receive probe: %v", err)
+ }
+ probeSentAt = time.Now()
+ }
+ }
+ }
+}
+
+// sendReceiveProbe sends a self-addressed heartbeat bound to ctx, so cancelStream
+// aborts an in-flight probe instead of leaving the watchdog blocked on send timeouts.
+// The Signal server routes it back to this client, exercising the exact receive
+// path the watchdog guards.
+func (c *GrpcClient) sendReceiveProbe(ctx context.Context) error {
+ self := c.key.PublicKey().String()
+ return c.send(ctx, &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 +543,24 @@ 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)
}
+ // Refresh liveness before clearing the flag so the window between here and
+ // the next Recv does not read a stale timestamp as a dead stream.
+ c.markReceived()
+ 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..a8bbafa29
--- /dev/null
+++ b/shared/signal/client/watchdog_test.go
@@ -0,0 +1,178 @@
+package client
+
+import (
+ "context"
+ "io"
+ "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(ctx))
+
+ 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")
+}
+
+// fakeRecvStream feeds the receive loop frames from a channel and reports EOF
+// once the channel is closed. Only Recv is exercised by the loop.
+type fakeRecvStream struct {
+ sigProto.SignalExchange_ConnectStreamClient
+ frames chan *sigProto.EncryptedMessage
+}
+
+func (s *fakeRecvStream) Recv() (*sigProto.EncryptedMessage, error) {
+ msg, ok := <-s.frames
+ if !ok {
+ return nil, io.EOF
+ }
+ return msg, nil
+}
+
+// TestReceiveLoopRefreshesLivenessAfterBlockedHandoff drives the real receive
+// loop into a handoff that blocks past the inactivity threshold, then checks the
+// window after the handoff drains but before the next Recv. The loop must have
+// refreshed the timestamp on unblocking, otherwise that window reads the stale
+// pre-handoff timestamp as a dead stream and the watchdog tears down a healthy
+// connection.
+func TestReceiveLoopRefreshesLivenessAfterBlockedHandoff(t *testing.T) {
+ ctx, cancel := context.WithCancel(context.Background())
+ t.Cleanup(cancel)
+ c := &GrpcClient{ctx: ctx}
+
+ handling := make(chan struct{}, 8)
+ gate := make(chan struct{})
+ decrypt := func(*sigProto.EncryptedMessage) (*sigProto.Message, error) { return &sigProto.Message{}, nil }
+ handler := func(*sigProto.Message) error {
+ handling <- struct{}{}
+ <-gate
+ return nil
+ }
+ c.decryptionWorker = NewWorker(decrypt, handler)
+ workerCtx, workerCancel := context.WithCancel(context.Background())
+ go c.decryptionWorker.Work(workerCtx)
+ t.Cleanup(workerCancel)
+
+ frames := make(chan *sigProto.EncryptedMessage)
+ t.Cleanup(func() { close(frames) })
+ go func() { _ = c.receive(&fakeRecvStream{frames: frames}) }()
+
+ // First frame: the worker drains it and parks in the blocking handler.
+ frames <- &sigProto.EncryptedMessage{}
+ <-handling
+ // Second frame fills the worker's single-slot pool.
+ frames <- &sigProto.EncryptedMessage{}
+ // Third frame: the pool is full, so the loop parks on the handoff.
+ frames <- &sigProto.EncryptedMessage{}
+
+ require.Eventually(t, c.receiveHandoffBlocked.Load, time.Second, time.Millisecond,
+ "receive loop should park on the worker handoff")
+
+ // Simulate the handoff having blocked past the inactivity threshold.
+ c.lastReceived.Store(time.Now().Add(-2 * receiveInactivityThreshold).UnixNano())
+ require.True(t, c.receiveAlive(), "a loop parked on the handoff must stay alive")
+
+ // Drain the worker so the handoff returns and the loop resumes reading.
+ close(gate)
+
+ // Once the handoff clears, the loop is parked on the next Recv with no frame
+ // pending. The stream must still read as alive in that window.
+ require.Eventually(t, func() bool { return !c.receiveHandoffBlocked.Load() }, time.Second, time.Millisecond,
+ "handoff should drain once the worker is released")
+ require.True(t, c.receiveAlive(),
+ "the loop must refresh liveness when the handoff drains, before the next Recv")
+}
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